Fixed a number of bugs

Fixed a bug where threads might not be getting marked as read correctly
Fixed a bug where the GarbageCollectionJob could end up blocking the database write thread (seemed to only hang when the debugger was attached but may have affected devices at some point)
Fixed a bug with thread sorting
Fixed a bug where joining an open group wouldn't appear until after the first poll completed
Fixed a bug where conversations with no interactions would display odd interaction copy
Fixed a bug where the sender name was appearing above outgoing messages in groups
pull/612/head
Morgan Pretty 3 years ago
parent 20dc74bc96
commit 2cd9f571da

@ -381,6 +381,9 @@ public final class FullConversationCell: UITableViewCell {
// MARK: - Snippet generation // MARK: - Snippet generation
private func getSnippet(cellViewModel: SessionThreadViewModel) -> NSMutableAttributedString { private func getSnippet(cellViewModel: SessionThreadViewModel) -> NSMutableAttributedString {
// If we don't have an interaction then do nothing
guard cellViewModel.interactionId != nil else { return NSMutableAttributedString() }
let result = NSMutableAttributedString() let result = NSMutableAttributedString()
if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) {

@ -462,7 +462,24 @@ public extension Interaction {
} }
// If we aren't including older interactions then update and save the current one // If we aren't including older interactions then update and save the current one
guard includingOlder else { struct InteractionReadInfo: Decodable, FetchableRecord {
let timestampMs: Int64
let wasRead: Bool
}
// Since there is no guarantee on the order messages are inserted into the database
// fetch the timestamp for the interaction and set everything before that as read
let maybeInteractionInfo: InteractionReadInfo? = try Interaction
.select(.timestampMs, .wasRead)
.filter(id: interactionId)
.asRequest(of: InteractionReadInfo.self)
.fetchOne(db)
guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else {
// Only mark as read and trigger the subsequent jobs if the interaction is
// actually not read (no point updating and triggering db changes otherwise)
guard maybeInteractionInfo?.wasRead == false else { return }
_ = try Interaction _ = try Interaction
.filter(id: interactionId) .filter(id: interactionId)
.updateAll(db, Columns.wasRead.set(to: true)) .updateAll(db, Columns.wasRead.set(to: true))
@ -472,9 +489,9 @@ public extension Interaction {
} }
let interactionQuery = Interaction let interactionQuery = Interaction
.filter(Columns.threadId == threadId) .filter(Interaction.Columns.threadId == threadId)
.filter(Columns.id <= interactionId) .filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs)
.filter(Columns.wasRead == false) .filter(Interaction.Columns.wasRead == false)
// The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted` // The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted`
.filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted) .filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted)
let interactionIdsToMarkAsRead: [Int64] = try interactionQuery let interactionIdsToMarkAsRead: [Int64] = try interactionQuery

