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

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

View File

@ -31,7 +31,7 @@ public enum SNMessagingKit { // Just to make the external API nice
public static func configure(storage: SessionMessagingKitStorageProtocol) {
// 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)

View File

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

View File

@ -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)
}

View File

@ -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
) {

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -128,7 +128,7 @@ final class LinkPreviewView: UIView {
with state: LinkPreviewState,
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,

View File

@ -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) = {

View File

@ -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()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

View File

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

View File

@ -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()

View File

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

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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),

View File

@ -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)

View File

@ -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"

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -22,7 +22,7 @@ enum _002_SetupStandardJobs: Migration {
).inserted(db)
_ = 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)
}
}
}

View File

@ -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)
}
}

View File

@ -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

View File

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

View File

@ -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)

View File

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

View File

@ -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(

View File

@ -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,

View File

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

View File

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

View File

@ -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;

View File

@ -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,

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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)

View File

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

View File

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

View File

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