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:
parent
3514ed4f50
commit
e2ee0e94ee
|
@ -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)
|
||||||
|
|
|
@ -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 */,
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) = {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) = (
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue