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:
parent
cfb8f1615a
commit
19cd9d13c5
|
@ -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 */,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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?) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 ]
|
||||
|
|
|
@ -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))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum MessageInputTypes: Equatable {
|
||||
case all
|
||||
case textOnly
|
||||
case none
|
||||
}
|
|
@ -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
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue