Merge remote-tracking branch 'upstream/dev' into fix/appium-interaction-issues

# Conflicts:
#	Session/Conversations/Message Cells/VisibleMessageCell.swift
This commit is contained in:
Morgan Pretty 2023-03-16 16:10:53 +11:00
commit e1c83dc999
81 changed files with 1213 additions and 331 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,8 @@ final class DeletedMessageView: UIView {
init(textColor: ThemeValue) {
super.init(frame: CGRect.zero)
accessibilityIdentifier = "Deleted message"
isAccessibilityElement = true
setUpViewHierarchy(textColor: textColor)
}

View File

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

View File

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

View File

@ -98,7 +98,8 @@ public final class VoiceMessageView: UIView {
init() {
super.init(frame: CGRect.zero)
self.accessibilityIdentifier = "Voice message"
self.isAccessibilityElement = true
setUpViewHierarchy()
}

View File

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

View File

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

View File

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

View File

@ -218,6 +218,7 @@ class GifPickerCell: UICollectionViewCell {
return
}
imageView.image = image
imageView.accessibilityIdentifier = "gif cell"
self.themeBackgroundColor = nil
if self.isCellSelected {

View File

@ -2,7 +2,7 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import Reachability
import SignalUtilitiesKit
import PromiseKit

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@
import Foundation
import UserNotifications
import PromiseKit
import SignalCoreKit
import SignalUtilitiesKit
import SessionMessagingKit
class UserNotificationConfig {

View File

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

View File

@ -3,6 +3,7 @@
import UIKit
import Reachability
import SessionUIKit
import SessionSnodeKit
final class PathStatusView: UIView {
enum Size {

View File

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

View File

@ -4,6 +4,7 @@ import UIKit
import AVFoundation
import Curve25519Kit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -98,6 +98,7 @@ public indirect enum ThemeValue: Hashable {
case clear
case primary
case defaultPrimary
case warning
case danger
case disabled
case backgroundPrimary

View File

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

View File

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