Cleaned up the ConversationVC query and started plugging in paging

Created a generic PagedDatabaseObserver (common logic for conversation & gallery paged database queries and observation)
Updated the MediaGallery to use the PagedDatabaseObserver
Split the interaction and thread data queries for the conversationVC
This commit is contained in:
Morgan Pretty 2022-05-25 18:48:04 +10:00
parent cfb8f1615a
commit 19cd9d13c5
32 changed files with 3201 additions and 1056 deletions

View File

@ -523,7 +523,7 @@
C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */; };
C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D92553860B00C340D1 /* JSON.swift */; };
C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5BC255385EE00C340D1 /* HTTP.swift */; };
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */; };
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */; };
C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */; };
C3C2A5A3255385C100C340D1 /* SessionSnodeKit.h in Headers */ = {isa = PBXBuildFile; fileRef = C3C2A5A1255385C100C340D1 /* SessionSnodeKit.h */; settings = {ATTRIBUTES = (Public, ); }; };
C3C2A5A7255385C100C340D1 /* SessionSnodeKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionSnodeKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -682,6 +682,9 @@
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 */; };
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 */; };
@ -1506,7 +1509,7 @@
C3C2A5D22553860900C340D1 /* String+Trimming.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Trimming.swift"; sourceTree = "<group>"; };
C3C2A5D32553860900C340D1 /* Promise+Delaying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Delaying.swift"; sourceTree = "<group>"; };
C3C2A5D42553860A00C340D1 /* Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = "<group>"; };
C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Description.swift"; sourceTree = "<group>"; };
C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+Utilities.swift"; sourceTree = "<group>"; };
C3C2A5D62553860B00C340D1 /* Promise+Retrying.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Promise+Retrying.swift"; sourceTree = "<group>"; };
C3C2A5D72553860B00C340D1 /* AESGCM.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AESGCM.swift; sourceTree = "<group>"; };
C3C2A5D82553860B00C340D1 /* Data+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = "<group>"; };
@ -1655,6 +1658,9 @@
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>"; };
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>"; };
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>"; };
@ -2107,11 +2113,12 @@
B835247725C38D190089A44F /* Message Cells */ = {
isa = PBXGroup;
children = (
FD848B85283B8438000E298B /* Models */,
B8041A7325C8F758003C2166 /* Content Views */,
B835247825C38D880089A44F /* MessageCell.swift */,
B835249A25C3AB650089A44F /* VisibleMessageCell.swift */,
B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */,
B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */,
B8041A7325C8F758003C2166 /* Content Views */,
);
path = "Message Cells";
sourceTree = "<group>";
@ -2217,7 +2224,7 @@
C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */,
B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */,
B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */,
C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */,
C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */,
B87EF18026377A1D00124B3C /* Features.swift */,
B8BC00BF257D90E30032E807 /* General.swift */,
C3C2A5CE2553860700C340D1 /* Logging.swift */,
@ -3374,6 +3381,7 @@
FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */,
FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */,
FD7162DA281B6C440060647B /* TypedTableAlias.swift */,
FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */,
);
path = Types;
sourceTree = "<group>";
@ -3443,6 +3451,7 @@
isa = PBXGroup;
children = (
FD3E0C83283B5835002A425C /* ConversationCellViewModel.swift */,
FD848B8C283E0B26000E298B /* MessageInputTypes.swift */,
);
path = "Shared Models";
sourceTree = "<group>";
@ -3463,6 +3472,14 @@
path = "Message Requests";
sourceTree = "<group>";
};
FD848B85283B8438000E298B /* Models */ = {
isa = PBXGroup;
children = (
FD848B86283B844B000E298B /* MessageCellViewModel.swift */,
);
path = Models;
sourceTree = "<group>";
};
FD88BAD727A7438E00BBC442 /* Views */ = {
isa = PBXGroup;
children = (
@ -4467,6 +4484,7 @@
FD09797B27FBB25900936362 /* Updatable.swift in Sources */,
C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */,
C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */,
FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */,
B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */,
B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */,
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */,
@ -4486,7 +4504,7 @@
FD17D7BD27F51F6900122BE0 /* GRDB+Notifications.swift in Sources */,
FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */,
C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */,
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */,
C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */,
C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */,
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */,
C3D9E35E25675F640040E4F3 /* OWSFileSystem.m in Sources */,
@ -4652,6 +4670,7 @@
C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.swift in Sources */,
C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */,
C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */,
FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */,
B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */,
C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */,
B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */,
@ -4682,6 +4701,7 @@
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 */,

View File

@ -8,85 +8,85 @@ extension ContextMenuVC {
let title: String
let work: () -> Void
static func reply(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func reply(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_reply"),
title: "context_menu_reply".localized()
) { delegate?.reply(item) }
) { delegate?.reply(cellViewModel) }
}
static func copy(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func copy(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_copy"),
title: "copy".localized()
) { delegate?.copy(item) }
) { delegate?.copy(cellViewModel) }
}
static func copySessionID(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func copySessionID(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_copy"),
title: "vc_conversation_settings_copy_session_id_button_title".localized()
) { delegate?.copySessionID(item) }
) { delegate?.copySessionID(cellViewModel) }
}
static func delete(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func delete(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_trash"),
title: "TXT_DELETE_TITLE".localized()
) { delegate?.delete(item) }
) { delegate?.delete(cellViewModel) }
}
static func save(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func save(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_download"),
title: "context_menu_save".localized()
) { delegate?.save(item) }
) { delegate?.save(cellViewModel) }
}
static func ban(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func ban(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_block"),
title: "context_menu_ban_user".localized()
) { delegate?.ban(item) }
) { delegate?.ban(cellViewModel) }
}
static func banAndDeleteAllMessages(_ item: ConversationViewModel.Item, _ delegate: ContextMenuActionDelegate?) -> Action {
static func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: UIImage(named: "ic_block"),
title: "context_menu_ban_and_delete_all".localized()
) { delegate?.banAndDeleteAllMessages(item) }
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
}
}
static func actions(for item: ConversationViewModel.Item, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
static func actions(for cellViewModel: MessageCell.ViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
// No context items for info messages
guard item.interactionVariant == .standardOutgoing || item.interactionVariant == .standardIncoming else {
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
return nil
}
let canReply: Bool = (
item.interactionVariant != .standardOutgoing || (
item.state != .failed &&
item.state != .sending
cellViewModel.variant != .standardOutgoing || (
cellViewModel.state != .failed &&
cellViewModel.state != .sending
)
)
let canCopy: Bool = (
item.cellType == .textOnlyMessage || (
cellViewModel.cellType == .textOnlyMessage || (
(
item.cellType == .genericAttachment ||
item.cellType == .mediaMessage
cellViewModel.cellType == .genericAttachment ||
cellViewModel.cellType == .mediaMessage
) &&
(item.attachments ?? []).count == 1 &&
(item.attachments ?? []).first?.isVisualMedia == true &&
(item.attachments ?? []).first?.isValid == true && (
(item.attachments ?? []).first?.state == .downloaded ||
(item.attachments ?? []).first?.state == .uploaded
(cellViewModel.attachments ?? []).count == 1 &&
(cellViewModel.attachments ?? []).first?.isVisualMedia == true &&
(cellViewModel.attachments ?? []).first?.isValid == true && (
(cellViewModel.attachments ?? []).first?.state == .downloaded ||
(cellViewModel.attachments ?? []).first?.state == .uploaded
)
)
)
let canSave: Bool = (
item.cellType == .mediaMessage &&
(item.attachments ?? [])
cellViewModel.cellType == .mediaMessage &&
(cellViewModel.attachments ?? [])
.filter { attachment in
attachment.isValid &&
attachment.isVisualMedia && (
@ -96,26 +96,26 @@ extension ContextMenuVC {
}.isEmpty == false
)
let canCopySessionId: Bool = (
item.interactionVariant == .standardIncoming &&
item.threadVariant != .openGroup
cellViewModel.variant == .standardIncoming &&
cellViewModel.threadVariant != .openGroup
)
let canDelete: Bool = (
item.threadVariant != .openGroup ||
cellViewModel.threadVariant != .openGroup ||
currentUserIsOpenGroupModerator
)
let canBan: Bool = (
item.threadVariant == .openGroup &&
cellViewModel.threadVariant == .openGroup &&
currentUserIsOpenGroupModerator
)
return [
(canReply ? Action.reply(item, delegate) : nil),
(canCopy ? Action.copy(item, delegate) : nil),
(canSave ? Action.save(item, delegate) : nil),
(canCopySessionId ? Action.copySessionID(item, delegate) : nil),
(canDelete ? Action.delete(item, delegate) : nil),
(canBan ? Action.ban(item, delegate) : nil),
(canBan ? Action.banAndDeleteAllMessages(item, delegate) : nil)
(canReply ? Action.reply(cellViewModel, delegate) : nil),
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
(canSave ? Action.save(cellViewModel, delegate) : nil),
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
(canBan ? Action.ban(cellViewModel, delegate) : nil),
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil)
]
.compactMap { $0 }
}
@ -124,11 +124,11 @@ extension ContextMenuVC {
// MARK: - Delegate
protocol ContextMenuActionDelegate {
func reply(_ item: ConversationViewModel.Item)
func copy(_ item: ConversationViewModel.Item)
func copySessionID(_ item: ConversationViewModel.Item)
func delete(_ item: ConversationViewModel.Item)
func save(_ item: ConversationViewModel.Item)
func ban(_ item: ConversationViewModel.Item)
func banAndDeleteAllMessages(_ item: ConversationViewModel.Item)
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)
}

View File

@ -9,7 +9,7 @@ final class ContextMenuVC: UIViewController {
private let snapshot: UIView
private let frame: CGRect
private let item: ConversationViewModel.Item
private let cellViewModel: MessageCell.ViewModel
private let actions: [Action]
private let dismiss: () -> Void
@ -32,7 +32,7 @@ final class ContextMenuVC: UIViewController {
result.font = .systemFont(ofSize: Values.verySmallFontSize)
result.textColor = (isLightMode ? .black : .white)
if let dateForUI: Date = item.dateForUI {
if let dateForUI: Date = cellViewModel.dateForUI {
result.text = DateUtil.formatDate(forDisplay: dateForUI)
}
@ -44,13 +44,13 @@ final class ContextMenuVC: UIViewController {
init(
snapshot: UIView,
frame: CGRect,
item: ConversationViewModel.Item,
cellViewModel: MessageCell.ViewModel,
actions: [Action],
dismiss: @escaping () -> Void
) {
self.snapshot = snapshot
self.frame = frame
self.item = item
self.cellViewModel = cellViewModel
self.actions = actions
self.dismiss = dismiss
@ -93,7 +93,7 @@ final class ContextMenuVC: UIViewController {
view.addSubview(timestampLabel)
timestampLabel.center(.vertical, in: snapshot)
if item.interactionVariant == .standardOutgoing {
if cellViewModel.variant == .standardOutgoing {
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
}
else {
@ -128,7 +128,7 @@ final class ContextMenuVC: UIViewController {
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
}
switch item.interactionVariant {
switch cellViewModel.variant {
case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot)
case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot)
default: break // Should never occur

View File

@ -21,7 +21,7 @@ extension ConversationVC:
{
@objc func handleTitleViewTapped() {
// Don't take the user to settings for unapproved threads
guard !viewModel.viewData.requiresApproval else { return }
guard viewModel.threadData.threadRequiresApproval == false else { return }
openSettings()
}
@ -29,11 +29,11 @@ extension ConversationVC:
@objc func openSettings() {
let settingsVC: OWSConversationSettingsViewController = OWSConversationSettingsViewController()
settingsVC.configure(
withThreadId: viewModel.viewData.thread.id,
threadName: viewModel.viewData.threadName,
isClosedGroup: (viewModel.viewData.thread.variant == .closedGroup),
isOpenGroup: (viewModel.viewData.thread.variant == .openGroup),
isNoteToSelf: viewModel.viewData.threadIsNoteToSelf
withThreadId: viewModel.threadData.threadId,
threadName: viewModel.threadData.displayName,
isClosedGroup: (viewModel.threadData.threadVariant == .closedGroup),
isOpenGroup: (viewModel.threadData.threadVariant == .openGroup),
isNoteToSelf: viewModel.threadData.threadIsNoteToSelf
)
settingsVC.conversationSettingsViewDelegate = self
navigationController?.pushViewController(settingsVC, animated: true, completion: nil)
@ -51,9 +51,9 @@ extension ConversationVC:
// MARK: - Blocking
@objc func unblock() {
guard self.viewModel.viewData.thread.variant == .contact else { return }
guard self.viewModel.threadData.threadVariant == .contact else { return }
let publicKey: String = self.viewModel.viewData.thread.id
let publicKey: String = self.viewModel.threadData.threadId
UIView.animate(
withDuration: 0.25,
@ -73,9 +73,9 @@ extension ConversationVC:
}
func showBlockedModalIfNeeded() -> Bool {
guard viewModel.viewData.threadIsBlocked else { return false }
guard viewModel.threadData.threadIsBlocked == true else { return false }
let blockedModal = BlockedModal(publicKey: viewModel.viewData.thread.id)
let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId)
blockedModal.modalPresentationStyle = .overFullScreen
blockedModal.modalTransitionStyle = .crossDissolve
present(blockedModal, animated: true, completion: nil)
@ -152,7 +152,7 @@ extension ConversationVC:
}
func handleLibraryButtonTapped() {
let threadId: String = self.viewModel.viewData.thread.id
let threadId: String = self.viewModel.threadData.threadId
requestLibraryPermissionIfNeeded { [weak self] in
DispatchQueue.main.async {
@ -175,7 +175,7 @@ extension ConversationVC:
SNLog("Proceeding without microphone access. Any recorded video will be silent.")
}
let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.viewData.thread.id)
let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(threadId: self.viewModel.threadData.threadId)
sendMediaNavController.sendMediaNavDelegate = self
sendMediaNavController.modalPresentationStyle = .fullScreen
@ -245,7 +245,7 @@ extension ConversationVC:
func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) {
let navController = AttachmentApprovalViewController.wrappedInNavController(
threadId: self.viewModel.viewData.thread.id,
threadId: self.viewModel.threadData.threadId,
attachments: attachments,
approvalDelegate: self
)
@ -298,7 +298,7 @@ extension ConversationVC:
guard !text.isEmpty else { return }
if text.contains(mnemonic) && !viewModel.viewData.threadIsNoteToSelf && !hasPermissionToSendSeed {
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
// Warn the user if they're about to send their seed to someone
let modal = SendSeedModal()
modal.modalPresentationStyle = .overFullScreen
@ -310,29 +310,34 @@ extension ConversationVC:
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
// use it to determine if the user is creating a new thread and update the 'isApproved'
// flags appropriately
let thread: SessionThread = viewModel.viewData.thread
let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible
let threadId: String = self.viewModel.threadData.threadId
let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true)
let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000)))
let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
approveMessageRequestIfNeeded(
for: thread,
for: threadId,
threadVariant: self.viewModel.threadData.threadVariant,
isNewThread: !oldThreadShouldBeVisible,
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
)
.done { [weak self] _ in
GRDBStorage.shared.writeAsync(
updates: { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
return
}
// Update the thread to be visible
_ = try SessionThread
.filter(id: thread.id)
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
// Create the interaction
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let interaction: Interaction = try Interaction(
threadId: thread.id,
threadId: threadId,
authorId: getUserHexEncodedPublicKey(db),
variant: .standardOutgoing,
body: text,
@ -405,27 +410,32 @@ extension ConversationVC:
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
// use it to determine if the user is creating a new thread and update the 'isApproved'
// flags appropriately
let thread: SessionThread = viewModel.viewData.thread
let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible
let threadId: String = self.viewModel.threadData.threadId
let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true)
let sentTimestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000)))
approveMessageRequestIfNeeded(
for: thread,
for: threadId,
threadVariant: self.viewModel.threadData.threadVariant,
isNewThread: !oldThreadShouldBeVisible,
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
)
.done { [weak self] _ in
GRDBStorage.shared.writeAsync(
updates: { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
return
}
// Update the thread to be visible
_ = try SessionThread
.filter(id: thread.id)
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
// Create the interaction
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let interaction: Interaction = try Interaction(
threadId: thread.id,
threadId: threadId,
authorId: getUserHexEncodedPublicKey(db),
variant: .standardOutgoing,
body: text,
@ -473,13 +483,13 @@ extension ConversationVC:
AudioServicesPlaySystemSound(soundID)
}
let thread: SessionThread = self.viewModel.viewData.thread
let threadId: String = self.viewModel.threadData.threadId
GRDBStorage.shared.writeAsync { db in
TypingIndicators.didStopTyping(db, in: thread, direction: .outgoing)
TypingIndicators.didStopTyping(db, threadId: threadId, direction: .outgoing)
_ = try SessionThread
.filter(id: thread.id)
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.messageDraft.set(to: ""))
}
}
@ -497,12 +507,16 @@ extension ConversationVC:
let newText: String = (inputTextView.text ?? "")
if !newText.isEmpty {
let thread: SessionThread = self.viewModel.viewData.thread
let threadId: String = self.viewModel.threadData.threadId
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true)
GRDBStorage.shared.writeAsync { db in
TypingIndicators.didStartTyping(
db,
in: thread,
threadId: threadId,
threadVariant: threadVariant,
threadIsMessageRequest: threadIsMessageRequest,
direction: .outgoing,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
)
@ -521,7 +535,7 @@ extension ConversationVC:
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium)
let approvalVC = AttachmentApprovalViewController.wrappedInNavController(
threadId: self.viewModel.viewData.thread.id,
threadId: self.viewModel.threadData.threadId,
attachments: [ attachment ],
approvalDelegate: self
)
@ -539,7 +553,7 @@ extension ConversationVC:
let newText: String = snInputView.text.replacingCharacters(
in: currentMentionStartIndex...,
with: "@\(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant)) "
with: "@\(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) "
)
snInputView.text = newText
@ -547,7 +561,7 @@ extension ConversationVC:
snInputView.hideMentionsUI()
mentions = mentions.filter { mentionInfo -> Bool in
newText.contains(mentionInfo.profile.displayName(for: self.viewModel.viewData.thread.variant))
newText.contains(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant))
}
}
@ -614,20 +628,20 @@ extension ConversationVC:
// MARK: MessageCellDelegate
func handleItemLongPressed(_ item: ConversationViewModel.Item) {
func handleItemLongPressed(_ cellViewModel: MessageCell.ViewModel) {
// Show the context menu if applicable
guard
let keyWindow: UIWindow = UIApplication.shared.keyWindow,
let index = viewModel.viewData.items.firstIndex(of: item),
let index = viewModel.interactionData.firstIndex(of: cellViewModel),
let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false),
contextMenuWindow == nil,
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
for: item,
for: cellViewModel,
currentUserIsOpenGroupModerator: OpenGroupAPIV2.isUserModerator(
self.viewModel.viewData.userPublicKey,
for: self.viewModel.viewData.openGroupRoom,
on: self.viewModel.viewData.openGroupServer
self.viewModel.threadData.currentUserPublicKey,
for: self.viewModel.threadData.openGroupRoom,
on: self.viewModel.threadData.openGroupServer
),
delegate: self
)
@ -638,7 +652,7 @@ extension ConversationVC:
self.contextMenuVC = ContextMenuVC(
snapshot: snapshot,
frame: cell.convert(cell.bubbleView.frame, to: keyWindow),
item: item,
cellViewModel: cellViewModel,
actions: actions
) { [weak self] in
self?.contextMenuWindow?.isHidden = true
@ -657,16 +671,16 @@ extension ConversationVC:
self.contextMenuWindow?.makeKeyAndVisible()
}
func handleItemTapped(_ item: ConversationViewModel.Item, gestureRecognizer: UITapGestureRecognizer) {
guard item.interactionVariant != .standardOutgoing || item.state != .failed else {
func handleItemTapped(_ cellViewModel: MessageCell.ViewModel, gestureRecognizer: UITapGestureRecognizer) {
guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else {
// Show the failed message sheet
showFailedMessageSheet(for: item)
showFailedMessageSheet(for: cellViewModel)
return
}
// If it's an incoming media message and the thread isn't trusted then show the placeholder view
if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted {
let modal = DownloadAttachmentModal(profile: item.profile)
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
let modal = DownloadAttachmentModal(profile: cellViewModel.profile)
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
@ -674,12 +688,12 @@ extension ConversationVC:
return
}
switch item.cellType {
case .audio: viewModel.playOrPauseAudio(for: item)
switch cellViewModel.cellType {
case .audio: viewModel.playOrPauseAudio(for: cellViewModel)
case .mediaMessage:
guard
let index = self.viewModel.viewData.items.firstIndex(where: { $0.interactionId == item.interactionId }),
let index = self.viewModel.interactionData.firstIndex(where: { $0.id == cellViewModel.id }),
let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
let albumView: MediaAlbumView = cell.albumView
else { return }
@ -702,9 +716,9 @@ extension ConversationVC:
default:
let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController(
for: self.viewModel.viewData.thread.id,
threadVariant: self.viewModel.viewData.thread.variant,
interactionId: item.interactionId,
for: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
interactionId: cellViewModel.id,
selectedAttachmentId: mediaView.attachment.id,
options: [ .sliderEnabled, .showAllMediaButton ]
)
@ -718,7 +732,7 @@ extension ConversationVC:
self.resignFirstResponder()
/// Delay the actual presentation to give the 'resignFirstResponder' call the chance to complete
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
/// Lock the contentOffset of the tableView so the transition doesn't look buggy
self?.tableView.lockContentOffset = true
@ -733,7 +747,7 @@ extension ConversationVC:
case .genericAttachment:
guard
let attachment: Attachment = item.attachments?.first,
let attachment: Attachment = cellViewModel.attachments?.first,
let originalFilePath: String = attachment.originalFilePath
else { return }
@ -766,19 +780,18 @@ extension ConversationVC:
joinOpenGroup(name: name, url: url)
}
default: break
}
}
}
func handleItemDoubleTapped(_ item: ConversationViewModel.Item) {
switch item.cellType {
func handleItemDoubleTapped(_ cellViewModel: MessageCell.ViewModel) {
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: item)
case .audio: self.viewModel.speedUpAudio(for: cellViewModel)
default: break
}
}
func handleItemSwiped(_ item: ConversationViewModel.Item, state: SwipeState) {
func handleItemSwiped(_ cellViewModel: MessageCell.ViewModel, state: SwipeState) {
switch state {
case .began: tableView.isScrollEnabled = false
case .ended, .cancelled: tableView.isScrollEnabled = true
@ -809,8 +822,8 @@ extension ConversationVC:
self.presentAlert(alertVC)
}
func handleReplyButtonTapped(for item: ConversationViewModel.Item) {
reply(item)
func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel) {
reply(cellViewModel)
}
func showUserDetails(for profile: Profile) {
@ -824,21 +837,22 @@ extension ConversationVC:
// MARK: --action handling
func showFailedMessageSheet(for item: ConversationViewModel.Item) {
let sheet = UIAlertController(title: item.mostRecentFailureText, message: nil, preferredStyle: .actionSheet)
func showFailedMessageSheet(for cellViewModel: MessageCell.ViewModel) {
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
GRDBStorage.shared.writeAsync { db in
try Interaction
.filter(id: item.interactionId)
.filter(id: cellViewModel.id)
.deleteAll(db)
}
}))
sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in
GRDBStorage.shared.writeAsync { [weak self] db in
guard
let interaction: Interaction = try? Interaction.fetchOne(db, id: item.interactionId),
let thread: SessionThread = self?.viewModel.viewData.thread
let threadId: String = self?.viewModel.threadData.threadId,
let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id),
let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId)
else { return }
try MessageSender.send(
db,
@ -850,7 +864,7 @@ extension ConversationVC:
// HACK: Extracting this info from the error string is pretty dodgy
let prefix: String = "HTTP request failed at destination (Service node "
if let mostRecentFailureText: String = item.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) {
if let mostRecentFailureText: String = cellViewModel.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) {
let rest = mostRecentFailureText.substring(from: prefix.count)
if let index = rest.firstIndex(of: ")") {
@ -876,37 +890,37 @@ extension ConversationVC:
// MARK: - ContextMenuActionDelegate
func reply(_ item: ConversationViewModel.Item) {
func reply(_ cellViewModel: MessageCell.ViewModel) {
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
threadId: self.viewModel.viewData.thread.id,
authorId: item.authorId,
variant: item.interactionVariant,
body: item.body,
timestampMs: item.timestampMs,
attachments: item.attachments,
linkPreview: item.linkPreview
threadId: self.viewModel.threadData.threadId,
authorId: cellViewModel.authorId,
variant: cellViewModel.variant,
body: cellViewModel.body,
timestampMs: cellViewModel.timestampMs,
attachments: cellViewModel.attachments,
linkPreviewAttachment: cellViewModel.linkPreviewAttachment
)
guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return }
snInputView.quoteDraftInfo = (
model: quoteDraft,
isOutgoing: (item.interactionVariant == .standardOutgoing)
isOutgoing: (cellViewModel.variant == .standardOutgoing)
)
snInputView.becomeFirstResponder()
}
func copy(_ item: ConversationViewModel.Item) {
switch item.cellType {
func copy(_ cellViewModel: MessageCell.ViewModel) {
switch cellViewModel.cellType {
case .typingIndicator: break
case .textOnlyMessage:
UIPasteboard.general.string = item.body
UIPasteboard.general.string = cellViewModel.body
case .audio, .genericAttachment, .mediaMessage:
guard
item.attachments?.count == 1,
let attachment: Attachment = item.attachments?.first,
cellViewModel.attachments?.count == 1,
let attachment: Attachment = cellViewModel.attachments?.first,
attachment.isValid,
(
attachment.state == .downloaded ||
@ -921,22 +935,22 @@ extension ConversationVC:
}
}
func copySessionID(_ item: ConversationViewModel.Item) {
guard item.interactionVariant == .standardIncoming || item.interactionVariant == .standardIncomingDeleted else {
func copySessionID(_ cellViewModel: MessageCell.ViewModel) {
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else {
return
}
UIPasteboard.general.string = item.authorId
UIPasteboard.general.string = cellViewModel.authorId
}
func delete(_ item: ConversationViewModel.Item) {
func delete(_ cellViewModel: MessageCell.ViewModel) {
// Only allow deletion on incoming and outgoing messages
guard item.interactionVariant == .standardIncoming || item.interactionVariant == .standardOutgoing else {
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
return
}
let thread: SessionThread = self.viewModel.viewData.thread
let threadName: String = self.viewModel.viewData.threadName
let threadId: String = self.viewModel.threadData.threadId
let threadName: String = self.viewModel.threadData.displayName
let userPublicKey: String = getUserHexEncodedPublicKey()
// Remote deletion logic
@ -954,7 +968,7 @@ extension ConversationVC:
// Delete the interaction (and associated data) from the database
GRDBStorage.shared.writeAsync { db in
_ = try Interaction
.filter(id: item.interactionId)
.filter(id: cellViewModel.id)
.deleteAll(db)
}
}
@ -971,7 +985,7 @@ extension ConversationVC:
}
// How we delete the message differs depending on the type of thread
switch item.threadVariant {
switch cellViewModel.threadVariant {
// Handle open group messages the old way
case .openGroup:
// If it's an incoming message the user must have moderator status
@ -979,17 +993,17 @@ extension ConversationVC:
(
try Interaction
.select(.openGroupServerMessageId)
.filter(id: item.interactionId)
.filter(id: cellViewModel.id)
.asRequest(of: Int64.self)
.fetchOne(db),
try OpenGroup.fetchOne(db, id: thread.id)
try OpenGroup.fetchOne(db, id: threadId)
)
}
guard
let openGroup: OpenGroup = result?.openGroup,
let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, (
item.interactionVariant != .standardIncoming ||
cellViewModel.variant != .standardIncoming ||
OpenGroupAPIV2.isUserModerator(userPublicKey, for: openGroup.room, on: openGroup.server)
)
else { return }
@ -1010,23 +1024,23 @@ extension ConversationVC:
let serverHash: String? = GRDBStorage.shared.read { db -> String? in
try Interaction
.select(.serverHash)
.filter(id: item.interactionId)
.filter(id: cellViewModel.id)
.asRequest(of: String.self)
.fetchOne(db)
}
let unsendRequest: UnsendRequest = UnsendRequest(
timestamp: UInt64(item.timestampMs),
author: (item.interactionVariant == .standardOutgoing ?
timestamp: UInt64(cellViewModel.timestampMs),
author: (cellViewModel.variant == .standardOutgoing ?
userPublicKey :
item.authorId
cellViewModel.authorId
)
)
// For incoming interactions or interactions with no serverHash just delete them locally
guard item.interactionVariant == .standardOutgoing, let serverHash: String = serverHash else {
guard cellViewModel.variant == .standardOutgoing, let serverHash: String = serverHash else {
GRDBStorage.shared.writeAsync { db in
_ = try Interaction
.filter(id: item.interactionId)
.filter(id: cellViewModel.id)
.deleteAll(db)
// No need to send the unsendRequest if there is no serverHash (ie. the message
@ -1037,7 +1051,7 @@ extension ConversationVC:
.send(
db,
message: unsendRequest,
threadId: thread.id,
threadId: threadId,
interactionId: nil,
to: .contact(publicKey: userPublicKey)
)
@ -1049,14 +1063,14 @@ extension ConversationVC:
alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in
GRDBStorage.shared.writeAsync { db in
_ = try Interaction
.filter(id: item.interactionId)
.filter(id: cellViewModel.id)
.deleteAll(db)
MessageSender
.send(
db,
message: unsendRequest,
threadId: thread.id,
threadId: threadId,
interactionId: nil,
to: .contact(publicKey: userPublicKey)
)
@ -1065,7 +1079,7 @@ extension ConversationVC:
})
alertVC.addAction(UIAlertAction(
title: (item.threadVariant == .closedGroup ?
title: (cellViewModel.threadVariant == .closedGroup ?
"delete_message_for_everyone".localized() :
String(format: "delete_message_for_me_and_recipient".localized(), threadName)
),
@ -1075,12 +1089,16 @@ extension ConversationVC:
from: self,
request: SnodeAPI
.deleteMessage(
publicKey: thread.id,
publicKey: threadId,
serverHashes: [serverHash]
)
.map { _ in () }
) { [weak self] in
GRDBStorage.shared.writeAsync { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
return
}
try MessageSender
.send(
db,
@ -1104,10 +1122,10 @@ extension ConversationVC:
}
}
func save(_ item: ConversationViewModel.Item) {
guard item.cellType == .mediaMessage else { return }
func save(_ cellViewModel: MessageCell.ViewModel) {
guard cellViewModel.cellType == .mediaMessage else { return }
let mediaAttachments: [(Attachment, String)] = (item.attachments ?? [])
let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? [])
.filter { attachment in
attachment.isValid &&
attachment.isVisualMedia && (
@ -1142,17 +1160,19 @@ extension ConversationVC:
}
// Send a 'media saved' notification if needed
guard self.viewModel.viewData.thread.variant == .contact, item.interactionVariant == .standardIncoming else {
guard self.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else {
return
}
let thread: SessionThread = self.viewModel.viewData.thread
let threadId: String = self.viewModel.threadData.threadId
GRDBStorage.shared.writeAsync { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return }
try MessageSender.send(
db,
message: DataExtractionNotification(
kind: .mediaSaved(timestamp: UInt64(item.timestampMs))
kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))
),
interactionId: nil,
in: thread
@ -1160,10 +1180,10 @@ extension ConversationVC:
}
}
func ban(_ item: ConversationViewModel.Item) {
guard item.threadVariant == .openGroup else { return }
func ban(_ cellViewModel: MessageCell.ViewModel) {
guard cellViewModel.threadVariant == .openGroup else { return }
let threadId: String = self.viewModel.viewData.thread.id
let threadId: String = self.viewModel.threadData.threadId
let alert: UIAlertController = UIAlertController(
title: "Session",
message: "This will ban the selected user from this room. It won't ban them from other rooms.",
@ -1175,7 +1195,7 @@ extension ConversationVC:
}
OpenGroupAPIV2
.ban(item.authorId, from: openGroup.room, on: openGroup.server)
.ban(cellViewModel.authorId, from: openGroup.room, on: openGroup.server)
.retainUntilComplete()
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
@ -1183,10 +1203,10 @@ extension ConversationVC:
present(alert, animated: true, completion: nil)
}
func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) {
guard item.threadVariant == .openGroup else { return }
func banAndDeleteAllMessages(_ cellViewModel: MessageCell.ViewModel) {
guard cellViewModel.threadVariant == .openGroup else { return }
let threadId: String = self.viewModel.viewData.thread.id
let threadId: String = self.viewModel.threadData.threadId
let alert: UIAlertController = UIAlertController(
title: "Session",
message: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.",
@ -1198,7 +1218,7 @@ extension ConversationVC:
}
OpenGroupAPIV2
.banAndDeleteAllMessages(item.authorId, from: openGroup.room, on: openGroup.server)
.banAndDeleteAllMessages(cellViewModel.authorId, from: openGroup.room, on: openGroup.server)
.retainUntilComplete()
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
@ -1451,18 +1471,25 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate {
extension ConversationVC {
fileprivate func approveMessageRequestIfNeeded(
for thread: SessionThread?,
for threadId: String,
threadVariant: SessionThread.Variant,
isNewThread: Bool,
timestampMs: Int64
) -> Promise<Void> {
guard let thread: SessionThread = thread, thread.variant == .contact else { return Promise.value(()) }
guard threadVariant == .contact else { return Promise.value(()) }
// If the contact doesn't exist then we should create it so we can store the 'isApproved' state
// (it'll be updated with correct profile info if they accept the message request so this
// shouldn't cause weird behaviours)
guard
let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: thread.id) }),
!contact.isApproved
let approvalData: (contact: Contact, thread: SessionThread?) = GRDBStorage.shared.read({ db in
return (
Contact.fetchOrCreate(db, id: threadId),
try SessionThread.fetchOne(db, id: threadId)
)
}),
let thread: SessionThread = approvalData.thread,
!approvalData.contact.isApproved
else {
return Promise.value(())
}
@ -1507,10 +1534,10 @@ extension ConversationVC {
// Default 'didApproveMe' to true for the person approving the message request
GRDBStorage.shared.writeAsync(
updates: { db in
try contact
try approvalData.contact
.with(
isApproved: true,
didApproveMe: .update(contact.didApproveMe || !isNewThread)
didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread)
)
.save(db)
@ -1559,7 +1586,8 @@ extension ConversationVC {
@objc func acceptMessageRequest() {
self.approveMessageRequestIfNeeded(
for: self.viewModel.viewData.thread,
for: self.viewModel.threadData.threadId,
threadVariant: self.viewModel.threadData.threadVariant,
isNewThread: false,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
)
@ -1577,9 +1605,9 @@ extension ConversationVC {
}
@objc func deleteMessageRequest() {
guard self.viewModel.viewData.thread.variant == .contact else { return }
guard self.viewModel.threadData.threadVariant == .contact else { return }
let threadId: String = self.viewModel.viewData.thread.id
let threadId: String = self.viewModel.threadData.threadId
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
message: nil,

View File

@ -62,8 +62,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
override var inputAccessoryView: UIView? {
guard
viewModel.viewData.thread.variant != .closedGroup ||
viewModel.viewData.isClosedGroupMember
viewModel.threadData.threadVariant != .closedGroup ||
viewModel.threadData.currentUserIsClosedGroupMember == true
else { return nil }
return (isShowingSearchUI ? searchController.resultsBar : snInputView)
@ -150,7 +150,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
}()
lazy var snInputView: InputView = InputView(
threadVariant: viewModel.viewData.thread.variant,
threadVariant: self.viewModel.threadData.threadVariant,
delegate: self
)
@ -176,7 +176,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
lazy var blockedBanner: InfoBanner = {
let result: InfoBanner = InfoBanner(
message: viewModel.blockedBannerMessage,
message: self.viewModel.blockedBannerMessage,
backgroundColor: Colors.destructive
)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock))
@ -203,7 +203,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
lazy var messageRequestView: UIView = {
let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false
result.isHidden = !viewModel.viewData.threadIsMessageRequest
result.isHidden = (self.viewModel.threadData.threadIsMessageRequest == false)
result.setGradient(Gradients.defaultBackground)
return result
@ -330,19 +330,19 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
navigationItem.titleView = titleView
titleView.update(
with: viewModel.viewData.threadName,
mutedUntilTimestamp: viewModel.viewData.thread.mutedUntilTimestamp,
onlyNotifyForMentions: viewModel.viewData.thread.onlyNotifyForMentions,
userCount: viewModel.viewData.userCount
with: viewModel.threadData.displayName,
mutedUntilTimestamp: viewModel.threadData.threadMutedUntilTimestamp,
onlyNotifyForMentions: (viewModel.threadData.threadOnlyNotifyForMentions == true),
userCount: viewModel.threadData.userCount
)
updateNavBarButtons(viewData: viewModel.viewData)
updateNavBarButtons(threadData: viewModel.threadData)
// Constraints
view.addSubview(tableView)
tableView.pin(to: view)
// Blocked banner
addOrRemoveBlockedBanner(threadIsBlocked: viewModel.viewData.threadIsBlocked)
addOrRemoveBlockedBanner(threadIsBlocked: (viewModel.threadData.threadIsBlocked == true))
// Message requests view & scroll to bottom
view.addSubview(scrollButton)
@ -359,8 +359,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16)
self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint
self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16)
self.scrollButtonMessageRequestsBottomConstraint?.isActive = viewModel.viewData.threadIsMessageRequest
self.scrollButtonBottomConstraint?.isActive = !viewModel.viewData.threadIsMessageRequest
self.scrollButtonMessageRequestsBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == true)
self.scrollButtonBottomConstraint?.isActive = (viewModel.threadData.threadIsMessageRequest == false)
messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestView, withInset: 10)
messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40)
@ -392,7 +392,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4)
unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true
unreadCountView.center(.horizontal, in: scrollButton)
updateUnreadCountView(unreadCount: viewModel.viewData.unreadCount)
updateUnreadCountView(unreadCount: viewModel.threadData.threadUnreadCount)
// Notifications
NotificationCenter.default.addObserver(
@ -420,12 +420,12 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
)
// Draft
if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty {
if let draft: String = viewModel.threadData.threadMessageDraft, !draft.isEmpty {
snInputView.text = draft
}
// Update the input state
snInputView.setEnabledMessageTypes(viewModel.viewData.enabledMessageTypes, message: nil)
snInputView.setEnabledMessageTypes(viewModel.threadData.enabledMessageTypes, message: nil)
}
@ -441,7 +441,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId {
self.scrollToInteraction(with: focusedInteractionId, isAnimated: false, highlighted: true)
}
else if let firstUnreadInteractionId: Int64 = self.viewModel.viewData.firstUnreadInteractionId {
else if let firstUnreadInteractionId: Int64 = self.viewModel.threadData.threadFirstUnreadInteractionId {
self.scrollToInteraction(with: firstUnreadInteractionId, position: .top, isAnimated: false)
self.unreadCountView.alpha = self.scrollButton.alpha
}
@ -478,8 +478,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
viewModel.updateDraft(to: snInputView.text)
inputAccessoryView?.resignFirstResponder()
}
@ -496,8 +495,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
}
// MARK: - Updating
@ -505,84 +503,104 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
private func startObservingChanges() {
// Start observing for data changes
dataChangeObservable = GRDBStorage.shared.start(
viewModel.observableViewData,
onError: { error in
},
onChange: { [weak self] viewData in
viewModel.observableThreadData,
onError: { _ in },
onChange: { [weak self] maybeThreadData in
guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return }
// The default scheduler emits changes on the main thread
self?.handleUpdates(viewData)
self?.handleThreadUpdates(threadData)
}
)
self.viewModel.onInteractionChange = { [weak self] updatedInteractionData in
self?.handleInteractionUpdates(updatedInteractionData)
}
}
private func handleUpdates(_ updatedViewData: ConversationViewModel.ViewData, initialLoad: Bool = false) {
private func stopObservingChanges() {
// Stop observing database changes
dataChangeObservable?.cancel()
self.viewModel.onInteractionChange = nil
}
private func handleThreadUpdates(_ updatedThreadData: ConversationCell.ViewModel, 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 hasLoadedInitialData && hasReloadedDataAfterDisappearance else {
hasLoadedInitialData = true
hasReloadedDataAfterDisappearance = true
UIView.performWithoutAnimation { handleUpdates(updatedViewData, initialLoad: true) }
UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) }
return
}
// Update general conversation UI
if
initialLoad ||
viewModel.viewData.threadName != updatedViewData.threadName ||
viewModel.viewData.thread.mutedUntilTimestamp != updatedViewData.thread.mutedUntilTimestamp ||
viewModel.viewData.thread.onlyNotifyForMentions != updatedViewData.thread.onlyNotifyForMentions ||
viewModel.viewData.userCount != updatedViewData.userCount
viewModel.threadData.displayName != updatedThreadData.displayName ||
viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp ||
viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions ||
viewModel.threadData.userCount != updatedThreadData.userCount
{
titleView.update(
with: updatedViewData.threadName,
mutedUntilTimestamp: updatedViewData.thread.mutedUntilTimestamp,
onlyNotifyForMentions: updatedViewData.thread.onlyNotifyForMentions,
userCount: updatedViewData.userCount
with: updatedThreadData.displayName,
mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp,
onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true),
userCount: updatedThreadData.userCount
)
}
if
initialLoad ||
viewModel.viewData.requiresApproval != updatedViewData.requiresApproval ||
viewModel.viewData.threadAvatarProfiles != updatedViewData.threadAvatarProfiles
viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
viewModel.threadData.profile != updatedThreadData.profile
{
updateNavBarButtons(viewData: updatedViewData)
updateNavBarButtons(threadData: updatedThreadData)
}
if viewModel.viewData.isClosedGroupMember != updatedViewData.isClosedGroupMember {
if viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember {
reloadInputViews()
}
if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes {
if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes {
snInputView.setEnabledMessageTypes(
updatedViewData.enabledMessageTypes,
updatedThreadData.enabledMessageTypes,
message: nil
)
}
if initialLoad || viewModel.viewData.threadIsBlocked != updatedViewData.threadIsBlocked {
addOrRemoveBlockedBanner(threadIsBlocked: updatedViewData.threadIsBlocked)
if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked {
addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true))
}
if initialLoad || viewModel.viewData.unreadCount != updatedViewData.unreadCount {
updateUnreadCountView(unreadCount: updatedViewData.unreadCount)
if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount {
updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount)
}
}
private func handleInteractionUpdates(_ updatedViewData: [MessageCell.ViewModel], 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 hasLoadedInitialData && hasReloadedDataAfterDisappearance else {
hasLoadedInitialData = true
hasReloadedDataAfterDisappearance = true
UIView.performWithoutAnimation { handleInteractionUpdates(updatedViewData, initialLoad: true) }
return
}
// Reload the table content (animate changes after the first load)
let changeset = StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items)
let changeset = StagedChangeset(source: viewModel.interactionData, target: updatedViewData)
tableView.reload(
using: StagedChangeset(source: viewModel.viewData.items, target: updatedViewData.items),
using: StagedChangeset(source: viewModel.interactionData, target: updatedViewData),
deleteSectionsAnimation: .bottom,
insertSectionsAnimation: .bottom,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .bottom,
reloadRowsAnimation: .none,
interrupt: {
return $0.changeCount > 100
} // Prevent too many changes from causing performance issues
) { [weak self] items in
self?.viewModel.updateData(updatedViewData.with(items: items))
interrupt: { $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
@ -601,7 +619,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
viewModel.sentMessageBeforeUpdate = false
}
func updateNavBarButtons(viewData: ConversationViewModel.ViewData) {
func updateNavBarButtons(threadData: ConversationCell.ViewModel) {
navigationItem.hidesBackButton = isShowingSearchUI
if isShowingSearchUI {
@ -609,7 +627,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
navigationItem.rightBarButtonItems = []
}
else {
guard !viewData.requiresApproval else {
guard threadData.threadRequiresApproval == false else {
// Note: Adding an empty button because without it the title alignment is
// busted (Note: The size was taken from the layout inspector for the back
// button in Xcode
@ -626,14 +644,14 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
return
}
switch viewData.thread.variant {
switch threadData.threadVariant {
case .contact:
let profilePictureView = ProfilePictureView()
profilePictureView.size = Values.verySmallProfilePictureSize
profilePictureView.update(
publicKey: viewData.thread.id, // Contact thread uses the contactId
profile: viewData.threadAvatarProfiles.first,
threadVariant: viewData.thread.variant
publicKey: threadData.threadId, // Contact thread uses the contactId
profile: threadData.profile,
threadVariant: threadData.threadVariant
)
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
profilePictureView.set(.height, to: Values.verySmallProfilePictureSize)
@ -882,26 +900,26 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
// MARK: - UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.viewData.items.count
return viewModel.interactionData.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item: ConversationViewModel.Item = viewModel.viewData.items[indexPath.row]
let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: item), for: indexPath)
let cellViewModel: MessageCell.ViewModel = viewModel.interactionData[indexPath.row]
let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath)
cell.update(
with: item,
with: cellViewModel,
mediaCache: mediaCache,
playbackInfo: viewModel.playbackInfo(for: item) { updatedInfo, error in
playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in
DispatchQueue.main.async {
guard error == nil else {
OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized())
return
}
cell.dynamicUpdate(with: item, playbackInfo: updatedInfo)
cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo)
}
},
lastSearchText: viewModel.viewData.lastSearchedText
lastSearchText: viewModel.lastSearchedText
)
cell.delegate = self
@ -919,11 +937,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
}
func scrollToBottom(isAnimated: Bool) {
guard !isUserScrolling && !viewModel.viewData.items.isEmpty else { return }
guard !isUserScrolling && !viewModel.interactionData.isEmpty else { return }
tableView.scrollToRow(
at: IndexPath(
row: viewModel.viewData.items.count - 1,
row: viewModel.interactionData.count - 1,
section: 0),
at: .bottom,
animated: isAnimated
@ -944,7 +962,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
autoLoadMoreIfNeeded()
}
func updateUnreadCountView(unreadCount: Int) {
func updateUnreadCountView(unreadCount: UInt?) {
let unreadCount: Int = Int(unreadCount ?? 0)
let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8)
unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+")
unreadCountLabel.font = .boldSystemFont(ofSize: fontSize)
@ -996,7 +1015,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
navigationItem.titleView = searchBar
// Nav bar buttons
updateNavBarButtons(viewData: viewModel.viewData)
updateNavBarButtons(threadData: self.viewModel.threadData)
// Hack so that the ResultsBar stays on the screen when dismissing the search field
// keyboard.
@ -1032,7 +1051,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
func hideSearchUI() {
isShowingSearchUI = false
navigationItem.titleView = titleView
updateNavBarButtons(viewData: viewModel.viewData)
updateNavBarButtons(threadData: self.viewModel.threadData)
let navBar = navigationController!.navigationBar as! OWSNavigationBar
navBar.stubbedNextResponder = nil

View File

@ -1,3 +1,513 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SessionMessagingKit
import SessionUtilitiesKit
public class ConversationViewModel: OWSAudioPlayerDelegate {
public enum Action {
case none
case compose
case audioCall
case videoCall
}
public static let pageSize: Int = 50
// MARK: - Initialization
init?(threadId: String, focusedInteractionId: Int64?) {
let maybeThreadData: ConversationCell.ViewModel? = GRDBStorage.shared.read { db in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try ConversationCell.ViewModel
.conversationQuery(
threadId: threadId,
userPublicKey: userPublicKey
)
.fetchOne(db)
}
guard let threadData: ConversationCell.ViewModel = maybeThreadData else { return nil }
self.threadId = threadId
self.threadData = threadData
self.focusedInteractionId = focusedInteractionId // TODO: This
self.pagedDataObserver = nil
var hasSavedIntialUpdate: Bool = false
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: Interaction.self,
pageSize: ConversationViewModel.pageSize,
idColumn: .id,
initialFocusedId: nil,
observedChanges: [
PagedData.ObservedChanges(
table: Interaction.self,
columns: Interaction.Columns
.allCases
.filter { $0 != .wasRead }
)
],
filterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId),
orderSQL: MessageCell.ViewModel.orderSQL,
dataQuery: MessageCell.ViewModel.baseQuery(
orderSQL: MessageCell.ViewModel.orderSQL,
baseFilterSQL: MessageCell.ViewModel.filterSQL(threadId: threadId)
),
associatedRecords: [
AssociatedRecord<MessageCell.AttachmentInteractionInfo, MessageCell.ViewModel>(
trackedAgainst: Attachment.self,
observedChanges: [
PagedData.ObservedChanges(
table: Attachment.self,
columns: [.state]
)
],
dataQuery: MessageCell.AttachmentInteractionInfo.baseQuery,
joinToPagedType: MessageCell.AttachmentInteractionInfo.joinToViewModelQuerySQL,
associateData: MessageCell.AttachmentInteractionInfo.createAssociateDataClosure()
)
],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedInteractionData: [MessageCell.ViewModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we haven't stored the data for the initial fetch then do so now (no need
// to call 'onInteractionsChange' in this case as it will always be null)
guard hasSavedIntialUpdate else {
self?.updateInteractionData(updatedInteractionData)
hasSavedIntialUpdate = true
return
}
self?.onInteractionChange?(updatedInteractionData)
}
)
}
// MARK: - Variables
private let threadId: String
public var sentMessageBeforeUpdate: Bool = false
public var lastSearchedText: String?
public let focusedInteractionId: Int64? // Note: This is used for global search
public lazy var blockedBannerMessage: String = {
switch self.threadData.threadVariant {
case .contact:
let name: String = Profile.displayName(
id: self.threadData.threadId,
threadVariant: self.threadData.threadVariant
)
return "\(name) is blocked. Unblock them?"
default: return "Thread is blocked. Unblock it?"
}
}()
// MARK: - Thread Data
/// This value is the current state of the view
public private(set) var threadData: ConversationCell.ViewModel
public lazy var observableThreadData = ValueObservation
.trackingConstantRegion { [threadId = self.threadId] db -> ConversationCell.ViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try ConversationCell.ViewModel
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
}
.removeDuplicates()
public func updateThreadData(_ updatedData: ConversationCell.ViewModel) {
self.threadData = updatedData
}
// MARK: - Interaction Data
public private(set) var interactionData: [MessageCell.ViewModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageCell.ViewModel>?
public var onInteractionChange: (([MessageCell.ViewModel]) -> ())?
private func process(data: [MessageCell.ViewModel], for pageInfo: PagedData.PageInfo) -> [MessageCell.ViewModel] {
let sortedData: [MessageCell.ViewModel] = data
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
return sortedData
.enumerated()
.map { index, cellViewModel -> MessageCell.ViewModel in
cellViewModel.withClusteringChanges(
prevModel: (index > 0 ? sortedData[index - 1] : nil),
nextModel: (index < (sortedData.count - 2) ? sortedData[index + 1] : nil),
isLast: (
index == (sortedData.count - 1) &&
pageInfo.currentCount == pageInfo.totalCount
)
)
}
}
public func updateInteractionData(_ updatedData: [MessageCell.ViewModel]) {
self.interactionData = updatedData
}
// MARK: - Mentions
public struct MentionInfo: FetchableRecord, Decodable {
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
fileprivate static let openGroupRoomKey = CodingKeys.openGroupRoom.stringValue
fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
let profile: Profile
let threadVariant: SessionThread.Variant
let openGroupRoom: String?
let openGroupServer: String?
}
public func mentions(for query: String = "") -> [MentionInfo] {
let threadData: ConversationCell.ViewModel = self.threadData
let results: [MentionInfo] = GRDBStorage.shared
.read { db -> [MentionInfo] in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
switch threadData.threadVariant {
case .contact:
guard userPublicKey != threadData.threadId else { return [] }
return [Profile.fetchOrCreate(db, id: threadData.threadId)]
.map { profile in
MentionInfo(
profile: profile,
threadVariant: threadData.threadVariant,
openGroupRoom: nil,
openGroupServer: nil
)
}
.filter {
query.count < 2 ||
$0.profile.displayName(for: $0.threadVariant).contains(query)
}
case .closedGroup:
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return try GroupMember
.select(
profile.allColumns(),
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
)
.filter(GroupMember.Columns.groupId == threadData.threadId)
.filter(GroupMember.Columns.profileId != userPublicKey)
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
.joining(
required: GroupMember.profile
.aliased(profile)
// Note: LIKE is case-insensitive in SQLite
.filter(
query.count < 2 || (
profile[.nickname] != nil &&
profile[.nickname].like("%\(query)%")
) || (
profile[.nickname] == nil &&
profile[.name].like("%\(query)%")
)
)
)
.asRequest(of: MentionInfo.self)
.fetchAll(db)
case .openGroup:
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return try Interaction
.select(
profile.allColumns(),
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
SQL("\(threadData.openGroupRoom)").forKey(MentionInfo.openGroupRoomKey),
SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey)
)
.distinct()
.group(Interaction.Columns.authorId)
.filter(Interaction.Columns.threadId == threadData.threadId)
.filter(Interaction.Columns.authorId != userPublicKey)
.joining(
required: Interaction.profile
.aliased(profile)
// Note: LIKE is case-insensitive in SQLite
.filter(
query.count < 2 || (
profile[.nickname] != nil &&
profile[.nickname].like("%\(query)%")
) || (
profile[.nickname] == nil &&
profile[.name].like("%\(query)%")
)
)
)
.order(Interaction.Columns.timestampMs.desc)
.limit(20)
.asRequest(of: MentionInfo.self)
.fetchAll(db)
}
}
.defaulting(to: [])
guard query.count >= 2 else {
return results.sorted { lhs, rhs -> Bool in
lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
}
}
return results
.sorted { lhs, rhs -> Bool in
let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
return true
}
return (lhsRange.lowerBound < rhsRange.lowerBound)
}
}
// MARK: - Functions
public func updateDraft(to draft: String) {
GRDBStorage.shared.write { db in
try SessionThread
.filter(id: self.threadId)
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
}
}
public func markAllAsRead() {
guard let lastInteractionId: Int64 = self.interactionData.last?.id else { return }
GRDBStorage.shared.write { db in
try Interaction.markAsRead(
db,
interactionId: lastInteractionId,
threadId: self.threadData.threadId,
includingOlder: true,
trySendReadReceipt: (self.threadData.threadIsMessageRequest == false)
)
}
}
// MARK: - Audio Playback
public struct PlaybackInfo {
let state: AudioPlaybackState
let progress: TimeInterval
let playbackRate: Double
let oldPlaybackRate: Double
let updateCallback: (PlaybackInfo?, Error?) -> ()
public func with(
state: AudioPlaybackState? = nil,
progress: TimeInterval? = nil,
playbackRate: Double? = nil,
updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil
) -> PlaybackInfo {
return PlaybackInfo(
state: (state ?? self.state),
progress: (progress ?? self.progress),
playbackRate: (playbackRate ?? self.playbackRate),
oldPlaybackRate: self.playbackRate,
updateCallback: (updateCallback ?? self.updateCallback)
)
}
}
private var audioPlayer: Atomic<OWSAudioPlayer?> = Atomic(nil)
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? {
// 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] {
let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
.with(updateCallback: updateCallback)
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
return updatedPlaybackInfo
}
// Validate the item is a valid audio item
guard
let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback,
let attachment: Attachment = viewModel.attachments?.first,
attachment.isAudio,
attachment.isValid,
let originalFilePath: String = attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return nil }
// Create the info with the update callback
let newPlaybackInfo: PlaybackInfo = PlaybackInfo(
state: .stopped,
progress: 0,
playbackRate: 1,
oldPlaybackRate: 1,
updateCallback: updateCallback
)
// Cache the info
playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo }
return newPlaybackInfo
}
public func playOrPauseAudio(for viewModel: MessageCell.ViewModel) {
guard
let attachment: Attachment = viewModel.attachments?.first,
let originalFilePath: String = attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return }
// If the user interacted with the currently playing item
guard currentPlayingInteraction.wrappedValue != viewModel.id else {
let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]
let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
.with(
state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
playbackRate: 1
)
audioPlayer.wrappedValue?.playbackRate = 1
switch currentPlaybackInfo?.state {
case .playing: audioPlayer.wrappedValue?.pause()
default: audioPlayer.wrappedValue?.play()
}
// Update the state and then update the UI with the updated state
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
return
}
// First stop any existing audio
audioPlayer.wrappedValue?.stop()
// Then setup the state for the new audio
currentPlayingInteraction.mutate { $0 = viewModel.id }
audioPlayer.mutate { [weak self] player in
let audioPlayer: OWSAudioPlayer = OWSAudioPlayer(
mediaUrl: URL(fileURLWithPath: originalFilePath),
audioBehavior: .audioMessagePlayback,
delegate: self
)
audioPlayer.play()
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
player = audioPlayer
}
}
public func speedUpAudio(for viewModel: MessageCell.ViewModel) {
// If we aren't playing the specified item then just start playing it
guard viewModel.id == currentPlayingInteraction.wrappedValue else {
playOrPauseAudio(for: viewModel)
return
}
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]?
.with(playbackRate: 1.5)
// Speed up the audio player
audioPlayer.wrappedValue?.playbackRate = 1.5
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
}
public func stopAudio() {
audioPlayer.wrappedValue?.stop()
currentPlayingInteraction.mutate { $0 = nil }
audioPlayer.mutate { $0 = nil }
}
// MARK: - OWSAudioPlayerDelegate
public func audioPlaybackState() -> AudioPlaybackState {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped }
return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped)
}
public func setAudioPlaybackState(_ state: AudioPlaybackState) {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(state: state)
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
}
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(progress: TimeInterval(progress))
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
}
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
guard successfully else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(
state: .stopped,
progress: 0,
playbackRate: 1
)
// Safe the changes and send one final update to the UI
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
// Clear out the currently playing record
currentPlayingInteraction.mutate { $0 = nil }
audioPlayer.mutate { $0 = nil }
// If the next interaction is another voice message then autoplay it
guard
let currentIndex: Int = self.interactionData.firstIndex(where: { $0.id == interactionId }),
currentIndex < (self.interactionData.count - 1),
self.interactionData[currentIndex + 1].cellType == .audio
else { return }
let nextItem: MessageCell.ViewModel = self.interactionData[currentIndex + 1]
playOrPauseAudio(for: nextItem)
}
public func showInvalidAudioFileAlert() {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(
state: .stopped,
progress: 0,
playbackRate: 1
)
currentPlayingInteraction.mutate { $0 = nil }
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
}
}

View File

@ -5,12 +5,6 @@ import SessionUIKit
import SessionMessagingKit
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
enum MessageTypes: Equatable {
case all
case textOnly
case none
}
// MARK: - Variables
private static let linkPreviewViewInset: CGFloat = 6
@ -37,7 +31,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
set { inputTextView.text = newValue }
}
var enabledMessageTypes: MessageTypes = .all {
var enabledMessageTypes: MessageInputTypes = .all {
didSet {
setEnabledMessageTypes(enabledMessageTypes, message: nil)
}
@ -308,7 +302,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
.retainUntilComplete()
}
func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) {
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
guard enabledMessageTypes != messageTypes else { return }
enabledMessageTypes = messageTypes

View File

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

View File

@ -9,10 +9,10 @@ final class MediaPlaceholderView: UIView {
// MARK: - Lifecycle
init(item: ConversationViewModel.Item, textColor: UIColor) {
init(cellViewModel: MessageCell.ViewModel, textColor: UIColor) {
super.init(frame: CGRect.zero)
setUpViewHierarchy(item: item, textColor: textColor)
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
}
override init(frame: CGRect) {
@ -24,13 +24,13 @@ final class MediaPlaceholderView: UIView {
}
private func setUpViewHierarchy(
item: ConversationViewModel.Item,
cellViewModel: MessageCell.ViewModel,
textColor: UIColor
) {
let (iconName, attachmentDescription): (String, String) = {
guard
item.interactionVariant == .standardIncoming,
let attachment: Attachment = item.attachments?.first
cellViewModel.variant == .standardIncoming,
let attachment: Attachment = cellViewModel.attachments?.first
else {
return ("actionsheet_document_black", "file") // Should never occur
}

View File

@ -52,20 +52,15 @@ final class InfoMessageCell: MessageCell {
// MARK: - Updating
override func update(with item: ConversationViewModel.Item, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
switch item.interactionVariant {
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
.infoMessageRequestAccepted:
break
default: return // Ignore non-info variants
}
override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard cellViewModel.variant.isInfoMessage else { return }
self.viewModel = cellViewModel
let icon: UIImage? = {
switch item.interactionVariant {
switch cellViewModel.variant {
case .infoDisappearingMessagesUpdate:
return (item.threadHasDisappearingMessagesEnabled ?
return (cellViewModel.threadHasDisappearingMessagesEnabled ?
UIImage(named: "ic_timer") :
UIImage(named: "ic_timer_disabled")
)
@ -83,9 +78,9 @@ final class InfoMessageCell: MessageCell {
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
self.label.text = item.body
self.label.text = cellViewModel.body
}
override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) {
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
}
}

View File

@ -9,9 +9,9 @@ public enum SwipeState {
case cancelled
}
class MessageCell: UITableViewCell {
public class MessageCell: UITableViewCell {
weak var delegate: MessageCellDelegate?
var item: ConversationViewModel.Item?
var viewModel: MessageCell.ViewModel?
// MARK: - Lifecycle
@ -43,22 +43,22 @@ class MessageCell: UITableViewCell {
// MARK: - Updating
func update(with item: ConversationViewModel.Item, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
func update(with cellViewModel: MessageCell.ViewModel, 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 item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) {
func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
preconditionFailure("Must be overridden by subclasses.")
}
// MARK: - Convenience
static func cellType(for item: ConversationViewModel.Item) -> MessageCell.Type {
guard item.cellType != .typingIndicator else { return TypingIndicatorCell.self }
static func cellType(for viewModel: MessageCell.ViewModel) -> MessageCell.Type {
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
switch item.interactionVariant {
switch viewModel.variant {
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
return VisibleMessageCell.self
@ -70,16 +70,14 @@ class MessageCell: UITableViewCell {
}
}
protocol MessageCellDelegate : AnyObject {
var lastSearchedText: String? { get }
func getMediaCache() -> NSCache<NSString, AnyObject>
func handleViewItemLongPressed(_ viewItem: ConversationViewItem)
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer)
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem)
func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState)
func showFullText(_ viewItem: ConversationViewItem)
func openURL(_ url: URL)
func handleReplyButtonTapped(for viewItem: ConversationViewItem)
func showUserDetails(for sessionID: String)
// 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 openUrl(_ urlString: String)
func handleReplyButtonTapped(for cellViewModel: MessageCell.ViewModel)
func showUserDetails(for profile: Profile)
}

View File

@ -0,0 +1,593 @@
// 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 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,
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
),
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: - 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,10 +39,10 @@ final class TypingIndicatorCell: MessageCell {
// MARK: - Updating
override func update(with item: ConversationViewModel.Item, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard item.cellType == .typingIndicator else { return }
override func update(with cellViewModel: MessageCell.ViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard cellViewModel.cellType == .typingIndicator else { return }
self.item = item
self.viewModel = cellViewModel
// Bubble view
updateBubbleViewCorners()
@ -51,7 +51,7 @@ final class TypingIndicatorCell: MessageCell {
typingIndicatorView.startAnimation()
}
override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) {
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
}
override func layoutSubviews() {
@ -82,9 +82,9 @@ final class TypingIndicatorCell: MessageCell {
// MARK: - Convenience
private func getCornersToRound() -> UIRectCorner {
guard item?.isOnlyMessageInCluster == false else { return .allCorners }
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
switch item?.positionInCluster {
switch viewModel?.positionInCluster {
case .top: return [ .topLeft, .topRight, .bottomRight ]
case .middle: return [ .topRight, .bottomRight ]
case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]

View File

@ -35,8 +35,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
result.delegate = self
return result
}()
var lastSearchedText: String? { delegate?.lastSearchedText }
// MARK: - UI Components
@ -200,79 +198,80 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
// MARK: - Updating
override func update(
with item: ConversationViewModel.Item,
with cellViewModel: MessageCell.ViewModel,
mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?,
lastSearchText: String?
) {
self.item = item
self.viewModel = cellViewModel
let isGroupThread: Bool = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup)
let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup)
let shouldInsetHeader: Bool = (
item.previousInteractionVariant?.isInfoMessage != true &&
cellViewModel.previousVariant?.isInfoMessage != true &&
(
item.positionInCluster == .top ||
item.isOnlyMessageInCluster
cellViewModel.positionInCluster == .top ||
cellViewModel.isOnlyMessageInCluster
)
)
// Profile picture view
profilePictureViewLeftConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0)
profilePictureView.isHidden = (!item.shouldShowProfile || item.profile == nil)
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
profilePictureView.update(
publicKey: item.authorId,
profile: item.profile,
threadVariant: item.threadVariant
publicKey: cellViewModel.authorId,
profile: cellViewModel.profile,
threadVariant: cellViewModel.threadVariant
)
moderatorIconImageView.isHidden = !item.isSenderOpenGroupModerator
moderatorIconImageView.isHidden = !cellViewModel.isSenderOpenGroupModerator
// Bubble view
bubbleViewLeftConstraint1.isActive = (
item.interactionVariant == .standardIncoming ||
item.interactionVariant == .standardIncomingDeleted
cellViewModel.variant == .standardIncoming ||
cellViewModel.variant == .standardIncomingDeleted
)
bubbleViewLeftConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing)
bubbleViewLeftConstraint2.isActive = (item.interactionVariant == .standardOutgoing)
bubbleViewTopConstraint.constant = (item.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing)
bubbleViewRightConstraint1.isActive = (item.interactionVariant == .standardOutgoing)
bubbleViewLeftConstraint2.isActive = (cellViewModel.variant == .standardOutgoing)
bubbleViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing)
bubbleViewRightConstraint1.isActive = (cellViewModel.variant == .standardOutgoing)
bubbleViewRightConstraint2.isActive = (
item.interactionVariant == .standardIncoming ||
item.interactionVariant == .standardIncomingDeleted
cellViewModel.variant == .standardIncoming ||
cellViewModel.variant == .standardIncomingDeleted
)
bubbleView.backgroundColor = ((
item.interactionVariant == .standardIncoming ||
item.interactionVariant == .standardIncomingDeleted
cellViewModel.variant == .standardIncoming ||
cellViewModel.variant == .standardIncomingDeleted
) ? Colors.receivedMessageBackground : Colors.sentMessageBackground)
updateBubbleViewCorners()
// Content view
populateContentView(for: item, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText)
populateContentView(for: cellViewModel, mediaCache: mediaCache, playbackInfo: playbackInfo, lastSearchText: lastSearchText)
// Date break
headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1)
headerView.subviews.forEach { $0.removeFromSuperview() }
populateHeader(for: item, shouldInsetHeader: shouldInsetHeader)
populateHeader(for: cellViewModel, shouldInsetHeader: shouldInsetHeader)
// Author label
authorLabel.textColor = Colors.text
authorLabel.isHidden = (item.senderName == nil)
authorLabel.text = item.senderName
authorLabel.isHidden = (cellViewModel.senderName == nil)
authorLabel.text = cellViewModel.senderName
let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * VisibleMessageCell.authorLabelInset)
let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorLabelInset)
let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude)
let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace)
authorLabelHeightConstraint.constant = (item.senderName != nil ? authorLabelSize.height : 0)
authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0)
// Message status image view
let (image, tintColor, backgroundColor) = getMessageStatusImage(for: item)
let (image, tintColor, backgroundColor) = getMessageStatusImage(for: cellViewModel)
messageStatusImageView.image = image
messageStatusImageView.tintColor = tintColor
messageStatusImageView.backgroundColor = backgroundColor
messageStatusImageView.isHidden = (
item.interactionVariant != .standardOutgoing || (
item.state == .sent &&
item.isLastInteraction
cellViewModel.variant != .standardOutgoing ||
(
cellViewModel.state == .sent &&
!cellViewModel.isLast
)
)
messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5)
@ -283,9 +282,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
// Timer
if
item.isExpiringMessage,
let expiresStartedAtMs: Double = item.expiresStartedAtMs,
let expiresInSeconds: TimeInterval = item.expiresInSeconds
let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs,
let expiresInSeconds: TimeInterval = cellViewModel.expiresInSeconds
{
let expirationTimestampMs: Double = (expiresStartedAtMs + (expiresInSeconds * 1000))
@ -294,17 +292,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
initialDurationSeconds: UInt32(floor(expiresInSeconds)),
tintColor: Colors.text
)
timerView.isHidden = false
}
else {
timerView.isHidden = true
}
timerView.isHidden = !item.isExpiringMessage
timerViewOutgoingMessageConstraint.isActive = (item.interactionVariant == .standardOutgoing)
timerViewOutgoingMessageConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
timerViewIncomingMessageConstraint.isActive = (
item.interactionVariant == .standardIncoming ||
item.interactionVariant == .standardIncomingDeleted
cellViewModel.variant == .standardIncoming ||
cellViewModel.variant == .standardIncomingDeleted
)
// Swipe to reply
if item.interactionVariant == .standardIncomingDeleted {
if cellViewModel.variant == .standardIncomingDeleted {
removeGestureRecognizer(panGestureRecognizer)
}
else {
@ -312,8 +313,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
}
private func populateHeader(for item: ConversationViewModel.Item, shouldInsetHeader: Bool) {
guard let date: Date = item.dateForUI else { return }
private func populateHeader(for cellViewModel: MessageCell.ViewModel, shouldInsetHeader: Bool) {
guard let date: Date = cellViewModel.dateForUI else { return }
let dateBreakLabel: UILabel = UILabel()
dateBreakLabel.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
@ -329,20 +330,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
headerView.pin(.bottom, to: .bottom, of: dateBreakLabel, withInset: Values.smallSpacing + additionalBottomInset)
dateBreakLabel.center(.horizontal, in: headerView)
let availableWidth = VisibleMessageCell.getMaxWidth(for: item)
let availableWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel)
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
let dateBreakLabelSize = dateBreakLabel.sizeThatFits(availableSpace)
dateBreakLabel.set(.height, to: dateBreakLabelSize.height)
}
private func populateContentView(
for item: ConversationViewModel.Item,
for cellViewModel: MessageCell.ViewModel,
mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?,
lastSearchText: String?
) {
let bodyLabelTextColor: UIColor = {
let direction: Direction = (item.interactionVariant == .standardOutgoing ?
let direction: Direction = (cellViewModel.variant == .standardOutgoing ?
.outgoing :
.incoming
)
@ -359,7 +360,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
bodyTextView = nil
// Handle the deleted state first (it's much simpler than the others)
guard item.interactionVariant != .standardIncomingDeleted else {
guard cellViewModel.variant != .standardIncomingDeleted else {
let deletedMessageView: DeletedMessageView = DeletedMessageView(textColor: bodyLabelTextColor)
snContentView.addSubview(deletedMessageView)
deletedMessageView.pin(to: snContentView)
@ -367,32 +368,32 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
// If it's an incoming media message and the thread isn't trusted then show the placeholder view
if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted {
let mediaPlaceholderView = MediaPlaceholderView(item: item, textColor: bodyLabelTextColor)
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor)
snContentView.addSubview(mediaPlaceholderView)
mediaPlaceholderView.pin(to: snContentView)
return
}
switch item.cellType {
switch cellViewModel.cellType {
case .typingIndicator: break
case .textOnlyMessage:
let inset: CGFloat = 12
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset)
let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
if let linkPreview: LinkPreview = item.linkPreview {
if let linkPreview: LinkPreview = cellViewModel.linkPreview {
switch linkPreview.variant {
case .standard:
let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth)
linkPreviewView.update(
with: LinkPreview.SentState(
linkPreview: linkPreview,
imageAttachment: item.attachments?.first
imageAttachment: cellViewModel.linkPreviewAttachment
),
isOutgoing: (item.interactionVariant == .standardOutgoing),
isOutgoing: (cellViewModel.variant == .standardOutgoing),
delegate: self,
item: item,
cellViewModel: cellViewModel,
bodyLabelTextColor: bodyLabelTextColor,
lastSearchText: lastSearchText
)
@ -406,7 +407,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
name: (linkPreview.title ?? ""),
url: linkPreview.url,
textColor: bodyLabelTextColor,
isOutgoing: (item.interactionVariant == .standardOutgoing)
isOutgoing: (cellViewModel.variant == .standardOutgoing)
)
snContentView.addSubview(openGroupInvitationView)
@ -421,18 +422,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
stackView.spacing = 2
// Quote view
if let quote: Quote = item.quote {
if let quote: Quote = cellViewModel.quote {
let hInset: CGFloat = 2
let quoteView: QuoteView = QuoteView(
for: .regular,
authorId: quote.authorId,
quotedText: quote.body,
threadVariant: item.threadVariant,
direction: (item.interactionVariant == .standardOutgoing ?
threadVariant: cellViewModel.threadVariant,
direction: (cellViewModel.variant == .standardOutgoing ?
.outgoing :
.incoming
),
attachment: item.attachments?.first,
attachment: cellViewModel.quoteAttachment,
hInset: hInset,
maxWidth: maxWidth
)
@ -441,7 +442,13 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
// Body text view
let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self)
let bodyTextView = VisibleMessageCell.getBodyTextView(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self
)
self.bodyTextView = bodyTextView
stackView.addArrangedSubview(bodyTextView)
@ -457,17 +464,17 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
stackView.spacing = Values.smallSpacing
// Album view
let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: item)
let maxMessageWidth: CGFloat = VisibleMessageCell.getMaxWidth(for: cellViewModel)
let albumView = MediaAlbumView(
mediaCache: mediaCache,
items: (item.attachments?
items: (cellViewModel.attachments?
.filter { $0.isVisualMedia })
.defaulting(to: []),
isOutgoing: (item.interactionVariant == .standardOutgoing),
isOutgoing: (cellViewModel.variant == .standardOutgoing),
maxMessageWidth: maxMessageWidth
)
self.albumView = albumView
let size = getSize(for: item)
let size = getSize(for: cellViewModel)
albumView.set(.width, to: size.width)
albumView.set(.height, to: size.height)
albumView.loadMedia()
@ -475,10 +482,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
stackView.addArrangedSubview(albumView)
// Body text view
if let body: String = item.body, !body.isEmpty {
if let body: String = cellViewModel.body, !body.isEmpty {
let inset: CGFloat = 12
let maxWidth = size.width - 2 * inset
let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self)
let bodyTextView = VisibleMessageCell.getBodyTextView(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self
)
self.bodyTextView = bodyTextView
stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset)))
}
@ -489,7 +502,9 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
stackView.pin(to: snContentView)
case .audio:
guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return }
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
return
}
let voiceMessageView: VoiceMessageView = VoiceMessageView()
voiceMessageView.update(
@ -506,10 +521,10 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
self.voiceMessageView = voiceMessageView
case .genericAttachment:
guard let attachment: Attachment = item.attachments?.first else { preconditionFailure() }
guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() }
let inset: CGFloat = 12
let maxWidth = (VisibleMessageCell.getMaxWidth(for: item) - 2 * inset)
let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset)
// Stack view
let stackView = UIStackView(arrangedSubviews: [])
@ -521,8 +536,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
stackView.addArrangedSubview(documentView)
// Body text view
if let body: String = item.body, !body.isEmpty { // delegate should always be set at this point
let bodyTextView = VisibleMessageCell.getBodyTextView(for: item, with: maxWidth, textColor: bodyLabelTextColor, searchText: lastSearchText, delegate: self)
if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point
let bodyTextView = VisibleMessageCell.getBodyTextView(
for: cellViewModel,
with: maxWidth,
textColor: bodyLabelTextColor,
searchText: lastSearchText,
delegate: self
)
self.bodyTextView = bodyTextView
stackView.addArrangedSubview(bodyTextView)
}
@ -554,17 +575,19 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound)
}
override func dynamicUpdate(with item: ConversationViewModel.Item, playbackInfo: ConversationViewModel.PlaybackInfo?) {
guard item.interactionVariant != .standardIncomingDeleted else { return }
override func dynamicUpdate(with cellViewModel: MessageCell.ViewModel, 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
if item.cellType != .textOnlyMessage && item.interactionVariant == .standardIncoming && !item.isThreadTrusted {
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
return
}
switch item.cellType {
switch cellViewModel.cellType {
case .audio:
guard let attachment: Attachment = item.attachments?.first(where: { $0.isAudio }) else { return }
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
return
}
self.voiceMessageView?.update(
with: attachment,
@ -631,17 +654,17 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
@objc func handleLongPress() {
guard let item: ConversationViewModel.Item = self.item else { return }
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
delegate?.handleItemLongPressed(item)
delegate?.handleItemLongPressed(cellViewModel)
}
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard let item: ConversationViewModel.Item = self.item else { return }
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
let location = gestureRecognizer.location(in: self)
if profilePictureView.frame.contains(location), let profile: Profile = item.profile, item.threadVariant != .openGroup {
if profilePictureView.frame.contains(location), let profile: Profile = cellViewModel.profile, cellViewModel.threadVariant != .openGroup {
delegate?.showUserDetails(for: profile)
}
else if replyButton.alpha > 0 && replyButton.frame.contains(location) {
@ -649,18 +672,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
reply()
}
else if bubbleView.frame.contains(location) {
delegate?.handleItemTapped(item, gestureRecognizer: gestureRecognizer)
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
}
}
@objc private func handleDoubleTap() {
guard let item: ConversationViewModel.Item = self.item else { return }
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
delegate?.handleItemDoubleTapped(item)
delegate?.handleItemDoubleTapped(cellViewModel)
}
@objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let item: ConversationViewModel.Item = self.item else { return }
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
let viewsToMove: [UIView] = [
bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView
@ -668,7 +691,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0)
switch gestureRecognizer.state {
case .began: delegate?.handleItemSwiped(item, state: .began)
case .began: delegate?.handleItemSwiped(cellViewModel, state: .began)
case .changed:
// The idea here is to asymptotically approach a maximum drag distance
@ -688,11 +711,11 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
case .ended, .cancelled:
if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold {
delegate?.handleItemSwiped(item, state: .ended)
delegate?.handleItemSwiped(cellViewModel, state: .ended)
reply()
}
else {
delegate?.handleItemSwiped(item, state: .cancelled)
delegate?.handleItemSwiped(cellViewModel, state: .cancelled)
resetReply()
}
@ -722,20 +745,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
private func reply() {
guard let item: ConversationViewModel.Item = self.item else { return }
guard let cellViewModel: MessageCell.ViewModel = self.viewModel else { return }
resetReply()
delegate?.handleReplyButtonTapped(for: item)
delegate?.handleReplyButtonTapped(for: cellViewModel)
}
// MARK: - Convenience
private func getCornersToRound() -> UIRectCorner {
guard item?.isOnlyMessageInCluster == false else { return .allCorners }
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
let direction: Direction = (item?.interactionVariant == .standardOutgoing ? .outgoing : .incoming)
let direction: Direction = (viewModel?.variant == .standardOutgoing ? .outgoing : .incoming)
switch (item?.positionInCluster, direction) {
switch (viewModel?.positionInCluster, direction) {
case (.top, .outgoing): return [ .bottomLeft, .topLeft, .topRight ]
case (.middle, .outgoing): return [ .bottomLeft, .topLeft ]
case (.bottom, .outgoing): return [ .bottomRight, .bottomLeft, .topLeft ]
@ -759,7 +782,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
return cornerMask
}
private static func getFontSize(for item: ConversationViewModel.Item) -> CGFloat {
private static func getFontSize(for cellViewModel: MessageCell.ViewModel) -> CGFloat {
let baselineFontSize = Values.mediumFontSize
switch viewItem.displayableBodyText?.jumbomojiCount {
case 1: return baselineFontSize + 30
@ -769,14 +792,14 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
}
private func getMessageStatusImage(for item: ConversationViewModel.Item) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) {
guard item.interactionVariant == .standardOutgoing else { return (nil, nil, nil) }
private func getMessageStatusImage(for cellViewModel: MessageCell.ViewModel) -> (image: UIImage?, tintColor: UIColor?, backgroundColor: UIColor?) {
guard cellViewModel.variant == .standardOutgoing else { return (nil, nil, nil) }
let image: UIImage
var tintColor: UIColor? = nil
var backgroundColor: UIColor? = nil
switch (item.state, item.hasAtLeastOneReadReceipt) {
switch (cellViewModel.state, cellViewModel.hasAtLeastOneReadReceipt) {
case (.sending, _):
image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate)
tintColor = Colors.text
@ -797,10 +820,12 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
return (image, tintColor, backgroundColor)
}
private func getSize(for item: ConversationViewModel.Item) -> CGSize {
guard let mediaAttachments: [Attachment] = item.attachments?.filter({ $0.isVisualMedia }) else { preconditionFailure() }
private func getSize(for cellViewModel: MessageCell.ViewModel) -> CGSize {
guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else {
preconditionFailure()
}
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: item)
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel)
let defaultSize = MediaAlbumView.layoutSize(forMaxMessageWidth: maxMessageWidth, items: mediaAttachments)
guard
@ -843,13 +868,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
return CGSize(width: width, height: height)
}
static func getMaxWidth(for item: ConversationViewModel.Item) -> CGFloat {
static func getMaxWidth(for cellViewModel: MessageCell.ViewModel) -> CGFloat {
let screen: CGRect = UIScreen.main.bounds
switch item.interactionVariant {
switch cellViewModel.variant {
case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize)
case .standardIncoming, .standardIncomingDeleted:
let isGroupThread = (item.threadVariant == .openGroup || item.threadVariant == .closedGroup)
let isGroupThread = (
cellViewModel.threadVariant == .openGroup ||
cellViewModel.threadVariant == .closedGroup
)
let leftGutterSize = (isGroupThread ? gutterSize : contactThreadHSpacing)
return (screen.width - leftGutterSize - gutterSize)
@ -859,7 +887,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
}
static func getBodyTextView(
for item: ConversationViewModel.Item,
for cellViewModel: MessageCell.ViewModel,
with availableWidth: CGFloat,
textColor: UIColor,
searchText: String?,
@ -872,18 +900,18 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
//
// Note: We can't just set 'isSelectable' to false otherwise the link detection/selection
// stops working
let isOutgoing: Bool = (item.interactionVariant == .standardOutgoing)
let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing)
let result: BodyTextView = BodyTextView(snDelegate: delegate)
result.isEditable = false
let attributedText: NSMutableAttributedString = NSMutableAttributedString(
attributedString: MentionUtilities.highlightMentions(
in: (item.body ?? ""),
threadVariant: item.threadVariant,
in: (cellViewModel.body ?? ""),
threadVariant: cellViewModel.threadVariant,
isOutgoingMessage: isOutgoing,
attributes: [
.foregroundColor : textColor,
.font : UIFont.systemFont(ofSize: getFontSize(for: item))
.font : UIFont.systemFont(ofSize: getFontSize(for: cellViewModel))
]
)
)

View File

@ -4,8 +4,20 @@ import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionUtilitiesKit
public class MediaGalleryViewModel: TransactionObserver {
public class MediaGalleryViewModel {
public typealias SectionModel = ArraySection<Section, Item>
// MARK: - Section
public enum Section: Differentiable, Equatable, Comparable, Hashable {
case emptyGallery
case loadOlder
case galleryMonth(date: GalleryDate)
case loadNewer
}
public let threadId: String
public let threadVariant: SessionThread.Variant
private var focusedAttachmentId: String?
@ -18,59 +30,61 @@ public class MediaGalleryViewModel: TransactionObserver {
public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue }
public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue }
public private(set) var albumData: [Int64: [Item]] = [:]
public private(set) var pagedDatabaseObserver: PagedDatabaseObserver<Attachment, Item>?
/// This value is the current state of a gallery view
public private(set) var galleryData: [SectionModel] = []
// MARK: - Paging
public struct PageInfo {
public enum Target: Equatable {
case before
case around(id: String)
case after
}
let pageSize: Int
let pageOffset: Int
let currentCount: Int
let totalCount: Int
// MARK: - Initizliation
init(
pageSize: Int,
pageOffset: Int = 0,
currentCount: Int = 0,
totalCount: Int = 0
) {
self.pageSize = pageSize
self.pageOffset = pageOffset
self.currentCount = currentCount
self.totalCount = totalCount
}
}
private var isFetchingMoreItems: Atomic<Bool> = Atomic(false)
private var pageInfo: Atomic<PageInfo>
// Gallery observing
private let updatedRows: Atomic<Set<TrackedChange>> = Atomic([])
public var onGalleryChange: (([SectionModel], PageInfo) -> ())?
public var onGalleryChange: (([SectionModel]) -> ())?
// MARK: - Initialization
init(
threadId: String,
threadVariant: SessionThread.Variant,
isPagedData: Bool,
pageSize: Int = 1,
focusedAttachmentId: String? = nil
) {
self.threadId = threadId
self.threadVariant = threadVariant
self.pageInfo = Atomic(PageInfo(pageSize: pageSize))
self.focusedAttachmentId = focusedAttachmentId
self.pagedDatabaseObserver = nil
guard isPagedData else { return }
var hasSavedIntialUpdate: Bool = false
let filterSQL: SQL = Item.filterSQL(threadId: threadId)
self.pagedDatabaseObserver = PagedDatabaseObserver(
pagedTable: Attachment.self,
pageSize: pageSize,
idColumn: .id,
initialFocusedId: focusedAttachmentId,
observedChanges: [
PagedData.ObservedChanges(
table: Attachment.self,
columns: [.isValid]
)
],
joinSQL: Item.joinSQL,
filterSQL: filterSQL,
orderSQL: Item.galleryOrderSQL,
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL, baseFilterSQL: filterSQL),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we haven't stored the data for the initial fetch then do so now (no need
// to call 'onGalleryChange' in this case as it will always be null)
guard hasSavedIntialUpdate else {
self?.updateGalleryData(updatedGalleryData)
hasSavedIntialUpdate = true
return
}
self?.onGalleryChange?(updatedGalleryData)
}
)
}
// MARK: - Data
@ -132,33 +146,26 @@ public class MediaGalleryViewModel: TransactionObserver {
}
}
public typealias SectionModel = ArraySection<Section, Item>
public enum Section: Differentiable, Equatable, Comparable, Hashable {
case emptyGallery
case loadNewer
case galleryMonth(date: GalleryDate)
case loadOlder
}
public struct Item: FetchableRecord, Decodable, Differentiable, Equatable, Hashable, Comparable {
fileprivate static let interactionIdKey: String = CodingKeys.interactionId.stringValue
fileprivate static let interactionVariantKey: String = CodingKeys.interactionVariant.stringValue
fileprivate static let interactionAuthorIdKey: String = CodingKeys.interactionAuthorId.stringValue
fileprivate static let interactionTimestampMsKey: String = CodingKeys.interactionTimestampMs.stringValue
fileprivate static let attachmentRowIdKey: String = CodingKeys.attachmentRowId.stringValue
fileprivate static let attachmentAlbumIndexKey: String = CodingKeys.attachmentAlbumIndex.stringValue
public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable {
fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue)
fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue)
public var differenceIdentifier: String {
return attachment.id
}
fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue
public var id: String { attachment.id }
public var differenceIdentifier: String { attachment.id }
let interactionId: Int64
let interactionVariant: Interaction.Variant
let interactionAuthorId: String
let interactionTimestampMs: Int64
let attachmentRowId: Int64
public var rowId: Int64
let attachmentAlbumIndex: Int
let attachment: Attachment
@ -182,25 +189,31 @@ public class MediaGalleryViewModel: TransactionObserver {
var captionForDisplay: String? { attachment.caption?.filterForDisplay }
// MARK: - Comparable
public static func < (lhs: Item, rhs: Item) -> Bool {
if lhs.interactionTimestampMs == rhs.interactionTimestampMs {
return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex)
}
return (lhs.interactionTimestampMs < rhs.interactionTimestampMs)
}
// MARK: - Query
private static let baseQueryFilterSQL: SQL = {
fileprivate static let joinSQL: SQL = {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return SQL("\(attachment[.isVisualMedia]) = true AND \(attachment[.isValid]) = true")
return """
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
"""
}()
private static let galleryQueryOrderSQL: SQL = {
fileprivate static func filterSQL(threadId: String) -> SQL {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
return SQL("""
\(attachment[.isVisualMedia]) = true AND
\(attachment[.isValid]) = true AND
\(interaction[.threadId]) = \(threadId)
""")
}
fileprivate static let galleryOrderSQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
@ -209,101 +222,72 @@ public class MediaGalleryViewModel: TransactionObserver {
return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])")
}()
/// Retrieve the index that the attachment with the given `attachmentId` will have in the gallery
fileprivate static func galleryIndex(for attachmentId: String) -> SQLRequest<Int> {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
fileprivate static let galleryReverseOrderSQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return """
SELECT
(gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed
FROM (
SELECT
\(attachment[.id]) AS id,
ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex
FROM \(Attachment.self)
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
WHERE \(baseQueryFilterSQL)
) AS gallery
WHERE \(SQL("gallery.id = \(attachmentId)"))
"""
}
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
/// very broken
return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)")
}()
/// Retrieve the indexes the given attachment row will have in the gallery
fileprivate static func galleryIndexes(for rowIds: Set<Int64>, threadId: String) -> SQLRequest<Int> {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return """
SELECT
(gallery.galleryIndex - 1) AS galleryIndex -- Converting from 1-Indexed to 0-indexed
FROM (
SELECT
\(attachment.alias[Column.rowID]) AS rowid,
ROW_NUMBER() OVER (ORDER BY \(galleryQueryOrderSQL)) AS galleryIndex
FROM \(Attachment.self)
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
JOIN \(Interaction.self) ON (
\(interaction[.id]) = \(interactionAttachment[.interactionId]) AND
\(SQL("\(interaction[.threadId]) = \(threadId)"))
)
WHERE \(baseQueryFilterSQL)
) AS gallery
WHERE \(SQL("gallery.rowid IN \(rowIds)"))
"""
}
private static let baseQuery: QueryInterfaceRequest<Item> = {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return Attachment
.select(
interaction[.id].forKey(Item.interactionIdKey),
interaction[.variant].forKey(Item.interactionVariantKey),
interaction[.authorId].forKey(Item.interactionAuthorIdKey),
interaction[.timestampMs].forKey(Item.interactionTimestampMsKey),
attachment.alias[Column.rowID].forKey(Item.attachmentRowIdKey),
interactionAttachment[.albumIndex].forKey(Item.attachmentAlbumIndexKey),
attachment.allColumns()
)
.aliased(attachment)
.filter(literal: baseQueryFilterSQL)
.joining(
required: Attachment.interactionAttachments
.aliased(interactionAttachment)
.joining(
required: InteractionAttachment.interaction
.aliased(interaction)
fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> ((SQL?, SQL?) -> AdaptedFetchRequest<SQLRequest<Item>>) {
return { additionalFilters, limitSQL -> AdaptedFetchRequest<SQLRequest<Item>> in
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let finalFilterSQL: SQL = {
guard let additionalFilters: SQL = additionalFilters else {
return """
WHERE (
\(baseFilterSQL)
)
"""
}
return """
WHERE (
\(baseFilterSQL) AND
\(additionalFilters)
)
)
.asRequest(of: Item.self)
}()
"""
}()
let finalLimitSQL: SQL = (limitSQL ?? SQL(stringLiteral: ""))
let numColumnsBeforeLinkedRecords: Int = 6
let request: SQLRequest<Item> = """
SELECT
\(interaction[.id]) AS \(Item.interactionIdKey),
\(interaction[.variant]) AS \(Item.interactionVariantKey),
\(interaction[.authorId]) AS \(Item.interactionAuthorIdKey),
\(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey),
\(attachment.alias[Column.rowID]) AS \(Item.rowIdKey),
\(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey),
\(Item.attachmentKey).*
FROM \(Attachment.self)
\(joinSQL)
\(finalFilterSQL)
ORDER BY \(orderSQL)
\(finalLimitSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Attachment.numberOfSelectedColumns(db)
])
return ScopeAdapter([
Item.attachmentString: adapters[1]
])
}
}
}
fileprivate static let albumQuery: QueryInterfaceRequest<Item> = {
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return Item.baseQuery.order(interactionAttachment[.albumIndex])
}()
fileprivate static let galleryQuery: QueryInterfaceRequest<Item> = {
return Item.baseQuery
.order(literal: galleryQueryOrderSQL)
}()
fileprivate static let galleryQueryReversed: QueryInterfaceRequest<Item> = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
/// **Note:** This **MUST** always result in the same data as `galleryQuery` but in the opposite order
return Item.baseQuery
.order(interaction[.timestampMs], interactionAttachment[.albumIndex].desc)
}()
fileprivate static func baseQuery(orderSQL: SQL, baseFilterSQL: SQL) -> AdaptedFetchRequest<SQLRequest<Item>> {
return Item.baseQuery(orderSQL: orderSQL, baseFilterSQL: baseFilterSQL)(nil, nil)
}
func thumbnailImage(async: @escaping (UIImage) -> ()) {
attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {})
@ -326,345 +310,18 @@ public class MediaGalleryViewModel: TransactionObserver {
guard let interactionId: Int64 = interactionId else { return [] }
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return try Item.albumQuery
.filter(interaction[.id] == interactionId)
return try Item
.baseQuery(
orderSQL: SQL(interactionAttachment[.albumIndex]),
baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)")
)
.fetchAll(db)
}
.removeDuplicates()
}
// MARK: - Gallery
/// This function is used to load a gallery page using the provided `limitInfo`, if a `focusedAttachmentId` is provided then
/// the `limitInfo.offset` value will be ignored and it will retrieve `limitInfo.limit` values positioning the focussed item
/// as closed to the middle as possible prioritising retrieving `limitInfo.limit` items total
///
/// **Note:** The `focusedAttachmentId` should only be provided during the first call, subsequent calls should solely provide
/// the `limitInfo` so content can be added before and after the initial page
private func loadGalleryPage(
_ target: PageInfo.Target,
currentPageInfo: PageInfo
) -> (items: [Item], updatedPageInfo: PageInfo) {
return GRDBStorage.shared
.read { db in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let totalCount: Int = try Item.galleryQuery
.filter(interaction[.threadId] == threadId)
.fetchCount(db)
let queryOffset: Int = {
switch target {
case .before:
return max(0, (currentPageInfo.pageOffset - currentPageInfo.pageSize))
case .around(let targetId):
// If we want to focus on a specific item then we need to find it's index in
// the queried data
guard let targetIndex: Int = try? Int.fetchOne(db, Item.galleryIndex(for: targetId)) else {
// If we couldn't find the targetId then just load the page after the current one
return (currentPageInfo.pageOffset + currentPageInfo.pageSize)
}
// If the focused item is within the first half of the page then we still want
// to retrieve a full page so calculate the offset needed to do so
let halfPageSize: Int = Int(floor(Double(currentPageInfo.pageSize) / 2))
// If the focused item is within the first or last half page then just
// start from the start/end of the content
guard targetIndex > halfPageSize else { return 0 }
guard targetIndex < (totalCount - halfPageSize) else {
return (totalCount - currentPageInfo.pageSize)
}
return (targetIndex - halfPageSize)
case .after:
return (currentPageInfo.pageOffset + currentPageInfo.currentCount)
}
}()
let items: [Item] = try Item.galleryQuery
.filter(interaction[.threadId] == threadId)
.limit(currentPageInfo.pageSize, offset: queryOffset)
.fetchAll(db)
let updatedLimitInfo: PageInfo = PageInfo(
pageSize: currentPageInfo.pageSize,
pageOffset: (target != .after ?
queryOffset :
currentPageInfo.pageOffset
),
currentCount: (currentPageInfo.currentCount + items.count),
totalCount: totalCount
)
return (items, updatedLimitInfo)
}
.defaulting(to: ([], currentPageInfo))
}
private func addingSystemSections(to data: [SectionModel], for pageInfo: PageInfo) -> [SectionModel] {
// Remove and re-add the custom sections as needed
return [
(data.isEmpty ? [SectionModel(section: .emptyGallery)] : []),
(!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []),
data.filter { section -> Bool in
switch section.model {
case .galleryMonth: return true
case .emptyGallery, .loadOlder, .loadNewer: return false
}
},
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadOlder)] :
[]
)
]
.flatMap { $0 }
}
private func updatedGalleryData(
with existingData: [SectionModel],
dataToUpsert: [Item],
pageInfoToUpdate: PageInfo
) -> (sections: [SectionModel], pageInfo: PageInfo) {
guard !dataToUpsert.isEmpty else { return (existingData, pageInfoToUpdate) }
let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData(
with: self.galleryData,
dataToUpsert: (dataToUpsert, pageInfoToUpdate)
)
let existingDataCount: Int = existingData
.map { $0.elements.count }
.reduce(0, +)
let updatedGalleryDataCount: Int = updatedGalleryData.sections
.map { $0.elements.count }
.reduce(0, +)
let gallerySizeDiff: Int = (updatedGalleryDataCount - existingDataCount)
let updatedPageInfo: PageInfo = PageInfo(
pageSize: pageInfoToUpdate.pageSize,
pageOffset: pageInfoToUpdate.pageOffset,
currentCount: (pageInfoToUpdate.currentCount + gallerySizeDiff),
totalCount: (pageInfoToUpdate.totalCount + gallerySizeDiff)
)
// Add the "system" sections, sort the sections and return the result
return (
self.addingSystemSections(to: updatedGalleryData.sections, for: updatedPageInfo)
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) },
updatedPageInfo
)
}
private func updatedGalleryData(
with existingData: [SectionModel],
dataToUpsert: (items: [Item], updatedPageInfo: PageInfo)
) -> (sections: [SectionModel], pageInfo: PageInfo) {
var updatedGalleryData: [SectionModel] = existingData
dataToUpsert
.items
.grouped(by: \.galleryDate)
.forEach { key, items in
guard let existingIndex = galleryData.firstIndex(where: { $0.model == .galleryMonth(date: key) }) else {
// Insert a new section
updatedGalleryData.append(
ArraySection(
model: .galleryMonth(date: key),
elements: items
.sorted()
.reversed()
)
)
return
}
// Filter out collisions, replacing them with the updated values and insert
// and new values
let itemRowIds: Set<Int64> = items.map { $0.attachmentRowId }.asSet()
updatedGalleryData[existingIndex] = ArraySection(
model: .galleryMonth(date: key),
elements: updatedGalleryData[existingIndex].elements
.filter { !itemRowIds.contains($0.attachmentRowId) }
.appending(contentsOf: items)
.sorted()
.reversed()
)
}
// Add the "system" sections, sort the sections and return the result
return (
self.addingSystemSections(to: updatedGalleryData, for: dataToUpsert.updatedPageInfo)
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) },
dataToUpsert.updatedPageInfo
)
}
// MARK: - TransactionObserver
private struct TrackedChange: Equatable, Hashable {
let kind: DatabaseEvent.Kind
let rowId: Int64
}
public func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
switch eventKind {
case .delete(let tableName): return (tableName == Attachment.databaseTableName)
case .update(let tableName, let columnNames):
/// **Warning:** This filtering allows us to ignore all changes to attachments except
/// for the 'isValid' column, unfortunately calling the `with()` function on an attachment
/// does result in this column being seen as updated (even if the value doesn't change) so
/// we need to be careful where we set it to avoid unnecessarily triggering updates
return (
tableName == Attachment.databaseTableName &&
columnNames.contains(Attachment.Columns.isValid.name)
)
// We can ignore 'insert' events as we only care about valid attachments
case .insert: return false
}
}
public func databaseDidChange(with event: DatabaseEvent) {
// This will get called for whenever an Attachment's 'isValid' column is
// updated (ie. an attachment finished uploading/downloading), unfortunately
// we won't know if the attachment is actually relevant yet as it could be for
// another thread or it might not be a media attachment
let trackedChange: TrackedChange = TrackedChange(
kind: event.kind,
rowId: event.rowID
)
updatedRows.mutate { $0.insert(trackedChange) }
}
// Note: We will process all updates which come through this method even if
// 'onGalleryChange' is null because if the UI stops observing and then starts again
// later we don't want them to have missed out on changes which happened while they
// weren't subscribed (and doing a full re-query seems painful...)
public func databaseDidCommit(_ db: Database) {
var committedUpdatedRows: Set<TrackedChange> = []
self.updatedRows.mutate { updatedRows in
committedUpdatedRows = updatedRows
updatedRows.removeAll()
}
// Note: This method will be called regardless of whether there were actually changes
// in the areas we are observing so we want to early-out if there aren't any relevant
// updated rows
guard !committedUpdatedRows.isEmpty else { return }
var updatedPageInfo: PageInfo = self.pageInfo.wrappedValue
let attachmentRowIdsToQuery: Set<Int64> = committedUpdatedRows
.filter { $0.kind != .delete }
.map { $0.rowId }
.asSet()
let attachmentRowIdsToDelete: Set<Int64> = committedUpdatedRows
.filter { $0.kind == .delete }
.map { $0.rowId }
.asSet()
let oldGalleryDataCount: Int = self.galleryData
.map { $0.elements.count }
.reduce(0, +)
var galleryDataWithDeletions: [SectionModel] = self.galleryData
// First remove any items which have been deleted
if !attachmentRowIdsToDelete.isEmpty {
galleryDataWithDeletions = galleryDataWithDeletions
.map { section -> SectionModel in
ArraySection(
model: section.model,
elements: section.elements
.filter { item -> Bool in !attachmentRowIdsToDelete.contains(item.attachmentRowId) }
)
}
.filter { section -> Bool in !section.elements.isEmpty }
let updatedGalleryDataCount: Int = galleryDataWithDeletions
.map { $0.elements.count }
.reduce(0, +)
// Make sure there were actually changes
if updatedGalleryDataCount != oldGalleryDataCount {
let gallerySizeDiff: Int = (updatedGalleryDataCount - oldGalleryDataCount)
updatedPageInfo = PageInfo(
pageSize: updatedPageInfo.pageSize,
pageOffset: updatedPageInfo.pageOffset,
currentCount: (updatedPageInfo.currentCount + gallerySizeDiff),
totalCount: (updatedPageInfo.totalCount + gallerySizeDiff)
)
}
}
/// Store the 'deletions-only' update logic in a block as there are a number of places we will fallback to this logic
let sendDeletionsOnlyUpdateIfNeeded: () -> () = {
guard !attachmentRowIdsToDelete.isEmpty else { return }
DispatchQueue.main.async { [weak self] in
self?.onGalleryChange?(galleryDataWithDeletions, updatedPageInfo)
}
}
// If there are no inserted/updated rows then trigger the update callback and stop here
guard !attachmentRowIdsToQuery.isEmpty else {
sendDeletionsOnlyUpdateIfNeeded()
return
}
// Fetch the indexes of the rowIds so we can determine whether they should be added to the screen
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let itemIndexes: [Int] = (try? Item.galleryIndexes(for: attachmentRowIdsToQuery, threadId: self.threadId)
.fetchAll(db))
.defaulting(to: [])
// 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 itemsAreSequential: Bool = (itemIndexes.map { $0 - 1 }.dropFirst() == itemIndexes.dropLast())
let validAttachmentRowIds: Set<Int64> = (itemsAreSequential && itemIndexes.contains(where: { $0 < updatedPageInfo.currentCount }) ?
attachmentRowIdsToQuery :
zip(itemIndexes, attachmentRowIdsToQuery)
.filter { index, _ -> Bool in index < updatedPageInfo.currentCount }
.map { _, rowId -> Int64 in rowId }
.asSet()
)
// If there are no valid attachment row ids then stop here
guard !validAttachmentRowIds.isEmpty else {
sendDeletionsOnlyUpdateIfNeeded()
return
}
// Fetch the inserted/updated rows
let updatedItems: [Item] = (try? Item.galleryQuery
.filter(validAttachmentRowIds.contains(Column.rowID))
.filter(interaction[.threadId] == self.threadId)
.fetchAll(db))
.defaulting(to: [])
// If the inserted/updated rows we irrelevant (eg. associated to another thread, a quote or a link
// preview) then trigger the update callback (if there were deletions) and stop here
guard !updatedItems.isEmpty else {
sendDeletionsOnlyUpdateIfNeeded()
return
}
// Process the upserted data
let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData(
with: galleryDataWithDeletions,
dataToUpsert: updatedItems,
pageInfoToUpdate: updatedPageInfo
)
DispatchQueue.main.async { [weak self] in
self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo)
}
}
public func databaseDidRollback(_ db: Database) {}
// MARK: - Functions
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
@ -673,19 +330,30 @@ public class MediaGalleryViewModel: TransactionObserver {
let maybeAlbumInfo: AlbumInfo? = GRDBStorage.shared
.read { db -> AlbumInfo in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let newAlbumData: [Item] = try Item.albumQuery
.filter(interaction[.id] == interactionId)
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let newAlbumData: [Item] = try Item
.baseQuery(
orderSQL: SQL(interactionAttachment[.albumIndex]),
baseFilterSQL: SQL("\(interaction[.id]) = \(interactionId)")
)
.fetchAll(db)
guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else {
return (newAlbumData, nil, nil)
}
let itemBefore: Item? = try Item.galleryQueryReversed
.filter(interaction[.timestampMs] > albumTimestampMs)
let itemBefore: Item? = try Item
.baseQuery(
orderSQL: Item.galleryReverseOrderSQL,
baseFilterSQL: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
)
.fetchOne(db)
let itemAfter: Item? = try Item.galleryQuery
.filter(interaction[.timestampMs] < albumTimestampMs)
let itemAfter: Item? = try Item
.baseQuery(
orderSQL: Item.galleryOrderSQL,
baseFilterSQL: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
)
.fetchOne(db)
return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId)
@ -709,9 +377,43 @@ public class MediaGalleryViewModel: TransactionObserver {
self.albumData[interactionId] = updatedData
}
public func updateGalleryData(_ updatedData: [SectionModel], pageInfo: PageInfo) {
// MARK: - Gallery
private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let galleryData: [SectionModel] = data
.grouped(by: \.galleryDate)
.mapValues { sectionItems -> [Item] in
sectionItems
.sorted { lhs, rhs -> Bool in
if lhs.interactionTimestampMs == rhs.interactionTimestampMs {
// Start of album first
return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex)
}
// Newer interactions first
return (lhs.interactionTimestampMs > rhs.interactionTimestampMs)
}
}
.map { galleryDate, items in
SectionModel(model: .galleryMonth(date: galleryDate), elements: items)
}
// Remove and re-add the custom sections as needed
return [
(data.isEmpty ? [SectionModel(section: .emptyGallery)] : []),
(!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []),
galleryData,
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadOlder)] :
[]
)
]
.flatMap { $0 }
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }
}
public func updateGalleryData(_ updatedData: [SectionModel]) {
self.galleryData = updatedData
self.pageInfo.mutate { $0 = pageInfo }
// If we have a focused attachment id then we need to make sure the 'focusedIndexPath'
// is updated to be accurate
@ -732,49 +434,11 @@ public class MediaGalleryViewModel: TransactionObserver {
}
public func loadNewerGalleryItems() {
// Only allow on 'load older' fetch at a time
guard !isFetchingMoreItems.wrappedValue else { return }
// Prevent more fetching until we have completed adding the page
isFetchingMoreItems.mutate { $0 = true }
// Load the page before the current data (newer items) then merge and sort
// with the current data
let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData(
with: galleryData,
dataToUpsert: loadGalleryPage(
.before,
currentPageInfo: pageInfo.wrappedValue
)
)
DispatchQueue.main.async { [weak self] in
self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo)
self?.isFetchingMoreItems.mutate { $0 = false }
}
self.pagedDatabaseObserver?.load(.pageBefore)
}
public func loadOlderGalleryItems() {
// Only allow on 'load older' fetch at a time
guard !isFetchingMoreItems.wrappedValue else { return }
// Prevent more fetching until we have completed adding the page
isFetchingMoreItems.mutate { $0 = true }
// Load the page after the current data (older items) then merge and sort
// with the current data
let updatedGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = updatedGalleryData(
with: galleryData,
dataToUpsert: loadGalleryPage(
.after,
currentPageInfo: pageInfo.wrappedValue
)
)
DispatchQueue.main.async { [weak self] in
self?.onGalleryChange?(updatedGalleryData.sections, updatedGalleryData.pageInfo)
self?.isFetchingMoreItems.mutate { $0 = false }
}
self.pagedDatabaseObserver?.load(.pageAfter)
}
public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) {
@ -798,7 +462,8 @@ public class MediaGalleryViewModel: TransactionObserver {
// transitions work nicely)
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
threadId: threadId,
threadVariant: threadVariant
threadVariant: threadVariant,
isPagedData: false
)
viewModel.loadAndCacheAlbumData(for: interactionId)
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
@ -831,32 +496,11 @@ public class MediaGalleryViewModel: TransactionObserver {
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
threadId: threadId,
threadVariant: threadVariant,
isPagedData: true,
pageSize: MediaTileViewController.itemPageSize,
focusedAttachmentId: focusedAttachmentId
)
// Load the data for the album immediately (needed before pushing to the screen so
// transitions work nicely)
let pageTarget: PageInfo.Target = {
// If we don't have a `focusedAttachmentId` then default to `.before` (it'll query
// from a `0` offset
guard let targetId: String = focusedAttachmentId else { return .before }
return .around(id: targetId)
}()
let initialGalleryData: (sections: [SectionModel], pageInfo: PageInfo) = viewModel.updatedGalleryData(
with: [],
dataToUpsert: viewModel.loadGalleryPage(
pageTarget,
currentPageInfo: PageInfo(pageSize: MediaTileViewController.itemPageSize)
)
)
viewModel.updateGalleryData(
initialGalleryData.sections,
pageInfo: initialGalleryData.pageInfo
)
return MediaTileViewController(
viewModel: viewModel
)

View File

@ -34,9 +34,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
init(viewModel: MediaGalleryViewModel) {
self.viewModel = viewModel
// Start observing database changes
GRDBStorage.shared.addObserver(viewModel)
GRDBStorage.shared.addObserver(viewModel.pagedDatabaseObserver)
super.init(nibName: nil, bundle: nil)
}
@ -163,8 +161,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
self.viewModel.onGalleryChange = nil
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
@ -172,8 +169,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
self.viewModel.onGalleryChange = nil
stopObservingChanges()
}
override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@ -240,17 +236,23 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
private func startObservingChanges() {
// Start observing for data changes (will callback on the main thread)
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData, pageInfo in
self?.handleUpdates(updatedGalleryData, pageInfo: pageInfo)
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in
self?.handleUpdates(updatedGalleryData)
}
}
private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel], pageInfo: MediaGalleryViewModel.PageInfo) {
private func stopObservingChanges() {
// Note: The 'PagedDatabaseObserver' will continue to get changes but
// we don't want to trigger any UI updates
self.viewModel.onGalleryChange = nil
}
private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) {
// 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 {
UIView.performWithoutAnimation {
handleUpdates(updatedGalleryData, pageInfo: pageInfo)
handleUpdates(updatedGalleryData)
triggerInitialDataLoadIfNeeded()
}
return
@ -291,7 +293,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData),
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
) { [weak self] updatedData in
self?.viewModel.updateGalleryData(updatedData, pageInfo: pageInfo)
self?.viewModel.updateGalleryData(updatedData)
}
CATransaction.setCompletionBlock { [weak self] in

View File

@ -36,7 +36,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case id
case serverHash
case threadId
@ -60,7 +60,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
case openGroupWhisperTo
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
case standardIncoming
case standardOutgoing
case standardIncomingDeleted

View File

@ -4,7 +4,7 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct InteractionAttachment: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "interactionAttachment" }
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
internal static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id])

View File

@ -7,7 +7,7 @@ import AFNetworking
import SignalCoreKit
import SessionUtilitiesKit
public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "linkPreview" }
internal static let interactionForeignKey = ForeignKey(
[Columns.url],
@ -28,7 +28,7 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor
case attachmentId
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
case standard
case openGroupInvitation
}

View File

@ -345,8 +345,8 @@ public extension Profile {
}
/// A standardised mechanism for truncating a user id for a given thread
static func truncated(id: String, thread: SessionThread) -> String {
switch thread.variant {
static func truncated(id: String, threadVariant: SessionThread.Variant = .contact) -> String {
switch threadVariant {
case .openGroup: return truncated(id: id, truncating: .start)
default: return truncated(id: id, truncating: .middle)
}
@ -378,7 +378,7 @@ public extension Profile {
if let nickname: String = nickname { return nickname }
guard let name: String = name, name != id else {
return (customFallback ?? Profile.truncated(id: id, truncating: .middle))
return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant))
}
switch threadVariant {

View File

@ -4,7 +4,7 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct Quote: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "quote" }
public static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
internal static let originalInteractionForeignKey = ForeignKey(

View File

@ -21,7 +21,7 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
case mostRecentFailureText
}
public enum State: Int, Codable, DatabaseValueConvertible {
public enum State: Int, Codable, Hashable, DatabaseValueConvertible {
case failed
case sending
case skipped
@ -117,3 +117,30 @@ public extension RecipientState {
)
}
}
// MARK: - GRDB Queries
public extension RecipientState {
static func selectInteractionState(tableLiteral: SQL, idColumnLiteral: SQL) -> SQL {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
SELECT * FROM (
SELECT
\(recipientState[.interactionId]),
\(recipientState[.state]),
\(recipientState[.mostRecentFailureText])
FROM \(RecipientState.self)
JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId])
WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped'
ORDER BY
-- If there is a single 'sending' then should be 'sending', otherwise if there is a single
-- 'failed' and there is no 'sending' then it should be 'failed'
\(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC,
\(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC
) AS \(tableLiteral)
GROUP BY \(tableLiteral).\(idColumnLiteral)
"""
}
}

View File

@ -32,7 +32,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
case onlyNotifyForMentions
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible {
case contact
case closedGroup
case openGroup

View File

@ -105,13 +105,15 @@ extension MessageReceiver {
case .started:
TypingIndicators.didStartTyping(
db,
in: thread,
threadId: thread.id,
threadVariant: thread.variant,
threadIsMessageRequest: thread.isMessageRequest(db),
direction: .incoming,
timestampMs: message.sentTimestamp.map { Int64($0) }
)
case .stopped:
TypingIndicators.didStopTyping(db, in: thread, direction: .incoming)
TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming)
default:
SNLog("Unknown TypingIndicator Kind ignored")
@ -582,7 +584,7 @@ extension MessageReceiver {
// Cancel any typing indicators if needed
if isMainAppActive {
TypingIndicators.didStopTyping(db, in: thread, direction: .incoming)
TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming)
}
// Update the contact's approval status of the current user if needed (if we are getting messages from

View File

@ -42,19 +42,21 @@ public struct QuotedReplyModel {
body: String?,
timestampMs: Int64,
attachments: [Attachment]?,
linkPreview: LinkPreview?
linkPreviewAttachment: Attachment?
) -> QuotedReplyModel? {
guard variant == .standardOutgoing || variant == .standardIncoming else { return nil }
guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil }
let targetAttachment: Attachment? = (attachments?.first ?? linkPreviewAttachment)
return QuotedReplyModel(
threadId: threadId,
authorId: authorId,
timestampMs: timestampMs,
body: body,
attachment: attachments?.first,
contentType: attachments?.first?.contentType,
sourceFileName: attachments?.first?.sourceFilename,
attachment: targetAttachment,
contentType: targetAttachment?.contentType,
sourceFileName: targetAttachment?.sourceFilename,
thumbnailDownloadFailed: false
)
}

View File

@ -13,38 +13,38 @@ public class TypingIndicators {
}
private class Indicator {
fileprivate let thread: SessionThread
fileprivate let threadId: String
fileprivate let direction: Direction
fileprivate let timestampMs: Int64
fileprivate var refreshTimer: Timer?
fileprivate var stopTimer: Timer?
init?(thread: SessionThread, direction: Direction, timestampMs: Int64?) {
init?(
threadId: String,
threadVariant: SessionThread.Variant,
threadIsMessageRequest: Bool,
direction: Direction,
timestampMs: Int64?
) {
// The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app
// preferences, if it's disabled we don't want to emit "typing indicator" messages
// or show typing indicators for other users
//
// We also don't want to show/send typing indicators for message requests
guard GRDBStorage.shared.read({ db in
(
db[.typingIndicatorsEnabled] == true &&
thread.isMessageRequest(db) == false
)
}) == true else {
guard GRDBStorage.shared[.typingIndicatorsEnabled] && !threadIsMessageRequest else {
return nil
}
// Don't send typing indicators in group threads
guard thread.variant != .closedGroup && thread.variant != .openGroup else { return nil }
guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil }
self.thread = thread
self.threadId = threadId
self.direction = direction
self.timestampMs = (timestampMs ?? Int64(floor(Date().timeIntervalSince1970 * 1000)))
}
fileprivate func starting(_ db: Database) -> Indicator {
let thread: SessionThread = self.thread
let direction: Direction = self.direction
let timestampMs: Int64 = self.timestampMs
@ -55,7 +55,7 @@ public class TypingIndicators {
case .incoming:
try? ThreadTypingIndicator(
threadId: thread.id,
threadId: self.threadId,
timestampMs: timestampMs
)
.save(db)
@ -83,6 +83,10 @@ public class TypingIndicators {
switch direction {
case .outgoing:
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else {
return nil
}
try? MessageSender.send(
db,
message: TypingIndicator(kind: .stopped),
@ -92,7 +96,7 @@ public class TypingIndicators {
case .incoming:
_ = try? ThreadTypingIndicator
.filter(ThreadTypingIndicator.Columns.threadId == thread.id)
.filter(ThreadTypingIndicator.Columns.threadId == self.threadId)
.deleteAll(db)
}
@ -101,6 +105,10 @@ public class TypingIndicators {
private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) {
if shouldSend {
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else {
return
}
try? MessageSender.send(
db,
message: TypingIndicator(kind: .started),
@ -130,37 +138,56 @@ public class TypingIndicators {
// MARK: - Functions
public static func didStartTyping(_ db: Database, in thread: SessionThread, direction: Direction, timestampMs: Int64?) {
public static func didStartTyping(
_ db: Database,
threadId: String,
threadVariant: SessionThread.Variant,
threadIsMessageRequest: Bool,
direction: Direction,
timestampMs: Int64?
) {
switch direction {
case .outgoing:
let updatedIndicator: Indicator? = (
outgoing.wrappedValue[thread.id] ??
Indicator(thread: thread, direction: direction, timestampMs: timestampMs)
outgoing.wrappedValue[threadId] ??
Indicator(
threadId: threadId,
threadVariant: threadVariant,
threadIsMessageRequest: threadIsMessageRequest,
direction: direction,
timestampMs: timestampMs
)
)?.starting(db)
outgoing.mutate { $0[thread.id] = updatedIndicator }
outgoing.mutate { $0[threadId] = updatedIndicator }
case .incoming:
let updatedIndicator: Indicator? = (
incoming.wrappedValue[thread.id] ??
Indicator(thread: thread, direction: direction, timestampMs: timestampMs)
incoming.wrappedValue[threadId] ??
Indicator(
threadId: threadId,
threadVariant: threadVariant,
threadIsMessageRequest: threadIsMessageRequest,
direction: direction,
timestampMs: timestampMs
)
)?.starting(db)
incoming.mutate { $0[thread.id] = updatedIndicator }
incoming.mutate { $0[threadId] = updatedIndicator }
}
}
public static func didStopTyping(_ db: Database, in thread: SessionThread, direction: Direction) {
public static func didStopTyping(_ db: Database, threadId: String, direction: Direction) {
switch direction {
case .outgoing:
let updatedIndicator: Indicator? = outgoing.wrappedValue[thread.id]?.stoping(db)
let updatedIndicator: Indicator? = outgoing.wrappedValue[threadId]?.stoping(db)
outgoing.mutate { $0[thread.id] = updatedIndicator }
outgoing.mutate { $0[threadId] = updatedIndicator }
case .incoming:
let updatedIndicator: Indicator? = incoming.wrappedValue[thread.id]?.stoping(db)
let updatedIndicator: Indicator? = incoming.wrappedValue[threadId]?.stoping(db)
incoming.mutate { $0[thread.id] = updatedIndicator }
incoming.mutate { $0[threadId] = updatedIndicator }
}
}
}

View File

@ -23,21 +23,31 @@ extension ConversationCell {
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)
@ -50,6 +60,9 @@ extension ConversationCell {
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
@ -64,14 +77,19 @@ extension ConversationCell {
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
@ -80,9 +98,14 @@ extension ConversationCell {
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
@ -135,6 +158,23 @@ extension ConversationCell {
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
@ -166,14 +206,19 @@ public extension ConversationCell.ViewModel {
self.threadMemberNames = nil
self.threadIsNoteToSelf = false
self.threadIsMessageRequest = false
self.threadRequiresApproval = false
self.threadShouldBeVisible = false
self.threadIsPinned = false
self.threadIsBlocked = nil
self.threadMutedUntilTimestamp = nil
self.threadOnlyNotifyForMentions = nil
self.threadMessageDraft = nil
self.threadContactIsTyping = nil
self.threadUnreadCount = unreadCount
self.threadUnreadMentionCount = nil
self.threadFirstUnreadInteractionId = nil
// Thread display info
@ -182,9 +227,14 @@ public extension ConversationCell.ViewModel {
self.closedGroupProfileBack = nil
self.closedGroupProfileBackFallback = nil
self.closedGroupName = nil
self.closedGroupUserCount = nil
self.currentUserIsClosedGroupMember = nil
self.currentUserIsClosedGroupAdmin = nil
self.openGroupName = nil
self.openGroupServer = nil
self.openGroupRoom = nil
self.openGroupProfilePictureData = nil
self.openGroupUserCount = nil
// Interaction display info
@ -221,7 +271,6 @@ public extension ConversationCell.ViewModel {
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table")
let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table")
@ -240,7 +289,7 @@ public extension ConversationCell.ViewModel {
let interactionStateStateColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.state.name)
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to
/// 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
@ -372,20 +421,10 @@ public extension ConversationCell.ViewModel {
FROM \(Attachment.self)
) AS \(ViewModel.interactionAttachmentDescriptionInfoKey) ON \(ViewModel.interactionAttachmentDescriptionInfoKey).\(attachmentIdColumnLiteral) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral)
LEFT JOIN (
SELECT * FROM (
SELECT
\(recipientState[.interactionId]),
\(recipientState[.state])
FROM \(RecipientState.self)
JOIN \(Interaction.self) ON \(interaction[.id]) = \(recipientState[.interactionId])
WHERE \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) -- Ignore 'skipped'
ORDER BY
-- If there is a single 'sending' then should be 'sending', otherwise if there is a single
-- 'failed' and there is no 'sending' then it should be 'failed'
\(SQL("\(recipientState[.state]) = \(RecipientState.State.sending)")) DESC,
\(SQL("\(recipientState[.state]) = \(RecipientState.State.failed)")) DESC
) AS \(interactionStateTableLiteral)
GROUP BY \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral)
\(RecipientState.selectInteractionState(
tableLiteral: interactionStateTableLiteral,
idColumnLiteral: interactionStateInteractionIdColumnLiteral
))
) AS \(interactionStateTableLiteral) ON \(interactionStateTableLiteral).\(interactionStateInteractionIdColumnLiteral) = \(interaction[.id])
WHERE (
@ -467,6 +506,153 @@ public extension ConversationCell.ViewModel {
}
}
// MARK: - ConversationVC
public extension ConversationCell.ViewModel {
static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<ConversationCell.ViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let unreadCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadCountString)_table")
let unreadMentionCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadUnreadMentionCountString)_table")
let firstUnreadInteractionTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.threadFirstUnreadInteractionIdString)_table")
let interactionIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.id.name)
let interactionThreadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table")
let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name)
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 = 16
let request: SQLRequest<ViewModel> = """
SELECT
\(thread[.id]) AS \(ViewModel.threadIdKey),
\(thread[.variant]) AS \(ViewModel.threadVariantKey),
\(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey),
(\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey),
(
\(thread[.shouldBeVisible]) = true AND
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND
\(SQL("\(thread[.id]) != \(userPublicKey)")) AND (
-- A '!= true' check doesn't work properly so we need to be explicit
\(contact[.isApproved]) IS NULL OR
\(contact[.isApproved]) = false
)
) AS \(ViewModel.threadIsMessageRequestKey),
(
IFNULL(\(contact[.isApproved]), false) = false OR
IFNULL(\(contact[.didApproveMe]), false) = false
) AS \(ViewModel.threadRequiresApprovalKey),
\(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey),
\(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey),
\(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey),
\(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey),
\(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey),
\(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey),
(\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey),
\(unreadCountTableLiteral).\(ViewModel.threadUnreadCountKey) AS \(ViewModel.threadUnreadCountKey),
\(unreadMentionCountTableLiteral).\(ViewModel.threadUnreadMentionCountKey) AS \(ViewModel.threadUnreadMentionCountKey),
\(firstUnreadInteractionTableLiteral).\(interactionIdLiteral) AS \(ViewModel.threadFirstUnreadInteractionIdKey),
\(ViewModel.contactProfileKey).*,
\(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey),
\(closedGroupUserCountTableLiteral).\(ViewModel.closedGroupUserCountKey) AS \(ViewModel.closedGroupUserCountKey),
(\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey),
\(openGroup[.name]) AS \(ViewModel.openGroupNameKey),
\(openGroup[.server]) AS \(ViewModel.openGroupServerKey),
\(openGroup[.room]) AS \(ViewModel.openGroupRoomKey),
\(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey),
\(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey),
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
FROM \(SessionThread.self)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])
LEFT JOIN (
SELECT
\(interaction[.id]),
\(interaction[.threadId]),
MIN(\(interaction[.timestampMs]))
FROM \(Interaction.self)
WHERE (
\(interaction[.wasRead]) = false AND
\(SQL("\(interaction[.threadId]) = \(threadId)"))
)
) AS \(firstUnreadInteractionTableLiteral) ON \(firstUnreadInteractionTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id])
LEFT JOIN (
SELECT
\(interaction[.threadId]),
COUNT(*) AS \(ViewModel.threadUnreadCountKey)
FROM \(Interaction.self)
WHERE (
\(interaction[.wasRead]) = false AND
\(SQL("\(interaction[.threadId]) = \(threadId)"))
)
GROUP BY \(interaction[.threadId])
) AS \(unreadCountTableLiteral) ON \(unreadCountTableLiteral).\(interactionThreadIdLiteral) = \(thread[.id])
LEFT JOIN (
SELECT
\(interaction[.threadId]),
COUNT(*) AS \(ViewModel.threadUnreadMentionCountKey)
FROM \(Interaction.self)
WHERE (
\(interaction[.wasRead]) = false AND
\(interaction[.hasMention]) = true AND
\(SQL("\(interaction[.threadId]) = \(threadId)"))
)
GROUP BY \(interaction[.threadId])
) AS \(unreadMentionCountTableLiteral) ON \(unreadMentionCountTableLiteral).\(interactionThreadIdLiteral) = \(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 \(GroupMember.self) ON (
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND
\(groupMember[.groupId]) = \(closedGroup[.threadId]) AND
\(SQL("\(groupMember[.profileId]) = \(userPublicKey)"))
)
LEFT JOIN (
SELECT
\(groupMember[.groupId]),
COUNT(*) AS \(ViewModel.closedGroupUserCountKey)
FROM \(GroupMember.self)
WHERE (
\(SQL("\(groupMember[.groupId]) = \(threadId)")) AND
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)"))
)
GROUP BY \(groupMember[.groupId])
) AS \(closedGroupUserCountTableLiteral) ON \(SQL("\(closedGroupUserCountTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(threadId)"))
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
WHERE \(SQL("\(thread[.id]) = \(threadId)"))
GROUP BY \(thread[.id])
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeProfiles,
Profile.numberOfSelectedColumns(db)
])
return ScopeAdapter([
ViewModel.contactProfileString: adapters[1]
])
}
}
}
// MARK: - Search Queries
public extension ConversationCell.ViewModel {
@ -524,7 +710,7 @@ public extension ConversationCell.ViewModel {
let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName)
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to
/// 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
@ -651,7 +837,7 @@ public extension ConversationCell.ViewModel {
let searchTermLiteral: SQL = SQL(stringLiteral: searchTerm.lowercased())
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to
/// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to
/// parse and might throw
///
/// We use `IFNULL(rank, 100)` because the custom `Note to Self` like comparison will get a null
@ -1005,7 +1191,7 @@ public extension ConversationCell.ViewModel {
let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name)
/// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before
/// the `ViewModel.closedGroupProfileFrontKey` entry below otherwise the query will fail to
/// 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
@ -1018,6 +1204,7 @@ public extension ConversationCell.ViewModel {
\(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey),
(\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey),
\(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey),
\(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey),

View File

@ -0,0 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
public enum MessageInputTypes: Equatable {
case all
case textOnly
case none
}

View File

@ -270,7 +270,9 @@ public final class GRDBStorage {
)
}
public func addObserver(_ observer: TransactionObserver) {
public func addObserver(_ observer: TransactionObserver?) {
guard let observer: TransactionObserver = observer else { return }
dbPool.add(transactionObserver: observer)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@ public extension Database {
}
}
public func makeFTS5Pattern<T>(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible {
func makeFTS5Pattern<T>(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible {
return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName)
}
}