Merge remote-tracking branch 'upstream/dev' into fix/appium-interaction-issues
# Conflicts: # Session/Conversations/Message Cells/VisibleMessageCell.swift
This commit is contained in:
commit
e1c83dc999
|
@ -113,6 +113,7 @@
|
|||
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; };
|
||||
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; };
|
||||
7B50D64D28AC7CF80086CCEC /* silence.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7B50D64C28AC7CF80086CCEC /* silence.aiff */; };
|
||||
7B521E0829BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B521E0729BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift */; };
|
||||
7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037422834B81F000DCF35 /* ReactionContainerView.swift */; };
|
||||
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037442834BCC0000DCF35 /* ReactionView.swift */; };
|
||||
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */; };
|
||||
|
@ -651,6 +652,8 @@
|
|||
FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */; };
|
||||
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
|
||||
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; };
|
||||
FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */; };
|
||||
FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD432433299C6985008A0213 /* PendingReadReceipt.swift */; };
|
||||
FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; };
|
||||
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; };
|
||||
FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
|
@ -1179,6 +1182,7 @@
|
|||
7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = "<group>"; };
|
||||
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = "<group>"; };
|
||||
7B50D64C28AC7CF80086CCEC /* silence.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = silence.aiff; sourceTree = "<group>"; };
|
||||
7B521E0729BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _012_AddFTSIfNeeded.swift; sourceTree = "<group>"; };
|
||||
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionContainerView.swift; sourceTree = "<group>"; };
|
||||
7B7037442834BCC0000DCF35 /* ReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionView.swift; sourceTree = "<group>"; };
|
||||
7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallBanner.swift; sourceTree = "<group>"; };
|
||||
|
@ -1735,6 +1739,8 @@
|
|||
FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.swift; sourceTree = "<group>"; };
|
||||
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
|
||||
FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = "<group>"; };
|
||||
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _011_AddPendingReadReceipts.swift; sourceTree = "<group>"; };
|
||||
FD432433299C6985008A0213 /* PendingReadReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingReadReceipt.swift; sourceTree = "<group>"; };
|
||||
FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = "<group>"; };
|
||||
FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = "<group>"; };
|
||||
FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = "<group>"; };
|
||||
|
@ -3524,6 +3530,7 @@
|
|||
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */,
|
||||
FD5C7308285007920029977D /* BlindedIdLookup.swift */,
|
||||
FD09B7E6288670FD00ED0B66 /* Reaction.swift */,
|
||||
FD432433299C6985008A0213 /* PendingReadReceipt.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3541,6 +3548,8 @@
|
|||
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */,
|
||||
7BAA7B6528D2DE4700AE1489 /* _009_OpenGroupPermission.swift */,
|
||||
FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */,
|
||||
FD432431299C6933008A0213 /* _011_AddPendingReadReceipts.swift */,
|
||||
7B521E0729BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift */,
|
||||
);
|
||||
path = Migrations;
|
||||
sourceTree = "<group>";
|
||||
|
@ -5426,6 +5435,7 @@
|
|||
FD09797527FAB64300936362 /* ProfileManager.swift in Sources */,
|
||||
FD245C57285065F100B966DD /* Poller.swift in Sources */,
|
||||
FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */,
|
||||
7B521E0829BFEAFF00C3C36A /* _012_AddFTSIfNeeded.swift in Sources */,
|
||||
FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */,
|
||||
FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */,
|
||||
FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */,
|
||||
|
@ -5490,6 +5500,7 @@
|
|||
FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */,
|
||||
FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */,
|
||||
FDC4384F27B4804F00C60D73 /* Header.swift in Sources */,
|
||||
FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */,
|
||||
FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */,
|
||||
FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */,
|
||||
FD245C652850665400B966DD /* ClosedGroupControlMessage.swift in Sources */,
|
||||
|
@ -5551,6 +5562,7 @@
|
|||
FDC438C127BB4E6800C60D73 /* SMKDependencies.swift in Sources */,
|
||||
FDC4383827B3863200C60D73 /* VersionResponse.swift in Sources */,
|
||||
B806ECA126C4A7E4008BDA44 /* WebRTCSession+UI.swift in Sources */,
|
||||
FD432432299C6933008A0213 /* _011_AddPendingReadReceipts.swift in Sources */,
|
||||
7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.swift in Sources */,
|
||||
FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */,
|
||||
FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */,
|
||||
|
@ -6032,7 +6044,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
CURRENT_PROJECT_VERSION = 397;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -6057,7 +6069,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -6105,7 +6117,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
CURRENT_PROJECT_VERSION = 397;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -6135,7 +6147,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -6171,7 +6183,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
CURRENT_PROJECT_VERSION = 397;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -6194,7 +6206,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -6245,7 +6257,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
CURRENT_PROJECT_VERSION = 397;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -6273,7 +6285,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -7173,7 +7185,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
CURRENT_PROJECT_VERSION = 397;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -7212,7 +7224,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
|
@ -7245,7 +7257,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 392;
|
||||
CURRENT_PROJECT_VERSION = 397;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -7284,7 +7296,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.2.7;
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
PRODUCT_NAME = Session;
|
||||
|
|
|
@ -34,6 +34,17 @@ extension ContextMenuVC {
|
|||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(systemName: "arrow.triangle.2.circlepath"),
|
||||
title: (cellViewModel.state == .failedToSync ?
|
||||
"context_menu_resync".localized() :
|
||||
"context_menu_resend".localized()
|
||||
),
|
||||
accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message")
|
||||
) { delegate?.retry(cellViewModel) }
|
||||
}
|
||||
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
|
@ -46,14 +57,17 @@ extension ContextMenuVC {
|
|||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "copy".localized()
|
||||
title: "copy".localized(),
|
||||
accessibilityLabel: "Copy text"
|
||||
) { delegate?.copy(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "vc_conversation_settings_copy_session_id_button_title".localized()
|
||||
title: "vc_conversation_settings_copy_session_id_button_title".localized(),
|
||||
accessibilityLabel: "Copy Session ID"
|
||||
|
||||
) { delegate?.copySessionID(cellViewModel) }
|
||||
}
|
||||
|
||||
|
@ -76,14 +90,16 @@ extension ContextMenuVC {
|
|||
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_user".localized()
|
||||
title: "context_menu_ban_user".localized(),
|
||||
accessibilityLabel: "Ban user"
|
||||
) { delegate?.ban(cellViewModel) }
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_and_delete_all".localized()
|
||||
title: "context_menu_ban_and_delete_all".localized(),
|
||||
accessibilityLabel: "Ban user and delete"
|
||||
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||
}
|
||||
|
||||
|
@ -96,7 +112,8 @@ extension ContextMenuVC {
|
|||
|
||||
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
isEmojiPlus: true
|
||||
isEmojiPlus: true,
|
||||
accessibilityLabel: "Add emoji"
|
||||
) { delegate?.showFullEmojiKeyboard(cellViewModel) }
|
||||
}
|
||||
|
||||
|
@ -127,6 +144,14 @@ extension ContextMenuVC {
|
|||
case .standardOutgoing, .standardIncoming: break
|
||||
}
|
||||
|
||||
let canRetry: Bool = (
|
||||
cellViewModel.variant == .standardOutgoing && (
|
||||
cellViewModel.state == .failed || (
|
||||
cellViewModel.threadVariant == .contact &&
|
||||
cellViewModel.state == .failedToSync
|
||||
)
|
||||
)
|
||||
)
|
||||
let canReply: Bool = (
|
||||
cellViewModel.variant != .standardOutgoing || (
|
||||
cellViewModel.state != .failed &&
|
||||
|
@ -182,6 +207,7 @@ extension ContextMenuVC {
|
|||
}()
|
||||
|
||||
let generatedActions: [Action] = [
|
||||
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
|
||||
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
||||
|
@ -203,6 +229,7 @@ extension ContextMenuVC {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol ContextMenuActionDelegate {
|
||||
func retry(_ cellViewModel: MessageViewModel)
|
||||
func reply(_ cellViewModel: MessageViewModel)
|
||||
func copy(_ cellViewModel: MessageViewModel)
|
||||
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||
|
|
|
@ -7,6 +7,7 @@ import PhotosUI
|
|||
import Sodium
|
||||
import PromiseKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
@ -200,6 +201,30 @@ extension ConversationVC:
|
|||
// MARK: - ExpandingAttachmentsButtonDelegate
|
||||
|
||||
func handleGIFButtonTapped() {
|
||||
guard Storage.shared[.isGiphyEnabled] else {
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "GIPHY_PERMISSION_TITLE".localized(),
|
||||
explanation: "GIPHY_PERMISSION_MESSAGE".localized(),
|
||||
confirmTitle: "continue_2".localized()
|
||||
) { [weak self] _ in
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
db[.isGiphyEnabled] = true
|
||||
},
|
||||
completion: { _, _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.handleGIFButtonTapped()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
present(modal, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let gifVC = GifPickerViewController()
|
||||
gifVC.delegate = self
|
||||
|
||||
|
@ -436,10 +461,17 @@ extension ConversationVC:
|
|||
.filter(id: threadId)
|
||||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
|
||||
let authorId: String = {
|
||||
if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey {
|
||||
return blindedId
|
||||
}
|
||||
return self?.viewModel.threadData.currentUserPublicKey ?? getUserHexEncodedPublicKey(db)
|
||||
}()
|
||||
|
||||
// Create the interaction
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
authorId: authorId,
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
|
@ -829,7 +861,7 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else {
|
||||
guard cellViewModel.variant != .standardOutgoing || (cellViewModel.state != .failed && cellViewModel.state != .failedToSync) else {
|
||||
// Show the failed message sheet
|
||||
showFailedMessageSheet(for: cellViewModel)
|
||||
return
|
||||
|
@ -1451,30 +1483,34 @@ extension ConversationVC:
|
|||
// MARK: --action handling
|
||||
|
||||
func showFailedMessageSheet(for cellViewModel: MessageViewModel) {
|
||||
let sheet = UIAlertController(title: cellViewModel.mostRecentFailureText, message: nil, preferredStyle: .actionSheet)
|
||||
sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||
sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}))
|
||||
sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in
|
||||
Storage.shared.writeAsync { [weak self] db in
|
||||
guard
|
||||
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,
|
||||
interaction: interaction,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}))
|
||||
let sheet = UIAlertController(
|
||||
title: (cellViewModel.state == .failedToSync ?
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE".localized() :
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE".localized()
|
||||
),
|
||||
message: cellViewModel.mostRecentFailureText,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
sheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
if cellViewModel.state != .failedToSync {
|
||||
sheet.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive, handler: { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
sheet.addAction(UIAlertAction(
|
||||
title: (cellViewModel.state == .failedToSync ?
|
||||
"context_menu_resync".localized() :
|
||||
"context_menu_resend".localized()
|
||||
),
|
||||
style: .default,
|
||||
handler: { [weak self] _ in self?.retry(cellViewModel) }
|
||||
))
|
||||
|
||||
// HACK: Extracting this info from the error string is pretty dodgy
|
||||
let prefix: String = "HTTP request failed at destination (Service node "
|
||||
|
@ -1490,6 +1526,7 @@ extension ConversationVC:
|
|||
}
|
||||
}
|
||||
|
||||
Modal.setupForIPadIfNeeded(sheet, targetView: self.view)
|
||||
present(sheet, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -1558,6 +1595,52 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
// MARK: - ContextMenuActionDelegate
|
||||
|
||||
func retry(_ cellViewModel: MessageViewModel) {
|
||||
Storage.shared.writeAsync { [weak self] db in
|
||||
guard
|
||||
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 }
|
||||
|
||||
if
|
||||
let quote = try? interaction.quote.fetchOne(db),
|
||||
let quotedAttachment = try? quote.attachment.fetchOne(db),
|
||||
quotedAttachment.isVisualMedia,
|
||||
quotedAttachment.downloadUrl == Attachment.nonMediaQuoteFileId,
|
||||
let quotedInteraction = try? quote.originalInteraction.fetchOne(db)
|
||||
{
|
||||
let attachment: Attachment? = {
|
||||
if let attachment = try? quotedInteraction.attachments.fetchOne(db) {
|
||||
return attachment
|
||||
}
|
||||
if
|
||||
let linkPreview = try? quotedInteraction.linkPreview.fetchOne(db),
|
||||
let linkPreviewAttachment = try? linkPreview.attachment.fetchOne(db)
|
||||
{
|
||||
return linkPreviewAttachment
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
try quote.with(
|
||||
attachmentId: attachment?.cloneAsQuoteThumbnail()?.inserted(db).id
|
||||
).update(db)
|
||||
}
|
||||
|
||||
// Remove message sending jobs for the same interaction in database
|
||||
// Prevent the same message being sent twice
|
||||
try Job.filter(Job.Columns.interactionId == interaction.id).deleteAll(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
in: thread,
|
||||
isSyncMessage: (cellViewModel.state == .failedToSync)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func reply(_ cellViewModel: MessageViewModel) {
|
||||
let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending(
|
||||
|
@ -1808,7 +1891,11 @@ extension ConversationVC:
|
|||
}
|
||||
|
||||
let actionSheet: UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
actionSheet.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in
|
||||
actionSheet.addAction(UIAlertAction(
|
||||
title: "delete_message_for_me".localized(),
|
||||
accessibilityIdentifier: "Delete for me",
|
||||
style: .destructive
|
||||
) { [weak self] _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
|
@ -1827,10 +1914,17 @@ extension ConversationVC:
|
|||
})
|
||||
|
||||
actionSheet.addAction(UIAlertAction(
|
||||
title: (cellViewModel.threadVariant == .closedGroup ?
|
||||
"delete_message_for_everyone".localized() :
|
||||
String(format: "delete_message_for_me_and_recipient".localized(), threadName)
|
||||
),
|
||||
title: {
|
||||
switch cellViewModel.threadVariant {
|
||||
case .closedGroup: return "delete_message_for_everyone".localized()
|
||||
default:
|
||||
return (cellViewModel.threadId == userPublicKey ?
|
||||
"delete_message_for_me_and_my_devices".localized() :
|
||||
String(format: "delete_message_for_me_and_recipient".localized(), threadName)
|
||||
)
|
||||
}
|
||||
}(),
|
||||
accessibilityIdentifier: "Delete for everyone",
|
||||
style: .destructive
|
||||
) { [weak self] _ in
|
||||
deleteRemotely(
|
||||
|
@ -2317,6 +2411,7 @@ extension ConversationVC {
|
|||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -2364,6 +2459,7 @@ extension ConversationVC {
|
|||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,7 +82,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// distinct stutter)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: threadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
userPublicKey: getUserHexEncodedPublicKey(),
|
||||
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
|
@ -172,7 +176,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String, blindedPublicKey: String?) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
return PagedDatabaseObserver(
|
||||
pagedTable: Interaction.self,
|
||||
pageSize: ConversationViewModel.pageSize,
|
||||
|
@ -206,7 +210,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: RecipientState.self,
|
||||
columns: [.state, .mostRecentFailureText],
|
||||
columns: [.state, .readTimestampMs, .mostRecentFailureText],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||
|
@ -220,6 +224,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
orderSQL: MessageViewModel.orderSQL,
|
||||
dataQuery: MessageViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
blindedPublicKey: blindedPublicKey,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
groupSQL: MessageViewModel.groupSQL
|
||||
),
|
||||
|
@ -304,6 +309,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
index == (sortedData.count - 1) &&
|
||||
pageInfo.pageOffset == 0
|
||||
),
|
||||
isLastOutgoing: (
|
||||
cellViewModel.id == sortedData
|
||||
.filter {
|
||||
$0.authorId == threadData.currentUserPublicKey ||
|
||||
$0.authorId == threadData.currentUserBlindedPublicKey
|
||||
}
|
||||
.last?
|
||||
.id
|
||||
),
|
||||
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -449,7 +463,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: updatedThreadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
userPublicKey: getUserHexEncodedPublicKey(),
|
||||
blindedPublicKey: nil
|
||||
)
|
||||
|
||||
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||
|
|
|
@ -26,28 +26,31 @@ final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate {
|
|||
// MARK: UI Components
|
||||
lazy var gifButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = "GIF button"
|
||||
|
||||
result.accessibilityIdentifier = "GIF button"
|
||||
result.isAccessibilityElement = true
|
||||
return result
|
||||
}()
|
||||
lazy var gifButtonContainer = container(for: gifButton)
|
||||
lazy var documentButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = "Documents folder"
|
||||
result.accessibilityIdentifier = "Documents folder"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var documentButtonContainer = container(for: documentButton)
|
||||
lazy var libraryButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = "Images folder"
|
||||
result.accessibilityIdentifier = "Images folder"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
lazy var libraryButtonContainer = container(for: libraryButton)
|
||||
lazy var cameraButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityLabel = "Select camera button"
|
||||
result.accessibilityIdentifier = "Select camera button"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||
// MARK: - Variables
|
||||
|
@ -53,6 +54,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
private lazy var attachmentsButton: ExpandingAttachmentsButton = {
|
||||
let result = ExpandingAttachmentsButton(delegate: delegate)
|
||||
result.accessibilityLabel = "Attachments button"
|
||||
result.accessibilityIdentifier = "Attachments button"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
|
@ -61,6 +63,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
private lazy var voiceMessageButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
|
||||
result.accessibilityLabel = "New voice message"
|
||||
result.accessibilityIdentifier = "New voice message"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
|
@ -69,6 +72,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
private lazy var sendButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
|
||||
result.isHidden = true
|
||||
result.accessibilityIdentifier = "Send message button"
|
||||
result.accessibilityLabel = "Send message button"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
|
@ -86,6 +90,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
private lazy var mentionsViewContainer: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.accessibilityLabel = "Mentions list"
|
||||
result.accessibilityIdentifier = "Mentions list"
|
||||
result.isAccessibilityElement = true
|
||||
result.alpha = 0
|
||||
|
||||
|
@ -118,6 +123,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment)
|
||||
let result = InputTextView(delegate: self, maxWidth: maxWidth)
|
||||
result.accessibilityLabel = "Message input box"
|
||||
result.accessibilityIdentifier = "Message input box"
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
|
@ -404,8 +410,11 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {
|
||||
guard inputViewButton == voiceMessageButton else { return }
|
||||
|
||||
delegate?.startVoiceMessageRecording()
|
||||
// Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording'
|
||||
// because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to
|
||||
// end up in a state with the input content hidden
|
||||
showVoiceMessageUI()
|
||||
delegate?.startVoiceMessageRecording()
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {
|
||||
|
@ -466,9 +475,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
UIView.animate(withDuration: 0.25, animations: {
|
||||
allOtherViews.forEach { $0.alpha = 1 }
|
||||
self.voiceMessageRecordingView?.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.voiceMessageRecordingView?.removeFromSuperview()
|
||||
self.voiceMessageRecordingView = nil
|
||||
}, completion: { [weak self] _ in
|
||||
self?.voiceMessageRecordingView?.removeFromSuperview()
|
||||
self?.voiceMessageRecordingView = nil
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ final class DeletedMessageView: UIView {
|
|||
|
||||
init(textColor: ThemeValue) {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
accessibilityIdentifier = "Deleted message"
|
||||
isAccessibilityElement = true
|
||||
setUpViewHierarchy(textColor: textColor)
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,8 @@ final class MediaPlaceholderView: UIView {
|
|||
|
||||
init(cellViewModel: MessageViewModel, textColor: ThemeValue) {
|
||||
super.init(frame: CGRect.zero)
|
||||
self.accessibilityLabel = "Untrusted attachment message"
|
||||
self.accessibilityIdentifier = "Untrusted attachment message"
|
||||
self.isAccessibilityElement = true
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
}
|
||||
|
||||
|
|
|
@ -144,7 +144,7 @@ final class QuoteView: UIView {
|
|||
.messageBubble_outgoingText :
|
||||
.messageBubble_incomingText
|
||||
)
|
||||
case .draft: return .messageBubble_outgoingText
|
||||
case .draft: return .textPrimary
|
||||
}
|
||||
}()
|
||||
imageView.contentMode = .center
|
||||
|
@ -156,10 +156,7 @@ final class QuoteView: UIView {
|
|||
mainStackView.addArrangedSubview(imageView)
|
||||
|
||||
if (body ?? "").isEmpty {
|
||||
body = (attachment.isImage ?
|
||||
"Image" :
|
||||
(isAudio ? "Audio" : "Document")
|
||||
)
|
||||
body = attachment.shortDescription
|
||||
}
|
||||
|
||||
// Generate the thumbnail if needed
|
||||
|
@ -223,10 +220,10 @@ final class QuoteView: UIView {
|
|||
}
|
||||
.defaulting(
|
||||
to: attachment.map {
|
||||
NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document")
|
||||
NSAttributedString(string: $0.shortDescription, attributes: [ .foregroundColor: textColor ])
|
||||
}
|
||||
)
|
||||
.defaulting(to: NSAttributedString(string: "Document"))
|
||||
.defaulting(to: NSAttributedString(string: "QUOTED_MESSAGE_NOT_FOUND".localized(), attributes: [ .foregroundColor: textColor ]))
|
||||
}
|
||||
|
||||
// Label stack view
|
||||
|
|
|
@ -98,7 +98,8 @@ public final class VoiceMessageView: UIView {
|
|||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
self.accessibilityIdentifier = "Voice message"
|
||||
self.isAccessibilityElement = true
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
|
|
@ -151,15 +151,17 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
internal lazy var messageStatusLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.accessibilityIdentifier = "Message sent status"
|
||||
result.accessibilityLabel = "Message sent status"
|
||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
result.themeTextColor = .messageBubble_deliveryStatus
|
||||
result.themeTextColor = .messageBubble_deliveryStatus
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
internal lazy var messageStatusImageView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
result.accessibilityIdentifier = "Message sent status tick"
|
||||
result.accessibilityLabel = "Message sent status tick"
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.themeTintColor = .messageBubble_deliveryStatus
|
||||
|
@ -426,13 +428,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
messageStatusLabel.text = statusText
|
||||
messageStatusLabel.themeTextColor = tintColor
|
||||
messageStatusImageView.image = image
|
||||
messageStatusLabel.accessibilityIdentifier = "Message sent status: \(statusText ?? "invalid")"
|
||||
messageStatusImageView.themeTintColor = tintColor
|
||||
messageStatusContainerView.isHidden = (
|
||||
cellViewModel.variant != .standardOutgoing ||
|
||||
cellViewModel.variant == .infoCall ||
|
||||
(
|
||||
cellViewModel.state == .sent &&
|
||||
!cellViewModel.isLast
|
||||
!cellViewModel.isLastOutgoing
|
||||
)
|
||||
)
|
||||
messageStatusLabelPaddingView.isHidden = (
|
||||
|
@ -471,6 +474,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
subview.removeFromSuperview()
|
||||
}
|
||||
albumView = nil
|
||||
albumView = nil
|
||||
bodyTappableLabel = nil
|
||||
|
||||
// Handle the deleted state first (it's much simpler than the others)
|
||||
|
@ -542,7 +546,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
let quoteView: QuoteView = QuoteView(
|
||||
for: .regular,
|
||||
authorId: quote.authorId,
|
||||
quotedText: quote.body ?? "QUOTED_MESSAGE_NOT_FOUND".localized(),
|
||||
quotedText: quote.body,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
||||
|
@ -777,9 +781,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
// only needs to custom handle touches for interacting with links so we check to see if it contains
|
||||
// links before forwarding touches to it
|
||||
if let bodyTappableLabel: TappableLabel = bodyTappableLabel, bodyTappableLabel.containsLinks {
|
||||
let btIngetBodyTappableLabelCoordinates: CGPoint = convert(point, to: bodyTappableLabel)
|
||||
let bodyTappableLabelLocalTapCoordinate: CGPoint = convert(point, to: bodyTappableLabel)
|
||||
|
||||
if bodyTappableLabel.bounds.contains(btIngetBodyTappableLabelCoordinates) {
|
||||
if bodyTappableLabel.bounds.contains(bodyTappableLabelLocalTapCoordinate) {
|
||||
return bodyTappableLabel
|
||||
}
|
||||
}
|
||||
|
|
|
@ -206,6 +206,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
threadVariant == .closedGroup &&
|
||||
threadViewModel.currentUserIsClosedGroupMember == true
|
||||
)
|
||||
let currentUserIsClosedGroupAdmin: Bool = (
|
||||
threadVariant == .closedGroup &&
|
||||
threadViewModel.currentUserIsClosedGroupAdmin == true
|
||||
)
|
||||
|
||||
return [
|
||||
SectionModel(
|
||||
|
@ -391,7 +395,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
accessibilityLabel: "Leave group",
|
||||
confirmationInfo: ConfirmationModal.Info(
|
||||
title: "CONFIRM_LEAVE_GROUP_TITLE".localized(),
|
||||
explanation: (currentUserIsClosedGroupMember ?
|
||||
explanation: (currentUserIsClosedGroupAdmin ?
|
||||
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone." :
|
||||
"CONFIRM_LEAVE_GROUP_DESCRIPTION".localized()
|
||||
),
|
||||
|
@ -678,9 +682,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
nil
|
||||
),
|
||||
accessibilityLabel: oldBlockedState == false ? "User blocked" : "Confirm unblock",
|
||||
accessibilityId: "OK",
|
||||
accessibilityId: "Test_name",
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelAccessibilityLabel: "OK",
|
||||
cancelAccessibilityLabel: "OK_BUTTON",
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
|
|
|
@ -449,6 +449,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -470,6 +472,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
@ -501,6 +505,8 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -218,6 +218,7 @@ class GifPickerCell: UICollectionViewCell {
|
|||
return
|
||||
}
|
||||
imageView.image = image
|
||||
imageView.accessibilityIdentifier = "gif cell"
|
||||
self.themeBackgroundColor = nil
|
||||
|
||||
if self.isCellSelected {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Reachability
|
||||
import SignalUtilitiesKit
|
||||
import PromiseKit
|
||||
|
|
|
@ -6,6 +6,8 @@ import AFNetworking
|
|||
import Foundation
|
||||
import PromiseKit
|
||||
import CoreServices
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// There's no UTI type for webp!
|
||||
enum GiphyFormat {
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -600,3 +600,12 @@
|
|||
"MESSAGE_DELIVERY_STATUS_SENT" = "Sent";
|
||||
"MESSAGE_DELIVERY_STATUS_READ" = "Read";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send";
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync";
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing";
|
||||
"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message";
|
||||
"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices";
|
||||
"delete_message_for_me_and_my_devices" = "Delete from all of my devices";
|
||||
"context_menu_resend" = "Resend";
|
||||
"context_menu_resync" = "Resync";
|
||||
"GIPHY_PERMISSION_TITLE" = "Search GIFs?";
|
||||
"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs.";
|
||||
|
|
|
@ -20,8 +20,11 @@ public enum SyncPushTokensJob: JobExecutor {
|
|||
failure: @escaping (Job, Error?, Bool) -> (),
|
||||
deferred: @escaping (Job) -> ()
|
||||
) {
|
||||
// Don't run when inactive or not in main app
|
||||
guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else {
|
||||
// Don't run when inactive or not in main app or if the user doesn't exist yet
|
||||
guard
|
||||
(UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false),
|
||||
Identity.userExists()
|
||||
else {
|
||||
deferred(job) // Don't need to do anything if it's not the main app
|
||||
return
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
import Foundation
|
||||
import UserNotifications
|
||||
import PromiseKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
class UserNotificationConfig {
|
||||
|
|
|
@ -172,7 +172,7 @@ final class RegisterVC : BaseVC {
|
|||
private func updatePublicKeyLabel() {
|
||||
let hexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
|
||||
publicKeyLabel.accessibilityLabel = hexEncodedPublicKey
|
||||
publicKeyLabel.accessibilityIdentifier = "Session ID generated"
|
||||
publicKeyLabel.accessibilityIdentifier = "Session ID"
|
||||
publicKeyLabel.isAccessibilityElement = true
|
||||
let characterCount = hexEncodedPublicKey.count
|
||||
var count = 0
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import Reachability
|
||||
import SessionUIKit
|
||||
import SessionSnodeKit
|
||||
|
||||
final class PathStatusView: UIView {
|
||||
enum Size {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Reachability
|
||||
import NVActivityIndicatorView
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
import SessionSnodeKit
|
||||
|
||||
final class PathVC: BaseVC {
|
||||
public static let dotSize: CGFloat = 8
|
||||
|
@ -239,6 +241,7 @@ private final class LineView: UIView {
|
|||
private var dotViewWidthConstraint: NSLayoutConstraint!
|
||||
private var dotViewHeightConstraint: NSLayoutConstraint!
|
||||
private var dotViewAnimationTimer: Timer!
|
||||
private let reachability: Reachability = Reachability.forInternetConnection()
|
||||
|
||||
enum Location {
|
||||
case top, middle, bottom
|
||||
|
@ -273,6 +276,7 @@ private final class LineView: UIView {
|
|||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
registerObservers()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -283,6 +287,12 @@ private final class LineView: UIView {
|
|||
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
dotViewAnimationTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let lineView = UIView()
|
||||
lineView.set(.width, to: Values.separatorThickness)
|
||||
|
@ -315,10 +325,33 @@ private final class LineView: UIView {
|
|||
self?.animate()
|
||||
}
|
||||
}
|
||||
|
||||
switch (reachability.isReachable(), OnionRequestAPI.paths.isEmpty) {
|
||||
case (false, _): setStatus(to: .error)
|
||||
case (true, true): setStatus(to: .connecting)
|
||||
case (true, false): setStatus(to: .connected)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
dotViewAnimationTimer?.invalidate()
|
||||
|
||||
private func registerObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleBuildingPathsNotification),
|
||||
name: .buildingPaths,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handlePathsBuiltNotification),
|
||||
name: .pathsBuilt,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(reachabilityChanged),
|
||||
name: .reachabilityChanged,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func animate() {
|
||||
|
@ -340,4 +373,41 @@ private final class LineView: UIView {
|
|||
self?.dotView.transform = CGAffineTransform.scale(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func setStatus(to status: PathStatusView.Status) {
|
||||
dotView.themeBackgroundColor = status.themeColor
|
||||
dotView.layer.themeShadowColor = status.themeColor
|
||||
}
|
||||
|
||||
@objc private func handleBuildingPathsNotification() {
|
||||
guard reachability.isReachable() else {
|
||||
setStatus(to: .error)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(to: .connecting)
|
||||
}
|
||||
|
||||
@objc private func handlePathsBuiltNotification() {
|
||||
guard reachability.isReachable() else {
|
||||
setStatus(to: .error)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(to: .connected)
|
||||
}
|
||||
|
||||
@objc private func reachabilityChanged() {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in self?.reachabilityChanged() }
|
||||
return
|
||||
}
|
||||
|
||||
guard reachability.isReachable() else {
|
||||
setStatus(to: .error)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(to: (!OnionRequestAPI.paths.isEmpty ? .connected : .connecting))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
|||
import AVFoundation
|
||||
import Curve25519Kit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate {
|
||||
|
|
|
@ -63,6 +63,7 @@ class SessionAvatarCell: UITableViewCell {
|
|||
fileprivate let displayNameContainer: UIView = {
|
||||
let view: UIView = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.accessibilityIdentifier = "Username"
|
||||
view.accessibilityLabel = "Username"
|
||||
view.isAccessibilityElement = true
|
||||
|
||||
|
@ -266,6 +267,7 @@ class SessionAvatarCell: UITableViewCell {
|
|||
}
|
||||
let completion: (Bool) -> Void = { [weak self] complete in
|
||||
self?.displayNameTextField.text = self?.originalInputValue
|
||||
self?.displayNameContainer.accessibilityLabel = self?.displayNameLabel.text
|
||||
}
|
||||
|
||||
if animated {
|
||||
|
|
|
@ -24,7 +24,9 @@ public enum SNMessagingKit { // Just to make the external API nice
|
|||
[
|
||||
_008_EmojiReacts.self,
|
||||
_009_OpenGroupPermission.self,
|
||||
_010_AddThreadIdToFTS.self
|
||||
_010_AddThreadIdToFTS.self,
|
||||
_011_AddPendingReadReceipts.self,
|
||||
_012_AddFTSIfNeeded.self
|
||||
]
|
||||
]
|
||||
)
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// This migration adds a table to track pending read receipts (it's possible to receive a read receipt message before getting the original
|
||||
/// message due to how one-to-one conversations work, by storing pending read receipts we should be able to prevent this case)
|
||||
enum _011_AddPendingReadReceipts: Migration {
|
||||
static let target: TargetMigrations.Identifier = .messagingKit
|
||||
static let identifier: String = "AddPendingReadReceipts"
|
||||
static let needsConfigSync: Bool = false
|
||||
static let minExpectedRunDuration: TimeInterval = 0.1
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
try db.create(table: PendingReadReceipt.self) { t in
|
||||
t.column(.threadId, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
|
||||
t.column(.interactionTimestampMs, .integer)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.readTimestampMs, .integer)
|
||||
.notNull()
|
||||
t.column(.serverExpirationTimestamp, .double)
|
||||
.notNull()
|
||||
|
||||
t.primaryKey([.threadId, .interactionTimestampMs])
|
||||
}
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// This migration adds the FTS table back for internal test users whose FTS table was removed unintentionally
|
||||
enum _012_AddFTSIfNeeded: Migration {
|
||||
static let target: TargetMigrations.Identifier = .messagingKit
|
||||
static let identifier: String = "AddFTSIfNeeded"
|
||||
static let needsConfigSync: Bool = false
|
||||
static let minExpectedRunDuration: TimeInterval = 0.1
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
// Fix an issue that the fullTextSearchTable was dropped unintentionally and global search won't work.
|
||||
// This issue only happens to internal test users.
|
||||
if try db.tableExists(Interaction.fullTextSearchTableName) == false {
|
||||
try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in
|
||||
t.synchronize(withTable: Interaction.databaseTableName)
|
||||
t.tokenizer = _001_InitialSetupMigration.fullTextSearchTokenizer
|
||||
|
||||
t.column(Interaction.Columns.body.name)
|
||||
t.column(Interaction.Columns.threadId.name)
|
||||
}
|
||||
}
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
}
|
|
@ -787,6 +787,13 @@ extension Attachment {
|
|||
public var isText: Bool { MIMETypeUtil.isText(contentType) }
|
||||
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
|
||||
|
||||
public var shortDescription: String {
|
||||
if isImage { return "Image" }
|
||||
if isAudio { return "Audio" }
|
||||
if isVideo { return "Video" }
|
||||
return "Document"
|
||||
}
|
||||
|
||||
public func readDataFromFile() throws -> Data? {
|
||||
guard let filePath: String = self.originalFilePath else {
|
||||
return nil
|
||||
|
@ -876,9 +883,10 @@ extension Attachment {
|
|||
let cloneId: String = UUID().uuidString
|
||||
let thumbnailName: String = "quoted-thumbnail-\(sourceFilename ?? "null")"
|
||||
|
||||
guard self.isVisualMedia else { return nil }
|
||||
|
||||
guard
|
||||
self.isValid,
|
||||
self.isVisualMedia,
|
||||
let thumbnailPath: String = Attachment.originalFilePath(
|
||||
id: cloneId,
|
||||
mimeType: OWSMimeTypeImageJpeg,
|
||||
|
|
|
@ -450,26 +450,33 @@ public extension Interaction {
|
|||
trySendReadReceipt: Bool
|
||||
) throws {
|
||||
guard let interactionId: Int64 = interactionId else { return }
|
||||
|
||||
struct InteractionReadInfo: Decodable, FetchableRecord {
|
||||
let id: Int64
|
||||
let variant: Interaction.Variant
|
||||
let timestampMs: Int64
|
||||
let wasRead: Bool
|
||||
}
|
||||
|
||||
// Once all of the below is done schedule the jobs
|
||||
func scheduleJobs(interactionIds: [Int64]) {
|
||||
func scheduleJobs(interactionInfo: [InteractionReadInfo]) {
|
||||
// Add the 'DisappearingMessagesJob' if needed - this will update any expiring
|
||||
// messages `expiresStartedAtMs` values
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
job: DisappearingMessagesJob.updateNextRunIfNeeded(
|
||||
db,
|
||||
interactionIds: interactionIds,
|
||||
interactionIds: interactionInfo.map { $0.id },
|
||||
startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs())
|
||||
)
|
||||
)
|
||||
|
||||
// Clear out any notifications for the interactions we mark as read
|
||||
Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications(
|
||||
identifiers: interactionIds
|
||||
.map { interactionId in
|
||||
identifiers: interactionInfo
|
||||
.map { interactionInfo in
|
||||
Interaction.notificationIdentifier(
|
||||
for: interactionId,
|
||||
for: interactionInfo.id,
|
||||
threadId: threadId,
|
||||
shouldGroupMessagesForThread: false
|
||||
)
|
||||
|
@ -482,43 +489,54 @@ public extension Interaction {
|
|||
)
|
||||
|
||||
// If we want to send read receipts and it's a contact thread then try to add the
|
||||
// 'SendReadReceiptsJob'
|
||||
// 'SendReadReceiptsJob' for and unread messages that weren't outgoing
|
||||
if trySendReadReceipt && threadVariant == .contact {
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
job: SendReadReceiptsJob.createOrUpdateIfNeeded(
|
||||
db,
|
||||
threadId: threadId,
|
||||
interactionIds: interactionIds
|
||||
interactionIds: interactionInfo
|
||||
.filter { !$0.wasRead && $0.variant != .standardOutgoing }
|
||||
.map { $0.id }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If we aren't including older interactions then update and save the current one
|
||||
struct InteractionReadInfo: Decodable, FetchableRecord {
|
||||
let timestampMs: Int64
|
||||
let wasRead: Bool
|
||||
}
|
||||
|
||||
// Since there is no guarantee on the order messages are inserted into the database
|
||||
// fetch the timestamp for the interaction and set everything before that as read
|
||||
let maybeInteractionInfo: InteractionReadInfo? = try Interaction
|
||||
.select(.timestampMs, .wasRead)
|
||||
.select(.id, .variant, .timestampMs, .wasRead)
|
||||
.filter(id: interactionId)
|
||||
.asRequest(of: InteractionReadInfo.self)
|
||||
.fetchOne(db)
|
||||
|
||||
// If we aren't including older interactions then update and save the current one
|
||||
guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else {
|
||||
// Only mark as read and trigger the subsequent jobs if the interaction is
|
||||
// actually not read (no point updating and triggering db changes otherwise)
|
||||
guard maybeInteractionInfo?.wasRead == false else { return }
|
||||
guard
|
||||
maybeInteractionInfo?.wasRead == false,
|
||||
let variant: Variant = try Interaction
|
||||
.filter(id: interactionId)
|
||||
.select(.variant)
|
||||
.asRequest(of: Variant.self)
|
||||
.fetchOne(db)
|
||||
else { return }
|
||||
|
||||
_ = try Interaction
|
||||
.filter(id: interactionId)
|
||||
.updateAll(db, Columns.wasRead.set(to: true))
|
||||
|
||||
scheduleJobs(interactionIds: [interactionId])
|
||||
scheduleJobs(interactionInfo: [
|
||||
InteractionReadInfo(
|
||||
id: interactionId,
|
||||
variant: variant,
|
||||
timestampMs: 0,
|
||||
wasRead: false
|
||||
)
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -526,16 +544,16 @@ public extension Interaction {
|
|||
.filter(Interaction.Columns.threadId == threadId)
|
||||
.filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs)
|
||||
.filter(Interaction.Columns.wasRead == false)
|
||||
let interactionIdsToMarkAsRead: [Int64] = try interactionQuery
|
||||
.select(.id)
|
||||
.asRequest(of: Int64.self)
|
||||
let interactionInfoToMarkAsRead: [InteractionReadInfo] = try interactionQuery
|
||||
.select(.id, .variant, .timestampMs, .wasRead)
|
||||
.asRequest(of: InteractionReadInfo.self)
|
||||
.fetchAll(db)
|
||||
|
||||
// If there are no other interactions to mark as read then just schedule the jobs
|
||||
// for this interaction (need to ensure the disapeparing messages run for sync'ed
|
||||
// outgoing messages which will always have 'wasRead' as false)
|
||||
guard !interactionIdsToMarkAsRead.isEmpty else {
|
||||
scheduleJobs(interactionIds: [interactionId])
|
||||
guard !interactionInfoToMarkAsRead.isEmpty else {
|
||||
scheduleJobs(interactionInfo: [interactionInfo])
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -543,27 +561,71 @@ public extension Interaction {
|
|||
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
|
||||
|
||||
// Retrieve the interaction ids we want to update
|
||||
scheduleJobs(interactionIds: interactionIdsToMarkAsRead)
|
||||
scheduleJobs(interactionInfo: interactionInfoToMarkAsRead)
|
||||
}
|
||||
|
||||
/// This method flags sent messages as read for the specified recipients
|
||||
///
|
||||
/// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method)
|
||||
static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws {
|
||||
guard db[.areReadReceiptsEnabled] == true else { return }
|
||||
@discardableResult static func markAsRead(
|
||||
_ db: Database,
|
||||
recipientId: String,
|
||||
timestampMsValues: [Int64],
|
||||
readTimestampMs: Int64
|
||||
) throws -> Set<Int64> {
|
||||
guard db[.areReadReceiptsEnabled] == true else { return [] }
|
||||
|
||||
try RecipientState
|
||||
// Update the read state
|
||||
let rowIds: [Int64] = try RecipientState
|
||||
.select(Column.rowID)
|
||||
.filter(RecipientState.Columns.recipientId == recipientId)
|
||||
.joining(
|
||||
required: RecipientState.interaction
|
||||
.filter(Columns.variant == Variant.standardOutgoing)
|
||||
.filter(timestampMsValues.contains(Columns.timestampMs))
|
||||
.filter(Columns.variant == Variant.standardOutgoing)
|
||||
)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchAll(db)
|
||||
|
||||
// If there were no 'rowIds' then no need to run the below queries, all of the timestamps
|
||||
// and for pending read receipts
|
||||
guard !rowIds.isEmpty else { return timestampMsValues.asSet() }
|
||||
|
||||
// Update the 'readTimestampMs' if it doesn't match (need to do this to prevent
|
||||
// the UI update from being triggered for a redundant update)
|
||||
try RecipientState
|
||||
.filter(rowIds.contains(Column.rowID))
|
||||
.filter(RecipientState.Columns.readTimestampMs == nil)
|
||||
.updateAll(
|
||||
db,
|
||||
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs)
|
||||
)
|
||||
|
||||
// If the message still appeared to be sending then mark it as sent
|
||||
try RecipientState
|
||||
.filter(rowIds.contains(Column.rowID))
|
||||
.filter(RecipientState.Columns.state == RecipientState.State.sending)
|
||||
.updateAll(
|
||||
db,
|
||||
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs),
|
||||
RecipientState.Columns.state.set(to: RecipientState.State.sent)
|
||||
)
|
||||
|
||||
// Retrieve the set of timestamps which were updated
|
||||
let timestampsUpdated: Set<Int64> = try Interaction
|
||||
.select(Columns.timestampMs)
|
||||
.filter(timestampMsValues.contains(Columns.timestampMs))
|
||||
.filter(Columns.variant == Variant.standardOutgoing)
|
||||
.joining(
|
||||
required: Interaction.recipientStates
|
||||
.filter(rowIds.contains(Column.rowID))
|
||||
)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchSet(db)
|
||||
|
||||
// Return the timestamps which weren't updated
|
||||
return timestampMsValues
|
||||
.asSet()
|
||||
.subtracting(timestampsUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public struct PendingReadReceipt: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "pendingReadReceipt" }
|
||||
public static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case threadId
|
||||
case interactionTimestampMs
|
||||
case readTimestampMs
|
||||
case serverExpirationTimestamp
|
||||
}
|
||||
|
||||
/// The id for the thread this ReadReceipt belongs to
|
||||
public let threadId: String
|
||||
|
||||
/// The timestamp in milliseconds since epoch for the interaction this read receipt relates to
|
||||
public let interactionTimestampMs: Int64
|
||||
|
||||
/// The timestamp in milliseconds since epoch that the interaction this read receipt relates to was read
|
||||
public let readTimestampMs: Int64
|
||||
|
||||
/// The timestamp for when this message will expire on the server (will be used for garbage collection)
|
||||
public let serverExpirationTimestamp: TimeInterval
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
threadId: String,
|
||||
interactionTimestampMs: Int64,
|
||||
readTimestampMs: Int64,
|
||||
serverExpirationTimestamp: TimeInterval
|
||||
) {
|
||||
self.threadId = threadId
|
||||
self.interactionTimestampMs = interactionTimestampMs
|
||||
self.readTimestampMs = readTimestampMs
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
}
|
||||
}
|
|
@ -76,6 +76,26 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Mutation
|
||||
|
||||
public extension Quote {
|
||||
func with(
|
||||
interactionId: Int64? = nil,
|
||||
authorId: String? = nil,
|
||||
timestampMs: Int64? = nil,
|
||||
body: String? = nil,
|
||||
attachmentId: String? = nil
|
||||
) -> Quote {
|
||||
return Quote(
|
||||
interactionId: interactionId ?? self.interactionId,
|
||||
authorId: authorId ?? self.authorId,
|
||||
timestampMs: timestampMs ?? self.timestampMs,
|
||||
body: body ?? self.body,
|
||||
attachmentId: attachmentId ?? self.attachmentId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protobuf
|
||||
|
||||
public extension Quote {
|
||||
|
|
|
@ -40,6 +40,8 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
|
|||
case failed
|
||||
case skipped
|
||||
case sent
|
||||
case failedToSync // One-to-one Only
|
||||
case syncing // One-to-one Only
|
||||
|
||||
func message(hasAttachments: Bool, hasAtLeastOneReadReceipt: Bool) -> String {
|
||||
switch self {
|
||||
|
@ -58,6 +60,9 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
|
|||
}
|
||||
|
||||
return "MESSAGE_STATUS_READ".localized()
|
||||
|
||||
case .failedToSync: return "MESSAGE_DELIVERY_STATUS_FAILED_SYNC".localized()
|
||||
case .syncing: return "MESSAGE_DELIVERY_STATUS_SYNCING".localized()
|
||||
|
||||
default:
|
||||
owsFailDebug("Message has unexpected status: \(self).")
|
||||
|
@ -96,6 +101,21 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
|
|||
"MESSAGE_DELIVERY_STATUS_FAILED".localized(),
|
||||
.danger
|
||||
)
|
||||
|
||||
case (.failedToSync, _):
|
||||
return (
|
||||
UIImage(systemName: "exclamationmark.triangle"),
|
||||
"MESSAGE_DELIVERY_STATUS_FAILED_SYNC".localized(),
|
||||
.warning
|
||||
)
|
||||
|
||||
case (.syncing, _):
|
||||
return (
|
||||
UIImage(systemName: "ellipsis.circle"),
|
||||
"MESSAGE_DELIVERY_STATUS_SYNCING".localized(),
|
||||
.warning
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -148,21 +168,3 @@ public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRe
|
|||
self.mostRecentFailureText = mostRecentFailureText
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mutation
|
||||
|
||||
public extension RecipientState {
|
||||
func with(
|
||||
state: State? = nil,
|
||||
readTimestampMs: Int64? = nil,
|
||||
mostRecentFailureText: String? = nil
|
||||
) -> RecipientState {
|
||||
return RecipientState(
|
||||
interactionId: interactionId,
|
||||
recipientId: recipientId,
|
||||
state: (state ?? self.state),
|
||||
readTimestampMs: (readTimestampMs ?? self.readTimestampMs),
|
||||
mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -322,18 +322,31 @@ public extension SessionThread {
|
|||
) -> String? {
|
||||
guard
|
||||
threadVariant == .openGroup,
|
||||
let blindingInfo: (edkeyPair: Box.KeyPair?, publicKey: String?) = Storage.shared.read({ db in
|
||||
let blindingInfo: (edkeyPair: Box.KeyPair?, publicKey: String?, capabilities: Set<Capability.Variant>) = Storage.shared.read({ db in
|
||||
struct OpenGroupInfo: Decodable, FetchableRecord {
|
||||
let publicKey: String?
|
||||
let server: String?
|
||||
}
|
||||
let openGroupInfo: OpenGroupInfo? = try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.select(.publicKey, .server)
|
||||
.asRequest(of: OpenGroupInfo.self)
|
||||
.fetchOne(db)
|
||||
|
||||
return (
|
||||
Identity.fetchUserEd25519KeyPair(db),
|
||||
try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.select(.publicKey)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
openGroupInfo?.publicKey,
|
||||
(try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == openGroupInfo?.server?.lowercased())
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchSet(db))
|
||||
.defaulting(to: [])
|
||||
)
|
||||
}),
|
||||
let userEdKeyPair: Box.KeyPair = blindingInfo.edkeyPair,
|
||||
let publicKey: String = blindingInfo.publicKey
|
||||
let publicKey: String = blindingInfo.publicKey,
|
||||
blindingInfo.capabilities.isEmpty || blindingInfo.capabilities.contains(.blind)
|
||||
else { return nil }
|
||||
|
||||
let sodium: Sodium = Sodium()
|
||||
|
|
|
@ -19,12 +19,16 @@ public enum FailedMessageSendsJob: JobExecutor {
|
|||
) {
|
||||
// Update all 'sending' message states to 'failed'
|
||||
Storage.shared.write { db in
|
||||
let changeCount: Int = try RecipientState
|
||||
let sendChangeCount: Int = try RecipientState
|
||||
.filter(RecipientState.Columns.state == RecipientState.State.sending)
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed))
|
||||
let syncChangeCount: Int = try RecipientState
|
||||
.filter(RecipientState.Columns.state == RecipientState.State.syncing)
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failedToSync))
|
||||
let attachmentChangeCount: Int = try Attachment
|
||||
.filter(Attachment.Columns.state == Attachment.State.uploading)
|
||||
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload))
|
||||
let changeCount: Int = (sendChangeCount + syncChangeCount)
|
||||
|
||||
SNLog("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)")
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
/// are shown)
|
||||
let lastGarbageCollection: Date = UserDefaults.standard[.lastGarbageCollection]
|
||||
.defaulting(to: Date.distantPast)
|
||||
let finalTypesToCollection: Set<Types> = {
|
||||
let finalTypesToCollect: Set<Types> = {
|
||||
guard
|
||||
job.behaviour != .recurringOnActive ||
|
||||
Date().timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60)
|
||||
|
@ -60,20 +60,20 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
/// Remove any typing indicators
|
||||
if finalTypesToCollection.contains(.threadTypingIndicators) {
|
||||
if finalTypesToCollect.contains(.threadTypingIndicators) {
|
||||
_ = try ThreadTypingIndicator
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
/// Remove any expired controlMessageProcessRecords
|
||||
if finalTypesToCollection.contains(.expiredControlMessageProcessRecords) {
|
||||
if finalTypesToCollect.contains(.expiredControlMessageProcessRecords) {
|
||||
_ = try ControlMessageProcessRecord
|
||||
.filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow)
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
/// Remove any old open group messages - open group messages which are older than six months
|
||||
if finalTypesToCollection.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] {
|
||||
if finalTypesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
|
||||
|
@ -104,7 +104,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// Orphaned jobs - jobs which have had their threads or interactions removed
|
||||
if finalTypesToCollection.contains(.orphanedJobs) {
|
||||
if finalTypesToCollect.contains(.orphanedJobs) {
|
||||
let job: TypedTableAlias<Job> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
@ -130,7 +130,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps
|
||||
if finalTypesToCollection.contains(.orphanedLinkPreviews) {
|
||||
if finalTypesToCollect.contains(.orphanedLinkPreviews) {
|
||||
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
|
@ -150,7 +150,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
|
||||
/// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which
|
||||
/// we want cached image data even if the user isn't in the group)
|
||||
if finalTypesToCollection.contains(.orphanedOpenGroups) {
|
||||
if finalTypesToCollect.contains(.orphanedOpenGroups) {
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
|
||||
|
@ -169,7 +169,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// Orphaned open group capabilities - capabilities which have no existing open groups with the same server
|
||||
if finalTypesToCollection.contains(.orphanedOpenGroupCapabilities) {
|
||||
if finalTypesToCollect.contains(.orphanedOpenGroupCapabilities) {
|
||||
let capability: TypedTableAlias<Capability> = TypedTableAlias()
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
|
||||
|
@ -185,7 +185,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id
|
||||
if finalTypesToCollection.contains(.orphanedBlindedIdLookups) {
|
||||
if finalTypesToCollect.contains(.orphanedBlindedIdLookups) {
|
||||
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
@ -213,7 +213,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
|
||||
/// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded
|
||||
/// contact record around anymore
|
||||
if finalTypesToCollection.contains(.approvedBlindedContactRecords) {
|
||||
if finalTypesToCollect.contains(.approvedBlindedContactRecords) {
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
|
||||
|
||||
|
@ -232,7 +232,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// Orphaned attachments - attachments which have no related interactions, quotes or link previews
|
||||
if finalTypesToCollection.contains(.orphanedAttachments) {
|
||||
if finalTypesToCollect.contains(.orphanedAttachments) {
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let quote: TypedTableAlias<Quote> = TypedTableAlias()
|
||||
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
|
||||
|
@ -255,7 +255,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
""")
|
||||
}
|
||||
|
||||
if finalTypesToCollection.contains(.orphanedProfiles) {
|
||||
if finalTypesToCollect.contains(.orphanedProfiles) {
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
@ -289,6 +289,12 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
)
|
||||
""")
|
||||
}
|
||||
|
||||
if finalTypesToCollect.contains(.expiredPendingReadReceipts) {
|
||||
_ = try PendingReadReceipt
|
||||
.filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow)
|
||||
.deleteAll(db)
|
||||
}
|
||||
},
|
||||
completion: { _, _ in
|
||||
// Dispatch async so we can swap from the write queue to a read one (we are done writing)
|
||||
|
@ -304,7 +310,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
var profileAvatarFilenames: Set<String> = []
|
||||
|
||||
/// Orphaned attachment files - attachment files which don't have an associated record in the database
|
||||
if finalTypesToCollection.contains(.orphanedAttachmentFiles) {
|
||||
if finalTypesToCollect.contains(.orphanedAttachmentFiles) {
|
||||
/// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage
|
||||
/// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow
|
||||
/// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running)
|
||||
|
@ -317,7 +323,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
/// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database
|
||||
if finalTypesToCollection.contains(.orphanedProfileAvatars) {
|
||||
if finalTypesToCollect.contains(.orphanedProfileAvatars) {
|
||||
profileAvatarFilenames = try Profile
|
||||
.select(.profilePictureFileName)
|
||||
.filter(Profile.Columns.profilePictureFileName != nil)
|
||||
|
@ -340,7 +346,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
var deletionErrors: [Error] = []
|
||||
|
||||
// Orphaned attachment files (actual deletion)
|
||||
if finalTypesToCollection.contains(.orphanedAttachmentFiles) {
|
||||
if finalTypesToCollect.contains(.orphanedAttachmentFiles) {
|
||||
// Note: Looks like in order to recursively look through files we need to use the
|
||||
// enumerator method
|
||||
let fileEnumerator = FileManager.default.enumerator(
|
||||
|
@ -384,7 +390,7 @@ public enum GarbageCollectionJob: JobExecutor {
|
|||
}
|
||||
|
||||
// Orphaned profile avatar files (actual deletion)
|
||||
if finalTypesToCollection.contains(.orphanedProfileAvatars) {
|
||||
if finalTypesToCollect.contains(.orphanedProfileAvatars) {
|
||||
let allAvatarProfileFilenames: Set<String> = (try? FileManager.default
|
||||
.contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath))
|
||||
.defaulting(to: [])
|
||||
|
@ -442,6 +448,7 @@ extension GarbageCollectionJob {
|
|||
case orphanedAttachments
|
||||
case orphanedAttachmentFiles
|
||||
case orphanedProfileAvatars
|
||||
case expiredPendingReadReceipts
|
||||
}
|
||||
|
||||
public struct Details: Codable {
|
||||
|
|
|
@ -36,6 +36,7 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: nil
|
||||
)
|
||||
|
@ -88,7 +89,7 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
failure(updatedJob, error, true)
|
||||
|
||||
case .some(let error):
|
||||
failure(updatedJob, error, false) // TODO: Confirm the 'noKeyPair' errors here aren't an issue
|
||||
failure(updatedJob, error, false)
|
||||
|
||||
case .none:
|
||||
success(updatedJob, false)
|
||||
|
@ -104,30 +105,36 @@ extension MessageReceiveJob {
|
|||
private enum CodingKeys: String, CodingKey {
|
||||
case message
|
||||
case variant
|
||||
case serverExpirationTimestamp
|
||||
case serializedProtoData
|
||||
}
|
||||
|
||||
public let message: Message
|
||||
public let variant: Message.Variant
|
||||
public let serverExpirationTimestamp: TimeInterval?
|
||||
public let serializedProtoData: Data
|
||||
|
||||
public init(
|
||||
message: Message,
|
||||
variant: Message.Variant,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
proto: SNProtoContent
|
||||
) throws {
|
||||
self.message = message
|
||||
self.variant = variant
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
self.serializedProtoData = try proto.serializedData()
|
||||
}
|
||||
|
||||
private init(
|
||||
message: Message,
|
||||
variant: Message.Variant,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
serializedProtoData: Data
|
||||
) {
|
||||
self.message = message
|
||||
self.variant = variant
|
||||
self.serverExpirationTimestamp = serverExpirationTimestamp
|
||||
self.serializedProtoData = serializedProtoData
|
||||
}
|
||||
|
||||
|
@ -144,6 +151,7 @@ extension MessageReceiveJob {
|
|||
self = MessageInfo(
|
||||
message: try variant.decode(from: container, forKey: .message),
|
||||
variant: variant,
|
||||
serverExpirationTimestamp: try? container.decode(TimeInterval.self, forKey: .serverExpirationTimestamp),
|
||||
serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData)
|
||||
)
|
||||
}
|
||||
|
@ -158,6 +166,7 @@ extension MessageReceiveJob {
|
|||
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encode(variant, forKey: .variant)
|
||||
try container.encodeIfPresent(serverExpirationTimestamp, forKey: .serverExpirationTimestamp)
|
||||
try container.encode(serializedProtoData, forKey: .serializedProtoData)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -167,7 +167,8 @@ public enum MessageSendJob: JobExecutor {
|
|||
message: details.message,
|
||||
to: details.destination
|
||||
.with(fileIds: messageFileIds),
|
||||
interactionId: job.interactionId
|
||||
interactionId: job.interactionId,
|
||||
isSyncMessage: (details.isSyncMessage == true)
|
||||
)
|
||||
}
|
||||
.done(on: queue) { _ in success(job, false) }
|
||||
|
@ -213,21 +214,25 @@ extension MessageSendJob {
|
|||
private enum CodingKeys: String, CodingKey {
|
||||
case destination
|
||||
case message
|
||||
case isSyncMessage
|
||||
case variant
|
||||
}
|
||||
|
||||
public let destination: Message.Destination
|
||||
public let message: Message
|
||||
public let isSyncMessage: Bool?
|
||||
public let variant: Message.Variant?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
destination: Message.Destination,
|
||||
message: Message
|
||||
message: Message,
|
||||
isSyncMessage: Bool? = nil
|
||||
) {
|
||||
self.destination = destination
|
||||
self.message = message
|
||||
self.isSyncMessage = isSyncMessage
|
||||
self.variant = Message.Variant(from: message)
|
||||
}
|
||||
|
||||
|
@ -243,7 +248,8 @@ extension MessageSendJob {
|
|||
|
||||
self = Details(
|
||||
destination: try container.decode(Message.Destination.self, forKey: .destination),
|
||||
message: try variant.decode(from: container, forKey: .message)
|
||||
message: try variant.decode(from: container, forKey: .message),
|
||||
isSyncMessage: try? container.decode(Bool.self, forKey: .isSyncMessage)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -257,6 +263,7 @@ extension MessageSendJob {
|
|||
|
||||
try container.encode(destination, forKey: .destination)
|
||||
try container.encode(message, forKey: .message)
|
||||
try container.encodeIfPresent(isSyncMessage, forKey: .isSyncMessage)
|
||||
try container.encode(variant, forKey: .variant)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,8 @@ public enum SendReadReceiptsJob: JobExecutor {
|
|||
timestamps: details.timestampMsValues.map { UInt64($0) }
|
||||
),
|
||||
to: details.destination,
|
||||
interactionId: nil
|
||||
interactionId: nil,
|
||||
isSyncMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: queue) {
|
||||
|
@ -104,6 +105,7 @@ public extension SendReadReceiptsJob {
|
|||
/// ensure that is done correctly beforehand
|
||||
@discardableResult static func createOrUpdateIfNeeded(_ db: Database, threadId: String, interactionIds: [Int64]) -> Job? {
|
||||
guard db[.areReadReceiptsEnabled] == true else { return nil }
|
||||
guard !interactionIds.isEmpty else { return nil }
|
||||
|
||||
// Retrieve the timestampMs values for the specified interactions
|
||||
let timestampMsValues: [Int64] = (try? Interaction
|
||||
|
|
|
@ -178,6 +178,11 @@ public extension Message {
|
|||
|
||||
static func shouldSync(message: Message) -> Bool {
|
||||
switch message {
|
||||
case is VisibleMessage: return true
|
||||
case is ExpirationTimerUpdate: return true
|
||||
case is ConfigurationMessage: return true
|
||||
case is UnsendRequest: return true
|
||||
|
||||
case let controlMessage as ClosedGroupControlMessage:
|
||||
switch controlMessage.kind {
|
||||
case .new: return true
|
||||
|
@ -189,9 +194,7 @@ public extension Message {
|
|||
case .answer, .endCall: return true
|
||||
default: return false
|
||||
}
|
||||
|
||||
case is ConfigurationMessage: return true
|
||||
case is UnsendRequest: return true
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
@ -544,6 +547,7 @@ public extension Message {
|
|||
try MessageReceiveJob.Details.MessageInfo(
|
||||
message: message,
|
||||
variant: variant,
|
||||
serverExpirationTimestamp: serverExpirationTimestamp,
|
||||
proto: proto
|
||||
)
|
||||
)
|
||||
|
|
|
@ -44,6 +44,7 @@ public final class VisibleMessage: Message {
|
|||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
sender: String? = nil,
|
||||
sentTimestamp: UInt64? = nil,
|
||||
recipient: String? = nil,
|
||||
groupPublicKey: String? = nil,
|
||||
|
@ -68,6 +69,7 @@ public final class VisibleMessage: Message {
|
|||
super.init(
|
||||
sentTimestamp: sentTimestamp,
|
||||
recipient: recipient,
|
||||
sender: sender,
|
||||
groupPublicKey: groupPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -228,6 +230,7 @@ public extension VisibleMessage {
|
|||
let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db)
|
||||
|
||||
return VisibleMessage(
|
||||
sender: interaction.authorId,
|
||||
sentTimestamp: UInt64(interaction.timestampMs),
|
||||
recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId,
|
||||
groupPublicKey: try? interaction.thread
|
||||
|
|
|
@ -576,6 +576,7 @@ public final class OpenGroupManager: NSObject {
|
|||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: openGroup.id,
|
||||
dependencies: dependencies
|
||||
|
@ -739,6 +740,7 @@ public final class OpenGroupManager: NSObject {
|
|||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
serverExpirationTimestamp: messageInfo.serverExpirationTimestamp,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: nil, // Intentionally nil as they are technically not open group messages
|
||||
dependencies: dependencies
|
||||
|
|
|
@ -420,7 +420,7 @@ extension MessageReceiver {
|
|||
// Delete the members to remove
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == id)
|
||||
.filter(updatedMemberIds.contains(GroupMember.Columns.profileId))
|
||||
.filter(membersToRemove.map{ $0.profileId }.contains(GroupMember.Columns.profileId))
|
||||
.deleteAll(db)
|
||||
|
||||
if didAdminLeave || sender == userPublicKey {
|
||||
|
|
|
@ -4,16 +4,32 @@ import Foundation
|
|||
import GRDB
|
||||
|
||||
extension MessageReceiver {
|
||||
internal static func handleReadReceipt(_ db: Database, message: ReadReceipt) throws {
|
||||
internal static func handleReadReceipt(
|
||||
_ db: Database,
|
||||
message: ReadReceipt,
|
||||
serverExpirationTimestamp: TimeInterval?
|
||||
) throws {
|
||||
guard let sender: String = message.sender else { return }
|
||||
guard let timestampMsValues: [Double] = message.timestamps?.map({ Double($0) }) else { return }
|
||||
guard let readTimestampMs: Double = message.receivedTimestamp.map({ Double($0) }) else { return }
|
||||
guard let timestampMsValues: [Int64] = message.timestamps?.map({ Int64($0) }) else { return }
|
||||
guard let readTimestampMs: Int64 = message.receivedTimestamp.map({ Int64($0) }) else { return }
|
||||
|
||||
try Interaction.markAsRead(
|
||||
let pendingTimestampMs: Set<Int64> = try Interaction.markAsRead(
|
||||
db,
|
||||
recipientId: sender,
|
||||
timestampMsValues: timestampMsValues,
|
||||
readTimestampMs: readTimestampMs
|
||||
)
|
||||
|
||||
guard !pendingTimestampMs.isEmpty else { return }
|
||||
|
||||
// We have some pending read receipts so store them in the database
|
||||
try pendingTimestampMs.forEach { timestampMs in
|
||||
try PendingReadReceipt(
|
||||
threadId: sender,
|
||||
interactionTimestampMs: timestampMs,
|
||||
readTimestampMs: readTimestampMs,
|
||||
serverExpirationTimestamp: (serverExpirationTimestamp ?? 0)
|
||||
).save(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,6 +161,7 @@ extension MessageReceiver {
|
|||
db,
|
||||
thread: thread,
|
||||
interactionId: existingInteractionId,
|
||||
messageSentTimestamp: messageSentTimestamp,
|
||||
variant: variant,
|
||||
syncTarget: message.syncTarget
|
||||
)
|
||||
|
@ -178,6 +179,7 @@ extension MessageReceiver {
|
|||
db,
|
||||
thread: thread,
|
||||
interactionId: interactionId,
|
||||
messageSentTimestamp: messageSentTimestamp,
|
||||
variant: variant,
|
||||
syncTarget: message.syncTarget
|
||||
)
|
||||
|
@ -363,11 +365,19 @@ extension MessageReceiver {
|
|||
_ db: Database,
|
||||
thread: SessionThread,
|
||||
interactionId: Int64,
|
||||
messageSentTimestamp: TimeInterval,
|
||||
variant: Interaction.Variant,
|
||||
syncTarget: String?
|
||||
) throws {
|
||||
guard variant == .standardOutgoing else { return }
|
||||
|
||||
// Immediately update any existing outgoing message 'RecipientState' records to be 'sent'
|
||||
_ = try? RecipientState
|
||||
.filter(RecipientState.Columns.interactionId == interactionId)
|
||||
.filter(RecipientState.Columns.state != RecipientState.State.sent)
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
|
||||
|
||||
// Create any addiitonal 'RecipientState' records as needed
|
||||
switch thread.variant {
|
||||
case .contact:
|
||||
if let syncTarget: String = syncTarget {
|
||||
|
@ -409,5 +419,22 @@ extension MessageReceiver {
|
|||
includingOlder: true,
|
||||
trySendReadReceipt: true
|
||||
)
|
||||
|
||||
// Process any PendingReadReceipt values
|
||||
let maybePendingReadReceipt: PendingReadReceipt? = try PendingReadReceipt
|
||||
.filter(PendingReadReceipt.Columns.threadId == thread.id)
|
||||
.filter(PendingReadReceipt.Columns.interactionTimestampMs == Int64(messageSentTimestamp * 1000))
|
||||
.fetchOne(db)
|
||||
|
||||
if let pendingReadReceipt: PendingReadReceipt = maybePendingReadReceipt {
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
recipientId: thread.id,
|
||||
timestampMsValues: [pendingReadReceipt.interactionTimestampMs],
|
||||
readTimestampMs: pendingReadReceipt.readTimestampMs
|
||||
)
|
||||
|
||||
_ = try pendingReadReceipt.delete(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,13 +179,18 @@ public enum MessageReceiver {
|
|||
public static func handle(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
serverExpirationTimestamp: TimeInterval?,
|
||||
associatedWithProto proto: SNProtoContent,
|
||||
openGroupId: String?,
|
||||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) throws {
|
||||
switch message {
|
||||
case let message as ReadReceipt:
|
||||
try MessageReceiver.handleReadReceipt(db, message: message)
|
||||
try MessageReceiver.handleReadReceipt(
|
||||
db,
|
||||
message: message,
|
||||
serverExpirationTimestamp: serverExpirationTimestamp
|
||||
)
|
||||
|
||||
case let message as TypingIndicator:
|
||||
try MessageReceiver.handleTypingIndicator(db, message: message)
|
||||
|
|
|
@ -9,7 +9,7 @@ extension MessageSender {
|
|||
|
||||
// MARK: - Durable
|
||||
|
||||
public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws {
|
||||
public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread, isSyncMessage: Bool = false) throws {
|
||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||
|
||||
try prep(db, signalAttachments: attachments, for: interactionId)
|
||||
|
@ -18,11 +18,12 @@ extension MessageSender {
|
|||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
threadId: thread.id,
|
||||
interactionId: interactionId,
|
||||
to: try Message.Destination.from(db, thread: thread)
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
isSyncMessage: isSyncMessage
|
||||
)
|
||||
}
|
||||
|
||||
public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread) throws {
|
||||
public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread, isSyncMessage: Bool = false) throws {
|
||||
// Only 'VisibleMessage' types can be sent via this method
|
||||
guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage }
|
||||
guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved }
|
||||
|
@ -32,21 +33,37 @@ extension MessageSender {
|
|||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
threadId: thread.id,
|
||||
interactionId: interactionId,
|
||||
to: try Message.Destination.from(db, thread: thread)
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
isSyncMessage: isSyncMessage
|
||||
)
|
||||
}
|
||||
|
||||
public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws {
|
||||
public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread, isSyncMessage: Bool = false) throws {
|
||||
send(
|
||||
db,
|
||||
message: message,
|
||||
threadId: thread.id,
|
||||
interactionId: interactionId,
|
||||
to: try Message.Destination.from(db, thread: thread)
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
isSyncMessage: isSyncMessage
|
||||
)
|
||||
}
|
||||
|
||||
public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination) {
|
||||
public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination, isSyncMessage: Bool = false) {
|
||||
// If it's a sync message then we need to make some slight tweaks before sending so use the proper
|
||||
// sync message sending process instead of the standard process
|
||||
guard !isSyncMessage else {
|
||||
scheduleSyncMessageIfNeeded(
|
||||
db,
|
||||
message: message,
|
||||
destination: destination,
|
||||
threadId: threadId,
|
||||
interactionId: interactionId,
|
||||
isAlreadySyncMessage: false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
|
@ -55,7 +72,8 @@ extension MessageSender {
|
|||
interactionId: interactionId,
|
||||
details: MessageSendJob.Details(
|
||||
destination: destination,
|
||||
message: message
|
||||
message: message,
|
||||
isSyncMessage: isSyncMessage
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -179,7 +197,8 @@ extension MessageSender {
|
|||
message: message,
|
||||
to: destination
|
||||
.with(fileIds: fileIds),
|
||||
interactionId: interactionId
|
||||
interactionId: interactionId,
|
||||
isSyncMessage: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -200,7 +219,7 @@ extension MessageSender {
|
|||
|
||||
if forceSyncNow {
|
||||
try MessageSender
|
||||
.sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil)
|
||||
.sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil, isSyncMessage: false)
|
||||
.done { seal.fulfill(()) }
|
||||
.catch { _ in seal.reject(StorageError.generic) }
|
||||
.retainUntilComplete()
|
||||
|
|
|
@ -42,10 +42,16 @@ public final class MessageSender {
|
|||
|
||||
// MARK: - Convenience
|
||||
|
||||
public static func sendImmediate(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise<Void> {
|
||||
public static func sendImmediate(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
to destination: Message.Destination,
|
||||
interactionId: Int64?,
|
||||
isSyncMessage: Bool
|
||||
) throws -> Promise<Void> {
|
||||
switch destination {
|
||||
case .contact, .closedGroup:
|
||||
return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId)
|
||||
return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId, isSyncMessage: isSyncMessage)
|
||||
|
||||
case .openGroup:
|
||||
return sendToOpenGroupDestination(db, message: message, to: destination, interactionId: interactionId)
|
||||
|
@ -65,7 +71,7 @@ public final class MessageSender {
|
|||
isSyncMessage: Bool = false
|
||||
) throws -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||
|
||||
// Set the timestamp, sender and recipient
|
||||
|
@ -73,7 +79,7 @@ public final class MessageSender {
|
|||
message.sentTimestamp ?? // Visible messages will already have their sent timestamp set
|
||||
UInt64(messageSendTimestamp)
|
||||
)
|
||||
message.sender = userPublicKey
|
||||
message.sender = currentUserPublicKey
|
||||
message.recipient = {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
|
@ -84,7 +90,7 @@ public final class MessageSender {
|
|||
|
||||
// Set the failure handler (need it here already for precondition failure handling)
|
||||
func handleFailure(_ db: Database, with error: MessageSenderError) {
|
||||
MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId)
|
||||
MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId, isSyncMessage: isSyncMessage)
|
||||
seal.reject(error)
|
||||
}
|
||||
|
||||
|
@ -94,21 +100,8 @@ public final class MessageSender {
|
|||
return promise
|
||||
}
|
||||
|
||||
// Stop here if this is a self-send, unless we should sync the message
|
||||
let isSelfSend: Bool = (message.recipient == userPublicKey)
|
||||
|
||||
guard
|
||||
!isSelfSend ||
|
||||
isSyncMessage ||
|
||||
Message.shouldSync(message: message)
|
||||
else {
|
||||
try MessageSender.handleSuccessfulMessageSend(db, message: message, to: destination, interactionId: interactionId)
|
||||
seal.fulfill(())
|
||||
return promise
|
||||
}
|
||||
|
||||
// Attach the user's profile if needed
|
||||
if var messageWithProfile: MessageWithProfile = message as? MessageWithProfile {
|
||||
if !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile {
|
||||
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
|
||||
if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl {
|
||||
|
@ -123,6 +116,9 @@ public final class MessageSender {
|
|||
}
|
||||
}
|
||||
|
||||
// Perform any pre-send actions
|
||||
handleMessageWillSend(db, message: message, interactionId: interactionId, isSyncMessage: isSyncMessage)
|
||||
|
||||
// Convert it to protobuf
|
||||
guard let proto = message.toProto(db) else {
|
||||
handleFailure(db, with: .protoConversionFailed)
|
||||
|
@ -233,6 +229,9 @@ public final class MessageSender {
|
|||
)
|
||||
|
||||
let shouldNotify: Bool = {
|
||||
// Don't send a notification when sending messages in 'Note to Self'
|
||||
guard message.recipient != currentUserPublicKey else { return false }
|
||||
|
||||
switch message {
|
||||
case is VisibleMessage, is UnsendRequest: return !isSyncMessage
|
||||
case let callMessage as CallMessage:
|
||||
|
@ -319,7 +318,6 @@ public final class MessageSender {
|
|||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let threadId: String
|
||||
|
||||
// Set the timestamp, sender and recipient
|
||||
if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set
|
||||
|
@ -329,7 +327,6 @@ public final class MessageSender {
|
|||
switch destination {
|
||||
case .contact, .closedGroup, .openGroupInbox: preconditionFailure()
|
||||
case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, _):
|
||||
threadId = OpenGroup.idFor(roomToken: roomToken, server: server)
|
||||
message.recipient = [
|
||||
server,
|
||||
roomToken,
|
||||
|
@ -344,34 +341,12 @@ public final class MessageSender {
|
|||
// which would go into this case, so rather than handling it as an invalid state we just want to
|
||||
// error in a non-retryable way
|
||||
guard
|
||||
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId),
|
||||
let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db),
|
||||
case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let fileIds) = destination
|
||||
else {
|
||||
seal.reject(MessageSenderError.invalidMessage)
|
||||
return promise
|
||||
}
|
||||
|
||||
message.sender = {
|
||||
let capabilities: [Capability.Variant] = (try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == server)
|
||||
.filter(Capability.Columns.isMissing == false)
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
||||
// If the server doesn't support blinding then go with an unblinded id
|
||||
guard capabilities.isEmpty || capabilities.contains(.blind) else {
|
||||
return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString
|
||||
}
|
||||
guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else {
|
||||
preconditionFailure()
|
||||
}
|
||||
|
||||
return SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString
|
||||
}()
|
||||
|
||||
// Set the failure handler (need it here already for precondition failure handling)
|
||||
func handleFailure(_ db: Database, with error: MessageSenderError) {
|
||||
MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId)
|
||||
|
@ -402,6 +377,9 @@ public final class MessageSender {
|
|||
return promise
|
||||
}
|
||||
|
||||
// Perform any pre-send actions
|
||||
handleMessageWillSend(db, message: message, interactionId: interactionId)
|
||||
|
||||
// Convert it to protobuf
|
||||
guard let proto = message.toProto(db) else {
|
||||
handleFailure(db, with: .protoConversionFailed)
|
||||
|
@ -465,7 +443,6 @@ public final class MessageSender {
|
|||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||
|
||||
guard case .openGroupInbox(let server, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else {
|
||||
preconditionFailure()
|
||||
|
@ -476,7 +453,6 @@ public final class MessageSender {
|
|||
message.sentTimestamp = UInt64(SnodeAPI.currentOffsetTimestampMs())
|
||||
}
|
||||
|
||||
message.sender = userPublicKey
|
||||
message.recipient = recipientBlindedPublicKey
|
||||
|
||||
// Set the failure handler (need it here already for precondition failure handling)
|
||||
|
@ -501,6 +477,9 @@ public final class MessageSender {
|
|||
}
|
||||
}
|
||||
|
||||
// Perform any pre-send actions
|
||||
handleMessageWillSend(db, message: message, interactionId: interactionId)
|
||||
|
||||
// Convert it to protobuf
|
||||
guard let proto = message.toProto(db) else {
|
||||
handleFailure(db, with: .protoConversionFailed)
|
||||
|
@ -569,6 +548,32 @@ public final class MessageSender {
|
|||
|
||||
// MARK: Success & Failure Handling
|
||||
|
||||
public static func handleMessageWillSend(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
interactionId: Int64?,
|
||||
isSyncMessage: Bool = false
|
||||
) {
|
||||
// If the message was a reaction then we don't want to do anything to the original
|
||||
// interaction (which the 'interactionId' is pointing to
|
||||
guard (message as? VisibleMessage)?.reaction == nil else { return }
|
||||
|
||||
// Mark messages as "sending"/"syncing" if needed (this is for retries)
|
||||
_ = try? RecipientState
|
||||
.filter(RecipientState.Columns.interactionId == interactionId)
|
||||
.filter(isSyncMessage ?
|
||||
RecipientState.Columns.state == RecipientState.State.failedToSync :
|
||||
RecipientState.Columns.state == RecipientState.State.failed
|
||||
)
|
||||
.updateAll(
|
||||
db,
|
||||
RecipientState.Columns.state.set(to: isSyncMessage ?
|
||||
RecipientState.State.syncing :
|
||||
RecipientState.State.sending
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func handleSuccessfulMessageSend(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
|
@ -578,7 +583,7 @@ public final class MessageSender {
|
|||
isSyncMessage: Bool = false
|
||||
) throws {
|
||||
// If the message was a reaction then we want to update the reaction instead of the original
|
||||
// interaciton (which the 'interactionId' is pointing to
|
||||
// interaction (which the 'interactionId' is pointing to
|
||||
if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction {
|
||||
try Reaction
|
||||
.filter(Reaction.Columns.interactionId == interactionId)
|
||||
|
@ -597,7 +602,6 @@ public final class MessageSender {
|
|||
// real message has no use when we delete a message. It is OK to let it be.
|
||||
try interaction.with(
|
||||
serverHash: message.serverHash,
|
||||
|
||||
// Track the open group server message ID and update server timestamp (use server
|
||||
// timestamp for open group messages otherwise the quote messages may not be able
|
||||
// to be found by the timestamp on other devices
|
||||
|
@ -610,6 +614,7 @@ public final class MessageSender {
|
|||
|
||||
// Mark the message as sent
|
||||
try interaction.recipientStates
|
||||
.filter(RecipientState.Columns.state != RecipientState.State.sent)
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
|
||||
|
||||
// Start the disappearing messages timer if needed
|
||||
|
@ -624,18 +629,20 @@ public final class MessageSender {
|
|||
}
|
||||
}
|
||||
|
||||
let threadId: String = {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
||||
case .openGroup(let roomToken, let server, _, _, _):
|
||||
return OpenGroup.idFor(roomToken: roomToken, server: server)
|
||||
|
||||
case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey
|
||||
}
|
||||
}()
|
||||
|
||||
// Prevent ControlMessages from being handled multiple times if not supported
|
||||
try? ControlMessageProcessRecord(
|
||||
threadId: {
|
||||
switch destination {
|
||||
case .contact(let publicKey): return publicKey
|
||||
case .closedGroup(let groupPublicKey): return groupPublicKey
|
||||
case .openGroup(let roomToken, let server, _, _, _):
|
||||
return OpenGroup.idFor(roomToken: roomToken, server: server)
|
||||
|
||||
case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey
|
||||
}
|
||||
}(),
|
||||
threadId: threadId,
|
||||
message: message,
|
||||
serverExpirationTimestamp: (
|
||||
(TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) +
|
||||
|
@ -643,35 +650,27 @@ public final class MessageSender {
|
|||
)
|
||||
)?.insert(db)
|
||||
|
||||
// Sync the message if:
|
||||
// • it's a visible message or an expiration timer update
|
||||
// • the destination was a contact
|
||||
// • we didn't sync it already
|
||||
let userPublicKey = getUserHexEncodedPublicKey(db)
|
||||
if case .contact(let publicKey) = destination, !isSyncMessage {
|
||||
if let message = message as? VisibleMessage { message.syncTarget = publicKey }
|
||||
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey }
|
||||
|
||||
// FIXME: Make this a job
|
||||
try sendToSnodeDestination(
|
||||
db,
|
||||
message: message,
|
||||
to: .contact(publicKey: userPublicKey),
|
||||
interactionId: interactionId,
|
||||
isSyncMessage: true
|
||||
).retainUntilComplete()
|
||||
}
|
||||
// Sync the message if needed
|
||||
scheduleSyncMessageIfNeeded(
|
||||
db,
|
||||
message: message,
|
||||
destination: destination,
|
||||
threadId: threadId,
|
||||
interactionId: interactionId,
|
||||
isAlreadySyncMessage: isSyncMessage
|
||||
)
|
||||
}
|
||||
|
||||
public static func handleFailedMessageSend(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
with error: MessageSenderError,
|
||||
interactionId: Int64?
|
||||
interactionId: Int64?,
|
||||
isSyncMessage: Bool = false
|
||||
) {
|
||||
// TODO: Revert the local database change
|
||||
// If the message was a reaction then we don't want to do anything to the original
|
||||
// interaciton (which the 'interactionId' is pointing to
|
||||
// interaction (which the 'interactionId' is pointing to
|
||||
guard (message as? VisibleMessage)?.reaction == nil else { return }
|
||||
|
||||
// Check if we need to mark any "sending" recipients as "failed"
|
||||
|
@ -682,7 +681,12 @@ public final class MessageSender {
|
|||
let rowIds: [Int64] = (try? RecipientState
|
||||
.select(Column.rowID)
|
||||
.filter(RecipientState.Columns.interactionId == interactionId)
|
||||
.filter(RecipientState.Columns.state == RecipientState.State.sending)
|
||||
.filter(!isSyncMessage ?
|
||||
RecipientState.Columns.state == RecipientState.State.sending : (
|
||||
RecipientState.Columns.state == RecipientState.State.syncing ||
|
||||
RecipientState.Columns.state == RecipientState.State.sent
|
||||
)
|
||||
)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
@ -697,7 +701,9 @@ public final class MessageSender {
|
|||
.filter(rowIds.contains(Column.rowID))
|
||||
.updateAll(
|
||||
db,
|
||||
RecipientState.Columns.state.set(to: RecipientState.State.failed),
|
||||
RecipientState.Columns.state.set(
|
||||
to: (isSyncMessage ? RecipientState.State.failedToSync : RecipientState.State.failed)
|
||||
),
|
||||
RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription)
|
||||
)
|
||||
}
|
||||
|
@ -719,6 +725,43 @@ public final class MessageSender {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func scheduleSyncMessageIfNeeded(
|
||||
_ db: Database,
|
||||
message: Message,
|
||||
destination: Message.Destination,
|
||||
threadId: String?,
|
||||
interactionId: Int64?,
|
||||
isAlreadySyncMessage: Bool
|
||||
) {
|
||||
// Sync the message if it's not a sync message, wasn't already sent to the current user and
|
||||
// it's a message type which should be synced
|
||||
let currentUserPublicKey = getUserHexEncodedPublicKey(db)
|
||||
|
||||
if
|
||||
case .contact(let publicKey) = destination,
|
||||
!isAlreadySyncMessage,
|
||||
publicKey != currentUserPublicKey,
|
||||
Message.shouldSync(message: message)
|
||||
{
|
||||
if let message = message as? VisibleMessage { message.syncTarget = publicKey }
|
||||
if let message = message as? ExpirationTimerUpdate { message.syncTarget = publicKey }
|
||||
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .messageSend,
|
||||
threadId: threadId,
|
||||
interactionId: interactionId,
|
||||
details: MessageSendJob.Details(
|
||||
destination: .contact(publicKey: currentUserPublicKey),
|
||||
message: message,
|
||||
isSyncMessage: true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
|
|
@ -29,75 +29,98 @@ public extension MentionInfo {
|
|||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
|
||||
let prefixLiteral: SQL = SQL(stringLiteral: "\(targetPrefix.rawValue)%")
|
||||
let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName)
|
||||
|
||||
/// **Note:** The `\(MentionInfo.profileKey).*` value **MUST** be first
|
||||
let limitSQL: SQL? = (threadVariant == .openGroup ? SQL("LIMIT 20") : nil)
|
||||
|
||||
/// The query needs to differ depending on the thread variant because the behaviour should be different:
|
||||
///
|
||||
/// **Contact:** We should show the profile directly (filtered out if the pattern doesn't match)
|
||||
/// **Closed Group:** We should show all profiles within the group, filtered by the pattern
|
||||
/// **Open Group:** We should show only the 20 most recent profiles which match the pattern
|
||||
let request: SQLRequest<MentionInfo> = {
|
||||
guard let pattern: FTS5Pattern = pattern else {
|
||||
let finalLimitSQL: SQL = (limitSQL ?? "")
|
||||
let hasValidPattern: Bool = (pattern != nil && pattern?.rawPattern != "\"\"*")
|
||||
let targetJoin: SQL = {
|
||||
guard hasValidPattern else { return "FROM \(Profile.self)" }
|
||||
|
||||
return """
|
||||
SELECT
|
||||
\(Profile.self).*,
|
||||
MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting)
|
||||
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")),
|
||||
\(openGroup[.server]) AS \(MentionInfo.openGroupServerKey),
|
||||
\(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey)
|
||||
|
||||
FROM \(Profile.self)
|
||||
JOIN \(Interaction.self) ON (
|
||||
\(SQL("\(interaction[.threadId]) = \(threadId)")) AND
|
||||
\(interaction[.authorId]) = \(profile[.id])
|
||||
)
|
||||
LEFT JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)"))
|
||||
|
||||
WHERE (
|
||||
FROM \(profileFullTextSearch)
|
||||
JOIN \(Profile.self) ON (
|
||||
\(Profile.self).rowid = \(profileFullTextSearch).rowid AND
|
||||
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
|
||||
\(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR
|
||||
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
|
||||
)
|
||||
)
|
||||
GROUP BY \(profile[.id])
|
||||
ORDER BY \(interaction[.timestampMs].desc)
|
||||
\(finalLimitSQL)
|
||||
"""
|
||||
}
|
||||
|
||||
// If we do have a search patern then use FTS
|
||||
let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)")
|
||||
let finalLimitSQL: SQL = (limitSQL ?? "")
|
||||
|
||||
return """
|
||||
SELECT
|
||||
\(Profile.self).*,
|
||||
MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting)
|
||||
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")),
|
||||
\(openGroup[.server]) AS \(MentionInfo.openGroupServerKey),
|
||||
\(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey)
|
||||
}()
|
||||
let targetWhere: SQL = {
|
||||
guard let pattern: FTS5Pattern = pattern, pattern.rawPattern != "\"\"*" else {
|
||||
return """
|
||||
WHERE (
|
||||
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
|
||||
\(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR
|
||||
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
|
||||
)
|
||||
)
|
||||
"""
|
||||
}
|
||||
|
||||
FROM \(profileFullTextSearch)
|
||||
JOIN \(Profile.self) ON (
|
||||
\(Profile.self).rowid = \(profileFullTextSearch).rowid AND
|
||||
\(SQL("\(profile[.id]) != \(userPublicKey)")) AND (
|
||||
\(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR
|
||||
\(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'"))
|
||||
)
|
||||
)
|
||||
JOIN \(Interaction.self) ON (
|
||||
\(SQL("\(interaction[.threadId]) = \(threadId)")) AND
|
||||
\(interaction[.authorId]) = \(profile[.id])
|
||||
)
|
||||
LEFT JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)"))
|
||||
let matchLiteral: SQL = SQL(stringLiteral: "\(Profile.Columns.nickname.name):\(pattern.rawPattern) OR \(Profile.Columns.name.name):\(pattern.rawPattern)")
|
||||
|
||||
return "WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'"
|
||||
}()
|
||||
|
||||
WHERE \(profileFullTextSearch) MATCH '\(matchLiteral)'
|
||||
GROUP BY \(profile[.id])
|
||||
ORDER BY \(interaction[.timestampMs].desc)
|
||||
\(finalLimitSQL)
|
||||
"""
|
||||
switch threadVariant {
|
||||
case .contact:
|
||||
return SQLRequest("""
|
||||
SELECT
|
||||
\(Profile.self).*,
|
||||
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)"))
|
||||
|
||||
\(targetJoin)
|
||||
\(targetWhere) AND \(SQL("\(profile[.id]) = \(threadId)"))
|
||||
""")
|
||||
|
||||
case .closedGroup:
|
||||
return SQLRequest("""
|
||||
SELECT
|
||||
\(Profile.self).*,
|
||||
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)"))
|
||||
|
||||
\(targetJoin)
|
||||
JOIN \(GroupMember.self) ON (
|
||||
\(SQL("\(groupMember[.groupId]) = \(threadId)")) AND
|
||||
\(groupMember[.profileId]) = \(profile[.id]) AND
|
||||
\(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)"))
|
||||
)
|
||||
\(targetWhere)
|
||||
GROUP BY \(profile[.id])
|
||||
ORDER BY IFNULL(\(profile[.nickname]), \(profile[.name])) ASC
|
||||
""")
|
||||
|
||||
case .openGroup:
|
||||
return SQLRequest("""
|
||||
SELECT
|
||||
\(Profile.self).*,
|
||||
MAX(\(interaction[.timestampMs])), -- Want the newest interaction (for sorting)
|
||||
\(SQL("\(threadVariant) AS \(MentionInfo.threadVariantKey)")),
|
||||
\(openGroup[.server]) AS \(MentionInfo.openGroupServerKey),
|
||||
\(openGroup[.roomToken]) AS \(MentionInfo.openGroupRoomTokenKey)
|
||||
|
||||
\(targetJoin)
|
||||
JOIN \(Interaction.self) ON (
|
||||
\(SQL("\(interaction[.threadId]) = \(threadId)")) AND
|
||||
\(interaction[.authorId]) = \(profile[.id])
|
||||
)
|
||||
JOIN \(OpenGroup.self) ON \(SQL("\(openGroup[.threadId]) = \(threadId)"))
|
||||
\(targetWhere)
|
||||
GROUP BY \(profile[.id])
|
||||
ORDER BY \(interaction[.timestampMs].desc)
|
||||
LIMIT 20
|
||||
""")
|
||||
}
|
||||
}()
|
||||
|
||||
return request.adapted { db in
|
||||
|
|
|
@ -39,6 +39,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
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 isLastOutgoingKey: SQL = SQL(stringLiteral: CodingKeys.isLastOutgoing.stringValue)
|
||||
|
||||
public static let profileString: String = CodingKeys.profile.stringValue
|
||||
public static let quoteString: String = CodingKeys.quote.stringValue
|
||||
|
@ -140,6 +141,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
/// This value indicates whether this is the last message in the thread
|
||||
public let isLast: Bool
|
||||
|
||||
public let isLastOutgoing: Bool
|
||||
|
||||
/// This is the users blinded key (will only be set for messages within open groups)
|
||||
public let currentUserBlindedPublicKey: String?
|
||||
|
||||
|
@ -191,6 +194,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
positionInCluster: self.positionInCluster,
|
||||
isOnlyMessageInCluster: self.isOnlyMessageInCluster,
|
||||
isLast: self.isLast,
|
||||
isLastOutgoing: self.isLastOutgoing,
|
||||
currentUserBlindedPublicKey: self.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -199,6 +203,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
prevModel: MessageViewModel?,
|
||||
nextModel: MessageViewModel?,
|
||||
isLast: Bool,
|
||||
isLastOutgoing: Bool,
|
||||
currentUserBlindedPublicKey: String?
|
||||
) -> MessageViewModel {
|
||||
let cellType: CellType = {
|
||||
|
@ -403,6 +408,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
|
|||
positionInCluster: positionInCluster,
|
||||
isOnlyMessageInCluster: isOnlyMessageInCluster,
|
||||
isLast: isLast,
|
||||
isLastOutgoing: isLastOutgoing,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
|
@ -498,7 +504,8 @@ public extension MessageViewModel {
|
|||
quote: Quote? = nil,
|
||||
cellType: CellType = .typingIndicator,
|
||||
isTypingIndicator: Bool? = nil,
|
||||
isLast: Bool = true
|
||||
isLast: Bool = true,
|
||||
isLastOutgoing: Bool = false
|
||||
) {
|
||||
self.threadId = "INVALID_THREAD_ID"
|
||||
self.threadVariant = .contact
|
||||
|
@ -554,6 +561,7 @@ public extension MessageViewModel {
|
|||
self.positionInCluster = .middle
|
||||
self.isOnlyMessageInCluster = true
|
||||
self.isLast = isLast
|
||||
self.isLastOutgoing = isLastOutgoing
|
||||
self.currentUserBlindedPublicKey = nil
|
||||
}
|
||||
}
|
||||
|
@ -621,6 +629,7 @@ public extension MessageViewModel {
|
|||
|
||||
static func baseQuery(
|
||||
userPublicKey: String,
|
||||
blindedPublicKey: String?,
|
||||
orderSQL: SQL,
|
||||
groupSQL: SQL?
|
||||
) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<MessageViewModel>>) {
|
||||
|
@ -700,7 +709,8 @@ public extension MessageViewModel {
|
|||
false AS \(ViewModel.shouldShowDateHeaderKey),
|
||||
\(Position.middle) AS \(ViewModel.positionInClusterKey),
|
||||
false AS \(ViewModel.isOnlyMessageInClusterKey),
|
||||
false AS \(ViewModel.isLastKey)
|
||||
false AS \(ViewModel.isLastKey),
|
||||
false AS \(ViewModel.isLastOutgoingKey)
|
||||
|
||||
FROM \(Interaction.self)
|
||||
JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId])
|
||||
|
@ -717,7 +727,12 @@ public extension MessageViewModel {
|
|||
\(interactionAttachment[.attachmentId]) AS \(Quote.Columns.attachmentId)
|
||||
FROM \(Quote.self)
|
||||
LEFT JOIN \(Interaction.self) ON (
|
||||
\(quote[.authorId]) = \(interaction[.authorId]) AND
|
||||
(
|
||||
\(quote[.authorId]) = \(interaction[.authorId]) OR (
|
||||
\(quote[.authorId]) = \(blindedPublicKey ?? "") AND
|
||||
\(userPublicKey) = \(interaction[.authorId])
|
||||
)
|
||||
) AND
|
||||
\(quote[.timestampMs]) = \(interaction[.timestampMs])
|
||||
)
|
||||
LEFT JOIN \(InteractionAttachment.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
|
||||
|
|
|
@ -825,6 +825,10 @@ public extension SessionThreadViewModel {
|
|||
|
||||
let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name)
|
||||
|
||||
let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name)
|
||||
let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name)
|
||||
let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.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
|
||||
|
@ -851,7 +855,8 @@ public extension SessionThreadViewModel {
|
|||
\(ViewModel.closedGroupProfileBackFallbackKey).*,
|
||||
|
||||
\(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey),
|
||||
(\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey),
|
||||
(\(ViewModel.currentUserIsClosedGroupMemberKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey),
|
||||
(\(ViewModel.currentUserIsClosedGroupAdminKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey),
|
||||
\(openGroup[.name]) AS \(ViewModel.openGroupNameKey),
|
||||
\(openGroup[.server]) AS \(ViewModel.openGroupServerKey),
|
||||
\(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey),
|
||||
|
@ -865,10 +870,15 @@ public extension SessionThreadViewModel {
|
|||
LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id])
|
||||
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(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 \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupMemberKey) ON (
|
||||
\(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberRoleColumnLiteral) != \(GroupMember.Role.zombie)")) AND
|
||||
\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND
|
||||
\(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)"))
|
||||
)
|
||||
LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupAdminKey) ON (
|
||||
\(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) AND
|
||||
\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND
|
||||
\(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)"))
|
||||
)
|
||||
|
||||
LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON (
|
||||
|
@ -1607,15 +1617,14 @@ public extension SessionThreadViewModel {
|
|||
\(SQL("\(thread[.variant]) != \(SessionThread.Variant.contact)")) OR
|
||||
\(SQL("\(thread[.id]) = \(userPublicKey)")) OR
|
||||
\(contact[.isApproved]) = true
|
||||
) AND (
|
||||
-- Only show the 'Note to Self' thread if it has an interaction
|
||||
\(SQL("\(thread[.id]) != \(userPublicKey)")) OR
|
||||
\(interaction[.id]) IS NOT NULL
|
||||
)
|
||||
-- Always show the 'Note to Self' thread when sharing
|
||||
OR \(SQL("\(thread[.id]) = \(userPublicKey)"))
|
||||
)
|
||||
|
||||
GROUP BY \(thread[.id])
|
||||
ORDER BY IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC
|
||||
-- 'Note to Self', then by most recent message
|
||||
ORDER BY \(SQL("\(thread[.id]) = \(userPublicKey)")) DESC, IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC
|
||||
"""
|
||||
|
||||
return request.adapted { db in
|
||||
|
|
|
@ -33,6 +33,11 @@ public extension Setting.BoolKey {
|
|||
/// **Note:** Link Previews are only enabled for HTTPS urls
|
||||
static let areLinkPreviewsEnabled: Setting.BoolKey = "areLinkPreviewsEnabled"
|
||||
|
||||
/// Controls whether Giphy search is enabled
|
||||
///
|
||||
/// **Note:** Link Previews are only enabled for HTTPS urls
|
||||
static let isGiphyEnabled: Setting.BoolKey = "isGiphyEnabled"
|
||||
|
||||
/// Controls whether Calls are enabled
|
||||
static let areCallsEnabled: Setting.BoolKey = "areCallsEnabled"
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ public class ConfirmationModal: Modal {
|
|||
let explanation: String?
|
||||
let attributedExplanation: NSAttributedString?
|
||||
let accessibilityLabel: String?
|
||||
let accessibilityId: String?
|
||||
let accessibilityIdentifier: String?
|
||||
public let stateToShow: State
|
||||
let confirmTitle: String?
|
||||
let confirmAccessibilityLabel: String?
|
||||
|
@ -57,7 +57,7 @@ public class ConfirmationModal: Modal {
|
|||
self.explanation = explanation
|
||||
self.attributedExplanation = attributedExplanation
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.accessibilityId = accessibilityId
|
||||
self.accessibilityIdentifier = accessibilityId
|
||||
self.stateToShow = stateToShow
|
||||
self.confirmTitle = confirmTitle
|
||||
self.confirmAccessibilityLabel = confirmAccessibilityLabel
|
||||
|
@ -241,8 +241,8 @@ public class ConfirmationModal: Modal {
|
|||
cancelButton.setTitle(info.cancelTitle, for: .normal)
|
||||
cancelButton.setThemeTitleColor(info.cancelStyle, for: .normal)
|
||||
|
||||
self.accessibilityLabel = info.accessibilityLabel
|
||||
self.contentView.accessibilityIdentifier = info.accessibilityId
|
||||
self.contentView.accessibilityLabel = info.accessibilityLabel
|
||||
self.contentView.accessibilityIdentifier = info.accessibilityIdentifier
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_ClassicDark: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.green.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerDark,
|
||||
.disabled: .disabledDark,
|
||||
.backgroundPrimary: .classicDark0,
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_ClassicLight: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.green.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerLight,
|
||||
.disabled: .disabledLight,
|
||||
.backgroundPrimary: .classicLight6,
|
||||
|
|
|
@ -41,6 +41,7 @@ public extension Theme {
|
|||
// MARK: - Standard Theme Colors
|
||||
|
||||
internal extension UIColor {
|
||||
static let warning: UIColor = #colorLiteral(red: 0.9882352941, green: 0.6941176471, blue: 0.3490196078, alpha: 1) // #FCB159
|
||||
static let dangerDark: UIColor = #colorLiteral(red: 1, green: 0.2274509804, blue: 0.2274509804, alpha: 1) // #FF3A3A
|
||||
static let dangerLight: UIColor = #colorLiteral(red: 0.8823529412, green: 0.1764705882, blue: 0.09803921569, alpha: 1) // #E12D19
|
||||
static let disabledDark: UIColor = #colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1) // #A1A2A1
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_OceanDark: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.blue.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerDark,
|
||||
.disabled: .disabledDark,
|
||||
.backgroundPrimary: .oceanDark2,
|
||||
|
|
|
@ -10,6 +10,7 @@ internal enum Theme_OceanLight: ThemeColors {
|
|||
.clear: .clear,
|
||||
.primary: .primary,
|
||||
.defaultPrimary: Theme.PrimaryColor.blue.color,
|
||||
.warning: .warning,
|
||||
.danger: .dangerLight,
|
||||
.disabled: .disabledLight,
|
||||
.backgroundPrimary: .oceanLight7,
|
||||
|
|
|
@ -98,6 +98,7 @@ public indirect enum ThemeValue: Hashable {
|
|||
case clear
|
||||
case primary
|
||||
case defaultPrimary
|
||||
case warning
|
||||
case danger
|
||||
case disabled
|
||||
case backgroundPrimary
|
||||
|
|
|
@ -58,6 +58,8 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate {
|
|||
self.themeBackgroundColor = .clear
|
||||
|
||||
textView.delegate = self
|
||||
textView.accessibilityIdentifier = "Text input box"
|
||||
textView.isAccessibilityElement = true
|
||||
|
||||
let sendTitle = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "Label for 'send' button in the 'attachment approval' dialog.")
|
||||
sendButton.setTitle(sendTitle, for: .normal)
|
||||
|
@ -66,6 +68,8 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate {
|
|||
sendButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
sendButton.titleLabel?.textAlignment = .center
|
||||
sendButton.themeTintColor = .textPrimary
|
||||
sendButton.accessibilityIdentifier = "Send button"
|
||||
sendButton.isAccessibilityElement = true
|
||||
|
||||
// Increase hit area of send button
|
||||
sendButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8)
|
||||
|
|
|
@ -40,6 +40,8 @@ public class ModalActivityIndicatorViewController: OWSViewController {
|
|||
result.set(.width, to: 64)
|
||||
result.set(.height, to: 64)
|
||||
|
||||
result.accessibilityIdentifier = "Loading animation"
|
||||
|
||||
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
|
||||
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
|
||||
|
||||
|
|
Loading…
Reference in New Issue