Added back the majority of the ConversationVC interactions
Removed some more legacy code Added back logic similar to the pre-processing de-duping logic (was resulting in "unsent" messages reappearing) Added a number of updated view files
This commit is contained in:
parent
5432f5582e
commit
3f062c044c
|
@ -251,7 +251,6 @@
|
|||
B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */; };
|
||||
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; };
|
||||
B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; };
|
||||
B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */; };
|
||||
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; };
|
||||
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */; };
|
||||
B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; };
|
||||
|
@ -722,6 +721,7 @@
|
|||
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; };
|
||||
FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; };
|
||||
FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; };
|
||||
FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; };
|
||||
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; };
|
||||
FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; };
|
||||
FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; };
|
||||
|
@ -1278,7 +1278,6 @@
|
|||
B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Contacts.swift"; sourceTree = "<group>"; };
|
||||
B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = "<group>"; };
|
||||
B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = "<group>"; };
|
||||
B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotificationInfoMessage.swift; sourceTree = "<group>"; };
|
||||
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = "<group>"; };
|
||||
B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAttachmentModal.swift; sourceTree = "<group>"; };
|
||||
B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = "<group>"; };
|
||||
|
@ -1771,6 +1770,7 @@
|
|||
FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = "<group>"; };
|
||||
FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = "<group>"; };
|
||||
FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = "<group>"; };
|
||||
FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = "<group>"; };
|
||||
FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = "<group>"; };
|
||||
FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
|
||||
FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = "<group>"; };
|
||||
|
@ -2439,14 +2439,6 @@
|
|||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B8F5F61925EDE4B0003BF8D4 /* Data Extraction */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */,
|
||||
);
|
||||
path = "Data Extraction";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B8FF8E6025C10D8B004D1F22 /* Countries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2503,7 +2495,6 @@
|
|||
children = (
|
||||
FDF0B7562807F35E004C14C5 /* Errors */,
|
||||
C3D9E3B52567685D0040E4F3 /* Attachments */,
|
||||
B8F5F61925EDE4B0003BF8D4 /* Data Extraction */,
|
||||
C32C5D22256DD496003C73A2 /* Link Previews */,
|
||||
C379DC6825672B5E0002D4EB /* Notifications */,
|
||||
C32C59F8256DB5A6003C73A2 /* Pollers */,
|
||||
|
@ -3759,6 +3750,7 @@
|
|||
children = (
|
||||
FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */,
|
||||
FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */,
|
||||
FD09C5EB282B8F17000CE219 /* AttachmentError.swift */,
|
||||
);
|
||||
path = Errors;
|
||||
sourceTree = "<group>";
|
||||
|
@ -4877,7 +4869,6 @@
|
|||
FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */,
|
||||
C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */,
|
||||
C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */,
|
||||
B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */,
|
||||
C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */,
|
||||
FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */,
|
||||
FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */,
|
||||
|
@ -4890,6 +4881,7 @@
|
|||
FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */,
|
||||
C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */,
|
||||
B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */,
|
||||
FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */,
|
||||
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */,
|
||||
C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */,
|
||||
FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */,
|
||||
|
|
|
@ -85,8 +85,15 @@ extension ContextMenuVC {
|
|||
)
|
||||
)
|
||||
let canSave: Bool = (
|
||||
item.cellType != .textOnlyMessage &&
|
||||
canCopy
|
||||
item.cellType == .mediaMessage &&
|
||||
(item.attachments ?? [])
|
||||
.filter { attachment in
|
||||
attachment.isValid &&
|
||||
attachment.isVisualMedia && (
|
||||
attachment.state == .downloaded ||
|
||||
attachment.state == .uploaded
|
||||
)
|
||||
}.isEmpty == false
|
||||
)
|
||||
let canCopySessionId: Bool = (
|
||||
item.interactionVariant == .standardIncoming &&
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -410,8 +410,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
// Mentions
|
||||
MentionsManager.populateUserPublicKeyCacheIfNeeded(for: viewModel.viewData.thread.id)
|
||||
|
||||
// Draft
|
||||
if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty {
|
||||
|
@ -535,6 +533,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
updateNavBarButtons(viewData: updatedViewData)
|
||||
}
|
||||
|
||||
if viewModel.viewData.isClosedGroupMember != updatedViewData.isClosedGroupMember {
|
||||
reloadInputViews()
|
||||
}
|
||||
|
||||
if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes {
|
||||
snInputView.setEnabledMessageTypes(
|
||||
updatedViewData.enabledMessageTypes,
|
||||
|
@ -821,11 +823,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
func conversationViewModelDidReset() {
|
||||
// Not currently in use
|
||||
}
|
||||
|
||||
@objc private func handleGroupUpdatedNotification() {
|
||||
thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date
|
||||
reloadInputViews()
|
||||
}
|
||||
|
||||
@objc private func handleMessageSentStatusChanged() {
|
||||
DispatchQueue.main.async {
|
||||
|
@ -869,7 +866,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
cell.update(
|
||||
with: item,
|
||||
mediaCache: mediaCache,
|
||||
playbackInfo: viewModel.playbackInfo(for: item) { [weak self] updatedInfo, error in
|
||||
playbackInfo: viewModel.playbackInfo(for: item) { updatedInfo, error in
|
||||
DispatchQueue.main.async {
|
||||
guard error == nil else {
|
||||
OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized())
|
||||
|
|
|
@ -11,22 +11,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
case none
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private weak var delegate: InputViewDelegate?
|
||||
var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
|
||||
|
||||
var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
|
||||
var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
|
||||
private var voiceMessageRecordingView: VoiceMessageRecordingView?
|
||||
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
|
||||
|
||||
private lazy var linkPreviewView: LinkPreviewView = {
|
||||
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
|
||||
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
|
||||
let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
|
||||
|
||||
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
|
||||
self?.linkPreviewInfo = nil
|
||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
}()
|
||||
|
||||
var text: String {
|
||||
get { inputTextView.text }
|
||||
get { inputTextView.text ?? "" }
|
||||
set { inputTextView.text = newValue }
|
||||
}
|
||||
|
||||
|
||||
var enabledMessageTypes: MessageTypes = .all {
|
||||
didSet {
|
||||
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
||||
|
@ -96,71 +106,78 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
label.textAlignment = .center
|
||||
label.alpha = 0
|
||||
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var additionalContentContainer = UIView()
|
||||
|
||||
// MARK: Settings
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
// MARK: - Initialization
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(delegate: InputViewDelegate) {
|
||||
init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
|
||||
self.threadVariant = threadVariant
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(delegate:) instead.")
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(delegate:) instead.")
|
||||
}
|
||||
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
autoresizingMask = .flexibleHeight
|
||||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
// Bottom stack view
|
||||
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
|
||||
bottomStackView.axis = .horizontal
|
||||
bottomStackView.spacing = Values.smallSpacing
|
||||
bottomStackView.alignment = .center
|
||||
self.bottomStackView = bottomStackView
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
|
||||
addSubview(mainStackView)
|
||||
mainStackView.pin(.top, to: .bottom, of: separator)
|
||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
||||
mainStackView.pin(.bottom, to: .bottom, of: self)
|
||||
|
||||
|
||||
addSubview(disabledInputLabel)
|
||||
|
||||
|
||||
disabledInputLabel.pin(.top, to: .top, of: mainStackView)
|
||||
disabledInputLabel.pin(.left, to: .left, of: mainStackView)
|
||||
disabledInputLabel.pin(.right, to: .right, of: mainStackView)
|
||||
disabledInputLabel.set(.height, to: InputViewButton.expandedSize)
|
||||
|
||||
|
||||
// Mentions
|
||||
insertSubview(mentionsViewContainer, belowSubview: mainStackView)
|
||||
mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
|
||||
|
@ -168,12 +185,14 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
mentionsViewContainer.addSubview(mentionsView)
|
||||
mentionsView.pin(to: mentionsViewContainer)
|
||||
mentionsViewHeightConstraint.isActive = true
|
||||
|
||||
// Voice message button
|
||||
addSubview(voiceMessageButtonContainer)
|
||||
voiceMessageButtonContainer.center(in: sendButton)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
// MARK: Updating
|
||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
@ -185,7 +204,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
autoGenerateLinkPreviewIfPossible()
|
||||
delegate?.inputTextViewDidChangeContent(inputTextView)
|
||||
}
|
||||
|
||||
|
||||
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {
|
||||
delegate?.didPasteImageFromPasteboard(image)
|
||||
}
|
||||
|
@ -193,15 +212,29 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
// We want to show either a link preview or a quote draft, but never both at the same time. When trying to
|
||||
// generate a link preview, wait until we're sure that we'll be able to build a link preview from the given
|
||||
// URL before removing the quote draft.
|
||||
|
||||
|
||||
private func handleQuoteDraftChanged() {
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
linkPreviewInfo = nil
|
||||
|
||||
guard let quoteDraftInfo = quoteDraftInfo else { return }
|
||||
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
|
||||
|
||||
let hInset: CGFloat = 6 // Slight visual adjustment
|
||||
let maxWidth = additionalContentContainer.bounds.width
|
||||
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
|
||||
|
||||
let quoteView: QuoteView = QuoteView(
|
||||
for: .draft,
|
||||
authorId: quoteDraftInfo.model.authorId,
|
||||
quotedText: quoteDraftInfo.model.body,
|
||||
threadVariant: threadVariant,
|
||||
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
|
||||
attachment: quoteDraftInfo.model.attachment,
|
||||
hInset: hInset,
|
||||
maxWidth: maxWidth
|
||||
) { [weak self] in
|
||||
self?.quoteDraftInfo = nil
|
||||
}
|
||||
|
||||
additionalContentContainer.addSubview(quoteView)
|
||||
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
|
||||
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
|
||||
|
@ -212,7 +245,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
private func autoGenerateLinkPreviewIfPossible() {
|
||||
// Don't allow link previews on 'none' or 'textOnly' input
|
||||
guard enabledMessageTypes == .all else { return }
|
||||
|
||||
|
||||
// Suggest that the user enable link previews if they haven't already and we haven't
|
||||
// told them about link previews yet
|
||||
let text = inputTextView.text!
|
||||
|
@ -234,42 +267,51 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against obsolete updates
|
||||
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
|
||||
|
||||
// Clear content container
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
quoteDraftInfo = nil
|
||||
|
||||
// Set the state to loading
|
||||
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
|
||||
linkPreviewView.linkPreviewState = LinkPreviewLoading()
|
||||
linkPreviewView.update(with: LinkPreviewLoading(), isOutgoing: false)
|
||||
|
||||
// Add the link preview view
|
||||
additionalContentContainer.addSubview(linkPreviewView)
|
||||
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
|
||||
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
|
||||
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
|
||||
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
|
||||
|
||||
// Build the link preview
|
||||
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
|
||||
guard let self = self else { return }
|
||||
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
||||
self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
|
||||
}.catch { _ in
|
||||
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
self.linkPreviewInfo = nil
|
||||
self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}.retainUntilComplete()
|
||||
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
|
||||
.done { [weak self] draft in
|
||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
|
||||
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
||||
self?.linkPreviewView.update(with: LinkPreviewDraft(linkPreviewDraft: draft), isOutgoing: false)
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
|
||||
self?.linkPreviewInfo = nil
|
||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
|
||||
func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) {
|
||||
guard enabledMessageTypes != messageTypes else { return }
|
||||
|
||||
|
||||
enabledMessageTypes = messageTypes
|
||||
disabledInputLabel.text = (message ?? "")
|
||||
|
||||
|
||||
attachmentsButton.isUserInteractionEnabled = (messageTypes == .all)
|
||||
voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all)
|
||||
|
||||
|
||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||
self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0)
|
||||
self?.attachmentsButton.alpha = (messageTypes == .all ?
|
||||
|
@ -283,35 +325,40 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
// MARK: Interaction
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
|
||||
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
|
||||
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||
if let buttonContainer = buttonContainer {
|
||||
|
||||
if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) {
|
||||
return buttonContainer
|
||||
} else {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
|
||||
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
|
||||
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||
let isPointInsideAttachmentsButton = buttonContainers
|
||||
.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||
|
||||
if isPointInsideAttachmentsButton {
|
||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||
return true
|
||||
} else if mentionsViewContainer.frame.contains(point) {
|
||||
}
|
||||
|
||||
if mentionsViewContainer.frame.contains(point) {
|
||||
// Needed so that the user can tap mentions
|
||||
return true
|
||||
} else {
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
|
||||
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
|
||||
}
|
||||
|
@ -334,10 +381,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
voiceMessageRecordingView.handleLongPressEnded(at: location)
|
||||
}
|
||||
|
||||
func handleQuoteViewCancelButtonTapped() {
|
||||
delegate?.handleQuoteViewCancelButtonTapped()
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
inputTextView.resignFirstResponder()
|
||||
}
|
||||
|
@ -346,11 +389,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
// Not relevant in this case
|
||||
}
|
||||
|
||||
func handleLinkPreviewCanceled() {
|
||||
linkPreviewInfo = nil
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
|
||||
@objc private func showVoiceMessageUI() {
|
||||
voiceMessageRecordingView?.removeFromSuperview()
|
||||
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
|
||||
|
@ -378,30 +416,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
}
|
||||
|
||||
func hideMentionsUI() {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.mentionsViewContainer.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.mentionsViewHeightConstraint.constant = 0
|
||||
self.mentionsView.tableView.contentOffset = CGPoint.zero
|
||||
})
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { [weak self] in
|
||||
self?.mentionsViewContainer.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.mentionsViewHeightConstraint.constant = 0
|
||||
self?.mentionsView.contentOffset = CGPoint.zero
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func showMentionsUI(for candidates: [Mention], in thread: TSThread) {
|
||||
if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
|
||||
mentionsView.openGroupServer = openGroupV2.server
|
||||
mentionsView.openGroupRoom = openGroupV2.room
|
||||
}
|
||||
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
|
||||
mentionsView.candidates = candidates
|
||||
let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing
|
||||
|
||||
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
|
||||
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
|
||||
layoutIfNeeded()
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.mentionsViewContainer.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mention, from: view)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mentionInfo, from: view)
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
@ -417,13 +457,12 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||
func showLinkPreviewSuggestionModal()
|
||||
func handleSendButtonTapped()
|
||||
func handleQuoteViewCancelButtonTapped()
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
func didPasteImageFromPasteboard(_ image: UIImage)
|
||||
}
|
||||
|
|
|
@ -32,21 +32,25 @@ final class DocumentView: UIView {
|
|||
let iconImageViewSize = DocumentView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize.width)
|
||||
imageView.set(.height, to: iconImageViewSize.height)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = attachment.sourceFilename ?? "File"
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
|
||||
|
||||
// Size label
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||
sizeLabel.textColor = textColor
|
||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack view
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,30 +1,24 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class OpenGroupInvitationView : UIView {
|
||||
private let name: String
|
||||
private let rawURL: String
|
||||
private let textColor: UIColor
|
||||
private let isOutgoing: Bool
|
||||
|
||||
private lazy var url: String = {
|
||||
if let range = rawURL.range(of: "?public_key=") {
|
||||
return String(rawURL[..<range.lowerBound])
|
||||
} else {
|
||||
return rawURL
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class OpenGroupInvitationView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 48
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
self.name = name
|
||||
self.rawURL = url
|
||||
self.textColor = textColor
|
||||
self.isOutgoing = isOutgoing
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(
|
||||
name: name,
|
||||
rawUrl: url,
|
||||
textColor: textColor,
|
||||
isOutgoing: isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -35,32 +29,42 @@ final class OpenGroupInvitationView : UIView {
|
|||
preconditionFailure("Use init(name:url:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = name
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
|
||||
// Subtitle
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
|
||||
subtitleLabel.textColor = textColor
|
||||
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
|
||||
// URL
|
||||
let urlLabel = UILabel()
|
||||
urlLabel.lineBreakMode = .byCharWrapping
|
||||
urlLabel.text = url
|
||||
urlLabel.text = {
|
||||
if let range = rawUrl.range(of: "?public_key=") {
|
||||
return String(rawUrl[..<range.lowerBound])
|
||||
}
|
||||
|
||||
return rawUrl
|
||||
}()
|
||||
urlLabel.textColor = textColor
|
||||
urlLabel.numberOfLines = 0
|
||||
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Icon
|
||||
let iconSize = OpenGroupInvitationView.iconSize
|
||||
let iconName = isOutgoing ? "Globe" : "Plus"
|
||||
let iconName = (isOutgoing ? "Globe" : "Plus")
|
||||
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
|
||||
let iconImageView = UIImageView(image: icon)
|
||||
|
@ -70,6 +74,7 @@ final class OpenGroupInvitationView : UIView {
|
|||
iconImageView.backgroundColor = Colors.accent
|
||||
iconImageView.set(.width, to: iconImageViewSize)
|
||||
iconImageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
// Main stack
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
|
|
@ -83,24 +83,33 @@ final class DownloadAttachmentModal: Modal {
|
|||
// MARK: - Interaction
|
||||
|
||||
@objc private func trust() {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
|
||||
|
||||
GRDBStorage.shared.writeAsync(
|
||||
updates: { db in
|
||||
try? Contact
|
||||
.fetchOrCreate(db, id: message.authorId)
|
||||
.with(isTrusted: true)
|
||||
.save(db)
|
||||
},
|
||||
completion: { _, _ in
|
||||
Storage.write(with: { transaction in
|
||||
MessageInvalidator.invalidate(message, with: transaction)
|
||||
}, completion: {
|
||||
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
guard let profileId: String = profile?.id else { return }
|
||||
|
||||
GRDBStorage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: profileId)
|
||||
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
||||
|
||||
// Start downloading any pending attachments for this contact (UI will automatically be
|
||||
// updated due to the database observation)
|
||||
try Attachment
|
||||
.stateInfo(authorId: profileId, state: .pending)
|
||||
.fetchAll(db)
|
||||
.forEach { attachmentDownloadInfo in
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .attachmentDownload,
|
||||
threadId: profileId,
|
||||
interactionId: attachmentDownloadInfo.interactionId,
|
||||
details: AttachmentDownloadJob.Details(
|
||||
attachmentId: attachmentDownloadInfo.attachmentId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -458,8 +458,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
progressiveSearchTimer = nil
|
||||
|
||||
guard let text = searchBar.text else {
|
||||
OWSAlerts.showErrorAlert(message: NSLocalizedString("GIF_PICKER_VIEW_MISSING_QUERY",
|
||||
comment: "Alert message shown when user tries to search for GIFs without entering any search terms."))
|
||||
// Alert message shown when user tries to search for GIFs without entering any search terms
|
||||
OWSAlerts.showErrorAlert(message: "GIF_PICKER_VIEW_MISSING_QUERY".localized())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Fehler";
|
||||
|
|
|
@ -630,3 +630,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Fallo";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "خطاء";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Erreur";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Galat";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Errore";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "エラー";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Błąd";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Erro";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Ошибка";
|
||||
|
|
|
@ -621,3 +621,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "Error";
|
||||
|
|
|
@ -620,3 +620,4 @@
|
|||
"NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred";
|
||||
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
|
||||
"ALERT_ERROR_TITLE" = "错误";
|
||||
|
|
|
@ -355,8 +355,8 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
return false
|
||||
}
|
||||
|
||||
let now = NSDate.ows_millisecondTimeStamp()
|
||||
let recentThreshold = now - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
|
||||
let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs))
|
||||
|
||||
let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold }
|
||||
|
||||
|
@ -364,7 +364,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
return false
|
||||
}
|
||||
|
||||
mostRecentNotifications.append(now)
|
||||
mostRecentNotifications.append(nowMs)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ final class RestoreVC: BaseVC {
|
|||
// MARK: Components
|
||||
private lazy var mnemonicTextView: TextView = {
|
||||
let result = TextView(placeholder: NSLocalizedString("vc_restore_seed_text_field_hint", comment: ""))
|
||||
result.autocapitalizationType = .none
|
||||
result.layer.borderColor = Colors.text.cgColor
|
||||
result.accessibilityLabel = "Recovery phrase text view"
|
||||
return result
|
||||
|
|
|
@ -365,15 +365,17 @@ final class ConversationCell: UITableViewCell {
|
|||
)
|
||||
displayNameLabel.text = threadInfo.displayName
|
||||
timestampLabel.text = DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate)
|
||||
// if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil {
|
||||
// snippetLabel.text = ""
|
||||
// typingIndicatorView.isHidden = false
|
||||
// typingIndicatorView.startAnimation()
|
||||
// } else {
|
||||
|
||||
if threadInfo.contactIsTyping {
|
||||
snippetLabel.text = ""
|
||||
typingIndicatorView.isHidden = false
|
||||
typingIndicatorView.startAnimation()
|
||||
}
|
||||
else {
|
||||
snippetLabel.attributedText = getSnippet(threadInfo: threadInfo)
|
||||
typingIndicatorView.isHidden = true
|
||||
typingIndicatorView.stopAnimation()
|
||||
// }
|
||||
}
|
||||
|
||||
statusIndicatorView.backgroundColor = nil
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
@objc(LKModal)
|
||||
class Modal: BaseVC, UIGestureRecognizerDelegate {
|
||||
|
|
|
@ -293,7 +293,8 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
.joined(separator: "-")
|
||||
}
|
||||
|
||||
try threads.forEach { thread in
|
||||
// Sort by id just so we can make the migration process more determinstic
|
||||
try threads.sorted(by: { lhs, rhs in (lhs.uniqueId ?? "") < (rhs.uniqueId ?? "") }).forEach { thread in
|
||||
guard
|
||||
let legacyThreadId: String = thread.uniqueId,
|
||||
let threadId: String = legacyThreadIdToIdMap[legacyThreadId]
|
||||
|
@ -423,7 +424,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
try interactions[legacyThreadId]?
|
||||
.sorted(by: { lhs, rhs in lhs.sortId < rhs.sortId }) // Maintain sort order
|
||||
.sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order
|
||||
.forEach { legacyInteraction in
|
||||
let serverHash: String?
|
||||
let variant: Interaction.Variant
|
||||
|
|
|
@ -579,10 +579,17 @@ public extension Attachment {
|
|||
) -> (isValid: Bool, duration: TimeInterval?) {
|
||||
guard let originalFilePath: String = originalFilePath else { return (false, nil) }
|
||||
|
||||
let constructedFilePath: String? = localRelativeFilePath.map {
|
||||
URL(fileURLWithPath: Attachment.attachmentsFolder)
|
||||
.appendingPathComponent($0)
|
||||
.path
|
||||
}
|
||||
let targetPath: String = (constructedFilePath ?? originalFilePath)
|
||||
|
||||
// Process audio attachments
|
||||
if MIMETypeUtil.isAudio(contentType) {
|
||||
do {
|
||||
let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: originalFilePath))
|
||||
let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: targetPath))
|
||||
|
||||
return ((audioPlayer.duration > 0), audioPlayer.duration)
|
||||
}
|
||||
|
@ -590,7 +597,7 @@ public extension Attachment {
|
|||
switch (error as NSError).code {
|
||||
case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile):
|
||||
// Ignore "invalid audio file" errors
|
||||
return (false, nil) // TODO: Confirm this behaviour (previously returned 0)
|
||||
return (false, nil)
|
||||
|
||||
default: return (false, nil)
|
||||
}
|
||||
|
@ -599,51 +606,23 @@ public extension Attachment {
|
|||
|
||||
// Process image attachments
|
||||
if MIMETypeUtil.isImage(contentType) {
|
||||
let specificFilePathIsValid: Bool = (
|
||||
localRelativeFilePath != nil &&
|
||||
localRelativeFilePath.map {
|
||||
NSData.ows_isValidImage(
|
||||
atPath: URL(fileURLWithPath: Attachment.attachmentsFolder)
|
||||
.appendingPathComponent($0)
|
||||
.path,
|
||||
mimeType: contentType
|
||||
)
|
||||
} == true
|
||||
)
|
||||
|
||||
return (
|
||||
(
|
||||
specificFilePathIsValid ||
|
||||
NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType)
|
||||
),
|
||||
NSData.ows_isValidImage(atPath: targetPath, mimeType: contentType),
|
||||
nil
|
||||
)
|
||||
}
|
||||
|
||||
// Process video attachments
|
||||
if MIMETypeUtil.isVideo(contentType) {
|
||||
let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath))
|
||||
let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: targetPath))
|
||||
let durationSeconds: TimeInterval? = videoPlayer.currentItem
|
||||
.map { item -> TimeInterval in
|
||||
// Accorting to the CMTime docs "value/timescale = seconds"
|
||||
(TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale))
|
||||
}
|
||||
let specificFilePathIsValid: Bool = (
|
||||
localRelativeFilePath != nil &&
|
||||
localRelativeFilePath.map {
|
||||
OWSMediaUtils.isValidVideo(
|
||||
path: URL(fileURLWithPath: Attachment.attachmentsFolder)
|
||||
.appendingPathComponent($0)
|
||||
.path
|
||||
)
|
||||
} == true
|
||||
)
|
||||
|
||||
return (
|
||||
(
|
||||
specificFilePathIsValid ||
|
||||
OWSMediaUtils.isValidVideo(path: originalFilePath)
|
||||
),
|
||||
OWSMediaUtils.isValidVideo(path: targetPath),
|
||||
durationSeconds
|
||||
)
|
||||
}
|
||||
|
@ -720,6 +699,8 @@ extension Attachment {
|
|||
public var isVideo: Bool { MIMETypeUtil.isVideo(contentType) }
|
||||
public var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) }
|
||||
public var isAudio: Bool { MIMETypeUtil.isAudio(contentType) }
|
||||
public var isText: Bool { MIMETypeUtil.isText(contentType) }
|
||||
public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) }
|
||||
|
||||
public var isVisualMedia: Bool { isImage || isVideo || isAnimated }
|
||||
|
||||
|
@ -793,22 +774,6 @@ extension Attachment {
|
|||
// MARK: - Upload
|
||||
|
||||
extension Attachment {
|
||||
internal enum UploadError: LocalizedError {
|
||||
case invalidStartState
|
||||
case noAttachment
|
||||
case notUploaded
|
||||
case encryptionFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidStartState: return "Cannot upload an attachment in this state."
|
||||
case .noAttachment: return "No such attachment."
|
||||
case .notUploaded: return "Attachment not uploaded."
|
||||
case .encryptionFailed: return "Couldn't encrypt file."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal func upload(
|
||||
using upload: (Data) -> Promise<UInt64>,
|
||||
encrypt: Bool,
|
||||
|
@ -817,14 +782,14 @@ extension Attachment {
|
|||
) {
|
||||
guard state != .uploaded else {
|
||||
SNLog("Attempted to upload an already uploaded/downloaded attachment.")
|
||||
failure?(UploadError.invalidStartState)
|
||||
failure?(AttachmentError.invalidStartState)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the attachment
|
||||
guard var data = try? readDataFromFile() else {
|
||||
SNLog("Couldn't read attachment from disk.")
|
||||
failure?(UploadError.noAttachment)
|
||||
failure?(AttachmentError.noAttachment)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -868,7 +833,7 @@ extension Attachment {
|
|||
|
||||
guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else {
|
||||
SNLog("Couldn't encrypt attachment.")
|
||||
failure?(UploadError.encryptionFailed)
|
||||
failure?(AttachmentError.encryptionFailed)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -64,20 +64,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis
|
|||
self.didApproveMe = didApproveMe
|
||||
self.hasBeenBlocked = (isBlocked || hasBeenBlocked)
|
||||
}
|
||||
|
||||
// MARK: - PersistableRecord
|
||||
|
||||
public func save(_ db: Database) throws {
|
||||
let oldContact: Contact? = try? Contact.fetchOne(db, id: id)
|
||||
|
||||
try performSave(db)
|
||||
|
||||
db.afterNextTransactionCommit { db in
|
||||
if isBlocked != oldContact?.isBlocked {
|
||||
NotificationCenter.default.post(name: .contactBlockedStateChanged, object: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
|
|
@ -474,6 +474,9 @@ public extension Interaction {
|
|||
)
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
|
|
|
@ -95,7 +95,7 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor
|
|||
// MARK: - Protobuf
|
||||
|
||||
public extension LinkPreview {
|
||||
init?(_ db: Database, proto: SNProtoDataMessage, body: String?) throws {
|
||||
init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws {
|
||||
guard OWSLinkPreview.featureEnabled else { throw LinkPreviewError.noPreview }
|
||||
guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview }
|
||||
guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput }
|
||||
|
@ -107,7 +107,7 @@ public extension LinkPreview {
|
|||
}
|
||||
|
||||
// Try to get an existing link preview first
|
||||
let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(proto.timestamp))
|
||||
let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs)
|
||||
let maybeLinkPreview: LinkPreview? = try? LinkPreview
|
||||
.filter(LinkPreview.Columns.url == previewProto.url)
|
||||
.filter(LinkPreview.Columns.timestamp == LinkPreview.timestampFor(
|
||||
|
|
|
@ -129,9 +129,9 @@ public extension OpenGroup {
|
|||
@objc(SMKOpenGroup)
|
||||
public class SMKOpenGroup: NSObject {
|
||||
@objc(inviteUsers:toOpenGroupFor:)
|
||||
public static func invite(selectedUsers: Set<String>, threadId: String) {
|
||||
public static func invite(selectedUsers: Set<String>, openGroupThreadId: String) {
|
||||
GRDBStorage.shared.write { db in
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return }
|
||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: openGroupThreadId) else { return }
|
||||
|
||||
let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)"
|
||||
|
||||
|
@ -146,7 +146,7 @@ public class SMKOpenGroup: NSObject {
|
|||
.save(db)
|
||||
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
threadId: thread.id,
|
||||
authorId: userId,
|
||||
variant: .standardOutgoing,
|
||||
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)),
|
||||
|
|
|
@ -7,7 +7,6 @@ public extension Notification.Name {
|
|||
static let profileUpdated = Notification.Name("profileUpdated")
|
||||
static let localProfileDidChange = Notification.Name("localProfileDidChange")
|
||||
static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange")
|
||||
static let contactBlockedStateChanged = Notification.Name("contactBlockedStateChanged")
|
||||
}
|
||||
|
||||
@objc public extension NSNotification {
|
||||
|
@ -15,7 +14,6 @@ public extension Notification.Name {
|
|||
@objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString
|
||||
@objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString
|
||||
@objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString
|
||||
@objc static let contactBlockedStateChanged = Notification.Name.contactBlockedStateChanged.rawValue as NSString
|
||||
}
|
||||
|
||||
extension Notification.Key {
|
||||
|
|
|
@ -57,16 +57,4 @@ extension AttachmentUploadJob {
|
|||
self.attachmentId = attachmentId
|
||||
}
|
||||
}
|
||||
|
||||
public enum AttachmentUploadError: LocalizedError {
|
||||
case noAttachment
|
||||
case encryptionFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAttachment: return "No such attachment."
|
||||
case .encryptionFailed: return "Couldn't encrypt file."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,10 +56,11 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
catch {
|
||||
switch error {
|
||||
// Note: This is the same as the 'MessageReceiverError.duplicateMessage'
|
||||
// which is not retryable so just skip to the next message to process
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE:
|
||||
SNLog("MessageReceiveJob skipping duplicate message.")
|
||||
continue
|
||||
// which is not retryable so just skip to the next message to process (no
|
||||
// longer logging this because all de-duping happens here now rather than
|
||||
// when parsing as it did previously - this change results in excessive
|
||||
// logging which isn't useful)
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: continue
|
||||
|
||||
default: break
|
||||
}
|
||||
|
@ -67,6 +68,11 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
// If the current message is a permanent failure then override it with the
|
||||
// new error (we want to retry if there is a single non-permanent error)
|
||||
switch error {
|
||||
// Ignore self-send errors (they will be permanently failed but no need
|
||||
// to log since we are going to have a lot of the due to the change to the
|
||||
// de-duping logic)
|
||||
case MessageReceiverError.selfSend: continue
|
||||
|
||||
case let receiverError as MessageReceiverError where !receiverError.isRetryable:
|
||||
SNLog("MessageReceiveJob permanently failed message due to error: \(error)")
|
||||
continue
|
||||
|
|
|
@ -101,7 +101,7 @@ public enum MessageSendJob: JobExecutor {
|
|||
// Note: If we have gotten to this point then any dependant attachment upload
|
||||
// jobs will have permanently failed so this message send should also do so
|
||||
guard attachmentState?.shouldFail == false else {
|
||||
failure(job, Attachment.UploadError.notUploaded, true)
|
||||
failure(job, AttachmentError.notUploaded, true)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -117,7 +117,7 @@ public enum MessageSendJob: JobExecutor {
|
|||
|
||||
// Perform the actual message sending
|
||||
GRDBStorage.shared.write { db -> Promise<Void> in
|
||||
try MessageSender.send(
|
||||
try MessageSender.sendImmediate(
|
||||
db,
|
||||
message: details.message,
|
||||
to: details.destination,
|
||||
|
|
|
@ -36,7 +36,7 @@ public enum SendReadReceiptsJob: JobExecutor {
|
|||
|
||||
GRDBStorage.shared
|
||||
.write { db in
|
||||
try MessageSender.send(
|
||||
try MessageSender.sendImmediate(
|
||||
db,
|
||||
message: ReadReceipt(
|
||||
timestamps: details.timestampMsValues.map { UInt64($0) }
|
||||
|
|
|
@ -25,7 +25,7 @@ public final class UnsendRequest: ControlMessage {
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
internal init(timestamp: UInt64, author: String) {
|
||||
public init(timestamp: UInt64, author: String) {
|
||||
super.init()
|
||||
|
||||
self.timestamp = timestamp
|
||||
|
|
|
@ -161,7 +161,12 @@ public final class VisibleMessage: Message {
|
|||
dataMessage.setAttachments(attachmentProtos)
|
||||
|
||||
// Open group invitation
|
||||
if let openGroupInvitation = openGroupInvitation, let openGroupInvitationProto = openGroupInvitation.toProto() { dataMessage.setOpenGroupInvitation(openGroupInvitationProto) }
|
||||
if
|
||||
let openGroupInvitation = openGroupInvitation,
|
||||
let openGroupInvitationProto = openGroupInvitation.toProto()
|
||||
{
|
||||
dataMessage.setOpenGroupInvitation(openGroupInvitationProto)
|
||||
}
|
||||
|
||||
// Group context
|
||||
do {
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc(SNDataExtractionNotificationInfoMessage)
|
||||
final class DataExtractionNotificationInfoMessage : TSInfoMessage {
|
||||
|
||||
init(type: TSInfoMessageType, sentTimestamp: UInt64, thread: TSThread, referencedAttachmentTimestamp: UInt64?) {
|
||||
super.init(timestamp: sentTimestamp, in: thread, messageType: type)
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
required init(dictionary dictionaryValue: [String: Any]!) throws {
|
||||
try super.init(dictionary: dictionaryValue)
|
||||
}
|
||||
|
||||
override func previewText(with transaction: YapDatabaseReadTransaction) -> String {
|
||||
guard let thread = thread as? TSContactThread else { return "" } // Should never occur
|
||||
|
||||
let displayName = Profile.displayName(for: thread.contactSessionID())
|
||||
|
||||
switch messageType {
|
||||
case .screenshotNotification:
|
||||
return String(format: NSLocalizedString("screenshot_taken", comment: ""), displayName)
|
||||
|
||||
case .mediaSavedNotification:
|
||||
// TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved
|
||||
return String(format: NSLocalizedString("meida_saved", comment: ""), displayName)
|
||||
|
||||
default: preconditionFailure()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +1,21 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AttachmentError: LocalizedError {
|
||||
case invalidStartState
|
||||
case noAttachment
|
||||
case notUploaded
|
||||
case invalidData
|
||||
case encryptionFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidStartState: return "Cannot upload an attachment in this state."
|
||||
case .noAttachment: return "No such attachment."
|
||||
case .notUploaded: return "Attachment not uploaded."
|
||||
case .invalidData: return "Invalid attachment data."
|
||||
case .encryptionFailed: return "Couldn't encrypt file."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -356,20 +356,21 @@ extension MessageReceiver {
|
|||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: interaction.notificationIdentifiers)
|
||||
}
|
||||
|
||||
if author == message.sender {
|
||||
if let serverHash: String = interaction.serverHash {
|
||||
SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete()
|
||||
}
|
||||
|
||||
_ = try interaction
|
||||
.markingAsDeleted()
|
||||
.saved(db)
|
||||
|
||||
_ = try interaction.attachments
|
||||
.deleteAll(db)
|
||||
if author == message.sender, let serverHash: String = interaction.serverHash {
|
||||
SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete()
|
||||
}
|
||||
else {
|
||||
_ = try interaction.delete(db)
|
||||
|
||||
switch (interaction.variant, (author == message.sender)) {
|
||||
case (.standardOutgoing, _), (_, false):
|
||||
_ = try interaction.delete(db)
|
||||
|
||||
case (_, true):
|
||||
_ = try interaction
|
||||
.markingAsDeleted()
|
||||
.saved(db)
|
||||
|
||||
_ = try interaction.attachments
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -511,7 +512,15 @@ extension MessageReceiver {
|
|||
return (attachment.downloadUrl != nil ? attachment : nil)
|
||||
}
|
||||
.map { attachment in
|
||||
try attachment.saved(db)
|
||||
let savedAttachment: Attachment = try attachment.saved(db)
|
||||
|
||||
// Link the attachment to the interaction and add to the id lookup
|
||||
try InteractionAttachment(
|
||||
interactionId: interactionId,
|
||||
attachmentId: savedAttachment.id
|
||||
).insert(db)
|
||||
|
||||
return savedAttachment
|
||||
}
|
||||
|
||||
message.attachmentIds = attachments.map { $0.id }
|
||||
|
@ -528,7 +537,8 @@ extension MessageReceiver {
|
|||
let linkPreview: LinkPreview? = try? LinkPreview(
|
||||
db,
|
||||
proto: dataMessage,
|
||||
body: message.text
|
||||
body: message.text,
|
||||
sentTimestampMs: (messageSentTimestamp * 1000)
|
||||
)?.saved(db)
|
||||
|
||||
// Open group invitations are stored as LinkPreview values so create one if needed
|
||||
|
|
|
@ -35,7 +35,7 @@ public enum MessageReceiver {
|
|||
else {
|
||||
switch envelope.type {
|
||||
case .sessionMessage:
|
||||
guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else {
|
||||
guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else {
|
||||
throw MessageReceiverError.noUserX25519KeyPair
|
||||
}
|
||||
|
||||
|
|
|
@ -13,11 +13,12 @@ extension MessageSender {
|
|||
guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved }
|
||||
// TODO: Is the 'prep' method needed anymore?
|
||||
// prep(db, attachments, for: message)
|
||||
try send(
|
||||
send(
|
||||
db,
|
||||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
threadId: thread.id,
|
||||
interactionId: interactionId,
|
||||
in: thread
|
||||
to: try Message.Destination.from(db, thread: thread)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -26,18 +27,34 @@ extension MessageSender {
|
|||
guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage }
|
||||
guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved }
|
||||
|
||||
return try send(db, message: VisibleMessage.from(db, interaction: interaction), interactionId: interactionId, in: thread)
|
||||
send(
|
||||
db,
|
||||
message: VisibleMessage.from(db, interaction: interaction),
|
||||
threadId: thread.id,
|
||||
interactionId: interactionId,
|
||||
to: try Message.Destination.from(db, thread: thread)
|
||||
)
|
||||
}
|
||||
|
||||
public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws {
|
||||
send(
|
||||
db,
|
||||
message: message,
|
||||
threadId: thread.id,
|
||||
interactionId: interactionId,
|
||||
to: try Message.Destination.from(db, thread: thread)
|
||||
)
|
||||
}
|
||||
|
||||
public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination) {
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .messageSend,
|
||||
threadId: thread.id,
|
||||
threadId: threadId,
|
||||
interactionId: interactionId,
|
||||
details: MessageSendJob.Details(
|
||||
destination: try Message.Destination.from(db, thread: thread),
|
||||
destination: destination,
|
||||
message: message
|
||||
)
|
||||
)
|
||||
|
@ -98,7 +115,7 @@ extension MessageSender {
|
|||
|
||||
|
||||
public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise<Void> {
|
||||
return try MessageSender.send(
|
||||
return try MessageSender.sendImmediate(
|
||||
db,
|
||||
message: message,
|
||||
to: try Message.Destination.from(db, thread: thread),
|
||||
|
@ -110,7 +127,7 @@ extension MessageSender {
|
|||
}
|
||||
|
||||
public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) throws -> Promise<Void> {
|
||||
return try MessageSender.send(
|
||||
return try MessageSender.sendImmediate(
|
||||
db,
|
||||
message: message,
|
||||
to: destination,
|
||||
|
@ -136,7 +153,7 @@ extension MessageSender {
|
|||
|
||||
if forceSyncNow {
|
||||
try MessageSender
|
||||
.send(db, message: configurationMessage, to: destination, interactionId: nil)
|
||||
.sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil)
|
||||
.done { seal.fulfill(()) }
|
||||
.catch { _ in seal.reject(GRDBStorageError.generic) }
|
||||
.retainUntilComplete()
|
||||
|
|
|
@ -67,7 +67,7 @@ public final class MessageSender : NSObject {
|
|||
|
||||
// MARK: - Convenience
|
||||
|
||||
public static func send(_ 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?) throws -> Promise<Void> {
|
||||
switch destination {
|
||||
case .contact(_), .closedGroup(_):
|
||||
return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId)
|
||||
|
@ -88,15 +88,13 @@ public final class MessageSender : NSObject {
|
|||
) throws -> Promise<Void> {
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false)
|
||||
|
||||
// Set the timestamp, sender and recipient
|
||||
if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set
|
||||
}
|
||||
message.sentTimestamp = UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
|
||||
let isSelfSend: Bool = (message.recipient == userPublicKey)
|
||||
message.sentTimestamp = (
|
||||
message.sentTimestamp ?? // Visible messages will already have their sent timestamp set
|
||||
UInt64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
)
|
||||
message.sender = userPublicKey
|
||||
message.recipient = {
|
||||
switch destination {
|
||||
|
@ -123,6 +121,7 @@ public final class MessageSender : NSObject {
|
|||
// • a sync message
|
||||
// • a closed group control message of type `new`
|
||||
// • an unsend request
|
||||
let isSelfSend: Bool = (message.recipient == userPublicKey)
|
||||
let isNewClosedGroupControlMessage: Bool = {
|
||||
switch (message as? ClosedGroupControlMessage)?.kind {
|
||||
case .new: return true
|
||||
|
@ -147,14 +146,14 @@ public final class MessageSender : NSObject {
|
|||
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
|
||||
if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl {
|
||||
message.profile = VisibleMessage.Profile(
|
||||
message.profile = VisibleMessage.VMProfile(
|
||||
displayName: profile.name,
|
||||
profileKey: profileKey,
|
||||
profilePictureUrl: profilePictureUrl
|
||||
)
|
||||
}
|
||||
else {
|
||||
message.profile = VisibleMessage.Profile(displayName: profile.name)
|
||||
message.profile = VisibleMessage.VMProfile(displayName: profile.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -371,7 +370,7 @@ public final class MessageSender : NSObject {
|
|||
}
|
||||
|
||||
// Attach the user's profile
|
||||
message.profile = VisibleMessage.Profile(
|
||||
message.profile = VisibleMessage.VMProfile(
|
||||
profile: Profile.fetchOrCreateCurrentUser()
|
||||
)
|
||||
|
||||
|
@ -471,8 +470,6 @@ public final class MessageSender : NSObject {
|
|||
try interaction.recipientStates
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
|
||||
|
||||
NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil)
|
||||
|
||||
// Start the disappearing messages timer if needed
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
|
|
|
@ -7,7 +7,7 @@ import SessionSnodeKit
|
|||
|
||||
@objc(LKClosedGroupPoller)
|
||||
public final class ClosedGroupPoller: NSObject {
|
||||
private var isPolling: [String: Bool] = [:]
|
||||
private var isPolling: Atomic<[String: Bool]> = Atomic([:])
|
||||
private var timers: [String: Timer] = [:]
|
||||
private let internalQueue: DispatchQueue = DispatchQueue(label: "isPollingQueue")
|
||||
|
||||
|
@ -63,11 +63,12 @@ public final class ClosedGroupPoller: NSObject {
|
|||
}
|
||||
|
||||
public func startPolling(for groupPublicKey: String) {
|
||||
guard !isPolling(for: groupPublicKey) else { return }
|
||||
guard isPolling.wrappedValue[groupPublicKey] != true else { return }
|
||||
|
||||
// Might be a race condition that the setUpPolling finishes too soon,
|
||||
// and the timer is not created, if we mark the group as is polling
|
||||
// after setUpPolling. So the poller may not work, thus misses messages.
|
||||
internalQueue.sync{ isPolling[groupPublicKey] = true }
|
||||
isPolling.mutate { $0[groupPublicKey] = true }
|
||||
setUpPolling(for: groupPublicKey)
|
||||
}
|
||||
|
||||
|
@ -86,7 +87,7 @@ public final class ClosedGroupPoller: NSObject {
|
|||
}
|
||||
|
||||
public func stopPolling(for groupPublicKey: String) {
|
||||
internalQueue.sync{ isPolling[groupPublicKey] = false }
|
||||
isPolling.mutate { $0[groupPublicKey] = false }
|
||||
timers[groupPublicKey]?.invalidate()
|
||||
}
|
||||
|
||||
|
@ -107,7 +108,7 @@ public final class ClosedGroupPoller: NSObject {
|
|||
|
||||
private func pollRecursively(_ groupPublicKey: String) {
|
||||
guard
|
||||
isPolling(for: groupPublicKey),
|
||||
isPolling.wrappedValue[groupPublicKey] == true,
|
||||
let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) })
|
||||
else { return }
|
||||
|
||||
|
@ -149,78 +150,82 @@ public final class ClosedGroupPoller: NSObject {
|
|||
}
|
||||
|
||||
private func poll(_ groupPublicKey: String) -> Promise<Void> {
|
||||
guard isPolling(for: groupPublicKey) else { return Promise.value(()) }
|
||||
guard isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) }
|
||||
|
||||
let promise = SnodeAPI.getSwarm(for: groupPublicKey)
|
||||
let promise: Promise<Void> = SnodeAPI.getSwarm(for: groupPublicKey)
|
||||
.then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) }
|
||||
guard let self = self, self.isPolling(for: groupPublicKey) else {
|
||||
guard self?.isPolling.wrappedValue[groupPublicKey] == true else {
|
||||
return Promise(error: Error.pollingCanceled)
|
||||
}
|
||||
|
||||
return SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey)
|
||||
.map2 { messages in (snode, messages) }
|
||||
}
|
||||
|
||||
promise.done2 { [weak self] snode, messages in
|
||||
guard self?.isPolling(for: groupPublicKey) == true else { return }
|
||||
|
||||
if !messages.isEmpty {
|
||||
SNLog("Received \(messages.count) message(s) in closed group with public key: \(groupPublicKey).")
|
||||
.done2 { [weak self] snode, messages in
|
||||
guard self?.isPolling.wrappedValue[groupPublicKey] == true else { return }
|
||||
|
||||
GRDBStorage.shared.write { db in
|
||||
var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = []
|
||||
if !messages.isEmpty {
|
||||
var messageCount: Int = 0
|
||||
|
||||
messages.forEach { message in
|
||||
guard let envelope = SNProtoEnvelope.from(message) else { return }
|
||||
GRDBStorage.shared.write { db in
|
||||
var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = []
|
||||
|
||||
do {
|
||||
jobDetailMessages.append(
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: try envelope.serializedData(),
|
||||
serverHash: message.info.hash,
|
||||
serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000)
|
||||
)
|
||||
)
|
||||
messages.forEach { message in
|
||||
guard let envelope = SNProtoEnvelope.from(message) else { return }
|
||||
|
||||
// Persist the received message after the MessageReceiveJob is created
|
||||
_ = try message.info.saved(db)
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob)
|
||||
case .SQLITE_CONSTRAINT_UNIQUE: break
|
||||
|
||||
default:
|
||||
SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||
do {
|
||||
let serialisedData: Data = try envelope.serializedData()
|
||||
_ = try message.info.inserted(db)
|
||||
|
||||
// Ignore hashes for messages we have previously handled
|
||||
guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else {
|
||||
throw MessageReceiverError.duplicateMessage
|
||||
}
|
||||
|
||||
jobDetailMessages.append(
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: serialisedData,
|
||||
serverHash: message.info.hash,
|
||||
serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000)
|
||||
)
|
||||
)
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore duplicate messages
|
||||
case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break
|
||||
default: SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
threadId: groupPublicKey,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: jobDetailMessages,
|
||||
isBackgroundPoll: false
|
||||
|
||||
messageCount = jobDetailMessages.count
|
||||
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .messageReceive,
|
||||
behaviour: .runOnce,
|
||||
threadId: groupPublicKey,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: jobDetailMessages,
|
||||
isBackgroundPoll: false
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SNLog("Received \(messageCount) message(s) in closed group with public key: \(groupPublicKey).")
|
||||
}
|
||||
}
|
||||
}
|
||||
.map { _ in }
|
||||
|
||||
promise.catch2 { error in
|
||||
SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).")
|
||||
}
|
||||
return promise.map { _ in }
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
private func isPolling(for groupPublicKey: String) -> Bool {
|
||||
return internalQueue.sync{ isPolling[groupPublicKey] ?? false }
|
||||
|
||||
return promise
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,12 @@ import SessionSnodeKit
|
|||
@objc(LKPoller)
|
||||
public final class Poller : NSObject {
|
||||
private let storage = OWSPrimaryStorage.shared()
|
||||
private var isPolling = false
|
||||
private var isPolling: Atomic<Bool> = Atomic(false)
|
||||
private var usedSnodes = Set<Snode>()
|
||||
private var pollCount = 0
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private static let pollInterval: TimeInterval = 1.5
|
||||
private static let retryInterval: TimeInterval = 0.25
|
||||
/// After polling a given snode this many times we always switch to a new one.
|
||||
|
@ -22,89 +23,103 @@ public final class Poller : NSObject {
|
|||
/// it isn't actually getting messages from other snodes.
|
||||
private static let maxPollCount: UInt = 6
|
||||
|
||||
// MARK: Error
|
||||
// MARK: - Error
|
||||
|
||||
private enum Error : LocalizedError {
|
||||
case pollLimitReached
|
||||
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .pollLimitReached: return "Poll limit reached for current snode."
|
||||
case .pollLimitReached: return "Poll limit reached for current snode."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public API
|
||||
// MARK: - Public API
|
||||
|
||||
@objc public func startIfNeeded() {
|
||||
guard !isPolling else { return }
|
||||
guard !isPolling.wrappedValue else { return }
|
||||
|
||||
SNLog("Started polling.")
|
||||
isPolling = true
|
||||
isPolling.mutate { $0 = true }
|
||||
setUpPolling()
|
||||
}
|
||||
|
||||
@objc public func stop() {
|
||||
SNLog("Stopped polling.")
|
||||
isPolling = false
|
||||
isPolling.mutate { $0 = false }
|
||||
usedSnodes.removeAll()
|
||||
}
|
||||
|
||||
// MARK: Private API
|
||||
// MARK: - Private API
|
||||
|
||||
private func setUpPolling() {
|
||||
guard isPolling else { return }
|
||||
Threading.pollerQueue.async {
|
||||
let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey()).then(on: Threading.pollerQueue) { [weak self] _ -> Promise<Void> in
|
||||
guard let strongSelf = self else { return Promise { $0.fulfill(()) } }
|
||||
strongSelf.usedSnodes.removeAll()
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
strongSelf.pollNextSnode(seal: seal)
|
||||
return promise
|
||||
}.ensure(on: Threading.pollerQueue) { [weak self] in // Timers don't do well on background queues
|
||||
guard let strongSelf = self, strongSelf.isPolling else { return }
|
||||
Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in
|
||||
guard let strongSelf = self else { return }
|
||||
strongSelf.setUpPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
guard isPolling.wrappedValue else { return }
|
||||
|
||||
Threading.pollerQueue.async {
|
||||
let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey())
|
||||
.then(on: Threading.pollerQueue) { [weak self] _ -> Promise<Void> in
|
||||
let (promise, seal) = Promise<Void>.pending()
|
||||
|
||||
self?.usedSnodes.removeAll()
|
||||
self?.pollNextSnode(seal: seal)
|
||||
|
||||
return promise
|
||||
}
|
||||
.ensure(on: Threading.pollerQueue) { [weak self] in // Timers don't do well on background queues
|
||||
guard self?.isPolling.wrappedValue == true else { return }
|
||||
|
||||
Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in
|
||||
self?.setUpPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pollNextSnode(seal: Resolver<Void>) {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let swarm = SnodeAPI.swarmCache[userPublicKey] ?? []
|
||||
let unusedSnodes = swarm.subtracting(usedSnodes)
|
||||
if !unusedSnodes.isEmpty {
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
let nextSnode = unusedSnodes.randomElement()!
|
||||
usedSnodes.insert(nextSnode)
|
||||
poll(nextSnode, seal: seal).done2 {
|
||||
|
||||
guard !unusedSnodes.isEmpty else {
|
||||
seal.fulfill(())
|
||||
return
|
||||
}
|
||||
|
||||
// randomElement() uses the system's default random generator, which is cryptographically secure
|
||||
let nextSnode = unusedSnodes.randomElement()!
|
||||
usedSnodes.insert(nextSnode)
|
||||
|
||||
poll(nextSnode, seal: seal)
|
||||
.done2 {
|
||||
seal.fulfill(())
|
||||
}.catch2 { [weak self] error in
|
||||
}
|
||||
.catch2 { [weak self] error in
|
||||
if let error = error as? Error, error == .pollLimitReached {
|
||||
self?.pollCount = 0
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SNLog("Polling \(nextSnode) failed; dropping it and switching to next snode.")
|
||||
SnodeAPI.dropSnodeFromSwarmIfNeeded(nextSnode, publicKey: userPublicKey)
|
||||
}
|
||||
|
||||
Threading.pollerQueue.async {
|
||||
self?.pollNextSnode(seal: seal)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seal.fulfill(())
|
||||
}
|
||||
}
|
||||
|
||||
private func poll(_ snode: Snode, seal longTermSeal: Resolver<Void>) -> Promise<Void> {
|
||||
guard isPolling else { return Promise { $0.fulfill(()) } }
|
||||
guard isPolling.wrappedValue else { return Promise { $0.fulfill(()) } }
|
||||
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
|
||||
return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey)
|
||||
.then(on: Threading.pollerQueue) { [weak self] messages -> Promise<Void> in
|
||||
guard self?.isPolling == true else { return Promise { $0.fulfill(()) } }
|
||||
guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } }
|
||||
|
||||
if !messages.isEmpty {
|
||||
SNLog("Received \(messages.count) message(s).")
|
||||
var messageCount: Int = 0
|
||||
|
||||
GRDBStorage.shared.write { db in
|
||||
var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:]
|
||||
|
@ -117,25 +132,33 @@ public final class Poller : NSObject {
|
|||
let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope)
|
||||
|
||||
if threadId == nil {
|
||||
// TODO: I assume a configuration message doesn't need a 'threadId' (confirm this and set the 'requiresThreadId' requirement accordingly)
|
||||
// TODO: Does the configuration message come through here????
|
||||
print("RAWR WHAT CASES LETS THIS BE NIL????")
|
||||
}
|
||||
|
||||
do {
|
||||
let serialisedData: Data = try envelope.serializedData()
|
||||
_ = try message.info.inserted(db)
|
||||
|
||||
// Ignore hashes for messages we have previously handled
|
||||
guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else {
|
||||
throw MessageReceiverError.duplicateMessage
|
||||
}
|
||||
|
||||
threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? [])
|
||||
.appending(
|
||||
MessageReceiveJob.Details.MessageInfo(
|
||||
data: try envelope.serializedData(),
|
||||
data: serialisedData,
|
||||
serverHash: message.info.hash,
|
||||
serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000)
|
||||
)
|
||||
)
|
||||
|
||||
// Persist the received message after the MessageReceiveJob is created
|
||||
_ = try message.info.saved(db)
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob)
|
||||
case .SQLITE_CONSTRAINT_UNIQUE: break
|
||||
// Ignore duplicate messages
|
||||
case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break
|
||||
|
||||
default:
|
||||
SNLog("Failed to deserialize envelope due to error: \(error).")
|
||||
|
@ -143,6 +166,10 @@ public final class Poller : NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
messageCount = threadMessages
|
||||
.values
|
||||
.reduce(into: 0) { prev, next in prev += next.count }
|
||||
|
||||
threadMessages.forEach { threadId, threadMessages in
|
||||
JobRunner.add(
|
||||
db,
|
||||
|
@ -158,6 +185,8 @@ public final class Poller : NSObject {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
SNLog("Received \(messageCount) message(s).")
|
||||
}
|
||||
|
||||
self?.pollCount += 1
|
||||
|
@ -167,7 +196,8 @@ public final class Poller : NSObject {
|
|||
}
|
||||
|
||||
return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) {
|
||||
guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } }
|
||||
guard let strongSelf = self, strongSelf.isPolling.wrappedValue else { return Promise { $0.fulfill(()) } }
|
||||
|
||||
return strongSelf.poll(snode, seal: longTermSeal)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,10 @@ public extension Notification.Name {
|
|||
|
||||
static let groupThreadUpdated = Notification.Name("groupThreadUpdated")
|
||||
static let muteSettingUpdated = Notification.Name("muteSettingUpdated")
|
||||
static let messageSentStatusDidChange = Notification.Name("messageSentStatusDidChange")
|
||||
}
|
||||
|
||||
@objc public extension NSNotification {
|
||||
|
||||
@objc static let groupThreadUpdated = Notification.Name.groupThreadUpdated.rawValue as NSString
|
||||
@objc static let muteSettingUpdated = Notification.Name.muteSettingUpdated.rawValue as NSString
|
||||
@objc static let messageSentStatusDidChange = Notification.Name.messageSentStatusDidChange.rawValue as NSString
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ public class SSKEnvironment: NSObject {
|
|||
@objc public let primaryStorage: OWSPrimaryStorage
|
||||
public let tsAccountManager: TSAccountManager
|
||||
public let reachabilityManager: SSKReachabilityManager
|
||||
@objc public let typingIndicators: TypingIndicators
|
||||
|
||||
// Note: This property is configured after Environment is created.
|
||||
public let notificationsManager: Atomic<NotificationsProtocol?> = Atomic(nil)
|
||||
|
@ -29,13 +28,11 @@ public class SSKEnvironment: NSObject {
|
|||
@objc public init(
|
||||
primaryStorage: OWSPrimaryStorage,
|
||||
tsAccountManager: TSAccountManager,
|
||||
reachabilityManager: SSKReachabilityManager,
|
||||
typingIndicators: TypingIndicators
|
||||
reachabilityManager: SSKReachabilityManager
|
||||
) {
|
||||
self.primaryStorage = primaryStorage
|
||||
self.tsAccountManager = tsAccountManager
|
||||
self.reachabilityManager = reachabilityManager
|
||||
self.typingIndicators = typingIndicators
|
||||
|
||||
self.objectReadWriteConnection = primaryStorage.newDatabaseConnection()
|
||||
self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection()
|
||||
|
|
|
@ -39,7 +39,9 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.column(.key, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.hash, .text).notNull()
|
||||
t.column(.hash, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.expirationDateMs, .integer)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
|
|
|
@ -122,8 +122,8 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
guard let lastMessageJson = object as? JSON else { return }
|
||||
guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return }
|
||||
|
||||
// Note: We remove the value from 'receivedMessageResults' as we don't want to default it's
|
||||
// expiration value to 0
|
||||
// Note: We remove the value from 'receivedMessageResults' as we want to try and use
|
||||
// it's actual 'expirationDate' value
|
||||
lastMessageResults[key] = (lastMessageHash, lastMessageJson)
|
||||
receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash)
|
||||
}
|
||||
|
@ -135,16 +135,21 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
_ = try SnodeReceivedMessageInfo(
|
||||
key: key,
|
||||
hash: hash,
|
||||
expirationDateMs: 0
|
||||
expirationDateMs: SnodeReceivedMessage.defaultExpirationSeconds
|
||||
).inserted(db)
|
||||
}
|
||||
}
|
||||
|
||||
try lastMessageResults.forEach { key, data in
|
||||
let expirationDateMs: Int64 = ((data.json["expirationDate"] as? Int64) ?? 0)
|
||||
|
||||
_ = try SnodeReceivedMessageInfo(
|
||||
key: key,
|
||||
hash: data.hash,
|
||||
expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0)
|
||||
expirationDateMs: (expirationDateMs > 0 ?
|
||||
expirationDateMs :
|
||||
SnodeReceivedMessage.defaultExpirationSeconds
|
||||
)
|
||||
).inserted(db)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,8 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist
|
|||
|
||||
/// This is the timestamp (in milliseconds since epoch) when the message hash should expire
|
||||
///
|
||||
/// **Note:** A value of `0` means this hash should not expire
|
||||
/// **Note:** If no value exists this will default to 15 days from now (since the service node caches messages for
|
||||
/// 14 days)
|
||||
public let expirationDateMs: Int64
|
||||
|
||||
// MARK: - Custom Database Interaction
|
||||
|
@ -62,9 +63,17 @@ public extension SnodeReceivedMessageInfo {
|
|||
|
||||
public extension SnodeReceivedMessageInfo {
|
||||
static func pruneExpiredMessageHashInfo(for snode: Snode, associatedWith publicKey: String) {
|
||||
// Delete any expired (but non-0) SnodeReceivedMessageInfo values associated to a specific node
|
||||
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node
|
||||
GRDBStorage.shared.write { db in
|
||||
try? SnodeReceivedMessageInfo
|
||||
// Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want
|
||||
// to clear out the legacy hashes)
|
||||
let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey))
|
||||
.isNotEmpty(db)
|
||||
|
||||
guard hasNonLegacyHash else { return }
|
||||
|
||||
try SnodeReceivedMessageInfo
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey))
|
||||
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000))
|
||||
.deleteAll(db)
|
||||
|
@ -78,11 +87,20 @@ public extension SnodeReceivedMessageInfo {
|
|||
/// pointless fetch for data the app has already received
|
||||
static func fetchLastNotExpired(for snode: Snode, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? {
|
||||
return GRDBStorage.shared.write { db in
|
||||
try SnodeReceivedMessageInfo
|
||||
let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey))
|
||||
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000))
|
||||
.order(SnodeReceivedMessageInfo.Columns.id.desc)
|
||||
.fetchOne(db)
|
||||
|
||||
// If we have a non-legacy hash then return it immediately (legacy hashes had a different
|
||||
// 'key' structure)
|
||||
if nonLegacyHash != nil { return nonLegacyHash }
|
||||
|
||||
return try SnodeReceivedMessageInfo
|
||||
.filter(SnodeReceivedMessageInfo.Columns.key == publicKey)
|
||||
.order(SnodeReceivedMessageInfo.Columns.id.desc)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,10 @@ import Foundation
|
|||
import SessionUtilitiesKit
|
||||
|
||||
public struct SnodeReceivedMessage: CustomDebugStringConvertible {
|
||||
/// Service nodes cache messages for 14 days so default the expiration for message hashes to '15' days
|
||||
/// so we don't end up indefinitely storing records which will never be used
|
||||
public static let defaultExpirationSeconds: Int64 = ((15 * 24 * 60 * 60) * 1000)
|
||||
|
||||
public let info: SnodeReceivedMessageInfo
|
||||
public let data: Data
|
||||
|
||||
|
@ -18,11 +22,12 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible {
|
|||
return nil
|
||||
}
|
||||
|
||||
let expirationDateMs: Int64? = (rawMessage["expiration"] as? Int64)
|
||||
self.info = SnodeReceivedMessageInfo(
|
||||
snode: snode,
|
||||
publicKey: publicKey,
|
||||
hash: hash,
|
||||
expirationDateMs: rawMessage["expiration"] as? Int64
|
||||
expirationDateMs: (expirationDateMs ?? SnodeReceivedMessage.defaultExpirationSeconds)
|
||||
)
|
||||
self.data = data
|
||||
}
|
||||
|
|
|
@ -28,10 +28,10 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration {
|
|||
|
||||
Storage.write(
|
||||
with: { transaction in
|
||||
var result: Set<SessionMessagingKit.Legacy.Contact> = []
|
||||
var result: Set<SessionMessagingKit.Legacy._Contact> = []
|
||||
|
||||
transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in
|
||||
guard let contact = object as? SessionMessagingKit.Legacy.Contact else { return }
|
||||
guard let contact = object as? SessionMessagingKit.Legacy._Contact else { return }
|
||||
result.insert(contact)
|
||||
}
|
||||
|
||||
|
|
|
@ -16,17 +16,17 @@ public class ContactsMigration : OWSDatabaseMigration {
|
|||
}
|
||||
|
||||
private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) {
|
||||
var contacts: [SMKLegacy.Contact] = []
|
||||
var contacts: [SMKLegacy._Contact] = []
|
||||
TSContactThread.enumerateCollectionObjects { object, _ in
|
||||
guard let thread = object as? TSContactThread else { return }
|
||||
let sessionID = thread.contactSessionID()
|
||||
var contact: SMKLegacy.Contact?
|
||||
var contact: SMKLegacy._Contact?
|
||||
|
||||
Storage.read { transaction in
|
||||
contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy.Contact
|
||||
contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact
|
||||
}
|
||||
|
||||
if let contact: SMKLegacy.Contact = contact {
|
||||
if let contact: SMKLegacy._Contact = contact {
|
||||
contact.isTrusted = true
|
||||
contacts.append(contact)
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration {
|
|||
}
|
||||
|
||||
private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) {
|
||||
var contacts: Set<SessionMessagingKit.Legacy.Contact> = Set()
|
||||
var contacts: Set<SMKLegacy._Contact> = Set()
|
||||
var threads: [TSThread] = []
|
||||
|
||||
TSThread.enumerateCollectionObjects { object, _ in
|
||||
|
@ -26,7 +26,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration {
|
|||
if let contactThread: TSContactThread = thread as? TSContactThread {
|
||||
let sessionId: String = contactThread.contactSessionID()
|
||||
|
||||
if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact {
|
||||
if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact {
|
||||
contact.isApproved = true
|
||||
contact.didApproveMe = true
|
||||
contacts.insert(contact)
|
||||
|
@ -36,7 +36,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration {
|
|||
let groupAdmins: [String] = groupThread.groupModel.groupAdminIds
|
||||
|
||||
groupAdmins.forEach { sessionId in
|
||||
if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact {
|
||||
if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact {
|
||||
contact.isApproved = true
|
||||
contact.didApproveMe = true
|
||||
contacts.insert(contact)
|
||||
|
@ -51,7 +51,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration {
|
|||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
|
||||
Storage.read { transaction in
|
||||
if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact {
|
||||
if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact {
|
||||
user.isApproved = true
|
||||
user.didApproveMe = true
|
||||
contacts.insert(user)
|
||||
|
|
Loading…
Reference in New Issue