Finished of the conversation screen and resolved a bug of bugs/TODOs

Fixed a number of scrolling behaviours in the ConversationVC
Fixed a bug with the PagedDataObserver when observing associated data (multiple associations with a single paged result were broken)
Fixed a bug with the PagedDataObserver where it would trigger updates for new entries even if the user is offset from the latest data
Fixed a bug where marking as read wasn't working properly
Fixed a bug where outgoing messages were being considered unread
Added an error state for a failed attachment send
Renamed a few types for clarity
Resolved a bunch of TODOs
This commit is contained in:
Morgan Pretty 2022-05-29 19:26:06 +10:00
parent 3514ed4f50
commit e2ee0e94ee
50 changed files with 2201 additions and 2368 deletions

View File

@ -31,7 +31,7 @@ public enum SNMessagingKit { // Just to make the external API nice
public static func configure(storage: SessionMessagingKitStorageProtocol) { public static func configure(storage: SessionMessagingKitStorageProtocol) {
// Configure the job executors // Configure the job executors
JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages)
JobRunner.add(executor: FailedMessagesJob.self, for: .failedMessages) JobRunner.add(executor: FailedMessageSendsJob.self, for: .failedMessageSends)
JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads) JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads)
JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture) JobRunner.add(executor: UpdateProfilePictureJob.self, for: .updateProfilePicture)
JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms) JobRunner.add(executor: RetrieveDefaultOpenGroupRoomsJob.self, for: .retrieveDefaultOpenGroupRooms)

View File

