From e2ee0e94eecb99bc1f120dc9a5455e3e4d9339bb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Sun, 29 May 2022 19:26:06 +1000 Subject: [PATCH] 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 --- Configuration.swift | 2 +- Session.xcodeproj/project.pbxproj | 34 +- .../Context Menu/ContextMenuVC+Action.swift | 31 +- .../Context Menu/ContextMenuVC.swift | 7 +- .../Conversations/ConversationSearch.swift | 2 +- .../ConversationVC+Interaction.swift | 30 +- Session/Conversations/ConversationVC.swift | 236 +++- .../Conversations/ConversationViewModel.swift | 68 +- .../Content Views/LinkPreviewState.swift | 2 +- .../Content Views/LinkPreviewView.swift | 4 +- .../Content Views/MediaPlaceholderView.swift | 4 +- .../Content Views/MediaView.swift | 16 +- .../Message Cells/InfoMessageCell.swift | 4 +- .../Message Cells/MessageCell.swift | 18 +- .../Models/MessageCellViewModel.swift | 652 ---------- .../Message Cells/TypingIndicatorCell.swift | 4 +- .../Message Cells/VisibleMessageCell.swift | 34 +- .../InsetLockableTableView.swift | 8 +- .../GlobalSearchViewController.swift | 22 +- Session/Home/HomeVC.swift | 9 +- Session/Home/HomeViewModel.swift | 10 +- .../MessageRequestsViewController.swift | 6 +- .../MessageRequestsViewModel.swift | 8 +- Session/Home/Views/MessageRequestsCell.swift | 6 +- .../MediaPageViewController.swift | 3 +- Session/Meta/Signal-Bridging-Header.h | 1 - .../UserNotificationsAdaptee.swift | 48 +- Session/Shared/FullConversationCell.swift | 1058 ++++++++--------- Session/Utilities/Date+Utilities.swift | 89 ++ Session/Utilities/DateUtil.h | 49 - Session/Utilities/DateUtil.m | 526 -------- .../Migrations/_002_SetupStandardJobs.swift | 7 +- .../Database/Models/Attachment.swift | 8 +- .../Database/Models/Interaction.swift | 15 +- .../Database/Models/SessionThread.swift | 30 - ...sJob.swift => FailedMessageSendsJob.swift} | 9 +- .../Jobs/Types/GarbageCollectionJob.swift | 1 + .../Jobs/Types/MessageSendJob.swift | 14 +- .../MessageReceiver+Handling.swift | 5 +- .../Shared Models/MessageViewModel.swift | 699 +++++++++++ ...del.swift => SessionThreadViewModel.swift} | 495 ++++---- .../Utilities/OWSPreferences.h | 11 - .../SimplifiedConversationCell.swift | 2 +- SessionShareExtension/ThreadPickerVC.swift | 2 +- .../ThreadPickerViewModel.swift | 8 +- SessionUtilitiesKit/Database/Models/Job.swift | 44 +- .../Database/Models/JobDependencies.swift | 1 + .../Types/PagedDatabaseObserver.swift | 101 +- SessionUtilitiesKit/JobRunner/JobRunner.swift | 49 +- .../Profile Pictures/ProfilePictureView.swift | 77 +- 50 files changed, 2201 insertions(+), 2368 deletions(-) delete mode 100644 Session/Conversations/Message Cells/Models/MessageCellViewModel.swift create mode 100644 Session/Utilities/Date+Utilities.swift delete mode 100644 Session/Utilities/DateUtil.h delete mode 100644 Session/Utilities/DateUtil.m rename SessionMessagingKit/Jobs/Types/{FailedMessagesJob.swift => FailedMessageSendsJob.swift} (65%) create mode 100644 SessionMessagingKit/Shared Models/MessageViewModel.swift rename SessionMessagingKit/Shared Models/{ConversationCellViewModel.swift => SessionThreadViewModel.swift} (77%) diff --git a/Configuration.swift b/Configuration.swift index 791f38f7c..055bd318a 100644 --- a/Configuration.swift +++ b/Configuration.swift @@ -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) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 0ab30227c..483ebef34 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -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 = ""; }; B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Locations-English"; path = "Countries/GeoLite2-Country-Locations-English"; sourceTree = ""; }; B8FF8EA525C11FEF004D1F22 /* IPv4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = ""; }; - B90418E4183E9DD40038554A /* DateUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DateUtil.h; sourceTree = ""; }; - B90418E5183E9DD40038554A /* DateUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DateUtil.m; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -1629,7 +1627,7 @@ FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = ""; }; FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; - FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = ""; }; + FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = ""; }; FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = ""; }; @@ -1644,18 +1642,19 @@ FD705A93278D052B00F16121 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD705A97278E9F4D00F16121 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; FD7162DA281B6C440060647B /* TypedTableAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlias.swift; sourceTree = ""; }; - FD848B86283B844B000E298B /* MessageCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCellViewModel.swift; sourceTree = ""; }; + FD848B86283B844B000E298B /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = ""; }; FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedDatabaseObserver.swift; sourceTree = ""; }; FD848B8C283E0B26000E298B /* MessageInputTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInputTypes.swift; sourceTree = ""; }; FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Utilities.swift"; sourceTree = ""; }; FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UnicodeScalar+Utilities.swift"; sourceTree = ""; }; + FD848B9728422F1A000E298B /* Date+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Utilities.swift"; sourceTree = ""; }; FD859EFF27C4691300510D0C /* MockDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = ""; }; FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = ""; }; FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetSnodePoolJob.swift; sourceTree = ""; }; FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = ""; }; 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 = ""; }; - FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = ""; }; + FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessageSendsJob.swift; sourceTree = ""; }; FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = ""; }; FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = ""; }; FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewError.swift; sourceTree = ""; }; @@ -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 = ""; @@ -3456,7 +3455,6 @@ FD848B85283B8438000E298B /* Models */ = { isa = PBXGroup; children = ( - FD848B86283B844B000E298B /* MessageCellViewModel.swift */, ); path = Models; sourceTree = ""; @@ -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 */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 63ba37af9..cf70f104d 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -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) } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 6cf1b0bc5..8d5340e3b 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -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 ) { diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 53ec8eb35..4b8f20602 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -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) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 8a635d2f0..d29acf2f6 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -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 diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index ebe03829f..c261436f7 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -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 diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 949f18c56..d25a8f966 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -7,7 +7,7 @@ import SessionMessagingKit import SessionUtilitiesKit public class ConversationViewModel: OWSAudioPlayerDelegate { - public typealias SectionModel = ArraySection + public typealias SectionModel = ArraySection // 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( + AssociatedRecord( 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? + public private(set) var pagedDataObserver: PagedDatabaseObserver? 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 = 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) } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 6559d4078..22fc0ea74 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -96,7 +96,7 @@ public extension LinkPreview { return .loaded case .pendingDownload, .downloading, .uploading: return .loading - case .failedDownload: return .invalid + case .failedDownload, .failedUpload: return .invalid } } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 076d49d94..1d8c058bd 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -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, diff --git a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift index f25b33bf6..fbd65d20a 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaPlaceholderView.swift @@ -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) = { diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index c26cbd295..a22cce4c6 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -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() } diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index a8dfb6ae0..86b270498 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -52,7 +52,7 @@ final class InfoMessageCell: MessageCell { // MARK: - Updating - override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, 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?) { } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 809ae0f14..ea24e5ba3 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -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, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, 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) } diff --git a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift b/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift deleted file mode 100644 index ca13a5c94..000000000 --- a/Session/Conversations/Message Cells/Models/MessageCellViewModel.swift +++ /dev/null @@ -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 = TypedTableAlias() - - return SQL("\(interaction[.threadId]) = \(threadId)") - } - - public static let orderSQL: SQL = { - let interaction: TypedTableAlias = TypedTableAlias() - - return SQL("\(interaction[.timestampMs].desc)") - }() - - public static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { - return { additionalFilters, limitSQL -> AdaptedFetchRequest> in - let interaction: TypedTableAlias = TypedTableAlias() - let thread: TypedTableAlias = TypedTableAlias() - let contact: TypedTableAlias = TypedTableAlias() - let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() - let profile: TypedTableAlias = TypedTableAlias() - let quote: TypedTableAlias = TypedTableAlias() - let linkPreview: TypedTableAlias = 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 = """ - 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>) = { - return { additionalFilters -> AdaptedFetchRequest> in - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - let finalFilterSQL: SQL = { - guard let additionalFilters: SQL = additionalFilters else { - return SQL(stringLiteral: "") - } - - return """ - WHERE \(additionalFilters) - """ - }() - let numColumnsBeforeLinkedRecords: Int = 1 - let request: SQLRequest = """ - 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 = TypedTableAlias() - let attachment: TypedTableAlias = TypedTableAlias() - let interactionAttachment: TypedTableAlias = TypedTableAlias() - - return """ - JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) - JOIN \(Interaction.self) ON - \(interaction[.id]) = \(interactionAttachment[.interactionId]) - """ - }() - - public static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { - return { dataCache, pagedDataCache -> DataCache in - var updatedPagedDataCache: DataCache = 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 - } - } -} diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index d95a445cb..0b40253c2 100644 --- a/Session/Conversations/Message Cells/TypingIndicatorCell.swift +++ b/Session/Conversations/Message Cells/TypingIndicatorCell.swift @@ -39,7 +39,7 @@ final class TypingIndicatorCell: MessageCell { // MARK: - Updating - override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, 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() { diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 3216593d4..408daab64 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -207,7 +207,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel // MARK: - Updating override func update( - with cellViewModel: MessageCell.ViewModel, + with cellViewModel: MessageViewModel, mediaCache: NSCache, 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, 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 } diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift index e1c67b4f0..cb0abdc1f 100644 --- a/Session/Conversations/Views & Modals/InsetLockableTableView.swift +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -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 diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index b5b7cdb68..98ee2f928 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -9,7 +9,7 @@ import SessionUtilitiesKit import SignalUtilitiesKit class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { - fileprivate typealias SectionModel = ArraySection + fileprivate typealias SectionModel = ArraySection // 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 } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 695a22a86..74eee689b 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -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() diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 0ad4fd760..e309e8076 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -12,7 +12,7 @@ public class HomeViewModel { } /// This value is the current state of the view - public private(set) var viewData: [ArraySection] = [] + public private(set) var viewData: [ArraySection] = [] /// 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] in + .trackingConstantRegion { db -> [ArraySection] 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]) { + public func updateData(_ updatedData: [ArraySection]) { self.viewData = updatedData } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index a2c6e0efd..4773b50aa 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -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 } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 56f845f16..9688d1c71 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -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 } } diff --git a/Session/Home/Views/MessageRequestsCell.swift b/Session/Home/Views/MessageRequestsCell.swift index c2807e1d6..b2b72f799 100644 --- a/Session/Home/Views/MessageRequestsCell.swift +++ b/Session/Home/Views/MessageRequestsCell.swift @@ -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), diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 61bddbf34..b02107a0b 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -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) diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 6cdb5eb57..01ad3a46c 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -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" diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 23349fb82..c7fd88113 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -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) } } } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index a906c9320..d645035d1 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -5,566 +5,564 @@ import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit -public extension ConversationCell { - public final class Full: UITableViewCell { - // MARK: - UI +public final class FullConversationCell: UITableViewCell { + // MARK: - UI + + private let accentLineView: UIView = UIView() + + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() + + private lazy var displayNameLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail - private let accentLineView: UIView = UIView() + return result + }() - private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() - - private lazy var displayNameLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var unreadCountView: UIView = { - let result: UIView = UIView() - result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) - let size = ConversationCell.Full.unreadCountViewSize - result.set(.width, greaterThanOrEqualTo: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = (size / 2) - - return result - }() - - private lazy var unreadCountLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.textAlignment = .center - - return result - }() - - private lazy var hasMentionView: UIView = { - let result: UIView = UIView() - result.backgroundColor = Colors.accent - let size = ConversationCell.Full.unreadCountViewSize - result.set(.width, to: size) - result.set(.height, to: size) - result.layer.masksToBounds = true - result.layer.cornerRadius = (size / 2) - - return result - }() - - private lazy var hasMentionLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.text = "@" - result.textAlignment = .center - - return result - }() - - private lazy var isPinnedIcon: UIImageView = { - let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) - result.contentMode = .scaleAspectFit - let size = ConversationCell.Full.unreadCountViewSize - result.set(.width, to: size) - result.set(.height, to: size) - result.tintColor = Colors.pinIcon - result.layer.masksToBounds = true - - return result - }() - - private lazy var timestampLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - result.alpha = Values.lowOpacity - - return result - }() - - private lazy var snippetLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var typingIndicatorView = TypingIndicatorView() - - private lazy var statusIndicatorView: UIImageView = { - let result: UIImageView = UIImageView() - result.contentMode = .scaleAspectFit - result.layer.cornerRadius = (ConversationCell.Full.statusIndicatorSize / 2) - result.layer.masksToBounds = true - - return result - }() - - private lazy var topLabelStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.alignment = .center - result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - - return result - }() - - private lazy var bottomLabelStackView: UIStackView = { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.alignment = .center - result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer - - return result - }() - - // MARK: Settings - - public static let unreadCountViewSize: CGFloat = 20 - private static let statusIndicatorSize: CGFloat = 14 - - // MARK: - Initialization + private lazy var unreadCountView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) + let size = FullConversationCell.unreadCountViewSize + result.set(.width, greaterThanOrEqualTo: size) + result.set(.height, to: size) + result.layer.masksToBounds = true + result.layer.cornerRadius = (size / 2) - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setUpViewHierarchy() - } + return result + }() - required init?(coder: NSCoder) { - super.init(coder: coder) - setUpViewHierarchy() - } + private lazy var unreadCountLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.textAlignment = .center + + return result + }() - private func setUpViewHierarchy() { - let cellHeight: CGFloat = 68 - - // Background color - backgroundColor = Colors.cellBackground - - // Highlight color - let selectedBackgroundView = UIView() - selectedBackgroundView.backgroundColor = Colors.cellSelected - self.selectedBackgroundView = selectedBackgroundView - - // Accent line view - accentLineView.set(.width, to: Values.accentLineThickness) - accentLineView.set(.height, to: cellHeight) - - // Profile picture view - let profilePictureViewSize = Values.mediumProfilePictureSize - profilePictureView.set(.width, to: profilePictureViewSize) - profilePictureView.set(.height, to: profilePictureViewSize) - profilePictureView.size = profilePictureViewSize - - // Unread count view - unreadCountView.addSubview(unreadCountLabel) - unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) - unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) - unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) - - // Has mention view - hasMentionView.addSubview(hasMentionLabel) - hasMentionLabel.pin(to: hasMentionView) - - // Label stack view - let topLabelSpacer = UIView.hStretchingSpacer() - [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in - topLabelStackView.addArrangedSubview(view) - } - - let snippetLabelContainer = UIView() - snippetLabelContainer.addSubview(snippetLabel) - snippetLabelContainer.addSubview(typingIndicatorView) - - let bottomLabelSpacer = UIView.hStretchingSpacer() - [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in - bottomLabelStackView.addArrangedSubview(view) - } - - let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) - labelContainerView.axis = .vertical - labelContainerView.alignment = .leading - labelContainerView.spacing = 6 - labelContainerView.isUserInteractionEnabled = false - - // Main stack view - let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) - stackView.axis = .horizontal - stackView.alignment = .center - stackView.spacing = Values.mediumSpacing - contentView.addSubview(stackView) - - // Constraints - accentLineView.pin(.top, to: .top, of: contentView) - accentLineView.pin(.bottom, to: .bottom, of: contentView) - timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) - - // HACK: The six lines below are part of a workaround for a weird layout bug - topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) - topLabelStackView.set(.height, to: 20) - topLabelSpacer.set(.height, to: 20) - - bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) - bottomLabelStackView.set(.height, to: 18) - bottomLabelSpacer.set(.height, to: 18) - - statusIndicatorView.set(.width, to: ConversationCell.Full.statusIndicatorSize) - statusIndicatorView.set(.height, to: ConversationCell.Full.statusIndicatorSize) - - snippetLabel.pin(to: snippetLabelContainer) - - typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) - typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true - - stackView.pin(.leading, to: .leading, of: contentView) - stackView.pin(.top, to: .top, of: contentView) - - // HACK: The two lines below are part of a workaround for a weird layout bug - stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) - stackView.set(.height, to: cellHeight) + private lazy var hasMentionView: UIView = { + let result: UIView = UIView() + result.backgroundColor = Colors.accent + let size = FullConversationCell.unreadCountViewSize + result.set(.width, to: size) + result.set(.height, to: size) + result.layer.masksToBounds = true + result.layer.cornerRadius = (size / 2) + + return result + }() + + private lazy var hasMentionLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) + result.textColor = Colors.text + result.text = "@" + result.textAlignment = .center + + return result + }() + + private lazy var isPinnedIcon: UIImageView = { + let result: UIImageView = UIImageView(image: UIImage(named: "Pin")?.withRenderingMode(.alwaysTemplate)) + result.contentMode = .scaleAspectFit + let size = FullConversationCell.unreadCountViewSize + result.set(.width, to: size) + result.set(.height, to: size) + result.tintColor = Colors.pinIcon + result.layer.masksToBounds = true + + return result + }() + + private lazy var timestampLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + result.alpha = Values.lowOpacity + + return result + }() + + private lazy var snippetLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.textColor = Colors.text + result.lineBreakMode = .byTruncatingTail + + return result + }() + + private lazy var typingIndicatorView = TypingIndicatorView() + + private lazy var statusIndicatorView: UIImageView = { + let result: UIImageView = UIImageView() + result.contentMode = .scaleAspectFit + result.layer.cornerRadius = (FullConversationCell.statusIndicatorSize / 2) + result.layer.masksToBounds = true + + return result + }() + + private lazy var topLabelStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + + return result + }() + + private lazy var bottomLabelStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.axis = .horizontal + result.alignment = .center + result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer + + return result + }() + + // MARK: Settings + + public static let unreadCountViewSize: CGFloat = 20 + private static let statusIndicatorSize: CGFloat = 14 + + // MARK: - Initialization + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setUpViewHierarchy() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUpViewHierarchy() + } + + private func setUpViewHierarchy() { + let cellHeight: CGFloat = 68 + + // Background color + backgroundColor = Colors.cellBackground + + // Highlight color + let selectedBackgroundView = UIView() + selectedBackgroundView.backgroundColor = Colors.cellSelected + self.selectedBackgroundView = selectedBackgroundView + + // Accent line view + accentLineView.set(.width, to: Values.accentLineThickness) + accentLineView.set(.height, to: cellHeight) + + // Profile picture view + let profilePictureViewSize = Values.mediumProfilePictureSize + profilePictureView.set(.width, to: profilePictureViewSize) + profilePictureView.set(.height, to: profilePictureViewSize) + profilePictureView.size = profilePictureViewSize + + // Unread count view + unreadCountView.addSubview(unreadCountLabel) + unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView) + unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) + unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) + + // Has mention view + hasMentionView.addSubview(hasMentionLabel) + hasMentionLabel.pin(to: hasMentionView) + + // Label stack view + let topLabelSpacer = UIView.hStretchingSpacer() + [ displayNameLabel, isPinnedIcon, unreadCountView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in + topLabelStackView.addArrangedSubview(view) } - // MARK: - Content + let snippetLabelContainer = UIView() + snippetLabelContainer.addSubview(snippetLabel) + snippetLabelContainer.addSubview(typingIndicatorView) - // MARK: --Search Results - - public func updateForMessageSearchResult(with cellViewModel: ViewModel, searchText: String) { - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) - ) - - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - displayNameLabel.attributedText = NSMutableAttributedString( - string: cellViewModel.displayName, - attributes: [ .foregroundColor: Colors.text] - ) - timestampLabel.isHidden = false - timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - bottomLabelStackView.isHidden = false - snippetLabel.attributedText = getHighlightedSnippet( - content: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: .contact), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) - ), - authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? - cellViewModel.authorName(for: .contact) : - nil - ), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize - ) + let bottomLabelSpacer = UIView.hStretchingSpacer() + [ snippetLabelContainer, bottomLabelSpacer, statusIndicatorView ].forEach{ view in + bottomLabelStackView.addArrangedSubview(view) } - public func updateForContactAndGroupSearchResult(with cellViewModel: ViewModel, searchText: String) { - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) - ) - - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - timestampLabel.isHidden = true - displayNameLabel.attributedText = getHighlightedSnippet( - content: cellViewModel.displayName, - searchText: searchText.lowercased(), - fontSize: Values.mediumFontSize - ) - - switch cellViewModel.threadVariant { - case .contact, .openGroup: bottomLabelStackView.isHidden = true - - case .closedGroup: - bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty - snippetLabel.attributedText = getHighlightedSnippet( - content: (cellViewModel.threadMemberNames ?? ""), - searchText: searchText.lowercased(), - fontSize: Values.smallFontSize - ) - } - } - - // MARK: --Standard + let labelContainerView = UIStackView(arrangedSubviews: [ topLabelStackView, bottomLabelStackView ]) + labelContainerView.axis = .vertical + labelContainerView.alignment = .leading + labelContainerView.spacing = 6 + labelContainerView.isUserInteractionEnabled = false - public func update(with cellViewModel: ViewModel) { - let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) - backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) - - if cellViewModel.threadIsBlocked == true { - accentLineView.backgroundColor = Colors.destructive - accentLineView.alpha = 1 - } - else { - accentLineView.backgroundColor = Colors.accent - accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 - } - - isPinnedIcon.isHidden = !cellViewModel.threadIsPinned - unreadCountView.isHidden = (unreadCount <= 0) - unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") - unreadCountLabel.font = .boldSystemFont( - ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) - ) - hasMentionView.isHidden = !( - ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && - (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) - ) - profilePictureView.update( - publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, - threadVariant: cellViewModel.threadVariant, - openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, - useFallbackPicture: ( - cellViewModel.threadVariant == .openGroup && - cellViewModel.openGroupProfilePictureData == nil + // Main stack view + let stackView = UIStackView(arrangedSubviews: [ accentLineView, profilePictureView, labelContainerView ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = Values.mediumSpacing + contentView.addSubview(stackView) + + // Constraints + accentLineView.pin(.top, to: .top, of: contentView) + accentLineView.pin(.bottom, to: .bottom, of: contentView) + timestampLabel.setContentCompressionResistancePriority(.required, for: NSLayoutConstraint.Axis.horizontal) + + // HACK: The six lines below are part of a workaround for a weird layout bug + topLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) + topLabelStackView.set(.height, to: 20) + topLabelSpacer.set(.height, to: 20) + + bottomLabelStackView.set(.width, to: UIScreen.main.bounds.width - Values.accentLineThickness - profilePictureViewSize - 3 * Values.mediumSpacing) + bottomLabelStackView.set(.height, to: 18) + bottomLabelSpacer.set(.height, to: 18) + + statusIndicatorView.set(.width, to: FullConversationCell.statusIndicatorSize) + statusIndicatorView.set(.height, to: FullConversationCell.statusIndicatorSize) + + snippetLabel.pin(to: snippetLabelContainer) + + typingIndicatorView.pin(.leading, to: .leading, of: snippetLabelContainer) + typingIndicatorView.centerYAnchor.constraint(equalTo: snippetLabel.centerYAnchor).isActive = true + + stackView.pin(.leading, to: .leading, of: contentView) + stackView.pin(.top, to: .top, of: contentView) + + // HACK: The two lines below are part of a workaround for a weird layout bug + stackView.set(.width, to: UIScreen.main.bounds.width - Values.mediumSpacing) + stackView.set(.height, to: cellHeight) + } + + // MARK: - Content + + // MARK: --Search Results + + public func updateForMessageSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + displayNameLabel.attributedText = NSMutableAttributedString( + string: cellViewModel.displayName, + attributes: [ .foregroundColor: Colors.text] + ) + timestampLabel.isHidden = false + timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + bottomLabelStackView.isHidden = false + snippetLabel.attributedText = getHighlightedSnippet( + content: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: .contact), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ? + cellViewModel.authorName(for: .contact) : + nil + ), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize + ) + } + + public func updateForContactAndGroupSearchResult(with cellViewModel: SessionThreadViewModel, searchText: String) { + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil) + ) + + isPinnedIcon.isHidden = true + unreadCountView.isHidden = true + hasMentionView.isHidden = true + timestampLabel.isHidden = true + displayNameLabel.attributedText = getHighlightedSnippet( + content: cellViewModel.displayName, + searchText: searchText.lowercased(), + fontSize: Values.mediumFontSize + ) + + switch cellViewModel.threadVariant { + case .contact, .openGroup: bottomLabelStackView.isHidden = true + + case .closedGroup: + bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty + snippetLabel.attributedText = getHighlightedSnippet( + content: (cellViewModel.threadMemberNames ?? ""), + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize ) - ) - displayNameLabel.text = cellViewModel.displayName - timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate) - - if cellViewModel.threadContactIsTyping == true { - snippetLabel.text = "" - typingIndicatorView.isHidden = false - typingIndicatorView.startAnimation() - } - else { - snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) - typingIndicatorView.isHidden = true - typingIndicatorView.stopAnimation() - } - - statusIndicatorView.backgroundColor = nil - - switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { - case (.standardOutgoing, .sending): - statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - statusIndicatorView.isHidden = false - - case (.standardOutgoing, .sent): - statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.text - statusIndicatorView.isHidden = false - - case (.standardOutgoing, .failed): - statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) - statusIndicatorView.tintColor = Colors.destructive - statusIndicatorView.isHidden = false - - default: - statusIndicatorView.isHidden = false - } + } + } + + // MARK: --Standard + + public func update(with cellViewModel: SessionThreadViewModel) { + let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0) + backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground) + + if cellViewModel.threadIsBlocked == true { + accentLineView.backgroundColor = Colors.destructive + accentLineView.alpha = 1 + } + else { + accentLineView.backgroundColor = Colors.accent + accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12 } - // MARK: - Snippet generation - - private func getSnippet(cellViewModel: ViewModel) -> NSMutableAttributedString { - let result = NSMutableAttributedString() - - if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { - result.append(NSAttributedString( - string: "\u{e067} ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor :Colors.unimportant - ] - )) - } - else if cellViewModel.threadOnlyNotifyForMentions == true { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) - imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) - - let imageString = NSAttributedString(attachment: imageAttachment) - result.append(imageString) - result.append(NSAttributedString( - string: " ", - attributes: [ - .font: UIFont.ows_elegantIconsFont(10), - .foregroundColor: Colors.unimportant - ] - )) - } - - let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? - .boldSystemFont(ofSize: Values.smallFontSize) : - .systemFont(ofSize: Values.smallFontSize) + isPinnedIcon.isHidden = !cellViewModel.threadIsPinned + unreadCountView.isHidden = (unreadCount <= 0) + unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") + unreadCountLabel.font = .boldSystemFont( + ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8) + ) + hasMentionView.isHidden = !( + ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && + (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) + ) + profilePictureView.update( + publicKey: cellViewModel.threadId, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile, + threadVariant: cellViewModel.threadVariant, + openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) }, + useFallbackPicture: ( + cellViewModel.threadVariant == .openGroup && + cellViewModel.openGroupProfilePictureData == nil ) - - if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { - let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) + ) + displayNameLabel.text = cellViewModel.displayName + timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay + + if cellViewModel.threadContactIsTyping == true { + snippetLabel.text = "" + typingIndicatorView.isHidden = false + typingIndicatorView.startAnimation() + } + else { + snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel) + typingIndicatorView.isHidden = true + typingIndicatorView.stopAnimation() + } + + statusIndicatorView.backgroundColor = nil + + switch (cellViewModel.interactionVariant, cellViewModel.interactionState) { + case (.standardOutgoing, .sending): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false - result.append(NSAttributedString( - string: "\(authorName): ", - attributes: [ - .font: font, - .foregroundColor: Colors.text - ] - )) - } + case (.standardOutgoing, .sent): + statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.text + statusIndicatorView.isHidden = false + + case (.standardOutgoing, .failed): + statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate) + statusIndicatorView.tintColor = Colors.destructive + statusIndicatorView.isHidden = false + + default: + statusIndicatorView.isHidden = false + } + } + + // MARK: - Snippet generation + + private func getSnippet(cellViewModel: SessionThreadViewModel) -> NSMutableAttributedString { + let result = NSMutableAttributedString() + + if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { + result.append(NSAttributedString( + string: "\u{e067} ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor :Colors.unimportant + ] + )) + } + else if cellViewModel.threadOnlyNotifyForMentions == true { + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant) + imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) + + let imageString = NSAttributedString(attachment: imageAttachment) + result.append(imageString) + result.append(NSAttributedString( + string: " ", + attributes: [ + .font: UIFont.ows_elegantIconsFont(10), + .foregroundColor: Colors.unimportant + ] + )) + } + + let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ? + .boldSystemFont(ofSize: Values.smallFontSize) : + .systemFont(ofSize: Values.smallFontSize) + ) + + if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup { + let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) result.append(NSAttributedString( - string: MentionUtilities.highlightMentions( - in: Interaction.previewText( - variant: (cellViewModel.interactionVariant ?? .standardIncoming), - body: cellViewModel.interactionBody, - authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), - attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, - attachmentCount: cellViewModel.interactionAttachmentCount, - isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) - ), - threadVariant: cellViewModel.threadVariant - ), + string: "\(authorName): ", attributes: [ .font: font, .foregroundColor: Colors.text ] )) - - return result } - private func getHighlightedSnippet( - content: String, - authorName: String? = nil, - searchText: String, - fontSize: CGFloat - ) -> NSAttributedString { - guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { - return NSMutableAttributedString( - string: (authorName != nil && authorName?.isEmpty != true ? - "\(authorName ?? ""): \(content)" : - content - ), + result.append(NSAttributedString( + string: MentionUtilities.highlightMentions( + in: Interaction.previewText( + variant: (cellViewModel.interactionVariant ?? .standardIncoming), + body: cellViewModel.interactionBody, + authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + threadVariant: cellViewModel.threadVariant + ), + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) + + return result + } + + private func getHighlightedSnippet( + content: String, + authorName: String? = nil, + searchText: String, + fontSize: CGFloat + ) -> NSAttributedString { + guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else { + return NSMutableAttributedString( + string: (authorName != nil && authorName?.isEmpty != true ? + "\(authorName ?? ""): \(content)" : + content + ), + attributes: [ .foregroundColor: Colors.text ] + ) + } + + // Replace mentions in the content + // + // Note: The 'threadVariant' is used for profile context but in the search results + // we don't want to include the truncated id as part of the name so we exclude it + let mentionReplacedContent: String = MentionUtilities.highlightMentions( + in: content, + threadVariant: .contact + ) + let result: NSMutableAttributedString = NSMutableAttributedString( + string: mentionReplacedContent, + attributes: [ + .foregroundColor: Colors.text + .withAlphaComponent(Values.lowOpacity) + ] + ) + + // Bold each part of the searh term which matched + let normalizedSnippet: String = mentionReplacedContent.lowercased() + var firstMatchRange: Range? + + SessionThreadViewModel.searchTermParts(searchText) + .map { part -> String in + guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } + + return String(part[part.index(after: part.startIndex).. NSAttributedString { + let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) + + guard ((bounds.width - approxFullWidth) < 0) else { return content } + + return content.attributedSubstring( + from: NSRange(startOfSnippet.. NSAttributedString? in + guard !authorName.isEmpty else { return nil } + + let authorPrefix: NSAttributedString = NSAttributedString( + string: "\(authorName): ...", attributes: [ .foregroundColor: Colors.text ] ) - } - - // Replace mentions in the content - // - // Note: The 'threadVariant' is used for profile context but in the search results - // we don't want to include the truncated id as part of the name so we exclude it - let mentionReplacedContent: String = MentionUtilities.highlightMentions( - in: content, - threadVariant: .contact - ) - let result: NSMutableAttributedString = NSMutableAttributedString( - string: mentionReplacedContent, - attributes: [ - .foregroundColor: Colors.text - .withAlphaComponent(Values.lowOpacity) - ] - ) - - // Bold each part of the searh term which matched - let normalizedSnippet: String = mentionReplacedContent.lowercased() - var firstMatchRange: Range? - - ConversationCell.ViewModel.searchTermParts(searchText) - .map { part -> String in - guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - - return String(part[part.index(after: part.startIndex).. NSAttributedString { - let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3)) - guard ((bounds.width - approxFullWidth) < 0) else { return content } - - return content.attributedSubstring( - from: NSRange(startOfSnippet.. NSAttributedString? in - guard !authorName.isEmpty else { return nil } - - let authorPrefix: NSAttributedString = NSAttributedString( - string: "\(authorName): ...", - attributes: [ .foregroundColor: Colors.text ] - ) - - return authorPrefix - .appending( - truncatingIfNeeded( - approxWidth: (authorPrefix.size().width + result.size().width), - content: result - ) + return authorPrefix + .appending( + truncatingIfNeeded( + approxWidth: (authorPrefix.size().width + result.size().width), + content: result ) - } - .defaulting( - to: truncatingIfNeeded( - approxWidth: result.size().width, - content: result ) + } + .defaulting( + to: truncatingIfNeeded( + approxWidth: result.size().width, + content: result ) - } + ) } } diff --git a/Session/Utilities/Date+Utilities.swift b/Session/Utilities/Date+Utilities.swift new file mode 100644 index 000000000..5336c20b4 --- /dev/null +++ b/Session/Utilities/Date+Utilities.swift @@ -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" + } +} diff --git a/Session/Utilities/DateUtil.h b/Session/Utilities/DateUtil.h deleted file mode 100644 index 1fc506fda..000000000 --- a/Session/Utilities/DateUtil.h +++ /dev/null @@ -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 diff --git a/Session/Utilities/DateUtil.m b/Session/Utilities/DateUtil.m deleted file mode 100644 index 566b47b6e..000000000 --- a/Session/Utilities/DateUtil.m +++ /dev/null @@ -1,526 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "DateUtil.h" -#import -#import -#import - -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 diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index b9c3a88aa..b0300db4b 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -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) } } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 3d22369bb..acc93db4d 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -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) } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 695b4f5b3..3a3779c0d 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -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 diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index f181f3a5a..48c643611 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -209,36 +209,6 @@ public extension SessionThread { // MARK: - Convenience public extension SessionThread { - static func displayName(userPublicKey: String) -> SQLSpecificExpressible { - let contactAlias: TypedTableAlias = 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 diff --git a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift similarity index 65% rename from SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift rename to SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index b72e37e05..0729b8445 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -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) diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 9d88c56f7..0d9bd516f 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -39,6 +39,7 @@ extension GarbageCollectionJob { case threadTypingIndicators case orphanedAttachmentFiles case orphanedProfileAvatars + case orphanedLinkPreviews } public struct Details: Codable { diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index f39421bec..dde5fd1fe 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -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( diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index b58b7614d..3b435b178 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -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, diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift new file mode 100644 index 000000000..cf780cdcb --- /dev/null +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -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 = TypedTableAlias() + + return SQL("\(interaction[.threadId]) = \(threadId)") + } + + static let orderSQL: SQL = { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("\(interaction[.timestampMs].desc)") + }() + + static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest>) { + return { additionalFilters, limitSQL -> AdaptedFetchRequest> in + let interaction: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() + let profile: TypedTableAlias = TypedTableAlias() + let quote: TypedTableAlias = TypedTableAlias() + let linkPreview: TypedTableAlias = 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 = """ + 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>) = { + return { additionalFilters -> AdaptedFetchRequest> in + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + let finalFilterSQL: SQL = { + guard let additionalFilters: SQL = additionalFilters else { + return SQL(stringLiteral: "") + } + + return """ + WHERE \(additionalFilters) + """ + }() + let numColumnsBeforeLinkedRecords: Int = 1 + let request: SQLRequest = """ + 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 = TypedTableAlias() + let attachment: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = 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 = TypedTableAlias() + + return "\(interaction[.id])" + }() + + static func createAssociateDataClosure() -> (DataCache, DataCache) -> DataCache { + return { dataCache, pagedDataCache -> DataCache in + var updatedPagedDataCache: DataCache = 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 + } + } +} diff --git a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift similarity index 77% rename from SessionMessagingKit/Shared Models/ConversationCellViewModel.swift rename to SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 19c26492f..3170537d3 100644 --- a/SessionMessagingKit/Shared Models/ConversationCellViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -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> { + ) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let typingIndicator: TypedTableAlias = 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> { + static func homeQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -481,7 +475,7 @@ public extension ConversationCell.ViewModel { ) } - static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { + static func messageRequestsQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -508,11 +502,11 @@ public extension ConversationCell.ViewModel { // MARK: - ConversationVC -public extension ConversationCell.ViewModel { - static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { +public extension SessionThreadViewModel { + static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let typingIndicator: TypedTableAlias = TypedTableAlias() + let typingIndicator: TypedTableAlias = TypedTableAlias() // TODO: Remove this (not needed here - tracked via the messages) let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = 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> { + let thread: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = 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 = """ + 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> { + static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = 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> { + static func contactsAndGroupsQuery(userPublicKey: String, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = 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> { + static func noteToSelfOnlyQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = 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> { +public extension SessionThreadViewModel { + static func shareQuery(userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = 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) = ( diff --git a/SessionMessagingKit/Utilities/OWSPreferences.h b/SessionMessagingKit/Utilities/OWSPreferences.h index 39904bd44..6bc4234fb 100644 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ b/SessionMessagingKit/Utilities/OWSPreferences.h @@ -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; diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index c1dc7cca8..b3427ebb3 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -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, diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index aee2717be..40f76ef27 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -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 { diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 120171882..b3090120e 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -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 } } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index e0db073ca..147d87fd1 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -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 { - 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 { - 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 { - let query: QueryInterfaceRequest = Job + internal static func filterPendingJobs( + variants: [Variant], + excludeFutureJobs: Bool = true, + includeJobsWithDependencies: Bool = false + ) -> QueryInterfaceRequest { + var query: QueryInterfaceRequest = 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) } } diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index 0ee8c10b0..9cda7ceb1 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -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) diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 6c29b8a42..caf730300 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -200,7 +200,7 @@ public class PagedDatabaseObserver: 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: 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: 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 = """ + 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 = """ SELECT (data.rowIndex - 1) AS rowIndex -- Converting from 1-Indexed to 0-indexed @@ -800,6 +842,7 @@ public class AssociatedRecord: 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> = Atomic(DataCache()) fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest> @@ -812,12 +855,14 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> AdaptedFetchRequest>, joinToPagedType: SQL, + groupPagedType: SQL? = nil, associateData: @escaping (DataCache, DataCache) -> DataCache ) { 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: ErasedAssociatedRecord where T: Fet observedChanges: [PagedData.ObservedChanges], dataQuery: @escaping (SQL?) -> SQLRequest, joinToPagedType: SQL, + groupPagedType: SQL? = nil, associateData: @escaping (DataCache, DataCache) -> DataCache ) { self.init( @@ -835,6 +881,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet dataQuery(additionalFilters).adapted { _ in ScopeAdapter([:]) } }, joinToPagedType: joinToPagedType, + groupPagedType: groupPagedType, associateData: associateData ) } @@ -879,19 +926,27 @@ public class AssociatedRecord: 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 } ) diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 3fb4f50c1..77598766a 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -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(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() { diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index 7381468ad..cecd4d3c8 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -7,17 +7,6 @@ import SessionMessagingKit @objc(LKProfilePictureView) public final class ProfilePictureView: UIView { - public static func closedGroupProfileQuery(threadId: String, userPublicKey: String) -> QueryInterfaceRequest { - 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 + ) ) }