@ -35,8 +35,6 @@ public enum GarbageCollectionJob: JobExecutor {
} }
let timestampNow: TimeInterval = Date().timeIntervalSince1970 let timestampNow: TimeInterval = Date().timeIntervalSince1970
var attachmentLocalRelativePaths: Set<String> = []
var profileAvatarFilenames: Set<String> = []
GRDBStorage.shared.writeAsync( GRDBStorage.shared.writeAsync(
updates: { db in updates: { db in
@ -203,109 +201,127 @@ public enum GarbageCollectionJob: JobExecutor {
) )
""") """)
} }
},
completion: { _, _ in
// Dispatch async so we can swap from the write queue to a read one (we are done writing)
queue.async {
// Retrieve a list of all valid attachmnet and avatar file paths
struct FileInfo {
let attachmentLocalRelativePaths: Set<String>
let profileAvatarFilenames: Set<String>
}
/// Orphaned attachment files - attachment files which don't have an associated record in the database let maybeFileInfo: FileInfo? = GRDBStorage.shared.read { db -> FileInfo in
if details.typesToCollect.contains(.orphanedAttachmentFiles) { var attachmentLocalRelativePaths: Set<String> = []
/// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage var profileAvatarFilenames: Set<String> = []
/// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow
/// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) /// Orphaned attachment files - attachment files which don't have an associated record in the database
/// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed if details.typesToCollect.contains(.orphanedAttachmentFiles) {
attachmentLocalRelativePaths = try Attachment /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage
.select(.localRelativeFilePath) /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow
.filter(Attachment.Columns.localRelativeFilePath != nil) /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running)
.asRequest(of: String.self) /// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed
.fetchSet(db) attachmentLocalRelativePaths = try Attachment
} .select(.localRelativeFilePath)
.filter(Attachment.Columns.localRelativeFilePath != nil)
.asRequest(of: String.self)
.fetchSet(db)
}
/// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database /// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database
if details.typesToCollect.contains(.orphanedProfileAvatars) { if details.typesToCollect.contains(.orphanedProfileAvatars) {
profileAvatarFilenames = try Profile profileAvatarFilenames = try Profile
.select(.profilePictureFileName) .select(.profilePictureFileName)
.filter(Profile.Columns.profilePictureFileName != nil) .filter(Profile.Columns.profilePictureFileName != nil)
.asRequest(of: String.self) .asRequest(of: String.self)
.fetchSet(db) .fetchSet(db)
} }
},
completion: { _, result in
// If any of the above failed then we don't want to continue (we would end up deleting all files since
// neither of the arrays would have been populated correctly)
guard case .success = result else {
SNLog("[GarbageCollectionJob] Database queries failed, skipping file cleanup")
return
}
var deletionErrors: [Error] = [] return FileInfo(
attachmentLocalRelativePaths: attachmentLocalRelativePaths,
// Orphaned attachment files (actual deletion) profileAvatarFilenames: profileAvatarFilenames
if details.typesToCollect.contains(.orphanedAttachmentFiles) { )
// Note: Looks like in order to recursively look through files we need to use the }
// enumerator method
let fileEnumerator = FileManager.default.enumerator( // If we couldn't get the file lists then fail (invalid state and don't want to delete all attachment/profile files)
at: URL(fileURLWithPath: Attachment.attachmentsFolder), guard let fileInfo: FileInfo = maybeFileInfo else {
includingPropertiesForKeys: nil, failure(job, StorageError.generic, false)
options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator return
) }
let allAttachmentFilePaths: Set<String> = (fileEnumerator? var deletionErrors: [Error] = []
.allObjects
.compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path) }) // Orphaned attachment files (actual deletion)
.defaulting(to: []) if details.typesToCollect.contains(.orphanedAttachmentFiles) {
.asSet() // Note: Looks like in order to recursively look through files we need to use the
// enumerator method
// Note: Directories will have their own entries in the list, if there is a folder with content let fileEnumerator = FileManager.default.enumerator(
// the file will include the directory in it's path with a forward slash so we can use this to at: URL(fileURLWithPath: Attachment.attachmentsFolder),
// distinguish empty directories from ones with content so we don't unintentionally delete a includingPropertiesForKeys: nil,
// directory which contains content to keep as well as delete (directories which end up empty after options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator
// this clean up will be removed during the next run) )
let directoryNamesContainingContent: [String] = allAttachmentFilePaths
.filter { path -> Bool in path.contains("/") } let allAttachmentFilePaths: Set<String> = (fileEnumerator?
.compactMap { path -> String? in path.components(separatedBy: "/").first } .allObjects
let orphanedAttachmentFiles: Set<String> = allAttachmentFilePaths .compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path) })
.subtracting(attachmentLocalRelativePaths) .defaulting(to: [])
.subtracting(directoryNamesContainingContent) .asSet()
orphanedAttachmentFiles.forEach { filepath in // Note: Directories will have their own entries in the list, if there is a folder with content
// We don't want a single deletion failure to block deletion of the other files so try // the file will include the directory in it's path with a forward slash so we can use this to
// each one and store the error to be used to determine success/failure of the job // distinguish empty directories from ones with content so we don't unintentionally delete a
do { // directory which contains content to keep as well as delete (directories which end up empty after
try FileManager.default.removeItem( // this clean up will be removed during the next run)
atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) let directoryNamesContainingContent: [String] = allAttachmentFilePaths
.appendingPathComponent(filepath) .filter { path -> Bool in path.contains("/") }
.path .compactMap { path -> String? in path.components(separatedBy: "/").first }
) let orphanedAttachmentFiles: Set<String> = allAttachmentFilePaths
.subtracting(fileInfo.attachmentLocalRelativePaths)
.subtracting(directoryNamesContainingContent)
orphanedAttachmentFiles.forEach { filepath in
// We don't want a single deletion failure to block deletion of the other files so try
// each one and store the error to be used to determine success/failure of the job
do {
try FileManager.default.removeItem(
atPath: URL(fileURLWithPath: Attachment.attachmentsFolder)
.appendingPathComponent(filepath)
.path
)
}
catch { deletionErrors.append(error) }
} }
catch { deletionErrors.append(error) }
} }
}
// Orphaned profile avatar files (actual deletion) // Orphaned profile avatar files (actual deletion)
if details.typesToCollect.contains(.orphanedProfileAvatars) { if details.typesToCollect.contains(.orphanedProfileAvatars) {
let allAvatarProfileFilenames: Set<String> = (try? FileManager.default let allAvatarProfileFilenames: Set<String> = (try? FileManager.default
.contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath))
.defaulting(to: []) .defaulting(to: [])
.asSet() .asSet()
let orphanedAvatarFiles: Set<String> = allAvatarProfileFilenames let orphanedAvatarFiles: Set<String> = allAvatarProfileFilenames
.subtracting(profileAvatarFilenames) .subtracting(fileInfo.profileAvatarFilenames)
orphanedAvatarFiles.forEach { filename in orphanedAvatarFiles.forEach { filename in
// We don't want a single deletion failure to block deletion of the other files so try // We don't want a single deletion failure to block deletion of the other files so try
// each one and store the error to be used to determine success/failure of the job // each one and store the error to be used to determine success/failure of the job
do { do {
try FileManager.default.removeItem( try FileManager.default.removeItem(
atPath: ProfileManager.profileAvatarFilepath(filename: filename) atPath: ProfileManager.profileAvatarFilepath(filename: filename)
) )
}
catch { deletionErrors.append(error) }
} }
catch { deletionErrors.append(error) }
} }
}
// Report a single file deletion as a job failure (even if other content was successfully removed) // Report a single file deletion as a job failure (even if other content was successfully removed)
guard deletionErrors.isEmpty else { guard deletionErrors.isEmpty else {
failure(job, (deletionErrors.first ?? StorageError.generic), false) failure(job, (deletionErrors.first ?? StorageError.generic), false)
return return
} }
success(job, false) success(job, false)
}
} }
) )
} }

@ -169,6 +169,7 @@ public final class OpenGroupManager: NSObject {
// Optionally try to insert a new version of the OpenGroup (it will fail if there is already an // Optionally try to insert a new version of the OpenGroup (it will fail if there is already an
// inactive one but that won't matter as we then activate it // inactive one but that won't matter as we then activate it
_ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .openGroup) _ = try? SessionThread.fetchOrCreate(db, id: threadId, variant: .openGroup)
_ = try? SessionThread.filter(id: threadId).updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
if (try? OpenGroup.exists(db, id: threadId)) == false { if (try? OpenGroup.exists(db, id: threadId)) == false {
try? OpenGroup try? OpenGroup

@ -101,6 +101,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
public let authorName: String public let authorName: String
/// This value will be used to populate the author label, if it's null then the label will be hidden /// This value will be used to populate the author label, if it's null then the label will be hidden
///
/// **Note:** This will only be populated for incoming messages
public let senderName: String? public let senderName: String?
/// A flag indicating whether the profile view should be displayed /// A flag indicating whether the profile view should be displayed
@ -331,6 +333,11 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
return nil return nil
} }
// Only show for incoming messages
guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else {
return nil
}
// Only if there is a date header or the senders are different // Only if there is a date header or the senders are different
guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else { guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else {
return nil return nil

@ -500,13 +500,13 @@ public extension SessionThreadViewModel {
static let homeOrderSQL: SQL = { static let homeOrderSQL: SQL = {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
return SQL("\(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC") return SQL("\(thread[.isPinned]) DESC, IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC")
}() }()
static let messageRequetsOrderSQL: SQL = { static let messageRequetsOrderSQL: SQL = {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), \(thread[.creationDateTimestamp])) DESC") return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC")
}() }()
} }
@ -1388,7 +1388,7 @@ public extension SessionThreadViewModel {
) )
GROUP BY \(thread[.id]) GROUP BY \(thread[.id])
ORDER BY IFNULL(\(interaction[.timestampMs]), \(thread[.creationDateTimestamp])) DESC ORDER BY IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC
""" """
return request.adapted { db in return request.adapted { db in

Loading…
Cancel
Save