@ -242,7 +242,6 @@
B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */; }; B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */; };
B8FF8E7425C10FC3004D1F22 /* GeoLite2-Country-Locations-English in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */; }; B8FF8E7425C10FC3004D1F22 /* GeoLite2-Country-Locations-English in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */; };
B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF8EA525C11FEF004D1F22 /* IPv4.swift */; }; B8FF8EA625C11FEF004D1F22 /* IPv4.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8FF8EA525C11FEF004D1F22 /* IPv4.swift */; };
B90418E6183E9DD40038554A /* DateUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B90418E5183E9DD40038554A /* DateUtil.m */; };
B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9EB5ABC1884C002007CBB57 /* MessageUI.framework */; }; B9EB5ABD1884C002007CBB57 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9EB5ABC1884C002007CBB57 /* MessageUI.framework */; };
C193959302ABEA1B4B1CDAFC /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; }; C193959302ABEA1B4B1CDAFC /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 038A3BABD5BA0CE41D8C17F5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; };
C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; };
@ -660,7 +659,7 @@
FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; }; FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; };
FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; }; FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; };
FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; }; FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; };
FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */; }; FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; };
FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; }; FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; };
FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; }; FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; };
@ -675,11 +674,12 @@
FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; }; FD705A94278D052B00F16121 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */; };
FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; }; FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */; };
FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; }; FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7162DA281B6C440060647B /* TypedTableAlias.swift */; };
FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageCellViewModel.swift */; };
FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; }; FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */; };
FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; }; FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.swift */; };
FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; }; FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */; };
FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */; }; FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */; };
FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageViewModel.swift */; };
FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; };
FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; }; FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; };
FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; }; FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; };
FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; }; FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; };
@ -688,7 +688,7 @@
FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; }; FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */; };
FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; };
FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; }; FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; };
FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */; }; FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */; };
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; }; FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; };
FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; }; FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; };
FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -1184,8 +1184,6 @@
B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = "<group>"; }; B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = "<group>"; };
B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Locations-English"; path = "Countries/GeoLite2-Country-Locations-English"; sourceTree = "<group>"; }; B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Locations-English"; path = "Countries/GeoLite2-Country-Locations-English"; sourceTree = "<group>"; };
B8FF8EA525C11FEF004D1F22 /* IPv4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = "<group>"; }; B8FF8EA525C11FEF004D1F22 /* IPv4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = "<group>"; };
B90418E4183E9DD40038554A /* DateUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DateUtil.h; sourceTree = "<group>"; };
B90418E5183E9DD40038554A /* DateUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DateUtil.m; sourceTree = "<group>"; };
B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; B9EB5ABC1884C002007CBB57 /* MessageUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; };
C022DD8E076866C6241610BF /* Pods-SessionSnodeKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionSnodeKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionSnodeKit/Pods-SessionSnodeKit.app store release.xcconfig"; sourceTree = "<group>"; }; C022DD8E076866C6241610BF /* Pods-SessionSnodeKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionSnodeKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionSnodeKit/Pods-SessionSnodeKit.app store release.xcconfig"; sourceTree = "<group>"; };
C1A746BC424B531D8ED478F6 /* Pods-SessionUIKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.app store release.xcconfig"; sourceTree = "<group>"; }; C1A746BC424B531D8ED478F6 /* Pods-SessionUIKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.app store release.xcconfig"; sourceTree = "<group>"; };
@ -1629,7 +1627,7 @@
FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; }; FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; };
FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = "<group>"; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = "<group>"; };
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = "<group>"; }; FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = "<group>"; };
FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; };
FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; };
FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = "<group>"; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = "<group>"; };
@ -1644,18 +1642,19 @@
FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = "<group>"; }; FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = "<group>"; };
FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = "<group>"; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = "<group>"; };
FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = "<group>"; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = "<group>"; };
FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = "<group>"; }; FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = "<group>"; };
FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = "<group>"; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = "<group>"; };
FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = "<group>"; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = "<group>"; };
FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = "<group>"; }; FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = "<group>"; };
FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = "<group>"; }; FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = "<group>"; };
FD848B9728422F1A000E298B /* Date+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Utilities.swift"; sourceTree = "<group>"; };
FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; };
FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = "<group>"; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = "<group>"; };
FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = "<group>"; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = "<group>"; };
FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = "<group>"; }; FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = "<group>"; };
FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = "<group>"; }; FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = "<group>"; };
FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = "<group>"; }; FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessageSendsJob.swift; sourceTree = "<group>"; };
FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = "<group>"; }; FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = "<group>"; };
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = "<group>"; }; FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = "<group>"; };
FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = "<group>"; }; FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = "<group>"; };
@ -1902,8 +1901,7 @@
34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */, 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */,
34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */,
4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */,
B90418E4183E9DD40038554A /* DateUtil.h */, FD848B9728422F1A000E298B /* Date+Utilities.swift */,
B90418E5183E9DD40038554A /* DateUtil.m */,
FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */,
4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */, 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */,
45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */,
@ -3431,8 +3429,9 @@
FD3E0C82283B581F002A425C /* Shared Models */ = { FD3E0C82283B581F002A425C /* Shared Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */,
FD848B8C283E0B26000E298B /* MessageInputTypes.swift */, FD848B8C283E0B26000E298B /* MessageInputTypes.swift */,
FD848B86283B844B000E298B /* MessageViewModel.swift */,
FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */,
); );
path = "Shared Models"; path = "Shared Models";
sourceTree = "<group>"; sourceTree = "<group>";
@ -3456,7 +3455,6 @@
FD848B85283B8438000E298B /* Models */ = { FD848B85283B8438000E298B /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FD848B86283B844B000E298B /* MessageCellViewModel.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@ -3482,7 +3480,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */, FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */,
FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */, FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */,
FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */, FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */,
FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */, FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */,
FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */, FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */,
@ -4571,13 +4569,14 @@
C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */, C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */,
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */,
FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */,
FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */,
FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */,
B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */,
C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */, C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */,
C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */,
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */,
FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, FD09797527FAB64300936362 /* ProfileManager.swift in Sources */,
FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */,
C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */,
C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */,
C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */,
@ -4591,7 +4590,7 @@
FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */, FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */,
FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */,
B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */,
FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */, FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */,
FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */,
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */,
C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */,
@ -4680,7 +4679,6 @@
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */,
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */,
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */,
B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */,
B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */,
@ -4766,6 +4764,7 @@
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */,
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */,
B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */,
FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */,
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */, B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
@ -4827,7 +4826,6 @@
B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */, B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */,
C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */,
3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */, 3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */,
B90418E6183E9DD40038554A /* DateUtil.m in Sources */,
C33100092558FF6D00070591 /* UserCell.swift in Sources */, C33100092558FF6D00070591 /* UserCell.swift in Sources */,
B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */,
C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */, C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */,

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import SessionMessagingKit
extension ContextMenuVC { extension ContextMenuVC {
struct Action { struct Action {
@ -8,49 +9,49 @@ extension ContextMenuVC {
let title: String let title: String
let work: () -> Void let work: () -> Void
static func reply(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_reply"), icon: UIImage(named: "ic_reply"),
title: "context_menu_reply".localized() title: "context_menu_reply".localized()
) { delegate?.reply(cellViewModel) } ) { delegate?.reply(cellViewModel) }
} }
static func copy(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_copy"), icon: UIImage(named: "ic_copy"),
title: "copy".localized() title: "copy".localized()
) { delegate?.copy(cellViewModel) } ) { delegate?.copy(cellViewModel) }
} }
static func copySessionID(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_copy"), icon: UIImage(named: "ic_copy"),
title: "vc_conversation_settings_copy_session_id_button_title".localized() title: "vc_conversation_settings_copy_session_id_button_title".localized()
) { delegate?.copySessionID(cellViewModel) } ) { delegate?.copySessionID(cellViewModel) }
} }
static func delete(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_trash"), icon: UIImage(named: "ic_trash"),
title: "TXT_DELETE_TITLE".localized() title: "TXT_DELETE_TITLE".localized()
) { delegate?.delete(cellViewModel) } ) { delegate?.delete(cellViewModel) }
} }
static func save(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_download"), icon: UIImage(named: "ic_download"),
title: "context_menu_save".localized() title: "context_menu_save".localized()
) { delegate?.save(cellViewModel) } ) { delegate?.save(cellViewModel) }
} }
static func ban(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_block"), icon: UIImage(named: "ic_block"),
title: "context_menu_ban_user".localized() title: "context_menu_ban_user".localized()
) { delegate?.ban(cellViewModel) } ) { delegate?.ban(cellViewModel) }
} }
static func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action( return Action(
icon: UIImage(named: "ic_block"), icon: UIImage(named: "ic_block"),
title: "context_menu_ban_and_delete_all".localized() title: "context_menu_ban_and_delete_all".localized()
@ -58,7 +59,7 @@ extension ContextMenuVC {
} }
} }
static func actions(for cellViewModel: MessageCell.ViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
// No context items for info messages // No context items for info messages
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
return nil return nil
@ -124,11 +125,11 @@ extension ContextMenuVC {
// MARK: - Delegate // MARK: - Delegate
protocol ContextMenuActionDelegate { protocol ContextMenuActionDelegate {
func reply(_ cellViewModel: MessageCell.ViewModel) func reply(_ cellViewModel: MessageViewModel)
func copy(_ cellViewModel: MessageCell.ViewModel) func copy(_ cellViewModel: MessageViewModel)
func copySessionID(_ cellViewModel: MessageCell.ViewModel) func copySessionID(_ cellViewModel: MessageViewModel)
func delete(_ cellViewModel: MessageCell.ViewModel) func delete(_ cellViewModel: MessageViewModel)
func save(_ cellViewModel: MessageCell.ViewModel) func save(_ cellViewModel: MessageViewModel)
func ban(_ cellViewModel: MessageCell.ViewModel) func ban(_ cellViewModel: MessageViewModel)
func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
} }

View File

@ -2,6 +2,7 @@
import UIKit import UIKit
import SessionUIKit import SessionUIKit
import SessionMessagingKit
final class ContextMenuVC: UIViewController { final class ContextMenuVC: UIViewController {
private static let actionViewHeight: CGFloat = 40 private static let actionViewHeight: CGFloat = 40
@ -9,7 +10,7 @@ final class ContextMenuVC: UIViewController {
private let snapshot: UIView private let snapshot: UIView
private let frame: CGRect private let frame: CGRect
private let cellViewModel: MessageCell.ViewModel private let cellViewModel: MessageViewModel
private let actions: [Action] private let actions: [Action]
private let dismiss: () -> Void private let dismiss: () -> Void
@ -33,7 +34,7 @@ final class ContextMenuVC: UIViewController {
result.textColor = (isLightMode ? .black : .white) result.textColor = (isLightMode ? .black : .white)
if let dateForUI: Date = cellViewModel.dateForUI { if let dateForUI: Date = cellViewModel.dateForUI {
result.text = DateUtil.formatDate(forDisplay: dateForUI) result.text = dateForUI.formattedForDisplay
} }
return result return result
@ -44,7 +45,7 @@ final class ContextMenuVC: UIViewController {
init( init(
snapshot: UIView, snapshot: UIView,
frame: CGRect, frame: CGRect,
cellViewModel: MessageCell.ViewModel, cellViewModel: MessageViewModel,
actions: [Action], actions: [Action],
dismiss: @escaping () -> Void dismiss: @escaping () -> Void
) { ) {

View File

@ -58,7 +58,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in
try Interaction.idsForTermWithin( try Interaction.idsForTermWithin(
threadId: threadId, threadId: threadId,
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
) )
.fetchAll(db) .fetchAll(db)
} }

View File

@ -398,7 +398,7 @@ extension ConversationVC:
func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) { func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) {
guard !showBlockedModalIfNeeded() else { return } guard !showBlockedModalIfNeeded() else { return }
for attachment in attachments { for attachment in attachments {
if attachment.hasError { if attachment.hasError {
return showErrorAlert(for: attachment, onDismiss: onComplete) return showErrorAlert(for: attachment, onDismiss: onComplete)
@ -628,7 +628,7 @@ extension ConversationVC:
// MARK: MessageCellDelegate // MARK: MessageCellDelegate
func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) { func handleItemLongPressed(_ cellViewModel: MessageViewModel) {
// Show the context menu if applicable // Show the context menu if applicable
guard guard
let keyWindow: UIWindow = UIApplication.shared.keyWindow, let keyWindow: UIWindow = UIApplication.shared.keyWindow,
@ -675,7 +675,7 @@ extension ConversationVC:
self.contextMenuWindow?.makeKeyAndVisible() self.contextMenuWindow?.makeKeyAndVisible()
} }
func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) { func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) {
guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else { guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else {
// Show the failed message sheet // Show the failed message sheet
showFailedMessageSheet(for: cellViewModel) showFailedMessageSheet(for: cellViewModel)
@ -717,7 +717,7 @@ extension ConversationVC:
// TODO: Tapped a failed incoming attachment // TODO: Tapped a failed incoming attachment
break break
case .failedDownload: case .failedDownload, .failedUpload:
// TODO: Tapped a failed incoming attachment // TODO: Tapped a failed incoming attachment
break break
@ -802,7 +802,7 @@ extension ConversationVC:
} }
} }
func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) { func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) {
switch cellViewModel.cellType { switch cellViewModel.cellType {
// The user can double tap a voice message when it's playing to speed it up // The user can double tap a voice message when it's playing to speed it up
case .audio: self.viewModel.speedUpAudio(for: cellViewModel) case .audio: self.viewModel.speedUpAudio(for: cellViewModel)
@ -810,7 +810,7 @@ extension ConversationVC:
} }
} }
func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) { func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) {
switch state { switch state {
case .began: tableView.isScrollEnabled = false case .began: tableView.isScrollEnabled = false
case .ended, .cancelled: tableView.isScrollEnabled = true case .ended, .cancelled: tableView.isScrollEnabled = true
@ -841,7 +841,7 @@ extension ConversationVC:
self.presentAlert(alertVC) self.presentAlert(alertVC)
} }
func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) { func handleReplyButtonTapped(for cellViewModel: MessageViewModel) {
reply(cellViewModel) reply(cellViewModel)
} }
@ -856,7 +856,7 @@ extension ConversationVC:
// MARK: --action handling // MARK: --action handling
func showFailedMessageSheet(for cellViewModel: MessageCell.ViewModel) { func showFailedMessageSheet(for cellViewModel: MessageViewModel) {
let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in
@ -909,7 +909,7 @@ extension ConversationVC:
// MARK: - ContextMenuActionDelegate // MARK: - ContextMenuActionDelegate
func reply(_ cellViewModel: MessageCell.ViewModel) { func reply(_ cellViewModel: MessageViewModel) {
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
threadId: self.viewModel.threadData.threadId, threadId: self.viewModel.threadData.threadId,
authorId: cellViewModel.authorId, authorId: cellViewModel.authorId,
@ -929,7 +929,7 @@ extension ConversationVC:
snInputView.becomeFirstResponder() snInputView.becomeFirstResponder()
} }
func copy(_ cellViewModel: MessageCell.ViewModel) { func copy(_ cellViewModel: MessageViewModel) {
switch cellViewModel.cellType { switch cellViewModel.cellType {
case .typingIndicator: break case .typingIndicator: break
@ -954,7 +954,7 @@ extension ConversationVC:
} }
} }
func copySessionID(_ cellViewModel: MessageCell.ViewModel) { func copySessionID(_ cellViewModel: MessageViewModel) {
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else { guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else {
return return
} }
@ -962,7 +962,7 @@ extension ConversationVC:
UIPasteboard.general.string = cellViewModel.authorId UIPasteboard.general.string = cellViewModel.authorId
} }
func delete(_ cellViewModel: MessageCell.ViewModel) { func delete(_ cellViewModel: MessageViewModel) {
// Only allow deletion on incoming and outgoing messages // Only allow deletion on incoming and outgoing messages
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
return return
@ -1141,7 +1141,7 @@ extension ConversationVC:
} }
} }
func save(_ cellViewModel: MessageCell.ViewModel) { func save(_ cellViewModel: MessageViewModel) {
guard cellViewModel.cellType == .mediaMessage else { return } guard cellViewModel.cellType == .mediaMessage else { return }
let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? [])
@ -1199,7 +1199,7 @@ extension ConversationVC:
} }
} }
func ban(_ cellViewModel: MessageCell.ViewModel) { func ban(_ cellViewModel: MessageViewModel) {
guard cellViewModel.threadVariant == .openGroup else { return } guard cellViewModel.threadVariant == .openGroup else { return }
let threadId: String = self.viewModel.threadData.threadId let threadId: String = self.viewModel.threadData.threadId
@ -1222,7 +1222,7 @@ extension ConversationVC:
present(alert, animated: true, completion: nil) present(alert, animated: true, completion: nil)
} }
func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) { func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) {
guard cellViewModel.threadVariant == .openGroup else { return } guard cellViewModel.threadVariant == .openGroup else { return }
let threadId: String = self.viewModel.threadData.threadId let threadId: String = self.viewModel.threadData.threadId

View File

@ -501,7 +501,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
viewModel.observableThreadData, viewModel.observableThreadData,
onError: { _ in }, onError: { _ in },
onChange: { [weak self] maybeThreadData in onChange: { [weak self] maybeThreadData in
guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return } guard let threadData: SessionThreadViewModel = maybeThreadData else { return }
// The default scheduler emits changes on the main thread // The default scheduler emits changes on the main thread
self?.handleThreadUpdates(threadData) self?.handleThreadUpdates(threadData)
@ -520,7 +520,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
self.viewModel.onInteractionChange = nil self.viewModel.onInteractionChange = nil
} }
private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, initialLoad: Bool = false) { private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) {
// Ensure the first load or a load when returning from a child screen runs without animations (if // Ensure the first load or a load when returning from a child screen runs without animations (if
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else {
@ -529,6 +529,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
return return
} }
// Update general conversation UI // Update general conversation UI
if if
@ -572,6 +573,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount {
updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount)
} }
// Now we have done all the needed diffs, update the viewModel with the latest data
self.viewModel.updateThreadData(updatedThreadData)
} }
private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) { private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) {
@ -590,68 +594,125 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// Determine if we are inserting content at the top of the collectionView // Determine if we are inserting content at the top of the collectionView
struct ItemChangeInfo { struct ItemChangeInfo {
let insertedAtTop: Bool enum InsertLocation {
case top
case bottom
case other
case none
}
let insertLocation: InsertLocation
let wasCloseToBottom: Bool
let sentMessageBeforeUpdate: Bool
let firstIndexIsVisible: Bool let firstIndexIsVisible: Bool
let visibleInteractionId: Int64 let visibleInteractionId: Int64
let visibleIndexPath: IndexPath let visibleIndexPath: IndexPath
let oldVisibleIndexPath: IndexPath let oldVisibleIndexPath: IndexPath
let lastVisibleIndexPath: IndexPath
init( init(
insertedAtTop: Bool, insertLocation: InsertLocation,
wasCloseToBottom: Bool,
sentMessageBeforeUpdate: Bool,
firstIndexIsVisible: Bool = false, firstIndexIsVisible: Bool = false,
visibleInteractionId: Int64 = -1, visibleInteractionId: Int64 = -1,
visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0),
oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0),
lastVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0)
) { ) {
self.insertedAtTop = insertedAtTop self.insertLocation = insertLocation
self.wasCloseToBottom = wasCloseToBottom
self.sentMessageBeforeUpdate = sentMessageBeforeUpdate
self.firstIndexIsVisible = firstIndexIsVisible self.firstIndexIsVisible = firstIndexIsVisible
self.visibleInteractionId = visibleInteractionId self.visibleInteractionId = visibleInteractionId
self.visibleIndexPath = visibleIndexPath self.visibleIndexPath = visibleIndexPath
self.oldVisibleIndexPath = oldVisibleIndexPath self.oldVisibleIndexPath = oldVisibleIndexPath
self.lastVisibleIndexPath = lastVisibleIndexPath
} }
} }
let changeset: StagedChangeset<[ConversationViewModel.SectionModel]> = StagedChangeset(
source: viewModel.interactionData,
target: updatedData
)
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
let itemChangeInfo: ItemChangeInfo = { let itemChangeInfo: ItemChangeInfo = {
guard guard
changeset.map { $0.elementInserted.count }.reduce(0, +) > 0,
let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }),
let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }),
let newFirstItemIndex: Int = updatedData[newSectionIndex].elements let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
.firstIndex(where: { item -> Bool in .firstIndex(where: { item -> Bool in
item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id
}), }),
let newLastItemIndex: Int = updatedData[newSectionIndex].elements
.lastIndex(where: { item -> Bool in
item.id == self.viewModel.interactionData[oldSectionIndex].elements.last?.id
}),
let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
.filter({ $0.section == oldSectionIndex }) .filter({ $0.section == oldSectionIndex })
.sorted() .sorted()
.first, .first,
let lastVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
.filter({ $0.section == oldSectionIndex })
.sorted()
.last,
let newVisibleIndex: Int = updatedData[newSectionIndex].elements let newVisibleIndex: Int = updatedData[newSectionIndex].elements
.firstIndex(where: { item in .firstIndex(where: { item in
item.id == self.viewModel.interactionData[oldSectionIndex] item.id == self.viewModel.interactionData[oldSectionIndex]
.elements[firstVisibleIndexPath.row] .elements[firstVisibleIndexPath.row]
.id .id
}), }),
( let newLastVisibleIndex: Int = updatedData[newSectionIndex].elements
newSectionIndex > oldSectionIndex || .firstIndex(where: { item in
newFirstItemIndex > 0 item.id == self.viewModel.interactionData[oldSectionIndex]
.elements[lastVisibleIndexPath.row]
.id
})
else {
return ItemChangeInfo(
insertLocation: .none,
wasCloseToBottom: isCloseToBottom,
sentMessageBeforeUpdate: self.viewModel.sentMessageBeforeUpdate
) )
else { return ItemChangeInfo(insertedAtTop: false) } }
return ItemChangeInfo( return ItemChangeInfo(
insertedAtTop: true, insertLocation: {
let insertedAtTop: Bool = (
newSectionIndex > oldSectionIndex ||
newFirstItemIndex > 0
)
let insertedAtBot: Bool = (
newSectionIndex < oldSectionIndex ||
newLastItemIndex < (updatedData[newSectionIndex].elements.count - 1)
)
// If anything was inserted at the top then we need to maintain the current
// offset so always return a 'top' insert location
switch (insertedAtTop, insertedAtBot) {
case (true, _): return .top
case (false, true): return .bottom
case (false, false): return .other
}
}(),
wasCloseToBottom: isCloseToBottom,
sentMessageBeforeUpdate: self.viewModel.sentMessageBeforeUpdate,
firstIndexIsVisible: (firstVisibleIndexPath.row == 0), firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id, visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id,
visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex), visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex),
oldVisibleIndexPath: firstVisibleIndexPath oldVisibleIndexPath: firstVisibleIndexPath,
lastVisibleIndexPath: IndexPath(row: newLastVisibleIndex, section: newSectionIndex)
) )
}() }()
/// If we are inserting at the top then we want to maintain the same visual position from before the table view was updated, /// UITableView doesn't really support bottom-aligned content very well and as such jumps around a lot when inserting content but
/// unfortunately the UITableView does some weird things when updating (where it won't have updated data until after it /// we want to maintain the current offset from before the data was inserted (except when adding at the bottom while the user is at
/// performs the next layout); the below code checks a condition on layout and if it passes it calls a closure /// the bottom, in which case we want to scroll down)
/// ///
/// In the below case we set the tableView offset of the first row to the same offset it had before the UI loaded with new /// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until
/// data (including the difference in height in case the date header was removed when loading the new cell) /// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure
if itemChangeInfo.insertedAtTop { if itemChangeInfo.insertLocation != .none {
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in
if !lhs.isHidden && rhs.isHidden { return true } if !lhs.isHidden && rhs.isHidden { return true }
if lhs.isHidden && !rhs.isHidden { return false } if lhs.isHidden && !rhs.isHidden { return false }
@ -665,42 +726,77 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
.frame) .frame)
.defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath)) .defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath))
let oldContentSize: CGSize = self.tableView.contentSize let oldContentSize: CGSize = self.tableView.contentSize
let oldContentOffset: CGPoint = self.tableView.contentOffset let oldOffsetFromTop: CGFloat = (self.tableView.contentOffset.y - oldRect.minY)
let oldOffsetFromBottom: CGFloat = (oldContentSize.height - self.tableView.contentOffset.y)
// Distance of 64 when paging works properly // Wait until the tableView has completed a layout and reported the correct number of
// sections/rows and then update the contentOffset
self.tableView.afterNextLayoutSubviews( self.tableView.afterNextLayoutSubviews(
when: { numSections, numRowsInSections -> Bool in when: { numSections, numRowsInSections, _ -> Bool in
numSections == updatedData.count && numSections == updatedData.count &&
numRowsInSections == numItemsInUpdatedData numRowsInSections == numItemsInUpdatedData
}, },
then: { [weak self] in then: { [weak self] in
self?.tableView.scrollToRow(at: itemChangeInfo.visibleIndexPath, at: .top, animated: false) UIView.performWithoutAnimation {
self?.tableView.layoutIfNeeded() self?.tableView.scrollToRow(
at: (itemChangeInfo.insertLocation == .top ?
/// **Note:** I wasn't able to get a prober equation to handle both "insert above first item" and "insert itemChangeInfo.visibleIndexPath :
/// at top off screen", it seems that the 'contentOffset' value won't expose negative values (eg. when you itemChangeInfo.lastVisibleIndexPath
/// over-scroll and trigger the bounce effect) and this results in requiring the conditional logic below ),
if itemChangeInfo.firstIndexIsVisible { at: (itemChangeInfo.insertLocation == .top ?
let newRect: CGRect = (self?.tableView.subviews .top :
.compactMap { $0 as? MessageCell } .bottom
.sorted(by: cellSorting) ),
.first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })? animated: false
.frame) )
.defaulting(to: oldRect) self?.tableView.layoutIfNeeded()
let heightDiff: CGFloat = (oldRect.height - newRect.height)
self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff))
}
else {
let newContentSize: CGSize = (self?.tableView.contentSize) let newContentSize: CGSize = (self?.tableView.contentSize)
.defaulting(to: oldContentSize) .defaulting(to: oldContentSize)
let contentSizeDiff: CGFloat = (newContentSize.height - oldContentSize.height)
self?.tableView.contentOffset.y = (contentSizeDiff + oldContentOffset.y) /// **Note:** I wasn't able to get a prober equation to handle both "insert" and "insert at top off screen", it
/// seems that the 'contentOffset' value won't expose negative values (eg. when you over-scroll and trigger
/// the bounce effect) and this results in requiring the conditional logic below
if itemChangeInfo.insertLocation == .top {
let newRect: CGRect = (self?.tableView.subviews
.compactMap { $0 as? MessageCell }
.sorted(by: cellSorting)
.first(where: { $0.viewModel?.id == itemChangeInfo.visibleInteractionId })?
.frame)
.defaulting(to: oldRect)
let heightDiff: CGFloat = (oldRect.height - newRect.height)
if itemChangeInfo.firstIndexIsVisible {
self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff))
}
else {
self?.tableView.contentOffset.y = ((newRect.minY + heightDiff) + oldOffsetFromTop)
}
}
else {
self?.tableView.contentOffset.y = (newContentSize.height - oldOffsetFromBottom)
}
/// **Note:** There is yet another weird issue where the tableView will layout again shortly after the initial
/// layout with a slightly different contentSize (usually about 8pt off), this catches that case and prevents it
/// from affecting the UI
if !itemChangeInfo.firstIndexIsVisible {
self?.tableView.afterNextLayoutSubviews(
when: { _, _, contentSize in (contentSize.height != newContentSize.height) },
then: { [weak self] in
let finalContentSize: CGSize = (self?.tableView.contentSize)
.defaulting(to: newContentSize)
self?.tableView.contentOffset.y += (finalContentSize.height - newContentSize.height)
}
)
}
} }
if let focusedInteractionId: Int64 = self?.focusedInteractionId { DispatchQueue.main.async { [weak self] in
DispatchQueue.main.async { if let focusedInteractionId: Int64 = self?.focusedInteractionId {
// If we had a focusedInteractionId then scroll to it (and hide the search
// result bar loading indicator)
self?.searchController.resultsBar.stopLoading() self?.searchController.resultsBar.stopLoading()
self?.scrollToInteractionIfNeeded( self?.scrollToInteractionIfNeeded(
with: focusedInteractionId, with: focusedInteractionId,
@ -708,8 +804,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
highlight: (self?.shouldHighlightNextScrollToInteraction == true) highlight: (self?.shouldHighlightNextScrollToInteraction == true)
) )
} }
else if itemChangeInfo.sentMessageBeforeUpdate || itemChangeInfo.wasCloseToBottom {
// Scroll to the bottom if an interaction was just inserted and we either
// just sent a message or are close enough to the bottom
self?.scrollToBottom(isAnimated: true)
}
} }
// Complete page loading // Complete page loading
self?.isLoadingMore = false self?.isLoadingMore = false
self?.autoLoadNextPageIfNeeded() self?.autoLoadNextPageIfNeeded()
@ -719,29 +820,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// Reload the table content (animate changes if we aren't inserting at the top) // Reload the table content (animate changes if we aren't inserting at the top)
self.tableView.reload( self.tableView.reload(
using: StagedChangeset(source: viewModel.interactionData, target: updatedData), using: changeset,
deleteSectionsAnimation: .none, deleteSectionsAnimation: .none,
insertSectionsAnimation: .none, insertSectionsAnimation: .none,
reloadSectionsAnimation: .none, reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom, deleteRowsAnimation: .bottom,
insertRowsAnimation: .bottom, insertRowsAnimation: .bottom,
reloadRowsAnimation: .none, reloadRowsAnimation: .none,
interrupt: { itemChangeInfo.insertedAtTop || $0.changeCount > ConversationViewModel.pageSize } interrupt: { itemChangeInfo.insertLocation == .top || $0.changeCount > ConversationViewModel.pageSize }
) { [weak self] updatedData in ) { [weak self] updatedData in
self?.viewModel.updateInteractionData(updatedData) self?.viewModel.updateInteractionData(updatedData)
} }
// Scroll to the bottom if we just inserted a message and are close enough
// to the bottom
if
changeset.contains(where: { !$0.elementInserted.isEmpty }) && (
updatedViewData.items.last?.interactionVariant == .standardOutgoing ||
isCloseToBottom
)
{
scrollToBottom(isAnimated: true)
}
// Mark received messages as read // Mark received messages as read
viewModel.markAllAsRead() viewModel.markAllAsRead()
viewModel.sentMessageBeforeUpdate = false viewModel.sentMessageBeforeUpdate = false
@ -817,7 +907,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
} }
} }
func updateNavBarButtons(threadData: ConversationCell.ViewModel) { func updateNavBarButtons(threadData: SessionThreadViewModel) {
navigationItem.hidesBackButton = isShowingSearchUI navigationItem.hidesBackButton = isShowingSearchUI
if isShowingSearchUI { if isShowingSearchUI {
@ -997,7 +1087,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
switch section.model { switch section.model {
case .messages: case .messages:
let cellViewModel: MessageCell.ViewModel = section.elements[indexPath.row] let cellViewModel: MessageViewModel = section.elements[indexPath.row]
let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath)
cell.update( cell.update(
with: cellViewModel, with: cellViewModel,
@ -1085,7 +1175,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
func scrollToBottom(isAnimated: Bool) { func scrollToBottom(isAnimated: Bool) {
guard guard
!isUserScrolling, !self.isUserScrolling,
let messagesSectionIndex: Int = self.viewModel.interactionData let messagesSectionIndex: Int = self.viewModel.interactionData
.firstIndex(where: { $0.model == .messages }), .firstIndex(where: { $0.model == .messages }),
!self.viewModel.interactionData[messagesSectionIndex] !self.viewModel.interactionData[messagesSectionIndex]
@ -1093,9 +1183,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
.isEmpty .isEmpty
else { return } else { return }
tableView.scrollToRow( // If the last interaction isn't loaded then scroll to the final interactionId on
// the thread data
let hasNewerItems: Bool = self.viewModel.interactionData.contains(where: { $0.model == .loadNewer })
guard !self.didFinishInitialLayout || !hasNewerItems else {
let messages: [MessageViewModel] = self.viewModel.interactionData[messagesSectionIndex].elements
let lastInteractionId: Int64 = self.viewModel.threadData.interactionId
.defaulting(to: messages[messages.count - 1].id)
self.scrollToInteractionIfNeeded(
with: lastInteractionId,
position: .bottom,
isAnimated: true
)
return
}
self.tableView.scrollToRow(
at: IndexPath( at: IndexPath(
row: viewModel.interactionData[messagesSectionIndex].elements.count - 1, row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1),
section: messagesSectionIndex section: messagesSectionIndex
), ),
at: .bottom, at: .bottom,
@ -1125,7 +1232,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
return return
} }
self.highlightCellIfNeeded(interactionId: focusedInteractionId) DispatchQueue.main.async { [weak self] in
self?.highlightCellIfNeeded(interactionId: focusedInteractionId)
}
} }
func updateUnreadCountView(unreadCount: UInt?) { func updateUnreadCountView(unreadCount: UInt?) {
@ -1245,6 +1354,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// load the up until the specified interaction // load the up until the specified interaction
guard self.didFinishInitialLayout else { return } guard self.didFinishInitialLayout else { return }
self.isLoadingMore = true
self.searchController.resultsBar.startLoading() self.searchController.resultsBar.startLoading()
DispatchQueue.global(qos: .default).async { [weak self] in DispatchQueue.global(qos: .default).async { [weak self] in

View File

@ -7,7 +7,7 @@ import SessionMessagingKit
import SessionUtilitiesKit import SessionUtilitiesKit
public class ConversationViewModel: OWSAudioPlayerDelegate { public class ConversationViewModel: OWSAudioPlayerDelegate {
public typealias SectionModel = ArraySection<Section, MessageCell.ViewModel> public typealias SectionModel = ArraySection<Section, MessageViewModel>
// MARK: - Action // MARK: - Action
@ -33,10 +33,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Initialization // MARK: - Initialization
init?(threadId: String, focusedInteractionId: Int64?) { init?(threadId: String, focusedInteractionId: Int64?) {
let maybeThreadData: ConversationCell.ViewModel? = GRDBStorage.shared.read { db in let maybeThreadData: SessionThreadViewModel? = GRDBStorage.shared.read { db in
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try ConversationCell.ViewModel return try SessionThreadViewModel
.conversationQuery( .conversationQuery(
threadId: threadId, threadId: threadId,
userPublicKey: userPublicKey userPublicKey: userPublicKey
@ -44,7 +44,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.fetchOne(db) .fetchOne(db)
} }
guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return nil } guard let threadData: SessionThreadViewModel = maybeThreadData else { return nil }
self.threadId = threadId self.threadId = threadId
self.threadData = threadData self.threadData = threadData
@ -71,14 +71,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
columns: ThreadTypingIndicator.Columns.allCases columns: ThreadTypingIndicator.Columns.allCases
) )
], ],
filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId), filterSQL: MessageViewModel.filterSQL(threadId: threadId),
orderSQL: MessageCell.ViewModel.orderSQL, orderSQL: MessageViewModel.orderSQL,
dataQuery: MessageCell.ViewModel.baseQuery( dataQuery: MessageViewModel.baseQuery(
orderSQL: MessageCell.ViewModel.orderSQL, orderSQL: MessageViewModel.orderSQL,
baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId) baseFilterSQL: MessageViewModel.filterSQL(threadId: threadId)
), ),
associatedRecords: [ associatedRecords: [
AssociatedRecord<MessageCell.AttachmentInteractionInfo, MessageCell.ViewModel>( AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
trackedAgainst: Attachment.self, trackedAgainst: Attachment.self,
observedChanges: [ observedChanges: [
PagedData.ObservedChanges( PagedData.ObservedChanges(
@ -86,9 +86,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
columns: [.state] columns: [.state]
) )
], ],
dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery, dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL, joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure() groupPagedType: MessageViewModel.AttachmentInteractionInfo.groupViewModelQuerySQL,
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
) )
], ],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
@ -137,32 +138,34 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Thread Data // MARK: - Thread Data
/// This value is the current state of the view /// This value is the current state of the view
public private(set) var threadData: ConversationCell.ViewModel public private(set) var threadData: SessionThreadViewModel
public lazy var observableThreadData = ValueObservation public lazy var observableThreadData = ValueObservation
.trackingConstantRegion { [threadId = self.threadId] db -> ConversationCell.ViewModel? in .trackingConstantRegion { [threadId = self.threadId] db -> SessionThreadViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try ConversationCell.ViewModel return try SessionThreadViewModel
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey) .conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db) .fetchOne(db)
} }
.removeDuplicates() .removeDuplicates()
public func updateThreadData(_ updatedData: ConversationCell.ViewModel) { public func updateThreadData(_ updatedData: SessionThreadViewModel) {
self.threadData = updatedData self.threadData = updatedData
} }
// MARK: - Interaction Data // MARK: - Interaction Data
public private(set) var interactionData: [SectionModel] = [] public private(set) var interactionData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageCell.ViewModel>? public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
public var onInteractionChange: (([SectionModel]) -> ())? public var onInteractionChange: (([SectionModel]) -> ())?
private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let sortedData: [MessageCell.ViewModel] = data let sortedData: [MessageViewModel] = data
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
// We load messages from newest to oldest so having a pageOffset larger than zero means
// there are newer pages to load
return [ return [
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadOlder)] : [SectionModel(section: .loadOlder)] :
@ -173,10 +176,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
section: .messages, section: .messages,
elements: sortedData elements: sortedData
.enumerated() .enumerated()
.map { index, cellViewModel -> MessageCell.ViewModel in .map { index, cellViewModel -> MessageViewModel in
cellViewModel.withClusteringChanges( cellViewModel.withClusteringChanges(
prevModel: (index > 0 ? sortedData[index - 1] : nil), prevModel: (index > 0 ? sortedData[index - 1] : nil),
nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil), nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil),
isLast: ( isLast: (
index == (sortedData.count - 1) && index == (sortedData.count - 1) &&
pageInfo.currentCount == pageInfo.totalCount pageInfo.currentCount == pageInfo.totalCount
@ -185,7 +188,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
} }
) )
], ],
(data.isEmpty && pageInfo.pageOffset > 0 ? (!data.isEmpty && pageInfo.pageOffset > 0 ?
[SectionModel(section: .loadNewer)] : [SectionModel(section: .loadNewer)] :
[] []
) )
@ -210,7 +213,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
} }
public func mentions(for query: String = "") -> [MentionInfo] { public func mentions(for query: String = "") -> [MentionInfo] {
let threadData: ConversationCell.ViewModel = self.threadData let threadData: SessionThreadViewModel = self.threadData
let results: [MentionInfo] = GRDBStorage.shared let results: [MentionInfo] = GRDBStorage.shared
.read { db -> [MentionInfo] in .read { db -> [MentionInfo] in
@ -336,13 +339,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.id .id
else { return } else { return }
GRDBStorage.shared.write { db in let threadId: String = self.threadData.threadId
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
GRDBStorage.shared.writeAsync { db in
try Interaction.markAsRead( try Interaction.markAsRead(
db, db,
interactionId: lastInteractionId, interactionId: lastInteractionId,
threadId: self.threadData.threadId, threadId: threadId,
includingOlder: true, includingOlder: true,
trySendReadReceipt: (self.threadData.threadIsMessageRequest == false) trySendReadReceipt: trySendReadReceipt
) )
} }
} }
@ -376,7 +382,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil) private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil)
private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:]) private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:])
public func playbackInfo(for viewModel: MessageCell.ViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? { public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? {
// Use the existing info if it already exists (update it's callback if provided as that means // Use the existing info if it already exists (update it's callback if provided as that means
// the cell was reloaded) // the cell was reloaded)
if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] { if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
@ -413,7 +419,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
return newPlaybackInfo return newPlaybackInfo
} }
public func playOrPauseAudio(for viewModel: MessageCell.ViewModel) { public func playOrPauseAudio(for viewModel: MessageViewModel) {
guard guard
let attachment: Attachment = viewModel.attachments?.first, let attachment: Attachment = viewModel.attachments?.first,
let originalFilePath: String = attachment.originalFilePath, let originalFilePath: String = attachment.originalFilePath,
@ -460,7 +466,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
} }
} }
public func speedUpAudio(for viewModel: MessageCell.ViewModel) { public func speedUpAudio(for viewModel: MessageViewModel) {
// If we aren't playing the specified item then just start playing it // If we aren't playing the specified item then just start playing it
guard viewModel.id == currentPlayingInteraction.wrappedValue else { guard viewModel.id == currentPlayingInteraction.wrappedValue else {
playOrPauseAudio(for: viewModel) playOrPauseAudio(for: viewModel)
@ -541,7 +547,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
messageSection.elements[currentIndex + 1].cellType == .audio messageSection.elements[currentIndex + 1].cellType == .audio
else { return } else { return }
let nextItem: MessageCell.ViewModel = messageSection.elements[currentIndex + 1] let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
playOrPauseAudio(for: nextItem) playOrPauseAudio(for: nextItem)
} }

View File

@ -96,7 +96,7 @@ public extension LinkPreview {
return .loaded return .loaded
case .pendingDownload, .downloading, .uploading: return .loading case .pendingDownload, .downloading, .uploading: return .loading
case .failedDownload: return .invalid case .failedDownload, .failedUpload: return .invalid
} }
} }

View File

@ -128,7 +128,7 @@ final class LinkPreviewView: UIView {
with state: LinkPreviewState, with state: LinkPreviewState,
isOutgoing: Bool, isOutgoing: Bool,
delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil, delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil,
cellViewModel: MessageCell.ViewModel? = nil, cellViewModel: MessageViewModel? = nil,
bodyLabelTextColor: UIColor? = nil, bodyLabelTextColor: UIColor? = nil,
lastSearchText: String? = nil lastSearchText: String? = nil
) { ) {
@ -184,7 +184,7 @@ final class LinkPreviewView: UIView {
// Body text view // Body text view
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
if let cellViewModel: MessageCell.ViewModel = cellViewModel { if let cellViewModel: MessageViewModel = cellViewModel {
let bodyTextView = VisibleMessageCell.getBodyTextView( let bodyTextView = VisibleMessageCell.getBodyTextView(
for: cellViewModel, for: cellViewModel,
with: maxWidth, with: maxWidth,

View File

@ -9,7 +9,7 @@ final class MediaPlaceholderView: UIView {
// MARK: - Lifecycle // MARK: - Lifecycle
init(cellViewModel: MessageCell.ViewModel, textColor: UIColor) { init(cellViewModel: MessageViewModel, textColor: UIColor) {
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor) setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
@ -24,7 +24,7 @@ final class MediaPlaceholderView: UIView {
} }
private func setUpViewHierarchy( private func setUpViewHierarchy(
cellViewModel: MessageCell.ViewModel, cellViewModel: MessageViewModel,
textColor: UIColor textColor: UIColor
) { ) {
let (iconName, attachmentDescription): (String, String) = { let (iconName, attachmentDescription): (String, String) = {

View File

@ -121,6 +121,10 @@ public class MediaView: UIView {
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
guard isOutgoing else { return false } guard isOutgoing else { return false }
guard attachment.state != .failedUpload else {
configure(forError: .failed)
return false
}
guard attachment.state != .uploaded else { return false } guard attachment.state != .uploaded else { return false }
let loader = MediaLoaderView() let loader = MediaLoaderView()
@ -326,8 +330,18 @@ public class MediaView: UIView {
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
// For failed ougoing messages add an overlay to make the icon more visible
if isOutgoing {
let attachmentOverlayView: UIView = UIView()
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
.withAlphaComponent(Values.lowOpacity)
addSubview(attachmentOverlayView)
attachmentOverlayView.pin(to: self)
}
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) iconView.tintColor = Colors.text
.withAlphaComponent(Values.mediumOpacity)
addSubview(iconView) addSubview(iconView)
iconView.autoCenterInSuperview() iconView.autoCenterInSuperview()
} }

View File

@ -52,7 +52,7 @@ final class InfoMessageCell: MessageCell {
// MARK: - Updating // MARK: - Updating
override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard cellViewModel.variant.isInfoMessage else { return } guard cellViewModel.variant.isInfoMessage else { return }
self.viewModel = cellViewModel self.viewModel = cellViewModel
@ -81,6 +81,6 @@ final class InfoMessageCell: MessageCell {
self.label.text = cellViewModel.body self.label.text = cellViewModel.body
} }
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
} }
} }

View File

@ -11,7 +11,7 @@ public enum SwipeState {
public class MessageCell: UITableViewCell { public class MessageCell: UITableViewCell {
weak var delegate: MessageCellDelegate? weak var delegate: MessageCellDelegate?
var viewModel: MessageCell.ViewModel? var viewModel: MessageViewModel?
// MARK: - Lifecycle // MARK: - Lifecycle
@ -43,19 +43,19 @@ public class MessageCell: UITableViewCell {
// MARK: - Updating // MARK: - Updating
func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
preconditionFailure("Must be overridden by subclasses.") preconditionFailure("Must be overridden by subclasses.")
} }
/// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content /// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content
/// like playing inline audio/video) /// like playing inline audio/video)
func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
preconditionFailure("Must be overridden by subclasses.") preconditionFailure("Must be overridden by subclasses.")
} }
// MARK: - Convenience // MARK: - Convenience
static func cellType(for viewModel: MessageCell.ViewModel) -> MessageCell.Type { static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self } guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
switch viewModel.variant { switch viewModel.variant {
@ -73,11 +73,11 @@ public class MessageCell: UITableViewCell {
// MARK: - MessageCellDelegate // MARK: - MessageCellDelegate
protocol MessageCellDelegate: AnyObject { protocol MessageCellDelegate: AnyObject {
func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) func handleItemLongPressed(_ cellViewModel: MessageViewModel)
func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
func openUrl(_ urlString: String) func openUrl(_ urlString: String)
func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
func showUserDetails(for profile: Profile) func showUserDetails(for profile: Profile)
} }

View File

@ -1,652 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SessionUtilitiesKit
import SessionMessagingKit
fileprivate typealias ViewModel = MessageCell.ViewModel
fileprivate typealias AttachmentInteractionInfo = MessageCell.AttachmentInteractionInfo
extension MessageCell {
public struct ViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable {
public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue)
public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue)
public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue)
public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue)
public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue)
public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue)
public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue)
public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue)
public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue)
public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue)
public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue)
public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue)
public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue)
public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue)
public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue)
public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue)
public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue)
public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue)
public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue)
public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue)
public static let profileString: String = CodingKeys.profile.stringValue
public static let quoteString: String = CodingKeys.quote.stringValue
public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue
public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue
public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue
public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible {
case top
case middle
case bottom
}
public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible {
case textOnlyMessage
case mediaMessage
case audio
case genericAttachment
case typingIndicator
}
public var differenceIdentifier: ViewModel { self }
// Thread Info
let threadVariant: SessionThread.Variant
let threadIsTrusted: Bool
let threadHasDisappearingMessagesEnabled: Bool
// Interaction Info
public let rowId: Int64
public let id: Int64
let variant: Interaction.Variant
let timestampMs: Int64
let authorId: String
private let authorNameInternal: String?
let body: String?
let expiresStartedAtMs: Double?
let expiresInSeconds: TimeInterval?
let state: RecipientState.State
let hasAtLeastOneReadReceipt: Bool
let mostRecentFailureText: String?
let isTypingIndicator: Bool
let isSenderOpenGroupModerator: Bool
let profile: Profile?
let quote: Quote?
let quoteAttachment: Attachment?
let linkPreview: LinkPreview?
let linkPreviewAttachment: Attachment?
// Post-Query Processing Data
/// This value includes the associated attachments
let attachments: [Attachment]?
/// This value defines what type of cell should appear and is generated based on the interaction variant
/// and associated attachment data
let cellType: CellType
/// This value includes the author name information
let authorName: String
/// This value will be used to populate the author label, if it's null then the label will be hidden
let senderName: String?
/// A flag indicating whether the profile view should be displayed
let shouldShowProfile: Bool
/// This value will be used to populate the date header, if it's null then the header will be hidden
let dateForUI: Date?
/// This value specifies whether the body contains only emoji characters
let containsOnlyEmoji: Bool?
/// This value specifies the number of emoji characters the body contains
let glyphCount: Int?
/// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item
let previousVariant: Interaction.Variant?
/// This value indicates the position of this message within a cluser of messages
let positionInCluster: Position
/// This value indicates whether this is the only message in a cluser of messages
let isOnlyMessageInCluster: Bool
/// This value indicates whether this is the last message in the thread
let isLast: Bool
// MARK: - Mutation
public func with(attachments: [Attachment]) -> ViewModel {
return ViewModel(
threadVariant: self.threadVariant,
threadIsTrusted: self.threadIsTrusted,
threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled,
rowId: self.rowId,
id: self.id,
variant: self.variant,
timestampMs: self.timestampMs,
authorId: self.authorId,
authorNameInternal: self.authorNameInternal,
body: self.body,
expiresStartedAtMs: self.expiresStartedAtMs,
expiresInSeconds: self.expiresInSeconds,
state: self.state,
hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt,
mostRecentFailureText: self.mostRecentFailureText,
isTypingIndicator: self.isTypingIndicator,
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
profile: self.profile,
quote: self.quote,
quoteAttachment: self.quoteAttachment,
linkPreview: self.linkPreview,
linkPreviewAttachment: self.linkPreviewAttachment,
attachments: attachments,
cellType: self.cellType,
authorName: self.authorName,
senderName: self.senderName,
shouldShowProfile: self.shouldShowProfile,
dateForUI: self.dateForUI,
containsOnlyEmoji: self.containsOnlyEmoji,
glyphCount: self.glyphCount,
previousVariant: self.previousVariant,
positionInCluster: self.positionInCluster,
isOnlyMessageInCluster: self.isOnlyMessageInCluster,
isLast: self.isLast
)
}
public func withClusteringChanges(
prevModel: ViewModel?,
nextModel: ViewModel?,
isLast: Bool
) -> ViewModel {
let cellType: CellType = {
guard !self.isTypingIndicator else { return .typingIndicator }
guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage }
guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage }
// The only case which currently supports multiple attachments is a 'mediaMessage'
// (the album view)
guard self.attachments?.count == 1 else { return .mediaMessage }
// Quote and LinkPreview overload the 'attachments' array and use it for their
// own purposes, otherwise check if the attachment is visual media
guard self.quote == nil else { return .textOnlyMessage }
guard self.linkPreview == nil else { return .textOnlyMessage }
// Pending audio attachments won't have a duration
if
attachment.isAudio && (
((attachment.duration ?? 0) > 0) ||
(
attachment.state != .downloaded &&
attachment.state != .uploaded
)
)
{
return .audio
}
if attachment.isVisualMedia {
return .mediaMessage
}
return .genericAttachment
}()
let authorDisplayName: String = Profile.displayName(
for: self.threadVariant,
id: self.authorId,
name: self.authorNameInternal,
nickname: nil // Folded into 'authorName' within the Query
)
let shouldShowDateOnThisModel: Bool = {
guard !self.isTypingIndicator else { return false }
guard let prevModel: ViewModel = prevModel else { return true }
return DateUtil.shouldShowDateBreak(
forTimestamp: UInt64(prevModel.timestampMs),
timestamp: UInt64(self.timestampMs)
)
}()
let shouldShowDateOnNextModel: Bool = {
// Should be nothing after a typing indicator
guard !self.isTypingIndicator else { return false }
guard let nextModel: ViewModel = nextModel else { return false }
return DateUtil.shouldShowDateBreak(
forTimestamp: UInt64(self.timestampMs),
timestamp: UInt64(nextModel.timestampMs)
)
}()
let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = {
let isFirstInCluster: Bool = (
prevModel == nil ||
shouldShowDateOnThisModel || (
self.variant == .standardOutgoing &&
prevModel?.variant != .standardOutgoing
) || (
(
self.variant == .standardIncoming ||
self.variant == .standardIncomingDeleted
) && (
prevModel?.variant != .standardIncoming &&
prevModel?.variant != .standardIncomingDeleted
)
) ||
self.authorId != prevModel?.authorId
)
let isLastInCluster: Bool = (
nextModel == nil ||
shouldShowDateOnNextModel || (
self.variant == .standardOutgoing &&
nextModel?.variant != .standardOutgoing
) || (
(
self.variant == .standardIncoming ||
self.variant == .standardIncomingDeleted
) && (
nextModel?.variant != .standardIncoming &&
nextModel?.variant != .standardIncomingDeleted
)
) ||
self.authorId != nextModel?.authorId
)
let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster)
switch (isFirstInCluster, isLastInCluster) {
case (true, true), (false, false): return (.middle, isOnlyMessageInCluster)
case (true, false): return (.top, isOnlyMessageInCluster)
case (false, true): return (.bottom, isOnlyMessageInCluster)
}
}()
return ViewModel(
threadVariant: self.threadVariant,
threadIsTrusted: self.threadIsTrusted,
threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled,
rowId: self.rowId,
id: self.id,
variant: self.variant,
timestampMs: self.timestampMs,
authorId: self.authorId,
authorNameInternal: self.authorNameInternal,
body: (!self.variant.isInfoMessage ?
self.body :
// Info messages might not have a body so we should use the 'previewText' value instead
Interaction.previewText(
variant: self.variant,
body: self.body,
authorDisplayName: authorDisplayName,
attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in
Attachment.DescriptionInfo(
id: firstAttachment.id,
variant: firstAttachment.variant,
contentType: firstAttachment.contentType,
sourceFilename: firstAttachment.sourceFilename
)
},
attachmentCount: self.attachments?.count,
isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation)
)
),
expiresStartedAtMs: self.expiresStartedAtMs,
expiresInSeconds: self.expiresInSeconds,
state: self.state,
hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt,
mostRecentFailureText: self.mostRecentFailureText,
isTypingIndicator: self.isTypingIndicator,
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
profile: self.profile,
quote: self.quote,
quoteAttachment: self.quoteAttachment,
linkPreview: self.linkPreview,
linkPreviewAttachment: self.linkPreviewAttachment,
attachments: self.attachments,
cellType: cellType,
authorName: authorDisplayName,
senderName: {
// Only show for group threads
guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else {
return nil
}
// Only if there is a date header or the senders are different
guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else {
return nil
}
return authorDisplayName
}(),
shouldShowProfile: (
// Only group threads
(self.threadVariant == .openGroup || self.threadVariant == .closedGroup) &&
// Only incoming messages
(self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) &&
// Show if the next message has a different sender or has a "date break"
(
self.authorId != nextModel?.authorId ||
shouldShowDateOnNextModel
) &&
// Need a profile to be able to show it
self.profile != nil
),
dateForUI: (shouldShowDateOnThisModel ?
Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) :
nil
),
containsOnlyEmoji: self.body?.containsOnlyEmoji,
glyphCount: self.body?.glyphCount,
previousVariant: prevModel?.variant,
positionInCluster: positionInCluster,
isOnlyMessageInCluster: isOnlyMessageInCluster,
isLast: isLast
)
}
}
public struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable {
public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue)
public static let attachmentString: String = CodingKeys.attachment.stringValue
public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue
public let rowId: Int64
public let attachment: Attachment
public let interactionAttachment: InteractionAttachment
// MARK: - Identifiable
public var id: String {
"\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)"
}
// MARK: - Comparable
public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool {
return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex)
}
}
}
// MARK: - Convenience Initialization
public extension MessageCell.ViewModel {
// Note: This init method is only used system-created cells or empty states
init(isTypingIndicator: Bool = false) {
self.threadVariant = .contact
self.threadIsTrusted = false
self.threadHasDisappearingMessagesEnabled = false
// Interaction Info
self.rowId = -1
self.id = -1
self.variant = .standardOutgoing
self.timestampMs = Int64.max
self.authorId = ""
self.authorNameInternal = nil
self.body = nil
self.expiresStartedAtMs = nil
self.expiresInSeconds = nil
self.state = .sent
self.hasAtLeastOneReadReceipt = false
self.mostRecentFailureText = nil
self.isTypingIndicator = isTypingIndicator
self.isSenderOpenGroupModerator = false
self.profile = nil
self.quote = nil
self.quoteAttachment = nil
self.linkPreview = nil
self.linkPreviewAttachment = nil
// Post-Query Processing Data
self.attachments = nil
self.cellType = .typingIndicator
self.authorName = ""
self.senderName = nil
self.shouldShowProfile = false
self.dateForUI = nil
self.containsOnlyEmoji = nil
self.glyphCount = nil
self.previousVariant = nil
self.positionInCluster = .middle
self.isOnlyMessageInCluster = true
self.isLast = true
}
}
// MARK: - ConversationVC
extension MessageCell.ViewModel {
public static func filterSQL(threadId: String) -> SQL {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("\(interaction[.threadId]) = \(threadId)")
}
public static let orderSQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("\(interaction[.timestampMs].desc)")
}()
public static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest<SQLRequest<MessageCell.ViewModel>>) {
return { additionalFilters, limitSQL -> AdaptedFetchRequest<SQLRequest<ViewModel>> in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let disappearingMessagesConfig: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState")
let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name)
let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name)
let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name)
let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt")
let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name)
let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name)
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
return """
WHERE \(baseFilterSQL)
"""
}
return """
WHERE (
\(baseFilterSQL) AND
\(additionalFilters)
)
"""
}()
let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: ""))
let numColumnsBeforeLinkedRecords: Int = 17
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.variant]) AS \(ViewModel.threadVariantKey),
-- Default to 'true' for non-contact threads
IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey),
-- Default to 'false' when no contact exists
IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey),
\(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey),
\(interaction[.id]),
\(interaction[.variant]),
\(interaction[.timestampMs]),
\(interaction[.authorId]),
IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey),
\(interaction[.body]),
\(interaction[.expiresStartedAtMs]),
\(interaction[.expiresInSeconds]),
-- Default to 'sending' assuming non-processed interaction when null
IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey),
(\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey),
\(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey),
false AS \(ViewModel.isTypingIndicatorKey),
false AS \(ViewModel.isSenderOpenGroupModeratorKey),
\(ViewModel.profileKey).*,
\(ViewModel.quoteKey).*,
\(ViewModel.quoteAttachmentKey).*,
\(ViewModel.linkPreviewKey).*,
\(ViewModel.linkPreviewAttachmentKey).*,
-- All of the below properties are set in post-query processing but to prevent the
-- query from crashing when decoding we need to provide default values
\(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey),
'' AS \(ViewModel.authorNameKey),
false AS \(ViewModel.shouldShowProfileKey),
\(Position.middle) AS \(ViewModel.positionInClusterKey),
false AS \(ViewModel.isOnlyMessageInClusterKey),
false AS \(ViewModel.isLastKey)
FROM \(Interaction.self)
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])
LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId])
LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])
LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id])
LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId])
LEFT JOIN \(LinkPreview.self) ON (
\(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND
\(Interaction.linkPreviewFilterLiteral)
)
LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId])
LEFT JOIN (
\(RecipientState.selectInteractionState(
tableLiteral: interactionStateTableLiteral,
idColumnLiteral: interactionStateInteractionIdColumnLiteral
))
) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id])
LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON (
\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND
\(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral)
)
\(finalFilterSQL)
ORDER BY \(orderSQL)
\(finalLimitSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Profile.numberOfSelectedColumns(db),
Quote.numberOfSelectedColumns(db),
Attachment.numberOfSelectedColumns(db),
LinkPreview.numberOfSelectedColumns(db),
Attachment.numberOfSelectedColumns(db)
])
return ScopeAdapter([
ViewModel.profileString: adapters[1],
ViewModel.quoteString: adapters[2],
ViewModel.quoteAttachmentString: adapters[3],
ViewModel.linkPreviewString: adapters[4],
ViewModel.linkPreviewAttachmentString: adapters[5]
])
}
}
}
}
extension MessageCell.AttachmentInteractionInfo {
public static let baseQuery: ((SQL?) -> AdaptedFetchRequest<SQLRequest<MessageCell.AttachmentInteractionInfo>>) = {
return { additionalFilters -> AdaptedFetchRequest<SQLRequest<AttachmentInteractionInfo>> in
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
return SQL(stringLiteral: "")
}
return """
WHERE \(additionalFilters)
"""
}()
let numColumnsBeforeLinkedRecords: Int = 1
let request: SQLRequest<AttachmentInteractionInfo> = """
SELECT
\(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey),
\(AttachmentInteractionInfo.attachmentKey).*,
\(AttachmentInteractionInfo.interactionAttachmentKey).*
FROM \(Attachment.self)
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
\(finalFilterSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Attachment.numberOfSelectedColumns(db),
InteractionAttachment.numberOfSelectedColumns(db)
])
return ScopeAdapter([
AttachmentInteractionInfo.attachmentString: adapters[1],
AttachmentInteractionInfo.interactionAttachmentString: adapters[2]
])
}
}
}()
public static var joinToViewModelQuerySQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return """
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
JOIN \(Interaction.self) ON
\(interaction[.id]) = \(interactionAttachment[.interactionId])
"""
}()
public static func createAssociateDataClosure() -> (DataCache<MessageCell.AttachmentInteractionInfo>, DataCache<MessageCell.ViewModel>) -> DataCache<MessageCell.ViewModel> {
return { dataCache, pagedDataCache -> DataCache<MessageCell.ViewModel> in
var updatedPagedDataCache: DataCache<MessageCell.ViewModel> = pagedDataCache
dataCache
.values
.grouped(by: \.interactionAttachment.interactionId)
.forEach { (interactionId: Int64, attachments: [MessageCell.AttachmentInteractionInfo]) in
guard
let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId],
let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId]
else { return }
updatedPagedDataCache = updatedPagedDataCache.upserting(
dataToUpdate.with(
attachments: attachments
.sorted()
.map { $0.attachment }
)
)
}
return updatedPagedDataCache
}
}
}

View File

@ -39,7 +39,7 @@ final class TypingIndicatorCell: MessageCell {
// MARK: - Updating // MARK: - Updating
override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard cellViewModel.cellType == .typingIndicator else { return } guard cellViewModel.cellType == .typingIndicator else { return }
self.viewModel = cellViewModel self.viewModel = cellViewModel
@ -51,7 +51,7 @@ final class TypingIndicatorCell: MessageCell {
typingIndicatorView.startAnimation() typingIndicatorView.startAnimation()
} }
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
} }
override func layoutSubviews() { override func layoutSubviews() {

View File

@ -207,7 +207,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
// MARK: - Updating // MARK: - Updating
override func update( override func update(
with cellViewModel: MessageCell.ViewModel, with cellViewModel: MessageViewModel,
mediaCache: NSCache<NSString, AnyObject>, mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?, playbackInfo: ConversationViewModel.PlaybackInfo?,
lastSearchText: String? lastSearchText: String?
@ -328,16 +328,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
} }
private func populateHeader(for cellViewModel: MessageCell.ViewModel, shouldInsetHeader: Bool) { private func populateHeader(for cellViewModel: MessageViewModel, shouldInsetHeader: Bool) {
guard let date: Date = cellViewModel.dateForUI else { return } guard let date: Date = cellViewModel.dateForUI else { return }
let dateBreakLabel: UILabel = UILabel() let dateBreakLabel: UILabel = UILabel()
dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize) dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
dateBreakLabel.textColor = Colors.text dateBreakLabel.textColor = Colors.text
dateBreakLabel.textAlignment = .center dateBreakLabel.textAlignment = .center
dateBreakLabel.text = date.formattedForDisplay
let description: String = DateUtil.formatDate(forDisplay: date)
dateBreakLabel.text = description
headerView.addSubview(dateBreakLabel) headerView.addSubview(dateBreakLabel)
dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing) dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing)
@ -352,7 +350,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
private func populateContentView( private func populateContentView(
for cellViewModel: MessageCell.ViewModel, for cellViewModel: MessageViewModel,
mediaCache: NSCache<NSString, AnyObject>, mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?, playbackInfo: ConversationViewModel.PlaybackInfo?,
lastSearchText: String? lastSearchText: String?
@ -579,7 +577,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound)
} }
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
guard cellViewModel.variant != .standardIncomingDeleted else { return } guard cellViewModel.variant != .standardIncomingDeleted else { return }
// If it's an incoming media message and the thread isn't trusted then show the placeholder view // If it's an incoming media message and the thread isn't trusted then show the placeholder view
@ -669,13 +667,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
@objc func handleLongPress() { @objc func handleLongPress() {
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } guard let cellViewModel: MessageViewModel = self.viewModel else { return }
delegate?.handleItemLongPressed(cellViewModel) delegate?.handleItemLongPressed(cellViewModel)
} }
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } guard let cellViewModel: MessageViewModel = self.viewModel else { return }
let location = gestureRecognizer.location(in: self) let location = gestureRecognizer.location(in: self)
@ -692,13 +690,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
@objc private func handleDoubleTap() { @objc private func handleDoubleTap() {
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } guard let cellViewModel: MessageViewModel = self.viewModel else { return }
delegate?.handleItemDoubleTapped(cellViewModel) delegate?.handleItemDoubleTapped(cellViewModel)
} }
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } guard let cellViewModel: MessageViewModel = self.viewModel else { return }
let viewsToMove: [UIView] = [ let viewsToMove: [UIView] = [
bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView
@ -760,7 +758,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
private func reply() { private func reply() {
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return } guard let cellViewModel: MessageViewModel = self.viewModel else { return }
resetReply() resetReply()
delegate?.handleReplyButtonTapped(for: cellViewModel) delegate?.handleReplyButtonTapped(for: cellViewModel)
@ -797,7 +795,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
return cornerMask return cornerMask
} }
private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat { private static func getFontSize(for cellViewModel: MessageViewModel) -> CGFloat {
let baselineFontSize = Values.mediumFontSize let baselineFontSize = Values.mediumFontSize
guard cellViewModel.containsOnlyEmoji == true else { return baselineFontSize } guard cellViewModel.containsOnlyEmoji == true else { return baselineFontSize }
@ -810,7 +808,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
} }
private func getMessageStatusImage(for cellViewModel: MessageCell.ViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) { private func getMessageStatusImage(for cellViewModel: MessageViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) {
guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) } guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) }
let image: UIImage let image: UIImage
@ -838,7 +836,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
return (image, tintColor, backgroundColor) return (image, tintColor, backgroundColor)
} }
private func getSize(for cellViewModel: MessageCell.ViewModel) -> CGSize { private func getSize(for cellViewModel: MessageViewModel) -> CGSize {
guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else {
preconditionFailure() preconditionFailure()
} }
@ -886,7 +884,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
return CGSize(width: width, height: height) return CGSize(width: width, height: height)
} }
static func getMaxWidth(for cellViewModel: MessageCell.ViewModel) -> CGFloat { static func getMaxWidth(for cellViewModel: MessageViewModel) -> CGFloat {
let screen: CGRect = UIScreen.main.bounds let screen: CGRect = UIScreen.main.bounds
switch cellViewModel.variant { switch cellViewModel.variant {
@ -905,7 +903,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
} }
static func getBodyTextView( static func getBodyTextView(
for cellViewModel: MessageCell.ViewModel, for cellViewModel: MessageViewModel,
with availableWidth: CGFloat, with availableWidth: CGFloat,
textColor: UIColor, textColor: UIColor,
searchText: String?, searchText: String?,
@ -938,7 +936,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength { if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength {
let normalizedBody: String = attributedText.string.lowercased() let normalizedBody: String = attributedText.string.lowercased()
ConversationCell.ViewModel.searchTermParts(searchText) SessionThreadViewModel.searchTermParts(searchText)
.map { part -> String in .map { part -> String in
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }

View File

@ -20,7 +20,7 @@ public class InsetLockableTableView: UITableView {
} }
public var oldOffset: CGPoint = .zero public var oldOffset: CGPoint = .zero
public var newOffset: CGPoint = .zero public var newOffset: CGPoint = .zero
private var callbackCondition: ((Int, [Int]) -> Bool)? private var callbackCondition: ((Int, [Int], CGSize) -> Bool)?
private var afterLayoutSubviewsCallback: (() -> ())? private var afterLayoutSubviewsCallback: (() -> ())?
public override func layoutSubviews() { public override func layoutSubviews() {
@ -54,7 +54,7 @@ public class InsetLockableTableView: UITableView {
// MARK: - Functions // MARK: - Functions
public func afterNextLayoutSubviews( public func afterNextLayoutSubviews(
when condition: @escaping (Int, [Int]) -> Bool, when condition: @escaping (Int, [Int], CGSize) -> Bool,
then callback: @escaping () -> () then callback: @escaping () -> ()
) { ) {
self.callbackCondition = condition self.callbackCondition = condition
@ -70,7 +70,9 @@ public class InsetLockableTableView: UITableView {
// Store the layout info locally so if they pass we can clear the states before running to // Store the layout info locally so if they pass we can clear the states before running to
// prevent layouts within the callbacks from triggering infinite loops // prevent layouts within the callbacks from triggering infinite loops
guard self.callbackCondition?(numSections, numRowInSections) == true else { return false } guard self.callbackCondition?(numSections, numRowInSections, self.contentSize) == true else {
return false
}
self.callbackCondition = nil self.callbackCondition = nil
return true return true

View File

@ -9,7 +9,7 @@ import SessionUtilitiesKit
import SignalUtilitiesKit import SignalUtilitiesKit
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
fileprivate typealias SectionModel = ArraySection<SearchSection, ConversationCell.ViewModel> fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
// MARK: - SearchSection // MARK: - SearchSection
@ -22,8 +22,8 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
// MARK: - Variables // MARK: - Variables
private lazy var defaultSearchResults: [SectionModel] = { private lazy var defaultSearchResults: [SectionModel] = {
let result: ConversationCell.ViewModel? = GRDBStorage.shared.read { db -> ConversationCell.ViewModel? in let result: SessionThreadViewModel? = GRDBStorage.shared.read { db -> SessionThreadViewModel? in
try ConversationCell.ViewModel try SessionThreadViewModel
.noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db)) .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db))
.fetchOne(db) .fetchOne(db)
} }
@ -63,7 +63,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
result.separatorStyle = .none result.separatorStyle = .none
result.keyboardDismissMode = .onDrag result.keyboardDismissMode = .onDrag
result.register(view: EmptySearchResultCell.self) result.register(view: EmptySearchResultCell.self)
result.register(view: ConversationCell.Full.self) result.register(view: FullConversationCell.self)
result.showsVerticalScrollIndicator = false result.showsVerticalScrollIndicator = false
return result return result
@ -143,18 +143,18 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
do { do {
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
.contactsAndGroupsQuery( .contactsAndGroupsQuery(
userPublicKey: userPublicKey, userPublicKey: userPublicKey,
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText), pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
searchTerm: searchText searchTerm: searchText
) )
.fetchAll(db) .fetchAll(db)
let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
.messagesQuery( .messagesQuery(
userPublicKey: userPublicKey, userPublicKey: userPublicKey,
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText) pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
) )
.fetchAll(db) .fetchAll(db)
@ -177,7 +177,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
self.termForCurrentSearchResultSet = searchText self.termForCurrentSearchResultSet = searchText
self.searchResultSet = [ self.searchResultSet = [
(hasResults ? nil : [ArraySection(model: .noResults, elements: [ConversationCell.ViewModel(unreadCount: 0)])]), (hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
(hasResults ? sections : nil) (hasResults ? sections : nil)
] ]
.compactMap { $0 } .compactMap { $0 }
@ -332,12 +332,12 @@ extension GlobalSearchViewController {
return cell return cell
case .contactsAndGroups: case .contactsAndGroups:
let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
return cell return cell
case .messages: case .messages:
let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
return cell return cell
} }

View File

@ -9,7 +9,7 @@ import SignalUtilitiesKit
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate {
typealias Section = HomeViewModel.Section typealias Section = HomeViewModel.Section
typealias Item = ConversationCell.ViewModel typealias Item = SessionThreadViewModel
private let viewModel: HomeViewModel = HomeViewModel() private let viewModel: HomeViewModel = HomeViewModel()
private var dataChangeObservable: DatabaseCancellable? private var dataChangeObservable: DatabaseCancellable?
@ -55,7 +55,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
) )
result.showsVerticalScrollIndicator = false result.showsVerticalScrollIndicator = false
result.register(view: MessageRequestsCell.self) result.register(view: MessageRequestsCell.self)
result.register(view: ConversationCell.Full.self) result.register(view: FullConversationCell.self)
result.dataSource = self result.dataSource = self
result.delegate = self result.delegate = self
@ -118,6 +118,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
} }
updateNavBarButtons() updateNavBarButtons()
setUpNavBarSessionHeading() setUpNavBarSessionHeading()
// Recovery phrase reminder // Recovery phrase reminder
let hasViewedSeed = UserDefaults.standard[.hasViewedSeed] let hasViewedSeed = UserDefaults.standard[.hasViewedSeed]
if !hasViewedSeed { if !hasViewedSeed {
@ -355,7 +356,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
return cell return cell
case .threads: case .threads:
let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: section.elements[indexPath.row]) cell.update(with: section.elements[indexPath.row])
return cell return cell
} }
@ -401,7 +402,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
return [hide] return [hide]
case .threads: case .threads:
let cellViewModel: ConversationCell.ViewModel = section.elements[indexPath.row] let cellViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let delete: UITableViewRowAction = UITableViewRowAction( let delete: UITableViewRowAction = UITableViewRowAction(
style: .destructive, style: .destructive,
title: "TXT_DELETE_TITLE".localized() title: "TXT_DELETE_TITLE".localized()

View File

@ -12,7 +12,7 @@ public class HomeViewModel {
} }
/// This value is the current state of the view /// This value is the current state of the view
public private(set) var viewData: [ArraySection<Section, ConversationCell.ViewModel>] = [] public private(set) var viewData: [ArraySection<Section, SessionThreadViewModel>] = []
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance /// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -20,7 +20,7 @@ public class HomeViewModel {
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
public lazy var observableViewData = ValueObservation public lazy var observableViewData = ValueObservation
.trackingConstantRegion { db -> [ArraySection<Section, ConversationCell.ViewModel>] in .trackingConstantRegion { db -> [ArraySection<Section, SessionThreadViewModel>] in
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
let unreadMessageRequestCount: Int = try SessionThread let unreadMessageRequestCount: Int = try SessionThread
.filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey)) .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
@ -40,7 +40,7 @@ public class HomeViewModel {
// If there are no unread message requests then hide the message request banner // If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ? (finalUnreadMessageRequestCount == 0 ?
nil : nil :
ConversationCell.ViewModel( SessionThreadViewModel(
unreadCount: UInt(finalUnreadMessageRequestCount) unreadCount: UInt(finalUnreadMessageRequestCount)
) )
) )
@ -48,7 +48,7 @@ public class HomeViewModel {
), ),
ArraySection( ArraySection(
model: .threads, model: .threads,
elements: try ConversationCell.ViewModel elements: try SessionThreadViewModel
.homeQuery(userPublicKey: userPublicKey) .homeQuery(userPublicKey: userPublicKey)
.fetchAll(db) .fetchAll(db)
) )
@ -58,7 +58,7 @@ public class HomeViewModel {
// MARK: - Functions // MARK: - Functions
public func updateData(_ updatedData: [ArraySection<Section, ConversationCell.ViewModel>]) { public func updateData(_ updatedData: [ArraySection<Section, SessionThreadViewModel>]) {
self.viewData = updatedData self.viewData = updatedData
} }
} }

View File

@ -19,7 +19,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.backgroundColor = .clear result.backgroundColor = .clear
result.separatorStyle = .none result.separatorStyle = .none
result.register(view: ConversationCell.Full.self) result.register(view: FullConversationCell.self)
result.dataSource = self result.dataSource = self
result.delegate = self result.delegate = self
@ -171,7 +171,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
) )
} }
private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) { private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) {
// Ensure the first load runs without animations (if we don't do this the cells will animate // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialData else { guard hasLoadedInitialData else {
@ -214,7 +214,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: ConversationCell.Full = tableView.dequeue(type: ConversationCell.Full.self, for: indexPath) let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: viewModel.viewData[indexPath.row]) cell.update(with: viewModel.viewData[indexPath.row])
return cell return cell
} }

View File

@ -7,7 +7,7 @@ import SignalUtilitiesKit
public class MessageRequestsViewModel { public class MessageRequestsViewModel {
/// This value is the current state of the view /// This value is the current state of the view
public private(set) var viewData: [ConversationCell.ViewModel] = [] public private(set) var viewData: [SessionThreadViewModel] = []
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance /// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -15,10 +15,10 @@ public class MessageRequestsViewModel {
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
public lazy var observableViewData = ValueObservation public lazy var observableViewData = ValueObservation
.trackingConstantRegion { db -> [ConversationCell.ViewModel] in .trackingConstantRegion { db -> [SessionThreadViewModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try ConversationCell.ViewModel return try SessionThreadViewModel
.messageRequestsQuery(userPublicKey: userPublicKey) .messageRequestsQuery(userPublicKey: userPublicKey)
.fetchAll(db) .fetchAll(db)
} }
@ -26,7 +26,7 @@ public class MessageRequestsViewModel {
// MARK: - Functions // MARK: - Functions
public func updateData(_ updatedData: [ConversationCell.ViewModel]) { public func updateData(_ updatedData: [SessionThreadViewModel]) {
self.viewData = updatedData self.viewData = updatedData
} }
} }

View File

@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell {
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.clipsToBounds = true result.clipsToBounds = true
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
result.layer.cornerRadius = (ConversationCell.Full.unreadCountViewSize / 2) result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
return result return result
}() }()
@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell {
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)), unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize), unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize), unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor), unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor), unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),

View File

@ -386,8 +386,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
// Start observing for data changes // Start observing for data changes
dataChangeObservable = GRDBStorage.shared.start( dataChangeObservable = GRDBStorage.shared.start(
viewModel.observableAlbumData, viewModel.observableAlbumData,
onError: { error in onError: { _ in },
},
onChange: { [weak self] albumData in onChange: { [weak self] albumData in
// The defaul scheduler emits changes on the main thread // The defaul scheduler emits changes on the main thread
self?.handleUpdates(albumData) self?.handleUpdates(albumData)

View File

@ -9,7 +9,6 @@
// Separate iOS Frameworks from other imports. // Separate iOS Frameworks from other imports.
#import "AvatarViewHelper.h" #import "AvatarViewHelper.h"
#import "AVAudioSession+OWS.h" #import "AVAudioSession+OWS.h"
#import "DateUtil.h"
#import "NotificationSettingsViewController.h" #import "NotificationSettingsViewController.h"
#import "OWSAnyTouchGestureRecognizer.h" #import "OWSAnyTouchGestureRecognizer.h"
#import "OWSAudioPlayer.h" #import "OWSAudioPlayer.h"

View File

@ -192,13 +192,13 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
owsFailDebug("threadId was unexpectedly nil") owsFailDebug("threadId was unexpectedly nil")
return true return true
} }
guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else { guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else {
return true return true
} }
// Show notifications for any *other* thread /// Show notifications for any **other** threads
return conversationViewController.thread.uniqueId != notificationThreadId return (conversationViewController.viewModel.threadData.threadId != notificationThreadId)
} }
} }
@ -230,16 +230,18 @@ public class UserNotificationActionHandler: NSObject {
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
switch response.actionIdentifier { switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier: case UNNotificationDefaultActionIdentifier:
Logger.debug("default action") Logger.debug("default action")
return try actionHandler.showThread(userInfo: userInfo) return try actionHandler.showThread(userInfo: userInfo)
case UNNotificationDismissActionIdentifier:
// TODO - mark as read? case UNNotificationDismissActionIdentifier:
Logger.debug("dismissed notification") // TODO - mark as read?
return Promise.value(()) Logger.debug("dismissed notification")
default: return Promise.value(())
// proceed
break default:
// proceed
break
} }
guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else { guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else {
@ -247,16 +249,18 @@ public class UserNotificationActionHandler: NSObject {
} }
switch action { switch action {
case .markAsRead: case .markAsRead:
return try actionHandler.markAsRead(userInfo: userInfo) return try actionHandler.markAsRead(userInfo: userInfo)
case .reply:
guard let textInputResponse = response as? UNTextInputNotificationResponse else { case .reply:
throw NotificationError.failDebug("response had unexpected type: \(response)") guard let textInputResponse = response as? UNTextInputNotificationResponse else {
} throw NotificationError.failDebug("response had unexpected type: \(response)")
}
return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText) return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText)
case .showThread:
return try actionHandler.showThread(userInfo: userInfo) case .showThread:
return try actionHandler.showThread(userInfo: userInfo)
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,89 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
public extension Date {
var formattedForDisplay: String {
let dateNow: Date = Date()
guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .year) else {
// Last year formatter: Nov 11 13:32 am, 2017
return Date.oldDateFormatter.string(from: self)
}
guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .weekOfYear) else {
// This year formatter: Jun 6 10:12 am
return Date.thisYearFormatter.string(from: self)
}
guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .day) else {
// Day of week formatter: Thu 9:11 pm
return Date.thisWeekFormatter.string(from: self)
}
guard Calendar.current.isDate(self, equalTo: dateNow, toGranularity: .minute) else {
// Today formatter: 8:32 am
return Date.todayFormatter.string(from: self)
}
return "DATE_NOW".localized()
}
}
// MARK: - Formatters
fileprivate extension Date {
static let oldDateFormatter: DateFormatter = {
let result: DateFormatter = DateFormatter()
result.locale = Locale.current
result.dateStyle = .medium
result.timeStyle = .short
result.doesRelativeDateFormatting = true
return result
}()
static let thisYearFormatter: DateFormatter = {
let result: DateFormatter = DateFormatter()
result.locale = Locale.current
// Jun 6 10:12 am
result.dateFormat = "MMM d \(hourFormat)"
return result
}()
static let thisWeekFormatter: DateFormatter = {
let result: DateFormatter = DateFormatter()
result.locale = Locale.current
// Mon 11:36 pm
result.dateFormat = "EEE \(hourFormat)"
return result
}()
static let todayFormatter: DateFormatter = {
let result: DateFormatter = DateFormatter()
result.locale = Locale.current
// 9:10 am
result.dateFormat = hourFormat
return result
}()
static var hourFormat: String {
guard
let format: String = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: Locale.current),
format.range(of: "a") != nil
else {
// If we didn't find 'a' then it's 24-hour time
return "HH:mm"
}
// If we found 'a' in the format then it's 12-hour time
return "h:mm a"
}
}

View File

@ -1,49 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@interface DateUtil : NSObject
+ (NSDateFormatter *)dateFormatter;
+ (NSDateFormatter *)timeFormatter;
+ (NSDateFormatter *)monthAndDayFormatter;
+ (NSDateFormatter *)shortDayOfWeekFormatter;
+ (BOOL)dateIsOlderThanToday:(NSDate *)date;
+ (BOOL)dateIsOlderThanOneWeek:(NSDate *)date;
+ (BOOL)dateIsToday:(NSDate *)date;
+ (BOOL)dateIsThisYear:(NSDate *)date;
+ (BOOL)dateIsYesterday:(NSDate *)date;
+ (NSString *)formatPastTimestampRelativeToNow:(uint64_t)pastTimestamp
NS_SWIFT_NAME(formatPastTimestampRelativeToNow(_:));
+ (NSString *)formatTimestampShort:(uint64_t)timestamp;
+ (NSString *)formatDateShort:(NSDate *)date;
+ (NSString *)formatTimestampAsTime:(uint64_t)timestamp;
+ (NSString *)formatDateAsTime:(NSDate *)date;
+ (NSString *)formatMessageTimestamp:(uint64_t)timestamp;
+ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp;
// These two "exemplary" values can be used by views to measure
// the likely size for recent values formatted using isTimestampFromLastHour:.
+ (NSString *)exemplaryNowTimeFormat;
+ (NSString *)exemplaryMinutesTimeFormat;
+ (NSString *)formatDateForDisplay:(NSDate *)date;
+ (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2;
+ (BOOL)isSameDayWithDate:(NSDate *)date1 date:(NSDate *)date2;
+ (BOOL)isSameHourWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2;
+ (BOOL)isSameHourWithDate:(NSDate *)date1 date:(NSDate *)date2;
+ (BOOL)shouldShowDateBreakForTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,526 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "DateUtil.h"
#import <SignalCoreKit/NSDate+OWS.h>
#import <SignalUtilitiesKit/OWSFormat.h>
#import <SessionUtilitiesKit/NSString+SSK.h>
NS_ASSUME_NONNULL_BEGIN
static NSString *const DATE_FORMAT_WEEKDAY = @"EEEE";
@implementation DateUtil
+ (NSString *)getHourFormat {
NSString *format = [NSDateFormatter dateFormatFromTemplate:@"j" options:0 locale:[NSLocale currentLocale]];
NSRange range = [format rangeOfString:@"a"];
BOOL is12HourTime = (range.location != NSNotFound);
return (is12HourTime) ? @"h:mm a" : @"HH:mm";
}
+ (NSDateFormatter *)dateFormatter {
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
[formatter setTimeStyle:NSDateFormatterNoStyle];
[formatter setDateStyle:NSDateFormatterShortStyle];
});
return formatter;
}
+ (NSDateFormatter *)displayDateTodayFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
formatter.locale = [NSLocale currentLocale];
// 9:10 am
formatter.dateFormat = [self getHourFormat];
});
return formatter;
}
+ (NSDateFormatter *)displayDateThisWeekDateFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
formatter.locale = [NSLocale currentLocale];
// Mon 11:36 pm
formatter.dateFormat = [NSString stringWithFormat:@"EEE %@", [self getHourFormat]];
});
return formatter;
}
+ (NSDateFormatter *)displayDateThisYearDateFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
formatter.locale = [NSLocale currentLocale];
// Jun 6 10:12 am
formatter.dateFormat = [NSString stringWithFormat:@"MMM d %@", [self getHourFormat]];
});
return formatter;
}
+ (NSDateFormatter *)displayDateOldDateFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
formatter.locale = [NSLocale currentLocale];
formatter.dateStyle = NSDateFormatterMediumStyle;
formatter.timeStyle = NSDateFormatterShortStyle;
formatter.doesRelativeDateFormatting = YES;
});
return formatter;
}
+ (NSDateFormatter *)weekdayFormatter {
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
[formatter setDateFormat:DATE_FORMAT_WEEKDAY];
});
return formatter;
}
+ (NSDateFormatter *)timeFormatter {
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
[formatter setTimeStyle:NSDateFormatterShortStyle];
[formatter setDateStyle:NSDateFormatterNoStyle];
});
return formatter;
}
+ (NSDateFormatter *)monthAndDayFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
formatter.dateFormat = @"MMM d";
});
return formatter;
}
+ (NSDateFormatter *)shortDayOfWeekFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
formatter.dateFormat = @"E";
});
return formatter;
}
+ (BOOL)isWithinOneMinute:(NSDate *)date
{
NSTimeInterval interval = [[NSDate new] timeIntervalSince1970] - [date timeIntervalSince1970];
return interval < 60;
}
+ (BOOL)dateIsOlderThanToday:(NSDate *)date
{
return [self dateIsOlderThanToday:date now:[NSDate date]];
}
+ (BOOL)dateIsOlderThanToday:(NSDate *)date now:(NSDate *)now
{
NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now];
return dayDifference > 0;
}
+ (BOOL)dateIsOlderThanYesterday:(NSDate *)date
{
return [self dateIsOlderThanYesterday:date now:[NSDate date]];
}
+ (BOOL)dateIsOlderThanYesterday:(NSDate *)date now:(NSDate *)now
{
NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now];
return dayDifference > 1;
}
+ (BOOL)dateIsOlderThanOneWeek:(NSDate *)date
{
return [self dateIsOlderThanOneWeek:date now:[NSDate date]];
}
+ (BOOL)dateIsOlderThanOneWeek:(NSDate *)date now:(NSDate *)now
{
NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now];
return dayDifference > 6;
}
+ (BOOL)dateIsToday:(NSDate *)date
{
return [self dateIsToday:date now:[NSDate date]];
}
+ (BOOL)dateIsToday:(NSDate *)date now:(NSDate *)now
{
NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now];
return dayDifference == 0;
}
+ (BOOL)dateIsThisWeek:(NSDate *)date
{
return [self dateIsThisWeek:date now:[NSDate date]];
}
+ (BOOL)dateIsThisWeek:(NSDate *)date now:(NSDate *)now
{
NSCalendar *calendar = [NSCalendar currentCalendar];
return (
[calendar component:NSCalendarUnitWeekOfYear fromDate:date] == [calendar component:NSCalendarUnitWeekOfYear fromDate:now]);
}
+ (BOOL)dateIsThisYear:(NSDate *)date
{
return [self dateIsThisYear:date now:[NSDate date]];
}
+ (BOOL)dateIsThisYear:(NSDate *)date now:(NSDate *)now
{
NSCalendar *calendar = [NSCalendar currentCalendar];
return (
[calendar component:NSCalendarUnitYear fromDate:date] == [calendar component:NSCalendarUnitYear fromDate:now]);
}
+ (BOOL)dateIsYesterday:(NSDate *)date
{
return [self dateIsYesterday:date now:[NSDate date]];
}
+ (BOOL)dateIsYesterday:(NSDate *)date now:(NSDate *)now
{
NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now];
return dayDifference == 1;
}
// Returns the difference in minutes, ignoring seconds.
// If both dates are the same date, returns 0.
// If firstDate is one minute before secondDate, returns 1.
//
// Note: Assumes both dates use the "current" calendar.
+ (NSInteger)MinutesFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate
{
NSCalendar *calendar = [NSCalendar currentCalendar];
NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour | NSCalendarUnitMinute;
NSDateComponents *comp1 = [calendar components:units fromDate:firstDate];
NSDateComponents *comp2 = [calendar components:units fromDate:secondDate];
NSDate *date1 = [calendar dateFromComponents:comp1];
NSDate *date2 = [calendar dateFromComponents:comp2];
return [[calendar components:NSCalendarUnitMinute fromDate:date1 toDate:date2 options:0] minute];
}
// Returns the difference in hours, ignoring minutes, seconds.
// If both dates are the same date, returns 0.
// If firstDate is an hour before secondDate, returns 1.
//
// Note: Assumes both dates use the "current" calendar.
+ (NSInteger)hoursFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate
{
NSCalendar *calendar = [NSCalendar currentCalendar];
NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour;
NSDateComponents *comp1 = [calendar components:units fromDate:firstDate];
NSDateComponents *comp2 = [calendar components:units fromDate:secondDate];
NSDate *date1 = [calendar dateFromComponents:comp1];
NSDate *date2 = [calendar dateFromComponents:comp2];
return [[calendar components:NSCalendarUnitHour fromDate:date1 toDate:date2 options:0] hour];
}
// Returns the difference in days, ignoring hours, minutes, seconds.
// If both dates are the same date, returns 0.
// If firstDate is a day before secondDate, returns 1.
//
// Note: Assumes both dates use the "current" calendar.
+ (NSInteger)daysFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate
{
NSCalendar *calendar = [NSCalendar currentCalendar];
NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay;
NSDateComponents *comp1 = [calendar components:units fromDate:firstDate];
NSDateComponents *comp2 = [calendar components:units fromDate:secondDate];
[comp1 setHour:12];
[comp2 setHour:12];
NSDate *date1 = [calendar dateFromComponents:comp1];
NSDate *date2 = [calendar dateFromComponents:comp2];
return [[calendar components:NSCalendarUnitDay fromDate:date1 toDate:date2 options:0] day];
}
// Returns the difference in years, ignoring shorter units of time.
// If both dates fall in the same year, returns 0.
// If firstDate is from the year before secondDate, returns 1.
//
// Note: Assumes both dates use the "current" calendar.
+ (NSInteger)yearsFromFirstDate:(NSDate *)firstDate toSecondDate:(NSDate *)secondDate
{
NSCalendar *calendar = [NSCalendar currentCalendar];
NSCalendarUnit units = NSCalendarUnitEra | NSCalendarUnitYear;
NSDateComponents *comp1 = [calendar components:units fromDate:firstDate];
NSDateComponents *comp2 = [calendar components:units fromDate:secondDate];
[comp1 setHour:12];
[comp2 setHour:12];
NSDate *date1 = [calendar dateFromComponents:comp1];
NSDate *date2 = [calendar dateFromComponents:comp2];
return [[calendar components:NSCalendarUnitYear fromDate:date1 toDate:date2 options:0] year];
}
+ (NSString *)formatPastTimestampRelativeToNow:(uint64_t)pastTimestamp
{
OWSCAssertDebug(pastTimestamp > 0);
uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp];
BOOL isFutureTimestamp = pastTimestamp >= nowTimestamp;
NSDate *pastDate = [NSDate ows_dateWithMillisecondsSince1970:pastTimestamp];
NSString *dateString;
if (isFutureTimestamp || [self dateIsToday:pastDate]) {
dateString = NSLocalizedString(@"DATE_TODAY", @"The current day.");
} else if ([self dateIsYesterday:pastDate]) {
dateString = NSLocalizedString(@"DATE_YESTERDAY", @"The day before today.");
} else {
dateString = [[self dateFormatter] stringFromDate:pastDate];
}
return [[dateString rtlSafeAppend:@" "] rtlSafeAppend:[[self timeFormatter] stringFromDate:pastDate]];
}
+ (NSString *)formatTimestampShort:(uint64_t)timestamp
{
return [self formatDateShort:[NSDate ows_dateWithMillisecondsSince1970:timestamp]];
}
+ (NSString *)formatDateShort:(NSDate *)date
{
OWSAssertIsOnMainThread();
OWSAssertDebug(date);
NSDate *now = [NSDate date];
NSInteger dayDifference = [self daysFromFirstDate:date toSecondDate:now];
BOOL dateIsOlderThanToday = dayDifference > 0;
BOOL dateIsOlderThanOneWeek = dayDifference > 6;
NSString *dateTimeString;
if (![DateUtil dateIsThisYear:date]) {
dateTimeString = [[DateUtil dateFormatter] stringFromDate:date];
} else if (dateIsOlderThanOneWeek) {
dateTimeString = [[DateUtil monthAndDayFormatter] stringFromDate:date];
} else if (dateIsOlderThanToday) {
dateTimeString = [[DateUtil shortDayOfWeekFormatter] stringFromDate:date];
} else {
dateTimeString = [[DateUtil timeFormatter] stringFromDate:date];
}
return dateTimeString.localizedUppercaseString;
}
+ (NSString *)formatDateForDisplay:(NSDate *)date
{
OWSAssertDebug(date);
if (![self dateIsThisYear:date]) {
// last year formatter: Nov 11 13:32 am, 2017
return [self.displayDateOldDateFormatter stringFromDate:date];
} else if (![self dateIsThisWeek:date]) {
// this year formatter: Jun 6 10:12 am
return [self.displayDateThisYearDateFormatter stringFromDate:date];
} else if (![self dateIsToday:date]) {
// day of week formatter: Thu 9:11 pm
return [self.displayDateThisWeekDateFormatter stringFromDate:date];
} else if (![self isWithinOneMinute:date]) {
// today formatter: 8:32 am
return [self.displayDateTodayFormatter stringFromDate:date];
} else {
return NSLocalizedString(@"DATE_NOW", @"");
}
}
+ (NSString *)formatTimestampAsTime:(uint64_t)timestamp
{
return [self formatDateAsTime:[NSDate ows_dateWithMillisecondsSince1970:timestamp]];
}
+ (NSString *)formatDateAsTime:(NSDate *)date
{
OWSAssertDebug(date);
NSString *dateTimeString = [[DateUtil timeFormatter] stringFromDate:date];
return dateTimeString.localizedUppercaseString;
}
+ (NSDateFormatter *)otherYearMessageFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
[formatter setDateFormat:@"MMM d, yyyy"];
});
return formatter;
}
+ (NSDateFormatter *)thisYearMessageFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
[formatter setDateFormat:@"MMM d"];
});
return formatter;
}
+ (NSDateFormatter *)thisWeekMessageFormatter
{
static NSDateFormatter *formatter;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [NSDateFormatter new];
[formatter setLocale:[NSLocale currentLocale]];
[formatter setDateFormat:@"E"];
});
return formatter;
}
+ (NSString *)formatMessageTimestamp:(uint64_t)timestamp
{
NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp];
uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp];
NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp];
NSCalendar *calendar = [NSCalendar currentCalendar];
NSDateComponents *relativeDiffComponents =
[calendar components:NSCalendarUnitMinute | NSCalendarUnitHour fromDate:date toDate:nowDate options:0];
NSInteger minutesDiff = MAX(0, [relativeDiffComponents minute]);
NSInteger hoursDiff = MAX(0, [relativeDiffComponents hour]);
if (hoursDiff < 1 && minutesDiff < 1) {
return NSLocalizedString(@"DATE_NOW", @"The present; the current time.");
}
if (hoursDiff < 1) {
NSString *minutesString = [OWSFormat formatInt:(int)minutesDiff];
return [NSString stringWithFormat:NSLocalizedString(@"DATE_MINUTES_AGO_FORMAT",
@"Format string for a relative time, expressed as a certain number of "
@"minutes in the past. Embeds {{The number of minutes}}."),
minutesString];
}
// Note: we are careful to treat "future" dates as "now".
NSInteger yearsDiff = [self yearsFromFirstDate:date toSecondDate:nowDate];
if (yearsDiff > 0) {
// "Long date" + locale-specific "short" time format.
NSString *dayOfWeek = [self.otherYearMessageFormatter stringFromDate:date];
NSString *formattedTime = [[self timeFormatter] stringFromDate:date];
return [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime];
}
NSInteger daysDiff = [self daysFromFirstDate:date toSecondDate:nowDate];
if (daysDiff >= 7) {
// "Short date" + locale-specific "short" time format.
NSString *dayOfWeek = [self.thisYearMessageFormatter stringFromDate:date];
NSString *formattedTime = [[self timeFormatter] stringFromDate:date];
return [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime];
} else if (daysDiff > 0) {
// "Day of week" + locale-specific "short" time format.
NSString *dayOfWeek = [self.thisWeekMessageFormatter stringFromDate:date];
NSString *formattedTime = [[self timeFormatter] stringFromDate:date];
return [[dayOfWeek rtlSafeAppend:@" "] rtlSafeAppend:formattedTime];
} else {
NSString *hoursString = [OWSFormat formatInt:(int)hoursDiff];
return [NSString stringWithFormat:NSLocalizedString(@"DATE_HOURS_AGO_FORMAT",
@"Format string for a relative time, expressed as a certain number of "
@"hours in the past. Embeds {{The number of hours}}."),
hoursString];
}
}
+ (BOOL)isTimestampFromLastHour:(uint64_t)timestamp
{
NSDate *date = [NSDate ows_dateWithMillisecondsSince1970:timestamp];
uint64_t nowTimestamp = [NSDate ows_millisecondTimeStamp];
NSDate *nowDate = [NSDate ows_dateWithMillisecondsSince1970:nowTimestamp];
NSCalendar *calendar = [NSCalendar currentCalendar];
NSInteger hoursDiff
= MAX(0, [[calendar components:NSCalendarUnitHour fromDate:date toDate:nowDate options:0] hour]);
return hoursDiff < 1;
}
+ (NSString *)exemplaryNowTimeFormat
{
return NSLocalizedString(@"DATE_NOW", @"The present; the current time.").localizedUppercaseString;
}
+ (NSString *)exemplaryMinutesTimeFormat
{
NSString *minutesString = [OWSFormat formatInt:(int)59];
return [NSString stringWithFormat:NSLocalizedString(@"DATE_MINUTES_AGO_FORMAT",
@"Format string for a relative time, expressed as a certain number of "
@"minutes in the past. Embeds {{The number of minutes}}."),
minutesString]
.uppercaseString;
}
+ (BOOL)isSameDayWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2
{
return [self isSameDayWithDate:[NSDate ows_dateWithMillisecondsSince1970:timestamp1]
date:[NSDate ows_dateWithMillisecondsSince1970:timestamp2]];
}
+ (BOOL)isSameDayWithDate:(NSDate *)date1 date:(NSDate *)date2
{
NSInteger dayDifference = [self daysFromFirstDate:date1 toSecondDate:date2];
return dayDifference == 0;
}
+ (BOOL)isSameHourWithTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2
{
return [self isSameHourWithDate:[NSDate ows_dateWithMillisecondsSince1970:timestamp1]
date:[NSDate ows_dateWithMillisecondsSince1970:timestamp2]];
}
+ (BOOL)isSameHourWithDate:(NSDate *)date1 date:(NSDate *)date2
{
NSInteger hourDifference = [self hoursFromFirstDate:date1 toSecondDate:date2];
return hourDifference == 0;
}
+ (BOOL)shouldShowDateBreakForTimestamp:(uint64_t)timestamp1 timestamp:(uint64_t)timestamp2
{
NSInteger maxMinutesBetweenTwoDateBreaks = 5;
NSDate *date1 = [NSDate ows_dateWithMillisecondsSince1970:timestamp1];
NSDate *date2 = [NSDate ows_dateWithMillisecondsSince1970:timestamp2];
return [self MinutesFromFirstDate:date1 toSecondDate:date2] > maxMinutesBetweenTwoDateBreaks;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -22,7 +22,7 @@ enum _002_SetupStandardJobs: Migration {
).inserted(db) ).inserted(db)
_ = try Job( _ = try Job(
variant: .failedMessages, variant: .failedMessageSends,
behaviour: .recurringOnLaunch, behaviour: .recurringOnLaunch,
shouldBlockFirstRunEachSession: true shouldBlockFirstRunEachSession: true
).inserted(db) ).inserted(db)
@ -42,6 +42,11 @@ enum _002_SetupStandardJobs: Migration {
variant: .retrieveDefaultOpenGroupRooms, variant: .retrieveDefaultOpenGroupRooms,
behaviour: .recurringOnActive behaviour: .recurringOnActive
).inserted(db) ).inserted(db)
_ = try Job(
variant: .garbageCollection,
behaviour: .recurringOnLaunch
).inserted(db)
} }
} }
} }

View File

@ -53,6 +53,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
case pendingDownload case pendingDownload
case downloading case downloading
case downloaded case downloaded
case failedUpload
case uploading case uploading
case uploaded case uploaded
} }
@ -351,7 +352,7 @@ extension Attachment {
) )
// Assume the data is already correct for "uploading" attachments (and don't override it) // Assume the data is already correct for "uploading" attachments (and don't override it)
case (.uploading, .failedDownload), (.uploaded, .failedDownload): return (self.isValid, self.duration) case (.uploading, _), (.uploaded, _), (.failedUpload, _): return (self.isValid, self.duration)
case (_, .failedDownload): return (false, nil) case (_, .failedDownload): return (false, nil)
default: return (self.isValid, self.duration) default: return (self.isValid, self.duration)
@ -1055,6 +1056,11 @@ extension Attachment {
success?() success?()
} }
.catch { error in .catch { error in
GRDBStorage.shared.write { db in
try updatedAttachment?
.with(state: .failedUpload)
.saved(db)
}
failure?(error) failure?(error)
} }
} }

View File

@ -465,19 +465,22 @@ public extension Interaction {
let interactionQuery = Interaction let interactionQuery = Interaction
.filter(Columns.threadId == threadId) .filter(Columns.threadId == threadId)
.filter(Columns.id <= interactionId) .filter(Columns.id <= interactionId)
.filter(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
.select(.id)
.asRequest(of: Int64.self)
.fetchAll(db)
// Don't bother continuing if there are not interactions to mark as read
guard !interactionIdsToMarkAsRead.isEmpty else { return }
// Update the `wasRead` flag to true // Update the `wasRead` flag to true
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
// Retrieve the interaction ids we want to update // Retrieve the interaction ids we want to update
scheduleJobs( scheduleJobs(interactionIds: interactionIdsToMarkAsRead)
interactionIds: try Int64.fetchAll(
db,
interactionQuery.select(.id)
)
)
} }
/// This method flags sent messages as read for the specified recipients /// This method flags sent messages as read for the specified recipients

View File

@ -209,36 +209,6 @@ public extension SessionThread {
// MARK: - Convenience // MARK: - Convenience
public extension SessionThread { public extension SessionThread {
static func displayName(userPublicKey: String) -> SQLSpecificExpressible {
let contactAlias: TypedTableAlias<Contact> = TypedTableAlias()
return (
(
(
SessionThread.Columns.variant == SessionThread.Variant.closedGroup &&
ClosedGroup.Columns.name
) || (
SessionThread.Columns.variant == SessionThread.Variant.openGroup &&
OpenGroup.Columns.name
) || (
isNoteToSelf(userPublicKey: userPublicKey)
) || (
Profile.Columns.nickname ||
Profile.Columns.name
//customFallback: Profile.truncated(id: thread.id, truncating: .middle)
)
)
)
}
/// This method can be used to create a query based on whether a thread is the note to self thread
static func isNoteToSelf(userPublicKey: String) -> SQLSpecificExpressible {
return (
SessionThread.Columns.variant == SessionThread.Variant.contact &&
SessionThread.Columns.id == userPublicKey
)
}
/// This method can be used to filter a thread query to only include messages requests /// This method can be used to filter a thread query to only include messages requests
/// ///
/// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the /// **Note:** In order to use this filter you **MUST** have a `joining(required/optional:)` to the

View File

@ -5,7 +5,7 @@ import GRDB
import SignalCoreKit import SignalCoreKit
import SessionUtilitiesKit import SessionUtilitiesKit
public enum FailedMessagesJob: JobExecutor { public enum FailedMessageSendsJob: JobExecutor {
public static let maxFailureCount: Int = -1 public static let maxFailureCount: Int = -1
public static let requiresThreadId: Bool = false public static let requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false public static let requiresInteractionId: Bool = false
@ -21,8 +21,11 @@ public enum FailedMessagesJob: JobExecutor {
let changeCount: Int = try RecipientState let changeCount: Int = try RecipientState
.filter(RecipientState.Columns.state == RecipientState.State.sending) .filter(RecipientState.Columns.state == RecipientState.State.sending)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed)) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed))
let attachmentChangeCount: Int = try Attachment
Logger.debug("Marked \(changeCount) messages as failed") .filter(Attachment.Columns.state == Attachment.State.uploading)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
Logger.debug("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)")
} }
success(job, false) success(job, false)

View File

@ -39,6 +39,7 @@ extension GarbageCollectionJob {
case threadTypingIndicators case threadTypingIndicators
case orphanedAttachmentFiles case orphanedAttachmentFiles
case orphanedProfileAvatars case orphanedProfileAvatars
case orphanedLinkPreviews
} }
public struct Details: Codable { public struct Details: Codable {

View File

@ -51,7 +51,7 @@ public enum MessageSendJob: JobExecutor {
return (true, false) return (true, false)
} }
// Create jobs for any pending attachment jobs and insert them into the // Create jobs for any pending (or failed) attachment jobs and insert them into the
// queue before the current job (this will mean the current job will re-run // queue before the current job (this will mean the current job will re-run
// after these inserted jobs complete) // after these inserted jobs complete)
// //
@ -60,7 +60,17 @@ public enum MessageSendJob: JobExecutor {
// but not on the message recipients device - both LinkPreview and Quote can // but not on the message recipients device - both LinkPreview and Quote can
// have this case) // have this case)
try allAttachmentStateInfo try allAttachmentStateInfo
.filter { $0.state == .uploading || $0.state == .downloaded } .filter { $0.state == .uploading || $0.state == .failedUpload || $0.state == .downloaded }
.filter { stateInfo in
// Don't add a new job if there is one already in the queue
!JobRunner.hasPendingOrRunningJob(
with: .attachmentUpload,
details: AttachmentUploadJob.Details(
messageSendJobId: jobId,
attachmentId: stateInfo.attachmentId
)
)
}
.compactMap { stateInfo in .compactMap { stateInfo in
JobRunner JobRunner
.insert( .insert(

View File

@ -444,6 +444,7 @@ extension MessageReceiver {
variant: variant, variant: variant,
body: message.text, body: message.text,
timestampMs: Int64(messageSentTimestamp * 1000), timestampMs: Int64(messageSentTimestamp * 1000),
wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read
hasMention: ( hasMention: (
message.text?.contains("@\(currentUserPublicKey)") == true || message.text?.contains("@\(currentUserPublicKey)") == true ||
dataMessage.quote?.author == currentUserPublicKey dataMessage.quote?.author == currentUserPublicKey
@ -646,7 +647,9 @@ extension MessageReceiver {
} }
} }
// For outgoing messages mark it and all older interactions as read // For outgoing messages mark all older interactions as read (the user should have seen
// them if they send a message - also avoids a situation where the user has "phantom"
// unread messages that they need to scroll back to before they become marked as read)
try Interaction.markAsRead( try Interaction.markAsRead(
db, db,
interactionId: interactionId, interactionId: interactionId,

View File

@ -0,0 +1,699 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SessionUtilitiesKit
fileprivate typealias ViewModel = MessageViewModel
fileprivate typealias AttachmentInteractionInfo = MessageViewModel.AttachmentInteractionInfo
public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable {
public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue)
public static let threadIsTrustedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsTrusted.stringValue)
public static let threadHasDisappearingMessagesEnabledKey: SQL = SQL(stringLiteral: CodingKeys.threadHasDisappearingMessagesEnabled.stringValue)
public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue)
public static let stateKey: SQL = SQL(stringLiteral: CodingKeys.state.stringValue)
public static let hasAtLeastOneReadReceiptKey: SQL = SQL(stringLiteral: CodingKeys.hasAtLeastOneReadReceipt.stringValue)
public static let mostRecentFailureTextKey: SQL = SQL(stringLiteral: CodingKeys.mostRecentFailureText.stringValue)
public static let isTypingIndicatorKey: SQL = SQL(stringLiteral: CodingKeys.isTypingIndicator.stringValue)
public static let isSenderOpenGroupModeratorKey: SQL = SQL(stringLiteral: CodingKeys.isSenderOpenGroupModerator.stringValue)
public static let profileKey: SQL = SQL(stringLiteral: CodingKeys.profile.stringValue)
public static let quoteKey: SQL = SQL(stringLiteral: CodingKeys.quote.stringValue)
public static let quoteAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.quoteAttachment.stringValue)
public static let linkPreviewKey: SQL = SQL(stringLiteral: CodingKeys.linkPreview.stringValue)
public static let linkPreviewAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.linkPreviewAttachment.stringValue)
public static let cellTypeKey: SQL = SQL(stringLiteral: CodingKeys.cellType.stringValue)
public static let authorNameKey: SQL = SQL(stringLiteral: CodingKeys.authorName.stringValue)
public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue)
public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue)
public static let isOnlyMessageInClusterKey: SQL = SQL(stringLiteral: CodingKeys.isOnlyMessageInCluster.stringValue)
public static let isLastKey: SQL = SQL(stringLiteral: CodingKeys.isLast.stringValue)
public static let profileString: String = CodingKeys.profile.stringValue
public static let quoteString: String = CodingKeys.quote.stringValue
public static let quoteAttachmentString: String = CodingKeys.quoteAttachment.stringValue
public static let linkPreviewString: String = CodingKeys.linkPreview.stringValue
public static let linkPreviewAttachmentString: String = CodingKeys.linkPreviewAttachment.stringValue
public enum Position: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible {
case top
case middle
case bottom
}
public enum CellType: Int, Decodable, Equatable, Hashable, DatabaseValueConvertible {
case textOnlyMessage
case mediaMessage
case audio
case genericAttachment
case typingIndicator
}
public var differenceIdentifier: Int64 { id }
// Thread Info
public let threadVariant: SessionThread.Variant
public let threadIsTrusted: Bool
public let threadHasDisappearingMessagesEnabled: Bool
// Interaction Info
public let rowId: Int64
public let id: Int64
public let variant: Interaction.Variant
public let timestampMs: Int64
public let authorId: String
private let authorNameInternal: String?
public let body: String?
public let expiresStartedAtMs: Double?
public let expiresInSeconds: TimeInterval?
public let state: RecipientState.State
public let hasAtLeastOneReadReceipt: Bool
public let mostRecentFailureText: String?
public let isTypingIndicator: Bool
public let isSenderOpenGroupModerator: Bool
public let profile: Profile?
public let quote: Quote?
public let quoteAttachment: Attachment?
public let linkPreview: LinkPreview?
public let linkPreviewAttachment: Attachment?
// Post-Query Processing Data
/// This value includes the associated attachments
public let attachments: [Attachment]?
/// This value defines what type of cell should appear and is generated based on the interaction variant
/// and associated attachment data
public let cellType: CellType
/// This value includes the author name information
public let authorName: String
/// This value will be used to populate the author label, if it's null then the label will be hidden
public let senderName: String?
/// A flag indicating whether the profile view should be displayed
public let shouldShowProfile: Bool
/// This value will be used to populate the date header, if it's null then the header will be hidden
public let dateForUI: Date?
/// This value specifies whether the body contains only emoji characters
public let containsOnlyEmoji: Bool?
/// This value specifies the number of emoji characters the body contains
public let glyphCount: Int?
/// This value indicates the variant of the previous ViewModel item, if it's null then there is no previous item
public let previousVariant: Interaction.Variant?
/// This value indicates the position of this message within a cluser of messages
public let positionInCluster: Position
/// This value indicates whether this is the only message in a cluser of messages
public let isOnlyMessageInCluster: Bool
/// This value indicates whether this is the last message in the thread
public let isLast: Bool
// MARK: - Mutation
public func with(attachments: [Attachment]) -> MessageViewModel {
return MessageViewModel(
threadVariant: self.threadVariant,
threadIsTrusted: self.threadIsTrusted,
threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled,
rowId: self.rowId,
id: self.id,
variant: self.variant,
timestampMs: self.timestampMs,
authorId: self.authorId,
authorNameInternal: self.authorNameInternal,
body: self.body,
expiresStartedAtMs: self.expiresStartedAtMs,
expiresInSeconds: self.expiresInSeconds,
state: self.state,
hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt,
mostRecentFailureText: self.mostRecentFailureText,
isTypingIndicator: self.isTypingIndicator,
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
profile: self.profile,
quote: self.quote,
quoteAttachment: self.quoteAttachment,
linkPreview: self.linkPreview,
linkPreviewAttachment: self.linkPreviewAttachment,
attachments: attachments,
cellType: self.cellType,
authorName: self.authorName,
senderName: self.senderName,
shouldShowProfile: self.shouldShowProfile,
dateForUI: self.dateForUI,
containsOnlyEmoji: self.containsOnlyEmoji,
glyphCount: self.glyphCount,
previousVariant: self.previousVariant,
positionInCluster: self.positionInCluster,
isOnlyMessageInCluster: self.isOnlyMessageInCluster,
isLast: self.isLast
)
}
public func withClusteringChanges(
prevModel: MessageViewModel?,
nextModel: MessageViewModel?,
isLast: Bool
) -> MessageViewModel {
let cellType: CellType = {
guard !self.isTypingIndicator else { return .typingIndicator }
guard self.variant != .standardIncomingDeleted else { return .textOnlyMessage }
guard let attachment: Attachment = self.attachments?.first else { return .textOnlyMessage }
// The only case which currently supports multiple attachments is a 'mediaMessage'
// (the album view)
guard self.attachments?.count == 1 else { return .mediaMessage }
// Quote and LinkPreview overload the 'attachments' array and use it for their
// own purposes, otherwise check if the attachment is visual media
guard self.quote == nil else { return .textOnlyMessage }
guard self.linkPreview == nil else { return .textOnlyMessage }
// Pending audio attachments won't have a duration
if
attachment.isAudio && (
((attachment.duration ?? 0) > 0) ||
(
attachment.state != .downloaded &&
attachment.state != .uploaded
)
)
{
return .audio
}
if attachment.isVisualMedia {
return .mediaMessage
}
return .genericAttachment
}()
let authorDisplayName: String = Profile.displayName(
for: self.threadVariant,
id: self.authorId,
name: self.authorNameInternal,
nickname: nil // Folded into 'authorName' within the Query
)
let shouldShowDateOnThisModel: Bool = {
guard !self.isTypingIndicator else { return false }
guard let prevModel: ViewModel = prevModel else { return true }
return MessageViewModel.shouldShowDateBreak(
between: prevModel.timestampMs,
and: self.timestampMs
)
}()
let shouldShowDateOnNextModel: Bool = {
// Should be nothing after a typing indicator
guard !self.isTypingIndicator else { return false }
guard let nextModel: ViewModel = nextModel else { return false }
return MessageViewModel.shouldShowDateBreak(
between: self.timestampMs,
and: nextModel.timestampMs
)
}()
let (positionInCluster, isOnlyMessageInCluster): (Position, Bool) = {
let isFirstInCluster: Bool = (
prevModel == nil ||
shouldShowDateOnThisModel || (
self.variant == .standardOutgoing &&
prevModel?.variant != .standardOutgoing
) || (
(
self.variant == .standardIncoming ||
self.variant == .standardIncomingDeleted
) && (
prevModel?.variant != .standardIncoming &&
prevModel?.variant != .standardIncomingDeleted
)
) ||
self.authorId != prevModel?.authorId
)
let isLastInCluster: Bool = (
nextModel == nil ||
shouldShowDateOnNextModel || (
self.variant == .standardOutgoing &&
nextModel?.variant != .standardOutgoing
) || (
(
self.variant == .standardIncoming ||
self.variant == .standardIncomingDeleted
) && (
nextModel?.variant != .standardIncoming &&
nextModel?.variant != .standardIncomingDeleted
)
) ||
self.authorId != nextModel?.authorId
)
let isOnlyMessageInCluster: Bool = (isFirstInCluster && isLastInCluster)
switch (isFirstInCluster, isLastInCluster) {
case (true, true), (false, false): return (.middle, isOnlyMessageInCluster)
case (true, false): return (.top, isOnlyMessageInCluster)
case (false, true): return (.bottom, isOnlyMessageInCluster)
}
}()
return ViewModel(
threadVariant: self.threadVariant,
threadIsTrusted: self.threadIsTrusted,
threadHasDisappearingMessagesEnabled: self.threadHasDisappearingMessagesEnabled,
rowId: self.rowId,
id: self.id,
variant: self.variant,
timestampMs: self.timestampMs,
authorId: self.authorId,
authorNameInternal: self.authorNameInternal,
body: (!self.variant.isInfoMessage ?
self.body :
// Info messages might not have a body so we should use the 'previewText' value instead
Interaction.previewText(
variant: self.variant,
body: self.body,
authorDisplayName: authorDisplayName,
attachmentDescriptionInfo: self.attachments?.first.map { firstAttachment in
Attachment.DescriptionInfo(
id: firstAttachment.id,
variant: firstAttachment.variant,
contentType: firstAttachment.contentType,
sourceFilename: firstAttachment.sourceFilename
)
},
attachmentCount: self.attachments?.count,
isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation)
)
),
expiresStartedAtMs: self.expiresStartedAtMs,
expiresInSeconds: self.expiresInSeconds,
state: self.state,
hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt,
mostRecentFailureText: self.mostRecentFailureText,
isTypingIndicator: self.isTypingIndicator,
isSenderOpenGroupModerator: self.isSenderOpenGroupModerator,
profile: self.profile,
quote: self.quote,
quoteAttachment: self.quoteAttachment,
linkPreview: self.linkPreview,
linkPreviewAttachment: self.linkPreviewAttachment,
attachments: self.attachments,
cellType: cellType,
authorName: authorDisplayName,
senderName: {
// Only show for group threads
guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else {
return nil
}
// Only if there is a date header or the senders are different
guard shouldShowDateOnThisModel || self.authorId != prevModel?.authorId else {
return nil
}
return authorDisplayName
}(),
shouldShowProfile: (
// Only group threads
(self.threadVariant == .openGroup || self.threadVariant == .closedGroup) &&
// Only incoming messages
(self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) &&
// Show if the next message has a different sender or has a "date break"
(
self.authorId != nextModel?.authorId ||
shouldShowDateOnNextModel
) &&
// Need a profile to be able to show it
self.profile != nil
),
dateForUI: (shouldShowDateOnThisModel ?
Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) :
nil
),
containsOnlyEmoji: self.body?.containsOnlyEmoji,
glyphCount: self.body?.glyphCount,
previousVariant: prevModel?.variant,
positionInCluster: positionInCluster,
isOnlyMessageInCluster: isOnlyMessageInCluster,
isLast: isLast
)
}
}
// MARK: - AttachmentInteractionInfo
public extension MessageViewModel {
struct AttachmentInteractionInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Comparable {
public static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
public static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
public static let interactionAttachmentKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachment.stringValue)
public static let attachmentString: String = CodingKeys.attachment.stringValue
public static let interactionAttachmentString: String = CodingKeys.interactionAttachment.stringValue
public let rowId: Int64
public let attachment: Attachment
public let interactionAttachment: InteractionAttachment
// MARK: - Identifiable
public var id: String {
"\(interactionAttachment.interactionId)-\(interactionAttachment.albumIndex)"
}
// MARK: - Comparable
public static func < (lhs: AttachmentInteractionInfo, rhs: AttachmentInteractionInfo) -> Bool {
return (lhs.interactionAttachment.albumIndex < rhs.interactionAttachment.albumIndex)
}
}
}
// MARK: - Convenience Initialization
public extension MessageViewModel {
// Note: This init method is only used system-created cells or empty states
init(isTypingIndicator: Bool = false) {
self.threadVariant = .contact
self.threadIsTrusted = false
self.threadHasDisappearingMessagesEnabled = false
// Interaction Info
self.rowId = -1
self.id = -1
self.variant = .standardOutgoing
self.timestampMs = Int64.max
self.authorId = ""
self.authorNameInternal = nil
self.body = nil
self.expiresStartedAtMs = nil
self.expiresInSeconds = nil
self.state = .sent
self.hasAtLeastOneReadReceipt = false
self.mostRecentFailureText = nil
self.isTypingIndicator = isTypingIndicator
self.isSenderOpenGroupModerator = false
self.profile = nil
self.quote = nil
self.quoteAttachment = nil
self.linkPreview = nil
self.linkPreviewAttachment = nil
// Post-Query Processing Data
self.attachments = nil
self.cellType = .typingIndicator
self.authorName = ""
self.senderName = nil
self.shouldShowProfile = false
self.dateForUI = nil
self.containsOnlyEmoji = nil
self.glyphCount = nil
self.previousVariant = nil
self.positionInCluster = .middle
self.isOnlyMessageInCluster = true
self.isLast = true
}
}
// MARK: - Convenience
extension MessageViewModel {
private static let maxMinutesBetweenTwoDateBreaks: Int = 5
/// Returns the difference in minutes, ignoring seconds
///
/// If both dates are the same date, returns 0
/// If firstDate is one minute before secondDate, returns 1
///
/// **Note:** Assumes both dates use the "current" calendar
private static func minutesFrom(_ firstDate: Date, to secondDate: Date) -> Int? {
let calendar: Calendar = Calendar.current
let components1: DateComponents = calendar.dateComponents(
[.era, .year, .month, .day, .hour, .minute],
from: firstDate
)
let components2: DateComponents = calendar.dateComponents(
[.era, .year, .month, .day, .hour, .minute],
from: secondDate
)
guard
let date1: Date = calendar.date(from: components1),
let date2: Date = calendar.date(from: components2)
else { return nil }
return calendar.dateComponents([.minute], from: date1, to: date2).minute
}
fileprivate static func shouldShowDateBreak(between timestamp1: Int64, and timestamp2: Int64) -> Bool {
let date1: Date = Date(timeIntervalSince1970: (TimeInterval(timestamp1) / 1000))
let date2: Date = Date(timeIntervalSince1970: (TimeInterval(timestamp2) / 1000))
return ((minutesFrom(date1, to: date2) ?? 0) > maxMinutesBetweenTwoDateBreaks)
}
}
// MARK: - ConversationVC
public extension MessageViewModel {
static func filterSQL(threadId: String) -> SQL {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("\(interaction[.threadId]) = \(threadId)")
}
static let orderSQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("\(interaction[.timestampMs].desc)")
}()
static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest<SQLRequest<MessageViewModel>>) {
return { additionalFilters, limitSQL -> AdaptedFetchRequest<SQLRequest<ViewModel>> in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let disappearingMessagesConfig: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let interactionStateTableLiteral: SQL = SQL(stringLiteral: "interactionState")
let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name)
let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name)
let interactionStateMostRecentFailureTextColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.mostRecentFailureText.name)
let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt")
let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name)
let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name)
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
return """
WHERE \(baseFilterSQL)
"""
}
return """
WHERE (
\(baseFilterSQL) AND
\(additionalFilters)
)
"""
}()
let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: ""))
let numColumnsBeforeLinkedRecords: Int = 17
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.variant]) AS \(ViewModel.threadVariantKey),
-- Default to 'true' for non-contact threads
IFNULL(\(contact[.isTrusted]), true) AS \(ViewModel.threadIsTrustedKey),
-- Default to 'false' when no contact exists
IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey),
\(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey),
\(interaction[.id]),
\(interaction[.variant]),
\(interaction[.timestampMs]),
\(interaction[.authorId]),
IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey),
\(interaction[.body]),
\(interaction[.expiresStartedAtMs]),
\(interaction[.expiresInSeconds]),
-- Default to 'sending' assuming non-processed interaction when null
IFNULL(\(interactionStateTableLiteral).\(interactionStateStateColumnLiteral), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey),
(\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey),
\(interactionStateTableLiteral).\(interactionStateMostRecentFailureTextColumnLiteral) AS \(ViewModel.mostRecentFailureTextKey),
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.isTypingIndicatorKey),
false AS \(ViewModel.isSenderOpenGroupModeratorKey),
\(ViewModel.profileKey).*,
\(ViewModel.quoteKey).*,
\(ViewModel.quoteAttachmentKey).*,
\(ViewModel.linkPreviewKey).*,
\(ViewModel.linkPreviewAttachmentKey).*,
-- All of the below properties are set in post-query processing but to prevent the
-- query from crashing when decoding we need to provide default values
\(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey),
'' AS \(ViewModel.authorNameKey),
false AS \(ViewModel.shouldShowProfileKey),
\(Position.middle) AS \(ViewModel.positionInClusterKey),
false AS \(ViewModel.isOnlyMessageInClusterKey),
false AS \(ViewModel.isLastKey)
FROM \(Interaction.self)
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])
LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])
LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId])
LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])
LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id])
LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId])
LEFT JOIN \(LinkPreview.self) ON (
\(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND
\(Interaction.linkPreviewFilterLiteral)
)
LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId])
LEFT JOIN (
\(RecipientState.selectInteractionState(
tableLiteral: interactionStateTableLiteral,
idColumnLiteral: interactionStateInteractionIdColumnLiteral
))
) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id])
LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON (
\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND
\(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral)
)
\(finalFilterSQL)
ORDER BY \(orderSQL)
\(finalLimitSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Profile.numberOfSelectedColumns(db),
Quote.numberOfSelectedColumns(db),
Attachment.numberOfSelectedColumns(db),
LinkPreview.numberOfSelectedColumns(db),
Attachment.numberOfSelectedColumns(db)
])
return ScopeAdapter([
ViewModel.profileString: adapters[1],
ViewModel.quoteString: adapters[2],
ViewModel.quoteAttachmentString: adapters[3],
ViewModel.linkPreviewString: adapters[4],
ViewModel.linkPreviewAttachmentString: adapters[5]
])
}
}
}
}
public extension MessageViewModel.AttachmentInteractionInfo {
static let baseQuery: ((SQL?) -> AdaptedFetchRequest<SQLRequest<MessageViewModel.AttachmentInteractionInfo>>) = {
return { additionalFilters -> AdaptedFetchRequest<SQLRequest<AttachmentInteractionInfo>> in
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
return SQL(stringLiteral: "")
}
return """
WHERE \(additionalFilters)
"""
}()
let numColumnsBeforeLinkedRecords: Int = 1
let request: SQLRequest<AttachmentInteractionInfo> = """
SELECT
\(attachment.alias[Column.rowID]) AS \(AttachmentInteractionInfo.rowIdKey),
\(AttachmentInteractionInfo.attachmentKey).*,
\(AttachmentInteractionInfo.interactionAttachmentKey).*
FROM \(Attachment.self)
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
\(finalFilterSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Attachment.numberOfSelectedColumns(db),
InteractionAttachment.numberOfSelectedColumns(db)
])
return ScopeAdapter([
AttachmentInteractionInfo.attachmentString: adapters[1],
AttachmentInteractionInfo.interactionAttachmentString: adapters[2]
])
}
}
}()
static var joinToViewModelQuerySQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return """
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
JOIN \(Interaction.self) ON
\(interaction[.id]) = \(interactionAttachment[.interactionId])
"""
}()
static var groupViewModelQuerySQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return "\(interaction[.id])"
}()
static func createAssociateDataClosure() -> (DataCache<MessageViewModel.AttachmentInteractionInfo>, DataCache<MessageViewModel>) -> DataCache<MessageViewModel> {
return { dataCache, pagedDataCache -> DataCache<MessageViewModel> in
var updatedPagedDataCache: DataCache<MessageViewModel> = pagedDataCache
dataCache
.values
.grouped(by: \.interactionAttachment.interactionId)
.forEach { (interactionId: Int64, attachments: [MessageViewModel.AttachmentInteractionInfo]) in
guard
let interactionRowId: Int64 = updatedPagedDataCache.lookup[interactionId],
let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId]
else { return }
updatedPagedDataCache = updatedPagedDataCache.upserting(
dataToUpdate.with(
attachments: attachments
.sorted()
.map { $0.attachment }
)
)
}
return updatedPagedDataCache
}
}
}

View File

@ -4,200 +4,194 @@ import Foundation
import GRDB import GRDB
import DifferenceKit import DifferenceKit
fileprivate typealias ViewModel = ConversationCell.ViewModel fileprivate typealias ViewModel = SessionThreadViewModel
public enum ConversationCell {} /// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the
/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each
// MARK: - ViewModel /// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places
///
extension ConversationCell { /// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values
/// This type is used to populate the `ConversationCell` in the `HomeVC`, `MessageRequestsViewController` and the /// in order to optimise their queries to only include the required data
/// `GlobalSearchViewController`, it has a number of query methods which can be used to retrieve the relevant data for each public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable {
/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue)
public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue)
public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue)
public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue)
public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue)
public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue)
public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue)
public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue)
public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue)
public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue)
public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue)
public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue)
public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue)
public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue)
public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue)
public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue)
public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue)
public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue)
public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue)
public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue)
public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue)
public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue)
public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue)
public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue)
public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue)
public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue)
public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue)
public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue)
public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue)
public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue)
public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue)
public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue)
public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue)
public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue)
public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue)
public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue)
public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue
public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue
public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue
public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue
public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue
public static let contactProfileString: String = CodingKeys.contactProfile.stringValue
public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue
public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue
public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue
public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue
public var differenceIdentifier: String { threadId }
public let threadId: String
public let threadVariant: SessionThread.Variant
private let threadCreationDateTimestamp: TimeInterval
public let threadMemberNames: String?
public let threadIsNoteToSelf: Bool
public var threadIsMessageRequest: Bool?
public let threadRequiresApproval: Bool?
public let threadShouldBeVisible: Bool?
public let threadIsPinned: Bool
public var threadIsBlocked: Bool?
public let threadMutedUntilTimestamp: TimeInterval?
public let threadOnlyNotifyForMentions: Bool?
public let threadMessageDraft: String?
public let threadContactIsTyping: Bool?
public let threadUnreadCount: UInt?
public let threadUnreadMentionCount: UInt?
public let threadFirstUnreadInteractionId: Int64?
// Thread display info
private let contactProfile: Profile?
private let closedGroupProfileFront: Profile?
private let closedGroupProfileBack: Profile?
private let closedGroupProfileBackFallback: Profile?
public let closedGroupName: String?
private let closedGroupUserCount: Int?
public let currentUserIsClosedGroupMember: Bool?
public let currentUserIsClosedGroupAdmin: Bool?
public let openGroupName: String?
public let openGroupServer: String?
public let openGroupRoom: String?
public let openGroupProfilePictureData: Data?
private let openGroupUserCount: Int?
// Interaction display info
public let interactionId: Int64?
public let interactionVariant: Interaction.Variant?
private let interactionTimestampMs: Int64?
public let interactionBody: String?
public let interactionState: RecipientState.State?
public let interactionIsOpenGroupInvitation: Bool?
public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo?
public let interactionAttachmentCount: Int?
public let authorId: String?
private let authorNameInternal: String?
public let currentUserPublicKey: String
// UI specific logic
public var displayName: String {
return SessionThread.displayName(
threadId: threadId,
variant: threadVariant,
closedGroupName: closedGroupName,
openGroupName: openGroupName,
isNoteToSelf: threadIsNoteToSelf,
profile: profile
)
}
public var profile: Profile? {
switch threadVariant {
case .contact: return contactProfile
case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback)
case .openGroup: return nil
}
}
public var additionalProfile: Profile? {
switch threadVariant {
case .closedGroup: return closedGroupProfileFront
default: return nil
}
}
public var lastInteractionDate: Date {
guard let interactionTimestampMs: Int64 = interactionTimestampMs else {
return Date(timeIntervalSince1970: threadCreationDateTimestamp)
}
return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000))
}
public var enabledMessageTypes: MessageInputTypes {
guard !threadIsNoteToSelf else { return .all }
return (threadRequiresApproval == false && threadIsMessageRequest == false ?
.all :
.textOnly
)
}
public var userCount: Int? {
switch threadVariant {
case .contact: return nil
case .closedGroup: return closedGroupUserCount
case .openGroup: return openGroupUserCount
}
}
/// This function returns the profile name formatted for the specific type of thread provided
/// ///
/// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values /// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this
/// in order to optimise their queries to only include the required data /// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided
public struct ViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { /// parameter
public static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) public func authorName(for threadVariant: SessionThread.Variant) -> String {
public static let threadVariantKey: SQL = SQL(stringLiteral: CodingKeys.threadVariant.stringValue) return Profile.displayName(
public static let threadCreationDateTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadCreationDateTimestamp.stringValue) for: threadVariant,
public static let threadMemberNamesKey: SQL = SQL(stringLiteral: CodingKeys.threadMemberNames.stringValue) id: (authorId ?? threadId),
public static let threadIsNoteToSelfKey: SQL = SQL(stringLiteral: CodingKeys.threadIsNoteToSelf.stringValue) name: authorNameInternal,
public static let threadIsMessageRequestKey: SQL = SQL(stringLiteral: CodingKeys.threadIsMessageRequest.stringValue) nickname: nil, // Folded into 'authorName' within the Query
public static let threadRequiresApprovalKey: SQL = SQL(stringLiteral: CodingKeys.threadRequiresApproval.stringValue) customFallback: (threadVariant == .contact ?
public static let threadShouldBeVisibleKey: SQL = SQL(stringLiteral: CodingKeys.threadShouldBeVisible.stringValue) "Anonymous" :
public static let threadIsPinnedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsPinned.stringValue) nil
public static let threadIsBlockedKey: SQL = SQL(stringLiteral: CodingKeys.threadIsBlocked.stringValue)
public static let threadMutedUntilTimestampKey: SQL = SQL(stringLiteral: CodingKeys.threadMutedUntilTimestamp.stringValue)
public static let threadOnlyNotifyForMentionsKey: SQL = SQL(stringLiteral: CodingKeys.threadOnlyNotifyForMentions.stringValue)
public static let threadMessageDraftKey: SQL = SQL(stringLiteral: CodingKeys.threadMessageDraft.stringValue)
public static let threadContactIsTypingKey: SQL = SQL(stringLiteral: CodingKeys.threadContactIsTyping.stringValue)
public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue)
public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue)
public static let threadFirstUnreadInteractionIdKey: SQL = SQL(stringLiteral: CodingKeys.threadFirstUnreadInteractionId.stringValue)
public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue)
public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue)
public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue)
public static let currentUserIsClosedGroupMemberKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupMember.stringValue)
public static let currentUserIsClosedGroupAdminKey: SQL = SQL(stringLiteral: CodingKeys.currentUserIsClosedGroupAdmin.stringValue)
public static let closedGroupProfileFrontKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileFront.stringValue)
public static let closedGroupProfileBackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBack.stringValue)
public static let closedGroupProfileBackFallbackKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupProfileBackFallback.stringValue)
public static let openGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.openGroupName.stringValue)
public static let openGroupServerKey: SQL = SQL(stringLiteral: CodingKeys.openGroupServer.stringValue)
public static let openGroupRoomKey: SQL = SQL(stringLiteral: CodingKeys.openGroupRoom.stringValue)
public static let openGroupProfilePictureDataKey: SQL = SQL(stringLiteral: CodingKeys.openGroupProfilePictureData.stringValue)
public static let openGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.openGroupUserCount.stringValue)
public static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
public static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
public static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
public static let interactionBodyKey: SQL = SQL(stringLiteral: CodingKeys.interactionBody.stringValue)
public static let interactionIsOpenGroupInvitationKey: SQL = SQL(stringLiteral: CodingKeys.interactionIsOpenGroupInvitation.stringValue)
public static let interactionAttachmentDescriptionInfoKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentDescriptionInfo.stringValue)
public static let interactionAttachmentCountKey: SQL = SQL(stringLiteral: CodingKeys.interactionAttachmentCount.stringValue)
public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue)
public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue)
public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue
public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue
public static let threadFirstUnreadInteractionIdString: String = CodingKeys.threadFirstUnreadInteractionId.stringValue
public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue
public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue
public static let contactProfileString: String = CodingKeys.contactProfile.stringValue
public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue
public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue
public static let closedGroupProfileBackFallbackString: String = CodingKeys.closedGroupProfileBackFallback.stringValue
public static let interactionAttachmentDescriptionInfoString: String = CodingKeys.interactionAttachmentDescriptionInfo.stringValue
public var differenceIdentifier: ViewModel { self } // TODO: Confirm this does what we want (ie. update on any data change)
public let threadId: String
public let threadVariant: SessionThread.Variant
private let threadCreationDateTimestamp: TimeInterval
public let threadMemberNames: String?
public let threadIsNoteToSelf: Bool
public var threadIsMessageRequest: Bool?
public let threadRequiresApproval: Bool?
public let threadShouldBeVisible: Bool?
public let threadIsPinned: Bool
public var threadIsBlocked: Bool?
public let threadMutedUntilTimestamp: TimeInterval?
public let threadOnlyNotifyForMentions: Bool?
public let threadMessageDraft: String?
public let threadContactIsTyping: Bool?
public let threadUnreadCount: UInt?
public let threadUnreadMentionCount: UInt?
public let threadFirstUnreadInteractionId: Int64?
// Thread display info
private let contactProfile: Profile?
private let closedGroupProfileFront: Profile?
private let closedGroupProfileBack: Profile?
private let closedGroupProfileBackFallback: Profile?
public let closedGroupName: String?
private let closedGroupUserCount: Int?
public let currentUserIsClosedGroupMember: Bool?
public let currentUserIsClosedGroupAdmin: Bool?
public let openGroupName: String?
public let openGroupServer: String?
public let openGroupRoom: String?
public let openGroupProfilePictureData: Data?
private let openGroupUserCount: Int?
// Interaction display info
public let interactionId: Int64?
public let interactionVariant: Interaction.Variant?
private let interactionTimestampMs: Int64?
public let interactionBody: String?
public let interactionState: RecipientState.State?
public let interactionIsOpenGroupInvitation: Bool?
public let interactionAttachmentDescriptionInfo: Attachment.DescriptionInfo?
public let interactionAttachmentCount: Int?
public let authorId: String?
private let authorNameInternal: String?
public let currentUserPublicKey: String
// UI specific logic
public var displayName: String {
return SessionThread.displayName(
threadId: threadId,
variant: threadVariant,
closedGroupName: closedGroupName,
openGroupName: openGroupName,
isNoteToSelf: threadIsNoteToSelf,
profile: profile
) )
} )
public var profile: Profile? {
switch threadVariant {
case .contact: return contactProfile
case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback)
case .openGroup: return nil
}
}
public var additionalProfile: Profile? {
switch threadVariant {
case .closedGroup: return closedGroupProfileFront
default: return nil
}
}
public var lastInteractionDate: Date {
guard let interactionTimestampMs: Int64 = interactionTimestampMs else {
return Date(timeIntervalSince1970: threadCreationDateTimestamp)
}
return Date(timeIntervalSince1970: (TimeInterval(interactionTimestampMs) / 1000))
}
public var enabledMessageTypes: MessageInputTypes {
guard !threadIsNoteToSelf else { return .all }
return (threadRequiresApproval == false && threadIsMessageRequest == false ?
.all :
.textOnly
)
}
public var userCount: Int? {
switch threadVariant {
case .contact: return nil
case .closedGroup: return closedGroupUserCount
case .openGroup: return openGroupUserCount
}
}
/// This function returns the profile name formatted for the specific type of thread provided
///
/// **Note:** The 'threadVariant' parameter is used for profile context but in the search results we actually want this
/// to always behave as the `contact` variant which is why this needs to be a function instead of just using the provided
/// parameter
public func authorName(for threadVariant: SessionThread.Variant) -> String {
return Profile.displayName(
for: threadVariant,
id: (authorId ?? threadId),
name: authorNameInternal,
nickname: nil, // Folded into 'authorName' within the Query
customFallback: (threadVariant == .contact ?
"Anonymous" :
nil
)
)
}
} }
} }
// MARK: - Convenience Initialization // MARK: - Convenience Initialization
public extension ConversationCell.ViewModel { public extension SessionThreadViewModel {
// Note: This init method is only used system-created cells or empty states // Note: This init method is only used system-created cells or empty states
init(unreadCount: UInt = 0) { init(unreadCount: UInt = 0) {
self.threadId = "INVALID_THREAD_ID" self.threadId = "INVALID_THREAD_ID"
@ -255,12 +249,12 @@ public extension ConversationCell.ViewModel {
// MARK: - HomeVC & MessageRequestsViewController // MARK: - HomeVC & MessageRequestsViewController
public extension ConversationCell.ViewModel { public extension SessionThreadViewModel {
private static func baseQuery( private static func baseQuery(
userPublicKey: String, userPublicKey: String,
filters: SQL, filters: SQL,
ordering: SQL ordering: SQL
) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { ) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias() let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
@ -370,7 +364,7 @@ public extension ConversationCell.ViewModel {
\(groupMember[.groupId]) = \(closedGroup[.threadId]) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND
\(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) \(SQL("\(groupMember[.profileId]) = \(userPublicKey)"))
) )
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON (
\(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = (
@ -456,7 +450,7 @@ public extension ConversationCell.ViewModel {
} }
} }
static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
@ -481,7 +475,7 @@ public extension ConversationCell.ViewModel {
) )
} }
static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
@ -508,11 +502,11 @@ public extension ConversationCell.ViewModel {
// MARK: - ConversationVC // MARK: - ConversationVC
public extension ConversationCell.ViewModel { public extension SessionThreadViewModel {
static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias() let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias() // TODO: Remove this (not needed here - tracked via the messages)
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias() let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias() let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias() let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
@ -550,8 +544,10 @@ public extension ConversationCell.ViewModel {
) )
) AS \(ViewModel.threadIsMessageRequestKey), ) AS \(ViewModel.threadIsMessageRequestKey),
( (
IFNULL(\(contact[.isApproved]), false) = false OR \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND (
IFNULL(\(contact[.didApproveMe]), false) = false IFNULL(\(contact[.isApproved]), false) = false OR
IFNULL(\(contact[.didApproveMe]), false) = false
)
) AS \(ViewModel.threadRequiresApprovalKey), ) AS \(ViewModel.threadRequiresApprovalKey),
\(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey),
@ -575,6 +571,8 @@ public extension ConversationCell.ViewModel {
\(openGroup[.room]) AS \(ViewModel.openGroupRoomKey), \(openGroup[.room]) AS \(ViewModel.openGroupRoomKey),
\(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey),
\(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey),
\(interaction[.id]) AS \(ViewModel.interactionIdKey),
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
@ -592,6 +590,11 @@ public extension ConversationCell.ViewModel {
\(SQL("\(interaction[.threadId]) = \(threadId)")) \(SQL("\(interaction[.threadId]) = \(threadId)"))
) )
) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id]) ) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id])
LEFT JOIN (
SELECT *, MAX(\(interaction[.timestampMs]))
FROM \(Interaction.self)
GROUP BY \(interaction[.threadId])
) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
\(interaction[.threadId]), \(interaction[.threadId]),
@ -651,11 +654,97 @@ public extension ConversationCell.ViewModel {
]) ])
} }
} }
static func conversationSettingsProfileQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name)
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to
/// parse and might throw
///
/// Explicitly set default values for the fields ignored for search results
let numColumnsBeforeProfiles: Int = 5
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.id]) AS \(ViewModel.threadIdKey),
\(thread[.variant]) AS \(ViewModel.threadVariantKey),
\(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey),
false AS \(ViewModel.threadIsNoteToSelfKey),
false AS \(ViewModel.threadIsPinnedKey),
\(ViewModel.contactProfileKey).*,
\(ViewModel.closedGroupProfileFrontKey).*,
\(ViewModel.closedGroupProfileBackKey).*,
\(ViewModel.closedGroupProfileBackFallbackKey).*,
\(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey),
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
FROM \(SessionThread.self)
LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id])
LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON (
\(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = (
SELECT MIN(\(groupMember[.profileId]))
FROM \(GroupMember.self)
WHERE (
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND
\(groupMember[.groupId]) = \(closedGroup[.threadId]) AND
\(SQL("\(groupMember[.profileId]) != \(userPublicKey)"))
)
)
)
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackKey) ON (
\(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) != \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) AND
\(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) = (
SELECT MAX(\(groupMember[.profileId]))
FROM \(GroupMember.self)
WHERE (
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND
\(groupMember[.groupId]) = \(closedGroup[.threadId]) AND
\(SQL("\(groupMember[.profileId]) != \(userPublicKey)"))
)
)
)
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileBackFallbackKey) ON (
\(closedGroup[.threadId]) IS NOT NULL AND
\(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND
\(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)"))
)
WHERE \(SQL("\(thread[.id]) = \(threadId)"))
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeProfiles,
Profile.numberOfSelectedColumns(db),
Profile.numberOfSelectedColumns(db),
Profile.numberOfSelectedColumns(db),
Profile.numberOfSelectedColumns(db)
])
return ScopeAdapter([
ViewModel.contactProfileString: adapters[1],
ViewModel.closedGroupProfileFrontString: adapters[2],
ViewModel.closedGroupProfileBackString: adapters[3],
ViewModel.closedGroupProfileBackFallbackString: adapters[4]
])
}
}
} }
// MARK: - Search Queries // MARK: - Search Queries
public extension ConversationCell.ViewModel { public extension SessionThreadViewModel {
static func searchTermParts(_ searchTerm: String) -> [String] { static func searchTermParts(_ searchTerm: String) -> [String] {
/// Process the search term in order to extract the parts of the search pattern we want /// Process the search term in order to extract the parts of the search pattern we want
/// ///
@ -698,7 +787,7 @@ public extension ConversationCell.ViewModel {
return pattern return pattern
} }
static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias() let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
@ -813,7 +902,7 @@ public extension ConversationCell.ViewModel {
/// - Closed group member name /// - Closed group member name
/// - Open group name /// - Open group name
/// - "Note to self" text match /// - "Note to self" text match
static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias() let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias() let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
@ -1177,7 +1266,7 @@ public extension ConversationCell.ViewModel {
} }
/// This method returns only the 'Note to Self' thread in the structure of a search result conversation /// This method returns only the 'Note to Self' thread in the structure of a search result conversation
static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name)
@ -1224,8 +1313,8 @@ public extension ConversationCell.ViewModel {
// MARK: - Share Extension // MARK: - Share Extension
public extension ConversationCell.ViewModel { public extension SessionThreadViewModel {
static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> { static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias() let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
@ -1272,7 +1361,7 @@ public extension ConversationCell.ViewModel {
) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id])
LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON (
\(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = (

View File

@ -9,17 +9,6 @@
NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_BEGIN
/**
* The users privacy preference for what kind of content to show in lock screen notifications.
*/
typedef NS_ENUM(NSUInteger, NotificationType) {
NotificationNoNameNoPreview,
NotificationNameNoPreview,
NotificationNamePreview,
};
NSString *NSStringForNotificationType(NotificationType value);
// Used when migrating logging to NSUserDefaults. // Used when migrating logging to NSUserDefaults.
extern NSString *const OWSPreferencesSignalDatabaseCollection; extern NSString *const OWSPreferencesSignalDatabaseCollection;
extern NSString *const OWSPreferencesCallLoggingDidChangeNotification; extern NSString *const OWSPreferencesCallLoggingDidChangeNotification;

View File

@ -87,7 +87,7 @@ final class SimplifiedConversationCell: UITableViewCell {
// MARK: - Updating // MARK: - Updating
public func update(with cellViewModel: ConversationCell.ViewModel) { public func update(with cellViewModel: SessionThreadViewModel) {
accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0)
profilePictureView.update( profilePictureView.update(
publicKey: cellViewModel.threadId, publicKey: cellViewModel.threadId,

View File

@ -152,7 +152,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
) )
} }
private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) { private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) {
// Ensure the first load runs without animations (if we don't do this the cells will animate // Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero) // in from a frame of CGRect.zero)
guard hasLoadedInitialData else { guard hasLoadedInitialData else {

View File

@ -8,7 +8,7 @@ import SessionMessagingKit
public class ThreadPickerViewModel { public class ThreadPickerViewModel {
/// This value is the current state of the view /// This value is the current state of the view
public private(set) var viewData: [ConversationCell.ViewModel] = [] public private(set) var viewData: [SessionThreadViewModel] = []
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance /// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -16,10 +16,10 @@ public class ThreadPickerViewModel {
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
public lazy var observableViewData = ValueObservation public lazy var observableViewData = ValueObservation
.trackingConstantRegion { db -> [ConversationCell.ViewModel] in .trackingConstantRegion { db -> [SessionThreadViewModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db) let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try ConversationCell.ViewModel return try SessionThreadViewModel
.shareQuery(userPublicKey: userPublicKey) .shareQuery(userPublicKey: userPublicKey)
.fetchAll(db) .fetchAll(db)
} }
@ -27,7 +27,7 @@ public class ThreadPickerViewModel {
// MARK: - Functions // MARK: - Functions
public func updateData(_ updatedData: [ConversationCell.ViewModel]) { public func updateData(_ updatedData: [SessionThreadViewModel]) {
self.viewData = updatedData self.viewData = updatedData
} }
} }

View File

@ -6,9 +6,24 @@ import GRDB
public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "job" } public static var databaseTableName: String { "job" }
internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId])
internal static let dependantJobForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.jobId]) public static let dependantJobDependency = hasMany(
internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey) JobDependencies.self,
internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey) using: JobDependencies.jobForeignKey
)
public static let dependancyJobDependency = hasMany(
JobDependencies.self,
using: JobDependencies.dependantForeignKey
)
internal static let jobsThisJobDependsOn = hasMany(
Job.self,
through: dependantJobDependency,
using: JobDependencies.dependant
)
internal static let jobsThatDependOnThisJob = hasMany(
Job.self,
through: dependancyJobDependency,
using: JobDependencies.job
)
public typealias Columns = CodingKeys public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression { public enum CodingKeys: String, CodingKey, ColumnExpression {
@ -50,7 +65,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// ///
/// **Note:** This is a blocking job so it will run before any other jobs and prevent them from /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from
/// running until it's complete /// running until it's complete
case failedMessages = 1000 case failedMessageSends = 1000
/// This is a recurring job that runs on launch and flags any attachments marked as 'uploading' to /// This is a recurring job that runs on launch and flags any attachments marked as 'uploading' to
/// be in their 'failed' state /// be in their 'failed' state
@ -151,7 +166,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// **Note:** When completing a job the dependencies **MUST** be cleared before the job is /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is
/// deleted or it will automatically delete any dependant jobs /// deleted or it will automatically delete any dependant jobs
public var dependencies: QueryInterfaceRequest<Job> { public var dependencies: QueryInterfaceRequest<Job> {
request(for: Job.dependencies) request(for: Job.jobsThisJobDependsOn)
} }
/// The other jobs which depend on this job /// The other jobs which depend on this job
@ -159,7 +174,7 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
/// **Note:** When completing a job the dependencies **MUST** be cleared before the job is /// **Note:** When completing a job the dependencies **MUST** be cleared before the job is
/// deleted or it will automatically delete any dependant jobs /// deleted or it will automatically delete any dependant jobs
public var dependantJobs: QueryInterfaceRequest<Job> { public var dependantJobs: QueryInterfaceRequest<Job> {
request(for: Job.dependantJobs) request(for: Job.jobsThatDependOnThisJob)
} }
// MARK: - Initialization // MARK: - Initialization
@ -242,8 +257,12 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
// MARK: - GRDB Interactions // MARK: - GRDB Interactions
extension Job { extension Job {
internal static func filterPendingJobs(variants: [Variant], excludeFutureJobs: Bool = true) -> QueryInterfaceRequest<Job> { internal static func filterPendingJobs(
let query: QueryInterfaceRequest<Job> = Job variants: [Variant],
excludeFutureJobs: Bool = true,
includeJobsWithDependencies: Bool = false
) -> QueryInterfaceRequest<Job> {
var query: QueryInterfaceRequest<Job> = Job
.filter( .filter(
// Retrieve all 'runOnce' and 'recurring' jobs // Retrieve all 'runOnce' and 'recurring' jobs
[ [
@ -263,12 +282,15 @@ extension Job {
.order(Job.Columns.nextRunTimestamp) .order(Job.Columns.nextRunTimestamp)
.order(Job.Columns.id) .order(Job.Columns.id)
guard excludeFutureJobs else { if excludeFutureJobs {
return query query = query.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970)
}
if !includeJobsWithDependencies {
query = query.having(Job.jobsThisJobDependsOn.isEmpty)
} }
return query return query
.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970)
} }
} }

View File

@ -6,6 +6,7 @@ import GRDB
public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "jobDependencies" } public static var databaseTableName: String { "jobDependencies" }
internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id]) internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id])
internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id])
internal static let job = belongsTo(Job.self, using: jobForeignKey) internal static let job = belongsTo(Job.self, using: jobForeignKey)
internal static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey) internal static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey)

View File

@ -200,7 +200,7 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
} }
// If there are no inserted/updated rows then trigger the update callback and stop here // If there are no inserted/updated rows then trigger the update callback and stop here
let rowIdsToQuery: [Int64] = committedChanges let rowIdsToQuery: [Int64] = relevantChanges
.filter { $0.kind != .delete } .filter { $0.kind != .delete }
.map { $0.rowId } .map { $0.rowId }
@ -223,17 +223,34 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
// 'currentCount' and the indexes are sequential (ie. more than the current loaded content was // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was
// added at once) // added at once)
let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast())
let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in
index >= updatedPageInfo.pageOffset &&
index < updatedPageInfo.currentCount
})
let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ?
rowIdsToQuery : rowIdsToQuery :
zip(itemIndexes, rowIdsToQuery) zip(itemIndexes, rowIdsToQuery)
.filter { index, _ -> Bool in index < updatedPageInfo.currentCount } .filter { index, _ -> Bool in
index >= updatedPageInfo.pageOffset &&
index < updatedPageInfo.currentCount
}
.map { _, rowId -> Int64 in rowId } .map { _, rowId -> Int64 in rowId }
) )
let countBefore: Int = itemIndexes.filter { $0 < updatedPageInfo.pageOffset }.count
// Update the offset and totalCount even if the rows are outside of the current page (need to
// in order to ensure the 'load more' sections are accurate)
updatedPageInfo = PagedData.PageInfo(
pageSize: updatedPageInfo.pageSize,
pageOffset: (updatedPageInfo.pageOffset + countBefore),
currentCount: updatedPageInfo.currentCount,
totalCount: (updatedPageInfo.totalCount + itemIndexes.count)
)
// If there are no valid attachment row ids then stop here // If there are no valid row ids then stop here (trigger updates though since the page info
// has changes)
guard !validRowIds.isEmpty else { guard !validRowIds.isEmpty else {
updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty) updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true)
return return
} }
@ -243,24 +260,17 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
.fetchAll(db)) .fetchAll(db))
.defaulting(to: []) .defaulting(to: [])
// If the inserted/updated rows we irrelevant (associated to data which doesn't pass
// the filter) then trigger the update callback (if there were deletions) and stop here
guard !updatedItems.isEmpty else {
updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty)
return
}
// Process the upserted data // Process the upserted data
updatedDataCache = updatedDataCache.upserting(items: updatedItems) updatedDataCache = updatedDataCache.upserting(items: updatedItems)
// Update the page info for the upserted data // Update the currentCount for the upserted data
let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount) let dataSizeDiff: Int = (updatedDataCache.count - oldDataCount)
updatedPageInfo = PagedData.PageInfo( updatedPageInfo = PagedData.PageInfo(
pageSize: updatedPageInfo.pageSize, pageSize: updatedPageInfo.pageSize,
pageOffset: updatedPageInfo.pageOffset, pageOffset: updatedPageInfo.pageOffset,
currentCount: (updatedPageInfo.currentCount + dataSizeDiff), currentCount: (updatedPageInfo.currentCount + dataSizeDiff),
totalCount: (updatedPageInfo.totalCount + dataSizeDiff) totalCount: updatedPageInfo.totalCount
) )
updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true)
@ -526,6 +536,7 @@ public protocol ErasedAssociatedRecord {
var databaseTableName: String { get } var databaseTableName: String { get }
var observedChanges: [PagedData.ObservedChanges] { get } var observedChanges: [PagedData.ObservedChanges] { get }
var joinToPagedType: SQL { get } var joinToPagedType: SQL { get }
var groupPagedType: SQL? { get }
func tryUpdateForDatabaseCommit( func tryUpdateForDatabaseCommit(
_ db: Database, _ db: Database,
@ -717,8 +728,7 @@ public enum PagedData {
idColumn: String, idColumn: String,
requiredJoinSQL: SQL? = nil, requiredJoinSQL: SQL? = nil,
orderSQL: SQL, orderSQL: SQL,
filterSQL: SQL, filterSQL: SQL
joinToPagedType: SQL? = nil
) -> Int? { ) -> Int? {
let tableNameLiteral: SQL = SQL(stringLiteral: tableName) let tableNameLiteral: SQL = SQL(stringLiteral: tableName)
let idColumnLiteral: SQL = SQL(stringLiteral: idColumn) let idColumnLiteral: SQL = SQL(stringLiteral: idColumn)
@ -731,7 +741,6 @@ public enum PagedData {
ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex
FROM \(tableNameLiteral) FROM \(tableNameLiteral)
\(requiredJoinSQL ?? "") \(requiredJoinSQL ?? "")
\(joinToPagedType ?? "")
WHERE \(filterSQL) WHERE \(filterSQL)
) AS data ) AS data
WHERE \(SQL("data.\(idColumnLiteral) = \(id)")) WHERE \(SQL("data.\(idColumnLiteral) = \(id)"))
@ -750,9 +759,42 @@ public enum PagedData {
requiredJoinSQL: SQL? = nil, requiredJoinSQL: SQL? = nil,
orderSQL: SQL, orderSQL: SQL,
filterSQL: SQL, filterSQL: SQL,
joinToPagedType: SQL? = nil joinToPagedType: SQL? = nil,
groupPagedType: SQL? = nil
) -> [Int64] { ) -> [Int64] {
let tableNameLiteral: SQL = SQL(stringLiteral: tableName) let tableNameLiteral: SQL = SQL(stringLiteral: tableName)
/// **Note:** `ROW_NUMBER` works by returning the index of the row in a given query, unfortunately when dealing
/// with associated data its possible for multiple results to connect to an individual paged result, this throws off the
/// indexes so in this case we need to do some sneaky aggregation and grouping and then individually retrieve each
/// index to prevent this
guard joinToPagedType == nil || rowIds.count == 1 else {
guard let groupPagedType: SQL = groupPagedType else { return [] }
let groupByLiteral: SQL = SQL(stringLiteral: "GROUP BY ")
return rowIds.compactMap { rowId in
let groupedRequest: SQLRequest<Int64> = """
SELECT
(data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed
FROM (
SELECT
\(tableNameLiteral).rowid AS rowid,
\(SQL("MAX(\(tableNameLiteral).rowid = \(rowId))")),
ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex
FROM \(tableNameLiteral)
\(requiredJoinSQL ?? "")
\(joinToPagedType ?? "")
WHERE \(filterSQL)
\(groupByLiteral)\(groupPagedType)
) AS data
WHERE \(SQL("data.rowid = \(rowId)"))
"""
return try? groupedRequest.fetchOne(db)
}
}
let request: SQLRequest<Int64> = """ let request: SQLRequest<Int64> = """
SELECT SELECT
(data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed
@ -800,6 +842,7 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
public let databaseTableName: String public let databaseTableName: String
public let observedChanges: [PagedData.ObservedChanges] public let observedChanges: [PagedData.ObservedChanges]
public let joinToPagedType: SQL public let joinToPagedType: SQL
public let groupPagedType: SQL?
fileprivate let dataCache: Atomic<DataCache<T>> = Atomic(DataCache()) fileprivate let dataCache: Atomic<DataCache<T>> = Atomic(DataCache())
fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest<SQLRequest<T>> fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest<SQLRequest<T>>
@ -812,12 +855,14 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
observedChanges: [PagedData.ObservedChanges], observedChanges: [PagedData.ObservedChanges],
dataQuery: @escaping (SQL?) -> AdaptedFetchRequest<SQLRequest<T>>, dataQuery: @escaping (SQL?) -> AdaptedFetchRequest<SQLRequest<T>>,
joinToPagedType: SQL, joinToPagedType: SQL,
groupPagedType: SQL? = nil,
associateData: @escaping (DataCache<T>, DataCache<PagedType>) -> DataCache<PagedType> associateData: @escaping (DataCache<T>, DataCache<PagedType>) -> DataCache<PagedType>
) { ) {
self.databaseTableName = trackedAgainst.databaseTableName self.databaseTableName = trackedAgainst.databaseTableName
self.observedChanges = observedChanges self.observedChanges = observedChanges
self.dataQuery = dataQuery self.dataQuery = dataQuery
self.joinToPagedType = joinToPagedType self.joinToPagedType = joinToPagedType
self.groupPagedType = groupPagedType
self.associateData = associateData self.associateData = associateData
} }
@ -826,6 +871,7 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
observedChanges: [PagedData.ObservedChanges], observedChanges: [PagedData.ObservedChanges],
dataQuery: @escaping (SQL?) -> SQLRequest<T>, dataQuery: @escaping (SQL?) -> SQLRequest<T>,
joinToPagedType: SQL, joinToPagedType: SQL,
groupPagedType: SQL? = nil,
associateData: @escaping (DataCache<T>, DataCache<PagedType>) -> DataCache<PagedType> associateData: @escaping (DataCache<T>, DataCache<PagedType>) -> DataCache<PagedType>
) { ) {
self.init( self.init(
@ -835,6 +881,7 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) } dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) }
}, },
joinToPagedType: joinToPagedType, joinToPagedType: joinToPagedType,
groupPagedType: groupPagedType,
associateData: associateData associateData: associateData
) )
} }
@ -879,19 +926,27 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
tableName: databaseTableName, tableName: databaseTableName,
orderSQL: orderSQL, orderSQL: orderSQL,
filterSQL: filterSQL, filterSQL: filterSQL,
joinToPagedType: joinToPagedType joinToPagedType: joinToPagedType,
groupPagedType: groupPagedType
) )
// Determine if the indexes for the row ids should be displayed on the screen and remove any // Determine if the indexes for the row ids should be displayed on the screen and remove any
// which shouldn't - values less than 'currentCount' or if there is at least one value less than // which shouldn't - values less than 'currentCount' or if there is at least one value less than
// 'currentCount' and the indexes are sequential (ie. more than the current loaded content was // 'currentCount' and the indexes are sequential (ie. more than the current loaded content was
// added at once) // added at once)
let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast()) let uniqueIndexes: [Int64] = itemIndexes.asSet().sorted()
let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < pageInfo.currentCount }) let itemIndexesAreSequential: Bool = (uniqueIndexes.map { $0 - 1 }.dropFirst() == uniqueIndexes.dropLast())
let hasOneValidIndex: Bool = itemIndexes.contains(where: { index -> Bool in
index >= pageInfo.pageOffset &&
index < pageInfo.currentCount
})
let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ? let validRowIds: [Int64] = (itemIndexesAreSequential && hasOneValidIndex ?
itemIndexes : rowIdsToQuery :
zip(itemIndexes, rowIdsToQuery) zip(itemIndexes, rowIdsToQuery)
.filter { index, _ -> Bool in index < pageInfo.currentCount } .filter { index, _ -> Bool in
index >= pageInfo.pageOffset &&
index < pageInfo.currentCount
}
.map { _, rowId -> Int64 in rowId } .map { _, rowId -> Int64 in rowId }
) )

