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) {
|
||||
// Configure the job executors
|
||||
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: UpdateProfilePictureJob.self, for: .updateProfilePicture)
|
||||
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 */; };
|
||||
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 */; };
|
||||
B90418E6183E9DD40038554A /* DateUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = B90418E5183E9DD40038554A /* DateUtil.m */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.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 */; };
|
||||
FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.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 */; };
|
||||
FD705A98278E9F4D00F16121 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A97278E9F4D00F16121 /* UIColor+Extensions.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 */; };
|
||||
FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B8C283E0B26000E298B /* MessageInputTypes.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 */; };
|
||||
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 */; };
|
||||
FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.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 */; };
|
||||
FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.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 */; };
|
||||
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, ); }; };
|
||||
|
@ -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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
|
@ -1629,7 +1627,7 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1644,18 +1642,19 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1902,8 +1901,7 @@
|
|||
34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */,
|
||||
34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */,
|
||||
4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */,
|
||||
B90418E4183E9DD40038554A /* DateUtil.h */,
|
||||
B90418E5183E9DD40038554A /* DateUtil.m */,
|
||||
FD848B9728422F1A000E298B /* Date+Utilities.swift */,
|
||||
FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */,
|
||||
4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */,
|
||||
45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */,
|
||||
|
@ -3431,8 +3429,9 @@
|
|||
FD3E0C82283B581F002A425C /* Shared Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */,
|
||||
FD848B8C283E0B26000E298B /* MessageInputTypes.swift */,
|
||||
FD848B86283B844B000E298B /* MessageViewModel.swift */,
|
||||
FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */,
|
||||
);
|
||||
path = "Shared Models";
|
||||
sourceTree = "<group>";
|
||||
|
@ -3456,7 +3455,6 @@
|
|||
FD848B85283B8438000E298B /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD848B86283B844B000E298B /* MessageCellViewModel.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3482,7 +3480,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */,
|
||||
FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */,
|
||||
FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */,
|
||||
FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */,
|
||||
FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */,
|
||||
FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */,
|
||||
|
@ -4571,13 +4569,14 @@
|
|||
C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */,
|
||||
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */,
|
||||
FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */,
|
||||
FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */,
|
||||
FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */,
|
||||
B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */,
|
||||
C3D9E52725677DF20040E4F3 /* ThumbnailService.swift in Sources */,
|
||||
C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */,
|
||||
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */,
|
||||
FD09797527FAB64300936362 /* ProfileManager.swift in Sources */,
|
||||
FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */,
|
||||
FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */,
|
||||
C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */,
|
||||
C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */,
|
||||
C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */,
|
||||
|
@ -4591,7 +4590,7 @@
|
|||
FDB4BBC92839BEF000B7C95D /* ProfileManagerError.swift in Sources */,
|
||||
FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */,
|
||||
B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */,
|
||||
FD3E0C84283B5835002A425C /* ConversationCellViewModel.swift in Sources */,
|
||||
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */,
|
||||
FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */,
|
||||
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */,
|
||||
C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */,
|
||||
|
@ -4680,7 +4679,6 @@
|
|||
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */,
|
||||
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
||||
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
||||
FD848B87283B844B000E298B /* MessageCellViewModel.swift in Sources */,
|
||||
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */,
|
||||
B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */,
|
||||
B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */,
|
||||
|
@ -4766,6 +4764,7 @@
|
|||
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */,
|
||||
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */,
|
||||
B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */,
|
||||
FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */,
|
||||
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
|
||||
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
|
||||
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
|
||||
|
@ -4827,7 +4826,6 @@
|
|||
B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */,
|
||||
C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */,
|
||||
3427C64320F500E000EEC730 /* OWSMessageTimerView.m in Sources */,
|
||||
B90418E6183E9DD40038554A /* DateUtil.m in Sources */,
|
||||
C33100092558FF6D00070591 /* UserCell.swift in Sources */,
|
||||
B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */,
|
||||
C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
struct Action {
|
||||
|
@ -8,49 +9,49 @@ extension ContextMenuVC {
|
|||
let title: String
|
||||
let work: () -> Void
|
||||
|
||||
static func reply(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_reply"),
|
||||
title: "context_menu_reply".localized()
|
||||
) { delegate?.reply(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copy(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "copy".localized()
|
||||
) { delegate?.copy(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "vc_conversation_settings_copy_session_id_button_title".localized()
|
||||
) { delegate?.copySessionID(cellViewModel) }
|
||||
}
|
||||
|
||||
static func delete(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_trash"),
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { delegate?.delete(cellViewModel) }
|
||||
}
|
||||
|
||||
static func save(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_download"),
|
||||
title: "context_menu_save".localized()
|
||||
) { delegate?.save(cellViewModel) }
|
||||
}
|
||||
|
||||
static func ban(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_user".localized()
|
||||
) { delegate?.ban(cellViewModel) }
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
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
|
||||
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||
return nil
|
||||
|
@ -124,11 +125,11 @@ extension ContextMenuVC {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol ContextMenuActionDelegate {
|
||||
func reply(_ cellViewModel: MessageCell.ViewModel)
|
||||
func copy(_ cellViewModel: MessageCell.ViewModel)
|
||||
func copySessionID(_ cellViewModel: MessageCell.ViewModel)
|
||||
func delete(_ cellViewModel: MessageCell.ViewModel)
|
||||
func save(_ cellViewModel: MessageCell.ViewModel)
|
||||
func ban(_ cellViewModel: MessageCell.ViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel)
|
||||
func reply(_ cellViewModel: MessageViewModel)
|
||||
func copy(_ cellViewModel: MessageViewModel)
|
||||
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||
func delete(_ cellViewModel: MessageViewModel)
|
||||
func save(_ cellViewModel: MessageViewModel)
|
||||
func ban(_ cellViewModel: MessageViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class ContextMenuVC: UIViewController {
|
||||
private static let actionViewHeight: CGFloat = 40
|
||||
|
@ -9,7 +10,7 @@ final class ContextMenuVC: UIViewController {
|
|||
|
||||
private let snapshot: UIView
|
||||
private let frame: CGRect
|
||||
private let cellViewModel: MessageCell.ViewModel
|
||||
private let cellViewModel: MessageViewModel
|
||||
private let actions: [Action]
|
||||
private let dismiss: () -> Void
|
||||
|
||||
|
@ -33,7 +34,7 @@ final class ContextMenuVC: UIViewController {
|
|||
result.textColor = (isLightMode ? .black : .white)
|
||||
|
||||
if let dateForUI: Date = cellViewModel.dateForUI {
|
||||
result.text = DateUtil.formatDate(forDisplay: dateForUI)
|
||||
result.text = dateForUI.formattedForDisplay
|
||||
}
|
||||
|
||||
return result
|
||||
|
@ -44,7 +45,7 @@ final class ContextMenuVC: UIViewController {
|
|||
init(
|
||||
snapshot: UIView,
|
||||
frame: CGRect,
|
||||
cellViewModel: MessageCell.ViewModel,
|
||||
cellViewModel: MessageViewModel,
|
||||
actions: [Action],
|
||||
dismiss: @escaping () -> Void
|
||||
) {
|
||||
|
|
|
@ -58,7 +58,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
|
|||
let results: [Int64] = GRDBStorage.shared.read { db -> [Int64] in
|
||||
try Interaction.idsForTermWithin(
|
||||
threadId: threadId,
|
||||
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText)
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||
)
|
||||
.fetchAll(db)
|
||||
}
|
||||
|
|
|
@ -398,7 +398,7 @@ extension ConversationVC:
|
|||
|
||||
func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) {
|
||||
guard !showBlockedModalIfNeeded() else { return }
|
||||
|
||||
|
||||
for attachment in attachments {
|
||||
if attachment.hasError {
|
||||
return showErrorAlert(for: attachment, onDismiss: onComplete)
|
||||
|
@ -628,7 +628,7 @@ extension ConversationVC:
|
|||
|
||||
// MARK: MessageCellDelegate
|
||||
|
||||
func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func handleItemLongPressed(_ cellViewModel: MessageViewModel) {
|
||||
// Show the context menu if applicable
|
||||
guard
|
||||
let keyWindow: UIWindow = UIApplication.shared.keyWindow,
|
||||
|
@ -675,7 +675,7 @@ extension ConversationVC:
|
|||
self.contextMenuWindow?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) {
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else {
|
||||
// Show the failed message sheet
|
||||
showFailedMessageSheet(for: cellViewModel)
|
||||
|
@ -717,7 +717,7 @@ extension ConversationVC:
|
|||
// TODO: Tapped a failed incoming attachment
|
||||
break
|
||||
|
||||
case .failedDownload:
|
||||
case .failedDownload, .failedUpload:
|
||||
// TODO: Tapped a failed incoming attachment
|
||||
break
|
||||
|
||||
|
@ -802,7 +802,7 @@ extension ConversationVC:
|
|||
}
|
||||
}
|
||||
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) {
|
||||
switch cellViewModel.cellType {
|
||||
// The user can double tap a voice message when it's playing to speed it up
|
||||
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 {
|
||||
case .began: tableView.isScrollEnabled = false
|
||||
case .ended, .cancelled: tableView.isScrollEnabled = true
|
||||
|
@ -841,7 +841,7 @@ extension ConversationVC:
|
|||
self.presentAlert(alertVC)
|
||||
}
|
||||
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) {
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel) {
|
||||
reply(cellViewModel)
|
||||
}
|
||||
|
||||
|
@ -856,7 +856,7 @@ extension ConversationVC:
|
|||
// MARK: --action handling
|
||||
|
||||
|
||||
func showFailedMessageSheet(for cellViewModel: MessageCell.ViewModel) {
|
||||
func showFailedMessageSheet(for cellViewModel: MessageViewModel) {
|
||||
let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet)
|
||||
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in
|
||||
|
@ -909,7 +909,7 @@ extension ConversationVC:
|
|||
|
||||
// MARK: - ContextMenuActionDelegate
|
||||
|
||||
func reply(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func reply(_ cellViewModel: MessageViewModel) {
|
||||
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
|
||||
threadId: self.viewModel.threadData.threadId,
|
||||
authorId: cellViewModel.authorId,
|
||||
|
@ -929,7 +929,7 @@ extension ConversationVC:
|
|||
snInputView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func copy(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func copy(_ cellViewModel: MessageViewModel) {
|
||||
switch cellViewModel.cellType {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
@ -962,7 +962,7 @@ extension ConversationVC:
|
|||
UIPasteboard.general.string = cellViewModel.authorId
|
||||
}
|
||||
|
||||
func delete(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func delete(_ cellViewModel: MessageViewModel) {
|
||||
// Only allow deletion on incoming and outgoing messages
|
||||
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
|
||||
return
|
||||
|
@ -1141,7 +1141,7 @@ extension ConversationVC:
|
|||
}
|
||||
}
|
||||
|
||||
func save(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func save(_ cellViewModel: MessageViewModel) {
|
||||
guard cellViewModel.cellType == .mediaMessage else { return }
|
||||
|
||||
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 }
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
|
@ -1222,7 +1222,7 @@ extension ConversationVC:
|
|||
present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) {
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) {
|
||||
guard cellViewModel.threadVariant == .openGroup else { return }
|
||||
|
||||
let threadId: String = self.viewModel.threadData.threadId
|
||||
|
|
|
@ -501,7 +501,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
viewModel.observableThreadData,
|
||||
onError: { _ 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
|
||||
self?.handleThreadUpdates(threadData)
|
||||
|
@ -520,7 +520,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
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
|
||||
// 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 {
|
||||
|
@ -529,6 +529,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
|
||||
return
|
||||
}
|
||||
|
||||
// Update general conversation UI
|
||||
|
||||
if
|
||||
|
@ -572,6 +573,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
if initialLoad || viewModel.threadData.threadUnreadCount != 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) {
|
||||
|
@ -590,68 +594,125 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
// Determine if we are inserting content at the top of the collectionView
|
||||
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 visibleInteractionId: Int64
|
||||
let visibleIndexPath: IndexPath
|
||||
let oldVisibleIndexPath: IndexPath
|
||||
let lastVisibleIndexPath: IndexPath
|
||||
|
||||
init(
|
||||
insertedAtTop: Bool,
|
||||
insertLocation: InsertLocation,
|
||||
wasCloseToBottom: Bool,
|
||||
sentMessageBeforeUpdate: Bool,
|
||||
firstIndexIsVisible: Bool = false,
|
||||
visibleInteractionId: Int64 = -1,
|
||||
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.visibleInteractionId = visibleInteractionId
|
||||
self.visibleIndexPath = visibleIndexPath
|
||||
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 = {
|
||||
guard
|
||||
changeset.map { $0.elementInserted.count }.reduce(0, +) > 0,
|
||||
let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }),
|
||||
let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }),
|
||||
let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
|
||||
.firstIndex(where: { item -> Bool in
|
||||
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?
|
||||
.filter({ $0.section == oldSectionIndex })
|
||||
.sorted()
|
||||
.first,
|
||||
let lastVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
|
||||
.filter({ $0.section == oldSectionIndex })
|
||||
.sorted()
|
||||
.last,
|
||||
let newVisibleIndex: Int = updatedData[newSectionIndex].elements
|
||||
.firstIndex(where: { item in
|
||||
item.id == self.viewModel.interactionData[oldSectionIndex]
|
||||
.elements[firstVisibleIndexPath.row]
|
||||
.id
|
||||
}),
|
||||
(
|
||||
newSectionIndex > oldSectionIndex ||
|
||||
newFirstItemIndex > 0
|
||||
let newLastVisibleIndex: Int = updatedData[newSectionIndex].elements
|
||||
.firstIndex(where: { item in
|
||||
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(
|
||||
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),
|
||||
visibleInteractionId: updatedData[newSectionIndex].elements[newVisibleIndex].id,
|
||||
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,
|
||||
/// unfortunately the UITableView does some weird things when updating (where it won't have updated data until after it
|
||||
/// performs the next layout); the below code checks a condition on layout and if it passes it calls a closure
|
||||
/// UITableView doesn't really support bottom-aligned content very well and as such jumps around a lot when inserting content but
|
||||
/// we want to maintain the current offset from before the data was inserted (except when adding at the bottom while the user is at
|
||||
/// 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
|
||||
/// data (including the difference in height in case the date header was removed when loading the new cell)
|
||||
if itemChangeInfo.insertedAtTop {
|
||||
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
|
||||
/// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until
|
||||
/// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure
|
||||
if itemChangeInfo.insertLocation != .none {
|
||||
let cellSorting: (MessageCell, MessageCell) -> Bool = { lhs, rhs -> Bool in
|
||||
if !lhs.isHidden && rhs.isHidden { return true }
|
||||
if lhs.isHidden && !rhs.isHidden { return false }
|
||||
|
@ -665,42 +726,77 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
.frame)
|
||||
.defaulting(to: self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath))
|
||||
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(
|
||||
when: { numSections, numRowsInSections -> Bool in
|
||||
when: { numSections, numRowsInSections, _ -> Bool in
|
||||
numSections == updatedData.count &&
|
||||
numRowsInSections == numItemsInUpdatedData
|
||||
},
|
||||
then: { [weak self] in
|
||||
self?.tableView.scrollToRow(at: itemChangeInfo.visibleIndexPath, at: .top, animated: false)
|
||||
self?.tableView.layoutIfNeeded()
|
||||
|
||||
/// **Note:** I wasn't able to get a prober equation to handle both "insert above first item" 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.firstIndexIsVisible {
|
||||
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)
|
||||
UIView.performWithoutAnimation {
|
||||
self?.tableView.scrollToRow(
|
||||
at: (itemChangeInfo.insertLocation == .top ?
|
||||
itemChangeInfo.visibleIndexPath :
|
||||
itemChangeInfo.lastVisibleIndexPath
|
||||
),
|
||||
at: (itemChangeInfo.insertLocation == .top ?
|
||||
.top :
|
||||
.bottom
|
||||
),
|
||||
animated: false
|
||||
)
|
||||
self?.tableView.layoutIfNeeded()
|
||||
|
||||
self?.tableView.contentOffset.y = (newRect.minY - (oldRect.minY + heightDiff))
|
||||
}
|
||||
else {
|
||||
let newContentSize: CGSize = (self?.tableView.contentSize)
|
||||
.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 {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
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?.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionId,
|
||||
|
@ -708,8 +804,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
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
|
||||
self?.isLoadingMore = false
|
||||
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)
|
||||
self.tableView.reload(
|
||||
using: StagedChangeset(source: viewModel.interactionData, target: updatedData),
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .bottom,
|
||||
insertRowsAnimation: .bottom,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { itemChangeInfo.insertedAtTop || $0.changeCount > ConversationViewModel.pageSize }
|
||||
interrupt: { itemChangeInfo.insertLocation == .top || $0.changeCount > ConversationViewModel.pageSize }
|
||||
) { [weak self] updatedData in
|
||||
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
|
||||
viewModel.markAllAsRead()
|
||||
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
|
||||
|
||||
if isShowingSearchUI {
|
||||
|
@ -997,7 +1087,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
switch section.model {
|
||||
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)
|
||||
cell.update(
|
||||
with: cellViewModel,
|
||||
|
@ -1085,7 +1175,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
func scrollToBottom(isAnimated: Bool) {
|
||||
guard
|
||||
!isUserScrolling,
|
||||
!self.isUserScrolling,
|
||||
let messagesSectionIndex: Int = self.viewModel.interactionData
|
||||
.firstIndex(where: { $0.model == .messages }),
|
||||
!self.viewModel.interactionData[messagesSectionIndex]
|
||||
|
@ -1093,9 +1183,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
.isEmpty
|
||||
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(
|
||||
row: viewModel.interactionData[messagesSectionIndex].elements.count - 1,
|
||||
row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1),
|
||||
section: messagesSectionIndex
|
||||
),
|
||||
at: .bottom,
|
||||
|
@ -1125,7 +1232,9 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
return
|
||||
}
|
||||
|
||||
self.highlightCellIfNeeded(interactionId: focusedInteractionId)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.highlightCellIfNeeded(interactionId: focusedInteractionId)
|
||||
}
|
||||
}
|
||||
|
||||
func updateUnreadCountView(unreadCount: UInt?) {
|
||||
|
@ -1245,6 +1354,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// load the up until the specified interaction
|
||||
guard self.didFinishInitialLayout else { return }
|
||||
|
||||
self.isLoadingMore = true
|
||||
self.searchController.resultsBar.startLoading()
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
|
|
|
@ -7,7 +7,7 @@ import SessionMessagingKit
|
|||
import SessionUtilitiesKit
|
||||
|
||||
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||
public typealias SectionModel = ArraySection<Section, MessageCell.ViewModel>
|
||||
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
||||
|
||||
// MARK: - Action
|
||||
|
||||
|
@ -33,10 +33,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// MARK: - Initialization
|
||||
|
||||
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)
|
||||
|
||||
return try ConversationCell.ViewModel
|
||||
return try SessionThreadViewModel
|
||||
.conversationQuery(
|
||||
threadId: threadId,
|
||||
userPublicKey: userPublicKey
|
||||
|
@ -44,7 +44,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.fetchOne(db)
|
||||
}
|
||||
|
||||
guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return nil }
|
||||
guard let threadData: SessionThreadViewModel = maybeThreadData else { return nil }
|
||||
|
||||
self.threadId = threadId
|
||||
self.threadData = threadData
|
||||
|
@ -71,14 +71,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
columns: ThreadTypingIndicator.Columns.allCases
|
||||
)
|
||||
],
|
||||
filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId),
|
||||
orderSQL: MessageCell.ViewModel.orderSQL,
|
||||
dataQuery: MessageCell.ViewModel.baseQuery(
|
||||
orderSQL: MessageCell.ViewModel.orderSQL,
|
||||
baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId)
|
||||
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
dataQuery: MessageViewModel.baseQuery(
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
baseFilterSQL: MessageViewModel.filterSQL(threadId: threadId)
|
||||
),
|
||||
associatedRecords: [
|
||||
AssociatedRecord<MessageCell.AttachmentInteractionInfo, MessageCell.ViewModel>(
|
||||
AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
|
||||
trackedAgainst: Attachment.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -86,9 +86,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
columns: [.state]
|
||||
)
|
||||
],
|
||||
dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery,
|
||||
joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure()
|
||||
dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
|
||||
groupPagedType: MessageViewModel.AttachmentInteractionInfo.groupViewModelQuerySQL,
|
||||
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
|
||||
)
|
||||
],
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
|
@ -137,32 +138,34 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// MARK: - Thread Data
|
||||
|
||||
/// 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
|
||||
.trackingConstantRegion { [threadId = self.threadId] db -> ConversationCell.ViewModel? in
|
||||
.trackingConstantRegion { [threadId = self.threadId] db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return try ConversationCell.ViewModel
|
||||
return try SessionThreadViewModel
|
||||
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
|
||||
public func updateThreadData(_ updatedData: ConversationCell.ViewModel) {
|
||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Interaction Data
|
||||
|
||||
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]) -> ())?
|
||||
|
||||
private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let sortedData: [MessageCell.ViewModel] = data
|
||||
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let sortedData: [MessageViewModel] = data
|
||||
.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 [
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadOlder)] :
|
||||
|
@ -173,10 +176,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
section: .messages,
|
||||
elements: sortedData
|
||||
.enumerated()
|
||||
.map { index, cellViewModel -> MessageCell.ViewModel in
|
||||
.map { index, cellViewModel -> MessageViewModel in
|
||||
cellViewModel.withClusteringChanges(
|
||||
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: (
|
||||
index == (sortedData.count - 1) &&
|
||||
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)] :
|
||||
[]
|
||||
)
|
||||
|
@ -210,7 +213,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||
let threadData: ConversationCell.ViewModel = self.threadData
|
||||
let threadData: SessionThreadViewModel = self.threadData
|
||||
|
||||
let results: [MentionInfo] = GRDBStorage.shared
|
||||
.read { db -> [MentionInfo] in
|
||||
|
@ -336,13 +339,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.id
|
||||
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(
|
||||
db,
|
||||
interactionId: lastInteractionId,
|
||||
threadId: self.threadData.threadId,
|
||||
threadId: threadId,
|
||||
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 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
|
||||
// the cell was reloaded)
|
||||
if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
|
||||
|
@ -413,7 +419,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
return newPlaybackInfo
|
||||
}
|
||||
|
||||
public func playOrPauseAudio(for viewModel: MessageCell.ViewModel) {
|
||||
public func playOrPauseAudio(for viewModel: MessageViewModel) {
|
||||
guard
|
||||
let attachment: Attachment = viewModel.attachments?.first,
|
||||
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
|
||||
guard viewModel.id == currentPlayingInteraction.wrappedValue else {
|
||||
playOrPauseAudio(for: viewModel)
|
||||
|
@ -541,7 +547,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
messageSection.elements[currentIndex + 1].cellType == .audio
|
||||
else { return }
|
||||
|
||||
let nextItem: MessageCell.ViewModel = messageSection.elements[currentIndex + 1]
|
||||
let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
|
||||
playOrPauseAudio(for: nextItem)
|
||||
}
|
||||
|
||||
|
|
|
@ -96,7 +96,7 @@ public extension LinkPreview {
|
|||
return .loaded
|
||||
|
||||
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,
|
||||
isOutgoing: Bool,
|
||||
delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil,
|
||||
cellViewModel: MessageCell.ViewModel? = nil,
|
||||
cellViewModel: MessageViewModel? = nil,
|
||||
bodyLabelTextColor: UIColor? = nil,
|
||||
lastSearchText: String? = nil
|
||||
) {
|
||||
|
@ -184,7 +184,7 @@ final class LinkPreviewView: UIView {
|
|||
// Body text view
|
||||
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
if let cellViewModel: MessageCell.ViewModel = cellViewModel {
|
||||
if let cellViewModel: MessageViewModel = cellViewModel {
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
|
|
|
@ -9,7 +9,7 @@ final class MediaPlaceholderView: UIView {
|
|||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageCell.ViewModel, textColor: UIColor) {
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
|
@ -24,7 +24,7 @@ final class MediaPlaceholderView: UIView {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy(
|
||||
cellViewModel: MessageCell.ViewModel,
|
||||
cellViewModel: MessageViewModel,
|
||||
textColor: UIColor
|
||||
) {
|
||||
let (iconName, attachmentDescription): (String, String) = {
|
||||
|
|
|
@ -121,6 +121,10 @@ public class MediaView: UIView {
|
|||
|
||||
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
||||
guard isOutgoing else { return false }
|
||||
guard attachment.state != .failedUpload else {
|
||||
configure(forError: .failed)
|
||||
return false
|
||||
}
|
||||
guard attachment.state != .uploaded else { return false }
|
||||
|
||||
let loader = MediaLoaderView()
|
||||
|
@ -326,8 +330,18 @@ public class MediaView: UIView {
|
|||
|
||||
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))
|
||||
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
iconView.tintColor = Colors.text
|
||||
.withAlphaComponent(Values.mediumOpacity)
|
||||
addSubview(iconView)
|
||||
iconView.autoCenterInSuperview()
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ final class InfoMessageCell: MessageCell {
|
|||
|
||||
// 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 }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
@ -81,6 +81,6 @@ final class InfoMessageCell: MessageCell {
|
|||
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 {
|
||||
weak var delegate: MessageCellDelegate?
|
||||
var viewModel: MessageCell.ViewModel?
|
||||
var viewModel: MessageViewModel?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
@ -43,19 +43,19 @@ public class MessageCell: UITableViewCell {
|
|||
|
||||
// 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.")
|
||||
}
|
||||
|
||||
/// 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)
|
||||
func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
preconditionFailure("Must be overridden by subclasses.")
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
switch viewModel.variant {
|
||||
|
@ -73,11 +73,11 @@ public class MessageCell: UITableViewCell {
|
|||
// MARK: - MessageCellDelegate
|
||||
|
||||
protocol MessageCellDelegate: AnyObject {
|
||||
func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel)
|
||||
func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel)
|
||||
func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState)
|
||||
func handleItemLongPressed(_ cellViewModel: MessageViewModel)
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
|
||||
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
|
||||
func openUrl(_ urlString: String)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
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
|
||||
|
||||
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 }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
@ -51,7 +51,7 @@ final class TypingIndicatorCell: MessageCell {
|
|||
typingIndicatorView.startAnimation()
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
|
|
|
@ -207,7 +207,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
// MARK: - Updating
|
||||
|
||||
override func update(
|
||||
with cellViewModel: MessageCell.ViewModel,
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
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 }
|
||||
|
||||
let dateBreakLabel: UILabel = UILabel()
|
||||
dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
dateBreakLabel.textColor = Colors.text
|
||||
dateBreakLabel.textAlignment = .center
|
||||
|
||||
let description: String = DateUtil.formatDate(forDisplay: date)
|
||||
dateBreakLabel.text = description
|
||||
dateBreakLabel.text = date.formattedForDisplay
|
||||
headerView.addSubview(dateBreakLabel)
|
||||
dateBreakLabel.pin(.top, to: .top, of: headerView, withInset: Values.smallSpacing)
|
||||
|
||||
|
@ -352,7 +350,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
private func populateContentView(
|
||||
for cellViewModel: MessageCell.ViewModel,
|
||||
for cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
lastSearchText: String?
|
||||
|
@ -579,7 +577,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
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 }
|
||||
|
||||
// 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() {
|
||||
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
delegate?.handleItemLongPressed(cellViewModel)
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
|
@ -692,13 +690,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
@objc private func handleDoubleTap() {
|
||||
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
delegate?.handleItemDoubleTapped(cellViewModel)
|
||||
}
|
||||
|
||||
@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] = [
|
||||
bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView
|
||||
|
@ -760,7 +758,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
private func reply() {
|
||||
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
resetReply()
|
||||
delegate?.handleReplyButtonTapped(for: cellViewModel)
|
||||
|
@ -797,7 +795,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
return cornerMask
|
||||
}
|
||||
|
||||
private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat {
|
||||
private static func getFontSize(for cellViewModel: MessageViewModel) -> CGFloat {
|
||||
let baselineFontSize = Values.mediumFontSize
|
||||
|
||||
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) }
|
||||
|
||||
let image: UIImage
|
||||
|
@ -838,7 +836,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
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 {
|
||||
preconditionFailure()
|
||||
}
|
||||
|
@ -886,7 +884,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
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
|
||||
|
||||
switch cellViewModel.variant {
|
||||
|
@ -905,7 +903,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
static func getBodyTextView(
|
||||
for cellViewModel: MessageCell.ViewModel,
|
||||
for cellViewModel: MessageViewModel,
|
||||
with availableWidth: CGFloat,
|
||||
textColor: UIColor,
|
||||
searchText: String?,
|
||||
|
@ -938,7 +936,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength {
|
||||
let normalizedBody: String = attributedText.string.lowercased()
|
||||
|
||||
ConversationCell.ViewModel.searchTermParts(searchText)
|
||||
SessionThreadViewModel.searchTermParts(searchText)
|
||||
.map { part -> String in
|
||||
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ public class InsetLockableTableView: UITableView {
|
|||
}
|
||||
public var oldOffset: CGPoint = .zero
|
||||
public var newOffset: CGPoint = .zero
|
||||
private var callbackCondition: ((Int, [Int]) -> Bool)?
|
||||
private var callbackCondition: ((Int, [Int], CGSize) -> Bool)?
|
||||
private var afterLayoutSubviewsCallback: (() -> ())?
|
||||
|
||||
public override func layoutSubviews() {
|
||||
|
@ -54,7 +54,7 @@ public class InsetLockableTableView: UITableView {
|
|||
// MARK: - Functions
|
||||
|
||||
public func afterNextLayoutSubviews(
|
||||
when condition: @escaping (Int, [Int]) -> Bool,
|
||||
when condition: @escaping (Int, [Int], CGSize) -> Bool,
|
||||
then callback: @escaping () -> ()
|
||||
) {
|
||||
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
|
||||
// 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
|
||||
return true
|
||||
|
|
|
@ -9,7 +9,7 @@ import SessionUtilitiesKit
|
|||
import SignalUtilitiesKit
|
||||
|
||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
fileprivate typealias SectionModel = ArraySection<SearchSection, ConversationCell.ViewModel>
|
||||
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
||||
|
||||
// MARK: - SearchSection
|
||||
|
||||
|
@ -22,8 +22,8 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
// MARK: - Variables
|
||||
|
||||
private lazy var defaultSearchResults: [SectionModel] = {
|
||||
let result: ConversationCell.ViewModel? = GRDBStorage.shared.read { db -> ConversationCell.ViewModel? in
|
||||
try ConversationCell.ViewModel
|
||||
let result: SessionThreadViewModel? = GRDBStorage.shared.read { db -> SessionThreadViewModel? in
|
||||
try SessionThreadViewModel
|
||||
.noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db))
|
||||
.fetchOne(db)
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
result.separatorStyle = .none
|
||||
result.keyboardDismissMode = .onDrag
|
||||
result.register(view: EmptySearchResultCell.self)
|
||||
result.register(view: ConversationCell.Full.self)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
|
@ -143,18 +143,18 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
do {
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel
|
||||
let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||
.contactsAndGroupsQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText),
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
|
||||
searchTerm: searchText
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel
|
||||
let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||
.messagesQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText)
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
|
@ -177,7 +177,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
self.termForCurrentSearchResultSet = searchText
|
||||
self.searchResultSet = [
|
||||
(hasResults ? nil : [ArraySection(model: .noResults, elements: [ConversationCell.ViewModel(unreadCount: 0)])]),
|
||||
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
|
||||
(hasResults ? sections : nil)
|
||||
]
|
||||
.compactMap { $0 }
|
||||
|
@ -332,12 +332,12 @@ extension GlobalSearchViewController {
|
|||
return cell
|
||||
|
||||
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)
|
||||
return cell
|
||||
|
||||
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)
|
||||
return cell
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import SignalUtilitiesKit
|
|||
|
||||
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate {
|
||||
typealias Section = HomeViewModel.Section
|
||||
typealias Item = ConversationCell.ViewModel
|
||||
typealias Item = SessionThreadViewModel
|
||||
|
||||
private let viewModel: HomeViewModel = HomeViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
|
@ -55,7 +55,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: MessageRequestsCell.self)
|
||||
result.register(view: ConversationCell.Full.self)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
|
@ -118,6 +118,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
}
|
||||
updateNavBarButtons()
|
||||
setUpNavBarSessionHeading()
|
||||
|
||||
// Recovery phrase reminder
|
||||
let hasViewedSeed = UserDefaults.standard[.hasViewedSeed]
|
||||
if !hasViewedSeed {
|
||||
|
@ -355,7 +356,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
return cell
|
||||
|
||||
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])
|
||||
return cell
|
||||
}
|
||||
|
@ -401,7 +402,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
return [hide]
|
||||
|
||||
case .threads:
|
||||
let cellViewModel: ConversationCell.ViewModel = section.elements[indexPath.row]
|
||||
let cellViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let delete: UITableViewRowAction = UITableViewRowAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
|
|
|
@ -12,7 +12,7 @@ public class HomeViewModel {
|
|||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
public lazy var observableViewData = ValueObservation
|
||||
.trackingConstantRegion { db -> [ArraySection<Section, ConversationCell.ViewModel>] in
|
||||
.trackingConstantRegion { db -> [ArraySection<Section, SessionThreadViewModel>] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let unreadMessageRequestCount: Int = try SessionThread
|
||||
.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
|
||||
(finalUnreadMessageRequestCount == 0 ?
|
||||
nil :
|
||||
ConversationCell.ViewModel(
|
||||
SessionThreadViewModel(
|
||||
unreadCount: UInt(finalUnreadMessageRequestCount)
|
||||
)
|
||||
)
|
||||
|
@ -48,7 +48,7 @@ public class HomeViewModel {
|
|||
),
|
||||
ArraySection(
|
||||
model: .threads,
|
||||
elements: try ConversationCell.ViewModel
|
||||
elements: try SessionThreadViewModel
|
||||
.homeQuery(userPublicKey: userPublicKey)
|
||||
.fetchAll(db)
|
||||
)
|
||||
|
@ -58,7 +58,7 @@ public class HomeViewModel {
|
|||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateData(_ updatedData: [ArraySection<Section, ConversationCell.ViewModel>]) {
|
||||
public func updateData(_ updatedData: [ArraySection<Section, SessionThreadViewModel>]) {
|
||||
self.viewData = updatedData
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.backgroundColor = .clear
|
||||
result.separatorStyle = .none
|
||||
result.register(view: ConversationCell.Full.self)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = 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
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
|
@ -214,7 +214,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
|
||||
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])
|
||||
return cell
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import SignalUtilitiesKit
|
|||
|
||||
public class MessageRequestsViewModel {
|
||||
/// 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
|
||||
/// 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
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
public lazy var observableViewData = ValueObservation
|
||||
.trackingConstantRegion { db -> [ConversationCell.ViewModel] in
|
||||
.trackingConstantRegion { db -> [SessionThreadViewModel] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return try ConversationCell.ViewModel
|
||||
return try SessionThreadViewModel
|
||||
.messageRequestsQuery(userPublicKey: userPublicKey)
|
||||
.fetchAll(db)
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ public class MessageRequestsViewModel {
|
|||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateData(_ updatedData: [ConversationCell.ViewModel]) {
|
||||
public func updateData(_ updatedData: [SessionThreadViewModel]) {
|
||||
self.viewData = updatedData
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
||||
result.layer.cornerRadius = (ConversationCell.Full.unreadCountViewSize / 2)
|
||||
result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell {
|
|||
|
||||
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
|
||||
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.Full.unreadCountViewSize),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
|
||||
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
|
||||
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),
|
||||
|
|
|
@ -386,8 +386,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
// Start observing for data changes
|
||||
dataChangeObservable = GRDBStorage.shared.start(
|
||||
viewModel.observableAlbumData,
|
||||
onError: { error in
|
||||
},
|
||||
onError: { _ in },
|
||||
onChange: { [weak self] albumData in
|
||||
// The defaul scheduler emits changes on the main thread
|
||||
self?.handleUpdates(albumData)
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
// Separate iOS Frameworks from other imports.
|
||||
#import "AvatarViewHelper.h"
|
||||
#import "AVAudioSession+OWS.h"
|
||||
#import "DateUtil.h"
|
||||
#import "NotificationSettingsViewController.h"
|
||||
#import "OWSAnyTouchGestureRecognizer.h"
|
||||
#import "OWSAudioPlayer.h"
|
||||
|
|
|
@ -192,13 +192,13 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
|||
owsFailDebug("threadId was unexpectedly nil")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else {
|
||||
return true
|
||||
}
|
||||
|
||||
// Show notifications for any *other* thread
|
||||
return conversationViewController.thread.uniqueId != notificationThreadId
|
||||
/// Show notifications for any **other** threads
|
||||
return (conversationViewController.viewModel.threadData.threadId != notificationThreadId)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,16 +230,18 @@ public class UserNotificationActionHandler: NSObject {
|
|||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
switch response.actionIdentifier {
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
Logger.debug("default action")
|
||||
return try actionHandler.showThread(userInfo: userInfo)
|
||||
case UNNotificationDismissActionIdentifier:
|
||||
// TODO - mark as read?
|
||||
Logger.debug("dismissed notification")
|
||||
return Promise.value(())
|
||||
default:
|
||||
// proceed
|
||||
break
|
||||
case UNNotificationDefaultActionIdentifier:
|
||||
Logger.debug("default action")
|
||||
return try actionHandler.showThread(userInfo: userInfo)
|
||||
|
||||
case UNNotificationDismissActionIdentifier:
|
||||
// TODO - mark as read?
|
||||
Logger.debug("dismissed notification")
|
||||
return Promise.value(())
|
||||
|
||||
default:
|
||||
// proceed
|
||||
break
|
||||
}
|
||||
|
||||
guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else {
|
||||
|
@ -247,16 +249,18 @@ public class UserNotificationActionHandler: NSObject {
|
|||
}
|
||||
|
||||
switch action {
|
||||
case .markAsRead:
|
||||
return try actionHandler.markAsRead(userInfo: userInfo)
|
||||
case .reply:
|
||||
guard let textInputResponse = response as? UNTextInputNotificationResponse else {
|
||||
throw NotificationError.failDebug("response had unexpected type: \(response)")
|
||||
}
|
||||
case .markAsRead:
|
||||
return try actionHandler.markAsRead(userInfo: userInfo)
|
||||
|
||||
case .reply:
|
||||
guard let textInputResponse = response as? UNTextInputNotificationResponse else {
|
||||
throw NotificationError.failDebug("response had unexpected type: \(response)")
|
||||
}
|
||||
|
||||
return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText)
|
||||
case .showThread:
|
||||
return try actionHandler.showThread(userInfo: userInfo)
|
||||
return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText)
|
||||
|
||||
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)
|
||||
|
||||
_ = try Job(
|
||||
variant: .failedMessages,
|
||||
variant: .failedMessageSends,
|
||||
behaviour: .recurringOnLaunch,
|
||||
shouldBlockFirstRunEachSession: true
|
||||
).inserted(db)
|
||||
|
@ -42,6 +42,11 @@ enum _002_SetupStandardJobs: Migration {
|
|||
variant: .retrieveDefaultOpenGroupRooms,
|
||||
behaviour: .recurringOnActive
|
||||
).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 downloading
|
||||
case downloaded
|
||||
case failedUpload
|
||||
case uploading
|
||||
case uploaded
|
||||
}
|
||||
|
@ -351,7 +352,7 @@ extension Attachment {
|
|||
)
|
||||
|
||||
// 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)
|
||||
|
||||
default: return (self.isValid, self.duration)
|
||||
|
@ -1055,6 +1056,11 @@ extension Attachment {
|
|||
success?()
|
||||
}
|
||||
.catch { error in
|
||||
GRDBStorage.shared.write { db in
|
||||
try updatedAttachment?
|
||||
.with(state: .failedUpload)
|
||||
.saved(db)
|
||||
}
|
||||
failure?(error)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -465,19 +465,22 @@ public extension Interaction {
|
|||
let interactionQuery = Interaction
|
||||
.filter(Columns.threadId == threadId)
|
||||
.filter(Columns.id <= interactionId)
|
||||
.filter(Columns.wasRead == false)
|
||||
// The `wasRead` flag doesn't apply to `standardOutgoing` or `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
|
||||
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
|
||||
|
||||
// Retrieve the interaction ids we want to update
|
||||
scheduleJobs(
|
||||
interactionIds: try Int64.fetchAll(
|
||||
db,
|
||||
interactionQuery.select(.id)
|
||||
)
|
||||
)
|
||||
scheduleJobs(interactionIds: interactionIdsToMarkAsRead)
|
||||
}
|
||||
|
||||
/// This method flags sent messages as read for the specified recipients
|
||||
|
|
|
@ -209,36 +209,6 @@ public extension SessionThread {
|
|||
// MARK: - Convenience
|
||||
|
||||
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
|
||||
///
|
||||
/// **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 SessionUtilitiesKit
|
||||
|
||||
public enum FailedMessagesJob: JobExecutor {
|
||||
public enum FailedMessageSendsJob: JobExecutor {
|
||||
public static let maxFailureCount: Int = -1
|
||||
public static let requiresThreadId: Bool = false
|
||||
public static let requiresInteractionId: Bool = false
|
||||
|
@ -21,8 +21,11 @@ public enum FailedMessagesJob: JobExecutor {
|
|||
let changeCount: Int = try RecipientState
|
||||
.filter(RecipientState.Columns.state == RecipientState.State.sending)
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed))
|
||||
|
||||
Logger.debug("Marked \(changeCount) messages as failed")
|
||||
let attachmentChangeCount: Int = try Attachment
|
||||
.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)
|
|
@ -39,6 +39,7 @@ extension GarbageCollectionJob {
|
|||
case threadTypingIndicators
|
||||
case orphanedAttachmentFiles
|
||||
case orphanedProfileAvatars
|
||||
case orphanedLinkPreviews
|
||||
}
|
||||
|
||||
public struct Details: Codable {
|
||||
|
|
|
@ -51,7 +51,7 @@ public enum MessageSendJob: JobExecutor {
|
|||
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
|
||||
// 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
|
||||
// have this case)
|
||||
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
|
||||
JobRunner
|
||||
.insert(
|
||||
|
|
|
@ -444,6 +444,7 @@ extension MessageReceiver {
|
|||
variant: variant,
|
||||
body: message.text,
|
||||
timestampMs: Int64(messageSentTimestamp * 1000),
|
||||
wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read
|
||||
hasMention: (
|
||||
message.text?.contains("@\(currentUserPublicKey)") == true ||
|
||||
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(
|
||||
db,
|
||||
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 DifferenceKit
|
||||
|
||||
fileprivate typealias ViewModel = ConversationCell.ViewModel
|
||||
fileprivate typealias ViewModel = SessionThreadViewModel
|
||||
|
||||
public enum ConversationCell {}
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
extension 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
|
||||
/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places
|
||||
/// 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
|
||||
/// screen in a single location in an attempt to avoid spreading out _almost_ duplicated code in multiple places
|
||||
///
|
||||
/// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values
|
||||
/// in order to optimise their queries to only include the required data
|
||||
public struct SessionThreadViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable {
|
||||
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
|
||||
/// in order to optimise their queries to only include the required data
|
||||
public struct ViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable {
|
||||
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: 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
|
||||
/// **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
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
public extension ConversationCell.ViewModel {
|
||||
public extension SessionThreadViewModel {
|
||||
// Note: This init method is only used system-created cells or empty states
|
||||
init(unreadCount: UInt = 0) {
|
||||
self.threadId = "INVALID_THREAD_ID"
|
||||
|
@ -255,12 +249,12 @@ public extension ConversationCell.ViewModel {
|
|||
|
||||
// MARK: - HomeVC & MessageRequestsViewController
|
||||
|
||||
public extension ConversationCell.ViewModel {
|
||||
public extension SessionThreadViewModel {
|
||||
private static func baseQuery(
|
||||
userPublicKey: String,
|
||||
filters: SQL,
|
||||
ordering: SQL
|
||||
) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> {
|
||||
) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||
|
@ -370,7 +364,7 @@ public extension ConversationCell.ViewModel {
|
|||
\(groupMember[.groupId]) = \(closedGroup[.threadId]) AND
|
||||
\(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 (
|
||||
\(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 contact: TypedTableAlias<Contact> = 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 contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
@ -508,11 +502,11 @@ public extension ConversationCell.ViewModel {
|
|||
|
||||
// MARK: - ConversationVC
|
||||
|
||||
public extension ConversationCell.ViewModel {
|
||||
static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> {
|
||||
public extension SessionThreadViewModel {
|
||||
static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
|
||||
let thread: TypedTableAlias<SessionThread> = 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 groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
|
@ -550,8 +544,10 @@ public extension ConversationCell.ViewModel {
|
|||
)
|
||||
) AS \(ViewModel.threadIsMessageRequestKey),
|
||||
(
|
||||
IFNULL(\(contact[.isApproved]), false) = false OR
|
||||
IFNULL(\(contact[.didApproveMe]), false) = false
|
||||
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND (
|
||||
IFNULL(\(contact[.isApproved]), false) = false OR
|
||||
IFNULL(\(contact[.didApproveMe]), false) = false
|
||||
)
|
||||
) AS \(ViewModel.threadRequiresApprovalKey),
|
||||
\(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey),
|
||||
|
||||
|
@ -575,6 +571,8 @@ public extension ConversationCell.ViewModel {
|
|||
\(openGroup[.room]) AS \(ViewModel.openGroupRoomKey),
|
||||
\(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey),
|
||||
\(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey),
|
||||
|
||||
\(interaction[.id]) AS \(ViewModel.interactionIdKey),
|
||||
|
||||
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
|
||||
|
||||
|
@ -592,6 +590,11 @@ public extension ConversationCell.ViewModel {
|
|||
\(SQL("\(interaction[.threadId]) = \(threadId)"))
|
||||
)
|
||||
) 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 (
|
||||
SELECT
|
||||
\(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
|
||||
|
||||
public extension ConversationCell.ViewModel {
|
||||
public extension SessionThreadViewModel {
|
||||
static func searchTermParts(_ searchTerm: String) -> [String] {
|
||||
/// 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
|
||||
}
|
||||
|
||||
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 thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||
|
@ -813,7 +902,7 @@ public extension ConversationCell.ViewModel {
|
|||
/// - Closed group member name
|
||||
/// - Open group name
|
||||
/// - "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 closedGroup: TypedTableAlias<ClosedGroup> = 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
|
||||
static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> {
|
||||
static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name)
|
||||
|
||||
|
@ -1224,8 +1313,8 @@ public extension ConversationCell.ViewModel {
|
|||
|
||||
// MARK: - Share Extension
|
||||
|
||||
public extension ConversationCell.ViewModel {
|
||||
static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> {
|
||||
public extension SessionThreadViewModel {
|
||||
static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||
|
@ -1272,7 +1361,7 @@ public extension ConversationCell.ViewModel {
|
|||
) AS \(Interaction.self) ON \(interaction[.threadId]) = \(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 \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId])
|
||||
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
|
||||
|
||||
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON (
|
||||
\(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = (
|
|
@ -9,17 +9,6 @@
|
|||
|
||||
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.
|
||||
extern NSString *const OWSPreferencesSignalDatabaseCollection;
|
||||
extern NSString *const OWSPreferencesCallLoggingDidChangeNotification;
|
||||
|
|
|
@ -87,7 +87,7 @@ final class SimplifiedConversationCell: UITableViewCell {
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
public func update(with cellViewModel: ConversationCell.ViewModel) {
|
||||
public func update(with cellViewModel: SessionThreadViewModel) {
|
||||
accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0)
|
||||
profilePictureView.update(
|
||||
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
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
|
|
|
@ -8,7 +8,7 @@ import SessionMessagingKit
|
|||
|
||||
public class ThreadPickerViewModel {
|
||||
/// 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
|
||||
/// 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
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
public lazy var observableViewData = ValueObservation
|
||||
.trackingConstantRegion { db -> [ConversationCell.ViewModel] in
|
||||
.trackingConstantRegion { db -> [SessionThreadViewModel] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return try ConversationCell.ViewModel
|
||||
return try SessionThreadViewModel
|
||||
.shareQuery(userPublicKey: userPublicKey)
|
||||
.fetchAll(db)
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ public class ThreadPickerViewModel {
|
|||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateData(_ updatedData: [ConversationCell.ViewModel]) {
|
||||
public func updateData(_ updatedData: [SessionThreadViewModel]) {
|
||||
self.viewData = updatedData
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,9 +6,24 @@ import GRDB
|
|||
public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "job" }
|
||||
internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId])
|
||||
internal static let dependantJobForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.jobId])
|
||||
internal static let dependencies = hasMany(Job.self, using: dependencyForeignKey)
|
||||
internal static let dependantJobs = hasMany(Job.self, using: dependencyForeignKey)
|
||||
public static let dependantJobDependency = hasMany(
|
||||
JobDependencies.self,
|
||||
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 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// deleted or it will automatically delete any dependant jobs
|
||||
public var dependencies: QueryInterfaceRequest<Job> {
|
||||
request(for: Job.dependencies)
|
||||
request(for: Job.jobsThisJobDependsOn)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// deleted or it will automatically delete any dependant jobs
|
||||
public var dependantJobs: QueryInterfaceRequest<Job> {
|
||||
request(for: Job.dependantJobs)
|
||||
request(for: Job.jobsThatDependOnThisJob)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
@ -242,8 +257,12 @@ public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePer
|
|||
// MARK: - GRDB Interactions
|
||||
|
||||
extension Job {
|
||||
internal static func filterPendingJobs(variants: [Variant], excludeFutureJobs: Bool = true) -> QueryInterfaceRequest<Job> {
|
||||
let query: QueryInterfaceRequest<Job> = Job
|
||||
internal static func filterPendingJobs(
|
||||
variants: [Variant],
|
||||
excludeFutureJobs: Bool = true,
|
||||
includeJobsWithDependencies: Bool = false
|
||||
) -> QueryInterfaceRequest<Job> {
|
||||
var query: QueryInterfaceRequest<Job> = Job
|
||||
.filter(
|
||||
// Retrieve all 'runOnce' and 'recurring' jobs
|
||||
[
|
||||
|
@ -263,12 +282,15 @@ extension Job {
|
|||
.order(Job.Columns.nextRunTimestamp)
|
||||
.order(Job.Columns.id)
|
||||
|
||||
guard excludeFutureJobs else {
|
||||
return query
|
||||
if excludeFutureJobs {
|
||||
query = query.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970)
|
||||
}
|
||||
|
||||
if !includeJobsWithDependencies {
|
||||
query = query.having(Job.jobsThisJobDependsOn.isEmpty)
|
||||
}
|
||||
|
||||
return query
|
||||
.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import GRDB
|
|||
public struct JobDependencies: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "jobDependencies" }
|
||||
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 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
|
||||
let rowIdsToQuery: [Int64] = committedChanges
|
||||
let rowIdsToQuery: [Int64] = relevantChanges
|
||||
.filter { $0.kind != .delete }
|
||||
.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
|
||||
// added at once)
|
||||
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 ?
|
||||
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 }
|
||||
)
|
||||
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 {
|
||||
updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty)
|
||||
updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -243,24 +260,17 @@ public class PagedDatabaseObserver<ObservedTable, T>: TransactionObserver where
|
|||
.fetchAll(db))
|
||||
.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
|
||||
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)
|
||||
|
||||
updatedPageInfo = PagedData.PageInfo(
|
||||
pageSize: updatedPageInfo.pageSize,
|
||||
pageOffset: updatedPageInfo.pageOffset,
|
||||
currentCount: (updatedPageInfo.currentCount + dataSizeDiff),
|
||||
totalCount: (updatedPageInfo.totalCount + dataSizeDiff)
|
||||
totalCount: updatedPageInfo.totalCount
|
||||
)
|
||||
|
||||
updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true)
|
||||
|
@ -526,6 +536,7 @@ public protocol ErasedAssociatedRecord {
|
|||
var databaseTableName: String { get }
|
||||
var observedChanges: [PagedData.ObservedChanges] { get }
|
||||
var joinToPagedType: SQL { get }
|
||||
var groupPagedType: SQL? { get }
|
||||
|
||||
func tryUpdateForDatabaseCommit(
|
||||
_ db: Database,
|
||||
|
@ -717,8 +728,7 @@ public enum PagedData {
|
|||
idColumn: String,
|
||||
requiredJoinSQL: SQL? = nil,
|
||||
orderSQL: SQL,
|
||||
filterSQL: SQL,
|
||||
joinToPagedType: SQL? = nil
|
||||
filterSQL: SQL
|
||||
) -> Int? {
|
||||
let tableNameLiteral: SQL = SQL(stringLiteral: tableName)
|
||||
let idColumnLiteral: SQL = SQL(stringLiteral: idColumn)
|
||||
|
@ -731,7 +741,6 @@ public enum PagedData {
|
|||
ROW_NUMBER() OVER (ORDER BY \(orderSQL)) AS rowIndex
|
||||
FROM \(tableNameLiteral)
|
||||
\(requiredJoinSQL ?? "")
|
||||
\(joinToPagedType ?? "")
|
||||
WHERE \(filterSQL)
|
||||
) AS data
|
||||
WHERE \(SQL("data.\(idColumnLiteral) = \(id)"))
|
||||
|
@ -750,9 +759,42 @@ public enum PagedData {
|
|||
requiredJoinSQL: SQL? = nil,
|
||||
orderSQL: SQL,
|
||||
filterSQL: SQL,
|
||||
joinToPagedType: SQL? = nil
|
||||
joinToPagedType: SQL? = nil,
|
||||
groupPagedType: SQL? = nil
|
||||
) -> [Int64] {
|
||||
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> = """
|
||||
SELECT
|
||||
(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 observedChanges: [PagedData.ObservedChanges]
|
||||
public let joinToPagedType: SQL
|
||||
public let groupPagedType: SQL?
|
||||
|
||||
fileprivate let dataCache: Atomic<DataCache<T>> = Atomic(DataCache())
|
||||
fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest<SQLRequest<T>>
|
||||
|
@ -812,12 +855,14 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
|
|||
observedChanges: [PagedData.ObservedChanges],
|
||||
dataQuery: @escaping (SQL?) -> AdaptedFetchRequest<SQLRequest<T>>,
|
||||
joinToPagedType: SQL,
|
||||
groupPagedType: SQL? = nil,
|
||||
associateData: @escaping (DataCache<T>, DataCache<PagedType>) -> DataCache<PagedType>
|
||||
) {
|
||||
self.databaseTableName = trackedAgainst.databaseTableName
|
||||
self.observedChanges = observedChanges
|
||||
self.dataQuery = dataQuery
|
||||
self.joinToPagedType = joinToPagedType
|
||||
self.groupPagedType = groupPagedType
|
||||
self.associateData = associateData
|
||||
}
|
||||
|
||||
|
@ -826,6 +871,7 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
|
|||
observedChanges: [PagedData.ObservedChanges],
|
||||
dataQuery: @escaping (SQL?) -> SQLRequest<T>,
|
||||
joinToPagedType: SQL,
|
||||
groupPagedType: SQL? = nil,
|
||||
associateData: @escaping (DataCache<T>, DataCache<PagedType>) -> DataCache<PagedType>
|
||||
) {
|
||||
self.init(
|
||||
|
@ -835,6 +881,7 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
|
|||
dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) }
|
||||
},
|
||||
joinToPagedType: joinToPagedType,
|
||||
groupPagedType: groupPagedType,
|
||||
associateData: associateData
|
||||
)
|
||||
}
|
||||
|
@ -879,19 +926,27 @@ public class AssociatedRecord<T, PagedType>: ErasedAssociatedRecord where T: Fet
|
|||
tableName: databaseTableName,
|
||||
orderSQL: orderSQL,
|
||||
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
|
||||
// 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
|
||||
// added at once)
|
||||
let itemIndexesAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast())
|
||||
let hasOneValidIndex: Bool = itemIndexes.contains(where: { $0 < pageInfo.currentCount })
|
||||
let uniqueIndexes: [Int64] = itemIndexes.asSet().sorted()
|
||||
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 ?
|
||||
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 }
|
||||
)
|
||||
|
||||
|
|
|
@ -56,7 +56,8 @@ public final class JobRunner {
|
|||
jobVariants: [
|
||||
jobVariants.remove(.attachmentUpload),
|
||||
jobVariants.remove(.messageSend),
|
||||
jobVariants.remove(.notifyPushServer)// TODO: Read receipts
|
||||
jobVariants.remove(.notifyPushServer),
|
||||
jobVariants.remove(.sendReadReceipts)
|
||||
].compactMap { $0 }
|
||||
)
|
||||
let messageReceiveQueue: JobQueue = JobQueue(
|
||||
|
@ -131,6 +132,11 @@ public final class JobRunner {
|
|||
guard let job: Job = job else { return } // Ignore null jobs
|
||||
|
||||
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? {
|
||||
|
@ -150,6 +156,11 @@ public final class JobRunner {
|
|||
|
||||
queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob)
|
||||
|
||||
// Start the job runner if needed
|
||||
db.afterNextTransactionCommit { _ in
|
||||
queues.wrappedValue[updatedJob.variant]?.start()
|
||||
}
|
||||
|
||||
return updatedJob
|
||||
}
|
||||
|
||||
|
@ -236,19 +247,26 @@ public final class JobRunner {
|
|||
}
|
||||
.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
|
||||
blockingQueue.wrappedValue?.appDidFinishLaunching(with: jobsToRun.blocking, canStart: true)
|
||||
|
||||
let blockingQueueIsRunning: Bool = (blockingQueue.wrappedValue?.isRunning.wrappedValue == true)
|
||||
// Add and start any non-blocking jobs (if there are no blocking jobs)
|
||||
let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.nonBlocking.grouped(by: \.variant)
|
||||
let jobQueues: [Job.Variant: JobQueue] = queues.wrappedValue
|
||||
|
||||
jobsByVariant.forEach { variant, jobs in
|
||||
jobQueues[variant]?.appDidBecomeActive(
|
||||
with: jobs,
|
||||
canStart: !blockingQueueIsRunning
|
||||
jobQueues.forEach { variant, queue in
|
||||
queue.appDidBecomeActive(
|
||||
with: (jobsByVariant[variant] ?? []),
|
||||
canStart: (!blockingQueueIsRunning && jobsToRun.blocking.isEmpty)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -259,6 +277,13 @@ public final class JobRunner {
|
|||
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
|
||||
|
||||
fileprivate static func getRetryInterval(for job: Job) -> TimeInterval {
|
||||
|
@ -450,6 +475,12 @@ private final class JobQueue {
|
|||
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
|
||||
|
||||
fileprivate func start() {
|
||||
|
|
|
@ -7,17 +7,6 @@ import SessionMessagingKit
|
|||
|
||||
@objc(LKProfilePictureView)
|
||||
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
|
||||
@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
|
||||
}
|
||||
|
||||
// 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:)
|
||||
public func update(forThreadId threadId: String?) {
|
||||
guard
|
||||
let threadId: String = threadId,
|
||||
let (thread, profiles, imageData) = GRDBStorage.shared.read({ db -> (SessionThread, [Profile], Data?) in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
||||
throw GRDBStorageError.objectNotFound
|
||||
}
|
||||
let viewModel: SessionThreadViewModel = GRDBStorage.shared.read({ db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
switch thread.variant {
|
||||
case .contact:
|
||||
return (
|
||||
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)
|
||||
)
|
||||
}
|
||||
return try SessionThreadViewModel
|
||||
.conversationSettingsProfileQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
})
|
||||
else { return }
|
||||
|
||||
update(
|
||||
publicKey: (imageData != nil ? "" : thread.id),
|
||||
profile: profiles.first,
|
||||
additionalProfile: profiles.last,
|
||||
threadVariant: thread.variant,
|
||||
openGroupProfilePicture: imageData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (thread.variant == .openGroup && imageData == nil)
|
||||
publicKey: viewModel.threadId,
|
||||
profile: viewModel.profile,
|
||||
additionalProfile: viewModel.additionalProfile,
|
||||
threadVariant: viewModel.threadVariant,
|
||||
openGroupProfilePicture: viewModel.openGroupProfilePictureData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (
|
||||
viewModel.threadVariant == .openGroup &&
|
||||
viewModel.openGroupProfilePictureData == nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue