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:
Morgan Pretty 2022-05-12 17:28:27 +10:00
parent 5432f5582e
commit 3f062c044c
65 changed files with 1103 additions and 842 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" = "خطاء";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" = "エラー";

View File

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

View File

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

View File

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

View File

@ -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" = "Ошибка";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" = "错误";

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
@objc(LKModal)
class Modal: BaseVC, UIGestureRecognizerDelegate {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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