View File

@ -56,7 +56,8 @@ public final class JobRunner {
jobVariants: [ jobVariants: [
jobVariants.remove(.attachmentUpload), jobVariants.remove(.attachmentUpload),
jobVariants.remove(.messageSend), jobVariants.remove(.messageSend),
jobVariants.remove(.notifyPushServer)// TODO: Read receipts jobVariants.remove(.notifyPushServer),
jobVariants.remove(.sendReadReceipts)
].compactMap { $0 } ].compactMap { $0 }
) )
let messageReceiveQueue: JobQueue = JobQueue( let messageReceiveQueue: JobQueue = JobQueue(
@ -131,6 +132,11 @@ public final class JobRunner {
guard let job: Job = job else { return } // Ignore null jobs guard let job: Job = job else { return } // Ignore null jobs
queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob)
// Start the job runner if needed
db.afterNextTransactionCommit { _ in
queues.wrappedValue[job.variant]?.start()
}
} }
@discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> Job? { @discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> Job? {
@ -150,6 +156,11 @@ public final class JobRunner {
queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob)
// Start the job runner if needed
db.afterNextTransactionCommit { _ in
queues.wrappedValue[updatedJob.variant]?.start()
}
return updatedJob return updatedJob
} }
@ -236,19 +247,26 @@ public final class JobRunner {
} }
.defaulting(to: ([], [])) .defaulting(to: ([], []))
guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } // Store the current queue state locally to avoid multiple atomic retrievals
let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue
let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true)
guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else {
if !blockingQueueIsRunning {
jobQueues.forEach { _, queue in queue.start() }
}
return
}
// Add and start any blocking jobs // Add and start any blocking jobs
blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true) blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true)
// Add and start any non-blocking jobs (if there are no blocking jobs)
let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true)
let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant) let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant)
let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue
jobsByVariant.forEach { variant, jobs in jobQueues.forEach { variant, queue in
jobQueues[variant]?.appDidBecomeActive( queue.appDidBecomeActive(
with: jobs, with: (jobsByVariant[variant] ?? []),
canStart: !blockingQueueIsRunning canStart: (!blockingQueueIsRunning && jobsToRun.blocking.isEmpty)
) )
} }
} }
@ -259,6 +277,13 @@ public final class JobRunner {
return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true)
} }
public static func hasPendingOrRunningJob<T: Encodable>(with variant: Job.Variant, details: T) -> Bool {
guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false }
guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false }
return targetQueue.hasPendingOrRunningJob(with: detailsData)
}
// MARK: - Convenience // MARK: - Convenience
fileprivate static func getRetryInterval(for job: Job) -> TimeInterval { fileprivate static func getRetryInterval(for job: Job) -> TimeInterval {
@ -450,6 +475,12 @@ private final class JobQueue {
return jobsCurrentlyRunning.wrappedValue.contains(jobId) return jobsCurrentlyRunning.wrappedValue.contains(jobId)
} }
fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool {
let pendingJobs: [Job] = queue.wrappedValue
return pendingJobs.contains { job in job.details == detailsData }
}
// MARK: - Job Running // MARK: - Job Running
fileprivate func start() { fileprivate func start() {

View File

@ -7,17 +7,6 @@ import SessionMessagingKit
@objc(LKProfilePictureView) @objc(LKProfilePictureView)
public final class ProfilePictureView: UIView { public final class ProfilePictureView: UIView {
public static func closedGroupProfileQuery(threadId: String, userPublicKey: String) -> QueryInterfaceRequest<Profile> {
return Profile
.filter(Profile.Columns.id != userPublicKey)
.joining(
required: Profile.groupMembers
.filter(GroupMember.Columns.groupId == threadId)
)
.order(.id)
.limit(2)
}
private var hasTappableProfilePicture: Bool = false private var hasTappableProfilePicture: Bool = false
@objc public var size: CGFloat = 0 // Not an implicitly unwrapped optional due to Obj-C limitations @objc public var size: CGFloat = 0 // Not an implicitly unwrapped optional due to Obj-C limitations
@ -65,66 +54,30 @@ public final class ProfilePictureView: UIView {
additionalImageView.layer.cornerRadius = additionalImageViewSize / 2 additionalImageView.layer.cornerRadius = additionalImageViewSize / 2
} }
// FIXME: Look to deprecate this and replace it with the pattern in HomeViewModel (screen should fetch only the required info) // FIXME: Remove this once we refactor the ConversationVC to Swift (use the HomeViewModel approach)
@objc(updateForThreadId:) @objc(updateForThreadId:)
public func update(forThreadId threadId: String?) { public func update(forThreadId threadId: String?) {
guard guard
let threadId: String = threadId, let threadId: String = threadId,
let (thread, profiles, imageData) = GRDBStorage.shared.read({ db -> (SessionThread, [Profile], Data?) in let viewModel: SessionThreadViewModel = GRDBStorage.shared.read({ db -> SessionThreadViewModel? in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { let userPublicKey: String = getUserHexEncodedPublicKey(db)
throw GRDBStorageError.objectNotFound
}
switch thread.variant { return try SessionThreadViewModel
case .contact: .conversationSettingsProfileQuery(threadId: threadId, userPublicKey: userPublicKey)
return ( .fetchOne(db)
thread,
[try? Profile.fetchOne(db, id: thread.id)].compactMap { $0 },
nil
)
case .closedGroup:
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let randomUsers: [Profile] = (try? ProfilePictureView
.closedGroupProfileQuery(threadId: thread.id, userPublicKey: userPublicKey)
.fetchAll(db))
.defaulting(to: [])
// If there is only a single user in the group then insert the current user
// at the back
if randomUsers.count == 1 {
return (
thread,
randomUsers.inserting(
Profile.fetchOrCreateCurrentUser(db),
at: 0
),
nil
)
}
return (thread, randomUsers, nil)
case .openGroup:
return (
thread,
[],
try? thread.openGroup
.select(OpenGroup.Columns.imageData)
.asRequest(of: Data.self)
.fetchOne(db)
)
}
}) })
else { return } else { return }
update( update(
publicKey: (imageData != nil ? "" : thread.id), publicKey: viewModel.threadId,
profile: profiles.first, profile: viewModel.profile,
additionalProfile: profiles.last, additionalProfile: viewModel.additionalProfile,
threadVariant: thread.variant, threadVariant: viewModel.threadVariant,
openGroupProfilePicture: imageData.map { UIImage(data: $0) }, openGroupProfilePicture: viewModel.openGroupProfilePictureData.map { UIImage(data: $0) },
useFallbackPicture: (thread.variant == .openGroup && imageData == nil) useFallbackPicture: (
viewModel.threadVariant == .openGroup &&
viewModel.openGroupProfilePictureData == nil
)
) )
} }