From e94811452536bbfcdc26c0714487bba53914fa1a Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Mon, 10 Aug 2026 15:45:44 +0700 Subject: [PATCH] fix message archive sync --- Sources/Shared/Models/AppModel.swift | 574 ++++++++++++------- Sources/Shared/XMPP/XMPPService.swift | 793 +++++++++++++++----------- 2 files changed, 824 insertions(+), 543 deletions(-) diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index e23ca77..a20bb0c 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -1,5 +1,5 @@ -import Combine import AVFoundation +import Combine import Foundation import UniformTypeIdentifiers import WebRTC @@ -7,7 +7,8 @@ import WebRTC @MainActor final class AppModel: ObservableObject { @Published private(set) var account: AccountConfiguration? - @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected(reason: nil) + @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected( + reason: nil) private(set) var conversations: [Conversation] = [] { willSet { if !isApplyingArchiveBatch { @@ -131,7 +132,8 @@ final class AppModel: ObservableObject { } guard self.account != nil else { - let error = "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch." + let error = + "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch." self.errorMessage = error self.watchBridge.reportVoiceResult( transferID: voice.transferID, @@ -168,7 +170,9 @@ final class AppModel: ObservableObject { messageID: voice.stableMessageID ) let success = messageID != nil - let failure = success ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.") + let failure = + success + ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.") if success { self.informationalMessage = "Голосовое сообщение с Apple Watch отправлено." } else if self.errorMessage == nil { @@ -214,7 +218,8 @@ final class AppModel: ObservableObject { if selectedMessagesCacheConversationID == selectedConversationID { return selectedMessagesCache } - let sorted = messages + let sorted = + messages .filter { $0.conversationID == selectedConversationID } .sorted { lhs, rhs in if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } @@ -376,7 +381,8 @@ final class AppModel: ObservableObject { guard let password = try credentials.password(for: account.normalizedJID) else { throw ReconnectError.passwordMissing } - try await xmpp.reconnectIfNeeded(password: password) + try await xmpp.reconnectIfNeeded( + password: password, archiveCheckpoint: durableArchiveSyncCheckpoint) } catch { errorMessage = error.localizedDescription } @@ -504,7 +510,8 @@ final class AppModel: ObservableObject { errorMessage = "Введите псевдоним для группового чата." return } - let validInvitees = invitees + let validInvitees = + invitees .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter(Self.isValidJID) @@ -534,9 +541,12 @@ final class AppModel: ObservableObject { func joinGroup(jid rawJID: String, nickname preferredNickname: String? = nil) async { let jid = rawJID.lowercased() - guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { return } + guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { + return + } guard !conversations[index].isGroupJoined, - joiningRoomJIDs.insert(jid).inserted else { return } + joiningRoomJIDs.insert(jid).inserted + else { return } defer { joiningRoomJIDs.remove(jid) } let trimmedNickname = preferredNickname?.trimmingCharacters(in: .whitespacesAndNewlines) let nickname: String @@ -565,7 +575,9 @@ final class AppModel: ObservableObject { func leaveGroup(jid rawJID: String) { let jid = rawJID.lowercased() xmpp.leaveRoom(roomJID: jid) - guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { return } + guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { + return + } conversations[index].isGroupJoined = false conversations[index].shouldAutojoin = false conversations[index].occupantCount = 0 @@ -573,7 +585,8 @@ final class AppModel: ObservableObject { } func inviteMembers(_ rawJIDs: [String], to roomJID: String) async { - let jids = rawJIDs + let jids = + rawJIDs .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter(Self.isValidJID) guard !jids.isEmpty else { @@ -597,14 +610,16 @@ final class AppModel: ObservableObject { ) async { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, - let account, - let peer = explicitJID ?? selectedConversation?.jid else { return } + let account, + let peer = explicitJID ?? selectedConversation?.jid + else { return } let conversation = conversations.first { $0.id == peer.lowercased() } let isGroup = conversation?.isGroup == true let encrypted = encryptionEnabled(for: peer) let replyTarget = replyingTo.flatMap { message -> ChatMessage? in guard message.canBeRepliedTo, - message.conversationID == peer.lowercased() else { return nil } + message.conversationID == peer.lowercased() + else { return nil } return message } @@ -636,7 +651,8 @@ final class AppModel: ObservableObject { return XMPPService.ReplyReference( id: replyID, authorJID: target.senderJID, - fallbackAuthor: target.senderDisplayName ?? displayName(for: target.senderJID), + fallbackAuthor: target.senderDisplayName + ?? displayName(for: target.senderJID), preview: target.quotePreview ) }, @@ -660,7 +676,8 @@ final class AppModel: ObservableObject { func sendLocation(_ location: GeoLocation, to explicitJID: String? = nil) async { guard let account, - let peer = explicitJID ?? selectedConversation?.jid else { return } + let peer = explicitJID ?? selectedConversation?.jid + else { return } let conversation = conversations.first { $0.id == peer.lowercased() } let isGroup = conversation?.isGroup == true let encrypted = encryptionEnabled(for: peer) @@ -702,8 +719,9 @@ final class AppModel: ObservableObject { func editMessage(id: String, newBody rawText: String) async { let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, - let index = messages.firstIndex(where: { $0.id == id }), - messages[index].canBeEdited else { return } + let index = messages.firstIndex(where: { $0.id == id }), + messages[index].canBeEdited + else { return } let previous = messages[index] guard text != previous.body else { return } @@ -712,7 +730,8 @@ final class AppModel: ObservableObject { let encrypted = previous.security == .omemo correctionReceiptTargets[correctionID] = id if correctionReceiptTargets.count > 100, - let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) { + let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) + { correctionReceiptTargets.removeValue(forKey: staleID) } updateMessage(id: id) { @@ -736,7 +755,8 @@ final class AppModel: ObservableObject { } catch { correctionReceiptTargets.removeValue(forKey: correctionID) if let currentIndex = messages.firstIndex(where: { $0.id == id }), - messages[currentIndex].delivery == .sending { + messages[currentIndex].delivery == .sending + { messages[currentIndex] = previous updateConversationPreview(for: previous, incrementUnread: false) } @@ -748,8 +768,9 @@ final class AppModel: ObservableObject { func retractMessage(id: String) async { guard let account, - let message = messages.first(where: { $0.id == id }), - message.canBeRetracted else { return } + let message = messages.first(where: { $0.id == id }), + message.canBeRetracted + else { return } let retractionID = UUID().uuidString do { @@ -759,14 +780,15 @@ final class AppModel: ObservableObject { retractionID: retractionID, encrypted: message.security == .omemo ) - applyRetraction(XMPPService.RetractionEnvelope( - peerJID: message.conversationID, - senderJID: account.normalizedJID, - targetID: message.id, - retractionID: retractionID, - timestamp: Date(), - isOutgoing: true - )) + applyRetraction( + XMPPService.RetractionEnvelope( + peerJID: message.conversationID, + senderJID: account.normalizedJID, + targetID: message.id, + retractionID: retractionID, + timestamp: Date(), + isOutgoing: true + )) } catch { errorMessage = error.localizedDescription } @@ -783,16 +805,18 @@ final class AppModel: ObservableObject { let normalizedConversationID = conversationID?.lowercased() let selected = messages.filter { message in ids.contains(message.id) - && (normalizedConversationID == nil || message.conversationID == normalizedConversationID) + && (normalizedConversationID == nil + || message.conversationID == normalizedConversationID) } guard !selected.isEmpty else { return } let affectedConversations = Set(selected.map(\.conversationID)) for message in selected { - locallyDeletedMessageIDs.insert(Self.localDeletionKey( - messageID: message.id, - conversationID: message.conversationID - )) + locallyDeletedMessageIDs.insert( + Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )) let pendingKey = Self.localDeletionKey( messageID: message.id, conversationID: message.conversationID @@ -805,20 +829,27 @@ final class AppModel: ObservableObject { } if envelope.targetID == message.id { return false } if let stanzaID = message.stanzaID, - envelope.targetID == stanzaID { return false } + envelope.targetID == stanzaID + { + return false + } return true } removeCachedMedia(for: message.id) } - let selectedKeys = Set(selected.map { Self.localDeletionKey( - messageID: $0.id, - conversationID: $0.conversationID - ) }) + let selectedKeys = Set( + selected.map { + Self.localDeletionKey( + messageID: $0.id, + conversationID: $0.conversationID + ) + }) messages.removeAll { message in - selectedKeys.contains(Self.localDeletionKey( - messageID: message.id, - conversationID: message.conversationID - )) + selectedKeys.contains( + Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )) } rebuildMessageIndex() for conversationID in affectedConversations { @@ -835,10 +866,12 @@ final class AppModel: ObservableObject { @discardableResult func forwardMessages(_ selectedMessages: [ChatMessage], to rawRecipient: String) async -> Bool { - let recipient = rawRecipient + let recipient = + rawRecipient .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() - let forwardableMessages = selectedMessages + let forwardableMessages = + selectedMessages .filter(\.canBeForwarded) .sorted { lhs, rhs in if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } @@ -857,7 +890,8 @@ final class AppModel: ObservableObject { for message in forwardableMessages { guard await forwardMessageItem(message, to: recipient) else { if forwardedCount > 0 { - informationalMessage = "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)." + informationalMessage = + "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)." } return false } @@ -870,7 +904,8 @@ final class AppModel: ObservableObject { } private func forwardMessageItem(_ message: ChatMessage, to recipient: String) async -> Bool { - let attribution = "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))" + let attribution = + "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))" switch message.kind { case .text: @@ -923,17 +958,20 @@ final class AppModel: ObservableObject { func message(withID id: String, in conversationID: String? = nil) -> ChatMessage? { guard let conversationID else { if let index = firstMessageIndexByID[id], - messages.indices.contains(index), - messages[index].id == id { + messages.indices.contains(index), + messages[index].id == id + { return messages[index] } return messages.first { $0.stanzaID == id } } let normalized = conversationID.lowercased() - guard let index = messageIndex( - referenceID: id, - conversationID: normalized - ) else { return nil } + guard + let index = messageIndex( + referenceID: id, + conversationID: normalized + ) + else { return nil } return messages[index] } @@ -994,7 +1032,8 @@ final class AppModel: ObservableObject { thumbnailData preparedThumbnailData: Data? = nil ) async -> String? { guard let account, - let peer = explicitJID ?? selectedConversation?.jid else { return nil } + let peer = explicitJID ?? selectedConversation?.jid + else { return nil } errorMessage = nil if connectionStatus != .connected { await reconnect() @@ -1085,7 +1124,8 @@ final class AppModel: ObservableObject { func retryMediaMessage(_ message: ChatMessage) async { guard canRetryMediaMessage(message), - let localURL = mediaPreviewURLs[message.id] else { return } + let localURL = mediaPreviewURLs[message.id] + else { return } let activityToken = beginMediaSendActivity() defer { endMediaSendActivity(activityToken) } errorMessage = nil @@ -1139,7 +1179,8 @@ final class AppModel: ObservableObject { do { let staged = try await mediaFileIO.stageAttachmentDraft(from: source) let filename = Self.safeFilename(staged.filename) - let contentType = staged.contentTypeIdentifier.flatMap { UTType($0) } + let contentType = + staged.contentTypeIdentifier.flatMap { UTType($0) } ?? UTType(filenameExtension: source.pathExtension) let kind = preferredKind ?? Self.mediaKind(for: contentType, filename: filename) let mimeType = contentType?.preferredMIMEType ?? "application/octet-stream" @@ -1148,15 +1189,16 @@ final class AppModel: ObservableObject { kind: kind, mimeType: mimeType ) - drafts.append(AttachmentDraft( - url: staged.url, - filename: filename, - mimeType: mimeType, - kind: kind, - duration: analysis.duration, - byteCount: staged.byteCount, - thumbnailData: analysis.thumbnailData - )) + drafts.append( + AttachmentDraft( + url: staged.url, + filename: filename, + mimeType: mimeType, + kind: kind, + duration: analysis.duration, + byteCount: staged.byteCount, + thumbnailData: analysis.thumbnailData + )) } catch { lastPreparationError = error } @@ -1280,13 +1322,15 @@ final class AppModel: ObservableObject { func prepareMediaPreview(_ message: ChatMessage) async { guard Self.supportsInlinePreview(message.kind), - message.remoteAttachmentURL != nil, - mediaPreviewURLs[message.id] == nil, - loadingMediaIDs.insert(message.id).inserted else { return } + message.remoteAttachmentURL != nil, + mediaPreviewURLs[message.id] == nil, + loadingMediaIDs.insert(message.id).inserted + else { return } defer { loadingMediaIDs.remove(message.id) } do { - let output = try await localAttachmentURL(for: message, directoryName: "LumaMediaPreviews") + let output = try await localAttachmentURL( + for: message, directoryName: "LumaMediaPreviews") mediaPreviewURLs[message.id] = output await analyzeMediaPreview( at: output, @@ -1373,7 +1417,8 @@ final class AppModel: ObservableObject { } func typingText(for conversation: Conversation) -> String? { - let names = typingParticipantsByConversation[conversation.id.lowercased()] + let names = + typingParticipantsByConversation[conversation.id.lowercased()] .map { Array($0.values) } ?? [] return ChatTypingPolicy.displayText(names: names, isGroup: conversation.isGroup) } @@ -1398,7 +1443,8 @@ final class AppModel: ObservableObject { return } guard let self, - self.localChatStateByConversation[conversationID] == .composing else { return } + self.localChatStateByConversation[conversationID] == .composing + else { return } self.transitionLocalChatState(.paused, in: conversation) self.localTypingPauseTasks[conversationID] = nil } @@ -1413,13 +1459,14 @@ final class AppModel: ObservableObject { func toggleReaction(_ emoji: String, on sourceMessage: ChatMessage) async { guard let account, - let message = message( - withID: sourceMessage.id, - in: sourceMessage.conversationID - ), - message.canBeReactedTo, - let targetID = message.reactionIdentifier, - let selectedEmoji = MessageReactionPolicy.sanitized([emoji]).first else { return } + let message = message( + withID: sourceMessage.id, + in: sourceMessage.conversationID + ), + message.canBeReactedTo, + let targetID = message.reactionIdentifier, + let selectedEmoji = MessageReactionPolicy.sanitized([emoji]).first + else { return } let previous = message.reactionEmojis(from: account.normalizedJID) var updated = previous @@ -1467,7 +1514,9 @@ final class AppModel: ObservableObject { } func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) { - guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else { return } + guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else { + return + } conversations[index].encryptionPreference = preference schedulePersist() syncWatch() @@ -1539,7 +1588,8 @@ final class AppModel: ObservableObject { } private func updateArchiveSyncSuspensionForMedia() { - let shouldSuspend = videoNoteCaptureIsActive + let shouldSuspend = + videoNoteCaptureIsActive || mediaSendActivity.isActive || !mediaPreparationTokens.isEmpty guard shouldSuspend != archiveSyncIsSuspendedForMedia else { return } @@ -1574,7 +1624,8 @@ final class AppModel: ObservableObject { ) } catch { guard attempt < maximumAttempts, - Self.isRetryableMediaSendError(error) else { throw error } + Self.isRetryableMediaSendError(error) + else { throw error } if connectionStatus != .connected { await reconnect() } @@ -1588,7 +1639,7 @@ final class AppModel: ObservableObject { guard let error = error as? LumaXMPPError else { return false } switch error { case .notConnected, .connection, .uploadDiscoveryFailed, - .uploadSlotFailed, .uploadTransportFailed: + .uploadSlotFailed, .uploadTransportFailed: return true case .uploadFailed(let statusCode): guard let statusCode else { return true } @@ -1680,13 +1731,15 @@ final class AppModel: ObservableObject { return case .groupMessageEcho(let roomJID, let messageID, let stanzaID, let senderJID): if let index = messageIndex(id: messageID, conversationID: roomJID) - ?? messageIndex(referenceID: stanzaID, conversationID: roomJID) { + ?? messageIndex(referenceID: stanzaID, conversationID: roomJID) + { messages[index].stanzaID = stanzaID messages[index].senderJID = senderJID - messageIndexByStanzaKey[Self.localDeletionKey( - messageID: stanzaID, - conversationID: roomJID - )] = index + messageIndexByStanzaKey[ + Self.localDeletionKey( + messageID: stanzaID, + conversationID: roomJID + )] = index if messages[index].delivery == .sending { messages[index].delivery = .sent } @@ -1699,7 +1752,9 @@ final class AppModel: ObservableObject { nickname: envelope.nickname, shouldAutojoin: envelope.joined ) - if let index = conversations.firstIndex(where: { $0.id == envelope.roomJID.lowercased() }) { + if let index = conversations.firstIndex(where: { + $0.id == envelope.roomJID.lowercased() + }) { conversations[index].isGroupJoined = envelope.joined conversations[index].occupantCount = envelope.occupantCount } @@ -1738,6 +1793,21 @@ final class AppModel: ObservableObject { case .archiveSyncCompleted(let checkpoint): lastSuccessfulMAMSync = checkpoint.timestamp lastSuccessfulMAMCursor = checkpoint.cursor + // Синхронно форсируем persist, чтобы checkpoint не потерялся + persistTask?.cancel() + persistTask = nil + Task { @MainActor [weak self] in + guard let self, let archive = self.archive else { return } + let snapshot = ChatArchive.Snapshot( + conversations: self.conversations, + messages: self.messages, + locallyDeletedMessageIDs: self.locallyDeletedMessageIDs, + rosterContactJIDs: self.rosterContactJIDs, + lastSuccessfulMAMSync: self.lastSuccessfulMAMSync, + lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor + ) + try? await archive.save(snapshot) + } case .call(let call): activeCall = call case .callHistory(let entry): @@ -1758,18 +1828,19 @@ final class AppModel: ObservableObject { private func applyArchiveBatch(_ mutations: [XMPPService.ArchiveMutation]) { guard !mutations.isEmpty else { return } - let selectedConversationWasMutated = selectedConversationID.map { selectedID in - mutations.contains { mutation in - switch mutation { - case .message(let envelope): - return envelope.peerJID.lowercased() == selectedID - case .retraction(let envelope): - return envelope.peerJID.lowercased() == selectedID - case .reaction(let envelope): - return envelope.peerJID.lowercased() == selectedID + let selectedConversationWasMutated = + selectedConversationID.map { selectedID in + mutations.contains { mutation in + switch mutation { + case .message(let envelope): + return envelope.peerJID.lowercased() == selectedID + case .retraction(let envelope): + return envelope.peerJID.lowercased() == selectedID + case .reaction(let envelope): + return envelope.peerJID.lowercased() == selectedID + } } - } - } ?? false + } ?? false isApplyingArchiveBatch = true for mutation in mutations { switch mutation { @@ -1797,6 +1868,11 @@ final class AppModel: ObservableObject { } private func consumeMessage(_ envelope: XMPPService.MessageEnvelope) { + if let stanzaID = envelope.stanzaID, + messageIndex(referenceID: stanzaID, conversationID: envelope.peerJID) != nil + { + return // уже есть это сообщение + } if envelope.isGroupMessage { upsertGroupConversation( jid: envelope.peerJID, @@ -1809,14 +1885,17 @@ final class AppModel: ObservableObject { messageID: envelope.id, conversationID: envelope.peerJID ) - let correctionWasDeleted = envelope.correctionTargetID.map { - locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: $0, - conversationID: envelope.peerJID - )) - } ?? false + let correctionWasDeleted = + envelope.correctionTargetID.map { + locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: $0, + conversationID: envelope.peerJID + )) + } ?? false guard !locallyDeletedMessageIDs.contains(deletionKey), - !correctionWasDeleted else { return } + !correctionWasDeleted + else { return } if envelope.correctionTargetID != nil { consumeCorrection(envelope) return @@ -1861,17 +1940,19 @@ final class AppModel: ObservableObject { } applyPendingReactions(for: message) if !envelope.isArchived, - NotificationPolicy.shouldPresentMessage( - inserted: inserted, - isOutgoing: envelope.isOutgoing, - appIsActive: appIsActive, - selectedConversationID: selectedConversationID, - conversationID: envelope.peerJID - ) { - let notificationMessage = self.message( - withID: envelope.id, - in: envelope.peerJID - ) ?? message + NotificationPolicy.shouldPresentMessage( + inserted: inserted, + isOutgoing: envelope.isOutgoing, + appIsActive: appIsActive, + selectedConversationID: selectedConversationID, + conversationID: envelope.peerJID + ) + { + let notificationMessage = + self.message( + withID: envelope.id, + in: envelope.peerJID + ) ?? message notifications.showIncomingMessage( id: envelope.id, sender: envelope.senderDisplayName ?? conversationName(for: envelope.peerJID), @@ -1883,7 +1964,8 @@ final class AppModel: ObservableObject { private func recordCallHistory(_ entry: CallHistoryEntry) { guard let account else { return } - let direction: ChatMessage.Direction = entry.direction == .outgoing + let direction: ChatMessage.Direction = + entry.direction == .outgoing ? .outgoing : .incoming let metadata = CallHistoryMetadata( @@ -1903,7 +1985,8 @@ final class AppModel: ObservableObject { duration: entry.duration, callHistory: metadata ) - let isUnreadMissedCall = direction == .incoming + let isUnreadMissedCall = + direction == .incoming && entry.outcome == .missed && selectedConversationID != entry.peerJID.lowercased() _ = upsertMessage(message, incrementUnread: isUnreadMissedCall) @@ -1955,18 +2038,20 @@ final class AppModel: ObservableObject { if let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { conversations[index].groupNickname = nickname } - conversations[index].shouldAutojoin = conversations[index].shouldAutojoin || shouldAutojoin + conversations[index].shouldAutojoin = + conversations[index].shouldAutojoin || shouldAutojoin if let invitedBy { conversations[index].invitedBy = invitedBy } } else { - conversations.append(Conversation( - jid: normalized, - displayName: initialDisplayName, - encryptionPreference: .inheritGlobal, - kind: .group, - groupNickname: nickname, - shouldAutojoin: shouldAutojoin, - invitedBy: invitedBy - )) + conversations.append( + Conversation( + jid: normalized, + displayName: initialDisplayName, + encryptionPreference: .inheritGlobal, + kind: .group, + groupNickname: nickname, + shouldAutojoin: shouldAutojoin, + invitedBy: invitedBy + )) } if !isApplyingArchiveBatch { sortConversations() @@ -1975,7 +2060,8 @@ final class AppModel: ObservableObject { private var defaultGroupNickname: String { if let displayName = account?.displayName.trimmingCharacters(in: .whitespacesAndNewlines), - !displayName.isEmpty { + !displayName.isEmpty + { return displayName } return account?.normalizedJID.split(separator: "@").first.map(String.init) ?? "Luma" @@ -1983,17 +2069,21 @@ final class AppModel: ObservableObject { private func consumeCorrection(_ envelope: XMPPService.MessageEnvelope) { guard let targetID = envelope.correctionTargetID, - !locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: targetID, - conversationID: envelope.peerJID - )), - envelope.kind == .text, - envelope.security != .decryptionFailed else { return } + !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: targetID, + conversationID: envelope.peerJID + )), + envelope.kind == .text, + envelope.security != .decryptionFailed + else { return } - guard let index = messageIndex( - id: targetID, - conversationID: envelope.peerJID - ) else { + guard + let index = messageIndex( + id: targetID, + conversationID: envelope.peerJID + ) + else { let pendingKey = Self.localDeletionKey( messageID: targetID, conversationID: envelope.peerJID @@ -2007,11 +2097,12 @@ final class AppModel: ObservableObject { let original = messages[index] guard !original.isRetracted, - original.kind == .text, - original.conversationID == envelope.peerJID.lowercased(), - original.senderJID == envelope.senderJID.lowercased(), - original.security == envelope.security, - original.editedAt == nil || envelope.timestamp >= original.editedAt! else { return } + original.kind == .text, + original.conversationID == envelope.peerJID.lowercased(), + original.senderJID == envelope.senderJID.lowercased(), + original.security == envelope.security, + original.editedAt == nil || envelope.timestamp >= original.editedAt! + else { return } messages[index].body = envelope.body messages[index].editedAt = envelope.timestamp @@ -2036,18 +2127,24 @@ final class AppModel: ObservableObject { guard allowedSenders.contains(MessageReactionPolicy.bareJID(senderJID)) else { return } } - guard let index = messageIndex( - referenceID: envelope.targetID, - conversationID: conversationID - ) else { + guard + let index = messageIndex( + referenceID: envelope.targetID, + conversationID: conversationID + ) + else { let key = pendingReactionKey(envelope) if let previous = pendingReactions[key], - previous.timestamp > envelope.timestamp { return } + previous.timestamp > envelope.timestamp + { + return + } pendingReactions[key] = envelope if pendingReactions.count > 200, - let staleKey = pendingReactions.min(by: { - $0.value.timestamp < $1.value.timestamp - })?.key { + let staleKey = pendingReactions.min(by: { + $0.value.timestamp < $1.value.timestamp + })?.key + { pendingReactions.removeValue(forKey: staleKey) } return @@ -2063,7 +2160,8 @@ final class AppModel: ObservableObject { ) } if let existingIndex, - updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp { + updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp + { return } @@ -2079,11 +2177,12 @@ final class AppModel: ObservableObject { ) } } else if !emojis.isEmpty { - updatedMessage.reactions.append(MessageReaction( - senderJID: senderJID, - emojis: emojis, - updatedAt: envelope.timestamp - )) + updatedMessage.reactions.append( + MessageReaction( + senderJID: senderJID, + emojis: emojis, + updatedAt: envelope.timestamp + )) } else { return } @@ -2108,7 +2207,7 @@ final class AppModel: ObservableObject { [ envelope.peerJID.lowercased(), envelope.targetID, - envelope.senderJID.lowercased() + envelope.senderJID.lowercased(), ].joined(separator: "|") } @@ -2123,7 +2222,8 @@ final class AppModel: ObservableObject { private func applyRemoteChatState(_ envelope: XMPPService.ChatStateEnvelope) { let conversationID = envelope.peerJID.lowercased() - let senderKey = envelope.isGroupMessage + let senderKey = + envelope.isGroupMessage ? envelope.senderJID.lowercased() : MessageReactionPolicy.bareJID(envelope.senderJID) let expiryKey = "\(conversationID)|\(senderKey)" @@ -2139,7 +2239,8 @@ final class AppModel: ObservableObject { } var participants = typingParticipantsByConversation[conversationID] ?? [:] - participants[senderKey] = envelope.senderDisplayName + participants[senderKey] = + envelope.senderDisplayName ?? displayName(for: envelope.senderJID) typingParticipantsByConversation[conversationID] = participants remoteTypingExpiryTasks[expiryKey] = Task { @MainActor [weak self] in @@ -2200,14 +2301,19 @@ final class AppModel: ObservableObject { } private func applyRetraction(_ envelope: XMPPService.RetractionEnvelope) { - guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: envelope.targetID, - conversationID: envelope.peerJID - )) else { return } - guard let index = messageIndex( - id: envelope.targetID, - conversationID: envelope.peerJID - ) else { + guard + !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: envelope.targetID, + conversationID: envelope.peerJID + )) + else { return } + guard + let index = messageIndex( + id: envelope.targetID, + conversationID: envelope.peerJID + ) + else { let pendingKey = Self.localDeletionKey( messageID: envelope.targetID, conversationID: envelope.peerJID @@ -2221,8 +2327,9 @@ final class AppModel: ObservableObject { let original = messages[index] guard original.conversationID == envelope.peerJID.lowercased(), - original.senderJID == envelope.senderJID.lowercased(), - original.retractedAt == nil || envelope.timestamp >= original.retractedAt! else { return } + original.senderJID == envelope.senderJID.lowercased(), + original.retractedAt == nil || envelope.timestamp >= original.retractedAt! + else { return } removeCachedMedia(for: original.id) messages[index].body = "Сообщение удалено" @@ -2243,10 +2350,11 @@ final class AppModel: ObservableObject { if envelope.isOutgoing, messages[index].delivery == .sending { messages[index].delivery = .sent } - pendingCorrections.removeValue(forKey: Self.localDeletionKey( - messageID: original.id, - conversationID: original.conversationID - )) + pendingCorrections.removeValue( + forKey: Self.localDeletionKey( + messageID: original.id, + conversationID: original.conversationID + )) updateConversationPreview(for: messages[index], incrementUnread: false) } @@ -2255,10 +2363,13 @@ final class AppModel: ObservableObject { _ message: ChatMessage, incrementUnread unreadOverride: Bool? = nil ) -> Bool { - guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: message.id, - conversationID: message.conversationID - )) else { return false } + guard + !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )) + else { return false } upsertConversation(jid: message.conversationID, name: nil) if let index = messageIndex( id: message.id, @@ -2294,7 +2405,8 @@ final class AppModel: ObservableObject { merged.reactions = previous.reactions } if let editedAt = previous.editedAt, - message.editedAt == nil || editedAt > message.editedAt! { + message.editedAt == nil || editedAt > message.editedAt! + { merged.body = previous.body merged.editedAt = editedAt merged.security = previous.security @@ -2302,30 +2414,34 @@ final class AppModel: ObservableObject { } messages[index] = merged if let stanzaID = merged.stanzaID { - messageIndexByStanzaKey[Self.localDeletionKey( - messageID: stanzaID, - conversationID: merged.conversationID - )] = index + messageIndexByStanzaKey[ + Self.localDeletionKey( + messageID: stanzaID, + conversationID: merged.conversationID + )] = index } updateConversationPreview(for: merged, incrementUnread: false) return false } messages.append(message) let insertedIndex = messages.index(before: messages.endIndex) - messageIndexByStorageKey[Self.localDeletionKey( - messageID: message.id, - conversationID: message.conversationID - )] = insertedIndex + messageIndexByStorageKey[ + Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )] = insertedIndex if firstMessageIndexByID[message.id] == nil { firstMessageIndexByID[message.id] = insertedIndex } if let stanzaID = message.stanzaID { - messageIndexByStanzaKey[Self.localDeletionKey( - messageID: stanzaID, - conversationID: message.conversationID - )] = insertedIndex + messageIndexByStanzaKey[ + Self.localDeletionKey( + messageID: stanzaID, + conversationID: message.conversationID + )] = insertedIndex } - let shouldIncrement = unreadOverride + let shouldIncrement = + unreadOverride ?? (message.direction == .incoming && selectedConversationID != message.conversationID) updateConversationPreview(for: message, incrementUnread: shouldIncrement) return true @@ -2333,14 +2449,16 @@ final class AppModel: ObservableObject { private func updateMessage(id: String, mutation: (inout ChatMessage) -> Void) { guard let index = firstMessageIndexByID[id], - messages.indices.contains(index), - messages[index].id == id else { return } + messages.indices.contains(index), + messages[index].id == id + else { return } mutation(&messages[index]) updateConversationPreview(for: messages[index], incrementUnread: false) } private func updateConversationPreview(for message: ChatMessage, incrementUnread: Bool) { - guard let index = conversations.firstIndex(where: { $0.id == message.conversationID }) else { return } + guard let index = conversations.firstIndex(where: { $0.id == message.conversationID }) + else { return } if message.timestamp >= conversations[index].lastActivity { conversations[index].lastActivity = message.timestamp conversations[index].lastMessage = message.previewText @@ -2354,8 +2472,11 @@ final class AppModel: ObservableObject { } private func rebuildConversationPreview(for conversationID: String) { - guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } - let latest = messages + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { + return + } + let latest = + messages .filter { $0.conversationID == conversationID } .max { lhs, rhs in if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } @@ -2374,7 +2495,8 @@ final class AppModel: ObservableObject { conversations.sort { lhs, rhs in if lhs.isPinned != rhs.isPinned { return lhs.isPinned } if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + == .orderedAscending } } @@ -2386,8 +2508,9 @@ final class AppModel: ObservableObject { ) guard let index = messageIndexByStorageKey[key] else { return nil } if messages.indices.contains(index), - messages[index].id == id, - messages[index].conversationID == normalizedConversationID { + messages[index].id == id, + messages[index].conversationID == normalizedConversationID + { return index } rebuildMessageIndex() @@ -2404,9 +2527,10 @@ final class AppModel: ObservableObject { conversationID: normalizedConversationID ) if let index = messageIndexByStanzaKey[key], - messages.indices.contains(index), - messages[index].conversationID == normalizedConversationID, - messages[index].stanzaID == referenceID { + messages.indices.contains(index), + messages[index].conversationID == normalizedConversationID, + messages[index].stanzaID == referenceID + { return index } rebuildMessageIndex() @@ -2467,10 +2591,11 @@ final class AppModel: ObservableObject { lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor messages = snapshot.messages.filter { - !locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: $0.id, - conversationID: $0.conversationID - )) + !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: $0.id, + conversationID: $0.conversationID + )) } rebuildMessageIndex() let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid]) @@ -2522,7 +2647,8 @@ final class AppModel: ObservableObject { private func loadCachedAvatarIfNeeded(for jid: String) { let normalized = jid.lowercased() guard avatarDataByJID[normalized] == nil, - avatarCacheRequests.insert(normalized).inserted else { return } + avatarCacheRequests.insert(normalized).inserted + else { return } Task { [weak self] in guard let self, let data = await self.avatarCache.data(for: normalized) else { return } self.avatarDataByJID[normalized] = data @@ -2536,7 +2662,7 @@ final class AppModel: ObservableObject { "LumaForwarding", "LumaAttachmentDrafts", "LumaPhotoPicker", - "LumaDuration" + "LumaDuration", ] { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(directoryName, isDirectory: true) @@ -2559,10 +2685,11 @@ final class AppModel: ObservableObject { for directoryName in ["LumaMediaPreviews", "LumaPreview", "LumaForwarding"] { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(directoryName, isDirectory: true) - let urls = (try? FileManager.default.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil - )) ?? [] + let urls = + (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + )) ?? [] for url in urls where url.lastPathComponent.hasPrefix(prefix) { if previewURL == url { previewURL = nil } try? FileManager.default.removeItem(at: url) @@ -2596,7 +2723,8 @@ final class AppModel: ObservableObject { if let preparedThumbnailData { mediaThumbnailData[messageID] = preparedThumbnailData } - let hasCompletePreparedPreview = preparedThumbnailData != nil + let hasCompletePreparedPreview = + preparedThumbnailData != nil && (kind == .photo || kind == .video) guard !hasCompletePreparedPreview else { return } Task { @MainActor [weak self] in @@ -2630,10 +2758,11 @@ final class AppModel: ObservableObject { audioWaveformSamples[messageID] = waveform } if let duration = analysis.duration, - let index = firstMessageIndexByID[messageID], - messages.indices.contains(index), - messages[index].id == messageID, - messages[index].duration == nil { + let index = firstMessageIndexByID[messageID], + messages.indices.contains(index), + messages[index].id == messageID, + messages[index].duration == nil + { messages[index].duration = duration schedulePersist() } @@ -2678,7 +2807,8 @@ final class AppModel: ObservableObject { private static func isValidJID(_ jid: String) -> Bool { let pieces = jid.split(separator: "@", omittingEmptySubsequences: false) - return pieces.count == 2 && !pieces[0].isEmpty && !pieces[1].isEmpty && !jid.contains(where: { $0.isWhitespace }) + return pieces.count == 2 && !pieces[0].isEmpty && !pieces[1].isEmpty + && !jid.contains(where: { $0.isWhitespace }) } private static func mediaKind(for contentType: UTType?, filename: String) -> ChatMessage.Kind { diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index bd034c2..3bcd44d 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -62,12 +62,14 @@ private final class ArchiveStanzaInbox: @unchecked Sendable { overflowed = true return } - stanzas.append(BufferedArchiveStanza( - queryID: queryID, - message: message, - timestamp: timestamp, - archiveID: archiveID - )) + stanzas.append( + BufferedArchiveStanza( + queryID: queryID, + message: message, + timestamp: timestamp, + archiveID: archiveID + )) + print("MAM source: \(source), allowed: \(allowedSources)") } func take(queryID: String) -> BufferedArchivePage { @@ -202,7 +204,8 @@ final class XMPPService { case retraction(RetractionEnvelope) case reaction(ReactionEnvelope) case chatState(ChatStateEnvelope) - case groupMessageEcho(roomJID: String, messageID: String, stanzaID: String, senderJID: String) + case groupMessageEcho( + roomJID: String, messageID: String, stanzaID: String, senderJID: String) case roomState(RoomStateEnvelope) case roomInvitation(RoomInvitationEnvelope) case avatar(jid: String, data: Data) @@ -464,18 +467,21 @@ final class XMPPService { self.archiveResumeAfterMediaWorkTask = nil self.archiveSyncSuspended = false guard let client = self.client, - client.state == .connected() else { return } + client.state == .connected() + else { return } self.startArchiveSyncIfAvailable() } } - func reconnectIfNeeded(password: String) async throws { + func reconnectIfNeeded(password: String, archiveCheckpoint: ArchiveSyncCheckpoint? = nil) + async throws + { guard connectionStatus != .connected, let account else { return } + if let archiveCheckpoint { + self.archiveSyncCheckpoint = archiveCheckpoint + } try await connect( - account: account, - password: password, - archiveCheckpoint: archiveSyncCheckpoint - ) + account: account, password: password, archiveCheckpoint: archiveSyncCheckpoint) } func startCall(to peerJID: String, withVideo: Bool) async throws { @@ -571,30 +577,36 @@ final class XMPPService { room = joinedRoom } observe(room: room) - eventHandler?(.roomState(RoomStateEnvelope( - roomJID: room.jid.stringValue, - nickname: room.nickname, - joined: room.state == .joined, - occupantCount: 0 - ))) + eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: room.jid.stringValue, + nickname: room.nickname, + joined: room.state == .joined, + occupantCount: 0 + ))) } func leaveRoom(roomJID: String) { guard let client else { return } let normalized = roomJID.lowercased() let muc = client.module(.muc) - guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)) else { return } + guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)) else { + return + } muc.leave(room: room) observedRoomJIDs.remove(normalized) roomOccupantsByJID.removeValue(forKey: normalized) roomRealJIDByNickname.removeValue(forKey: normalized) omemoConfiguredRoomJIDs.remove(normalized) - eventHandler?(.roomState(RoomStateEnvelope( - roomJID: normalized, - nickname: room.nickname, - joined: false, - occupantCount: 0 - ))) + eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: normalized, + nickname: room.nickname, + joined: false, + occupantCount: 0 + ))) } func inviteMembers(_ jids: [String], to roomJID: String) async throws { @@ -604,7 +616,8 @@ final class XMPPService { let normalized = roomJID.lowercased() let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)), - room.state == .joined else { + room.state == .joined + else { throw LumaXMPPError.roomNotJoined } let invitees = jids.map { @@ -624,14 +637,17 @@ final class XMPPService { ) if let owners, let admins { let privilegedJIDs = Set((owners + admins).map { $0.jid.bareJid }) - let memberships = invitees + let memberships = + invitees .filter { !privilegedJIDs.contains($0.bareJid) } .map { MucModule.RoomAffiliation(jid: $0, affiliation: .member) } if !memberships.isEmpty { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - muc.setRoomAffiliations(to: room, changedAffiliations: memberships) { result in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + muc.setRoomAffiliations(to: room, changedAffiliations: memberships) { + result in continuation.resume(with: result) } } @@ -679,7 +695,8 @@ final class XMPPService { let roomJID = BareJID(recipient.lowercased()) let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: roomJID), - room.state == .joined else { + room.state == .joined + else { throw LumaXMPPError.roomNotJoined } let message = room.createMessage(text: wireBody, id: messageID) @@ -711,7 +728,10 @@ final class XMPPService { let peer = BareJID(recipient.lowercased()) let manager = client.module(.message).chatManager - guard let chat = manager.chat(for: client, with: peer) ?? manager.createChat(for: client, with: peer) else { + guard + let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) + else { throw LumaXMPPError.cannotCreateChat } @@ -747,11 +767,15 @@ final class XMPPService { let peer = BareJID(recipient.lowercased()) let manager = client.module(.message).chatManager - guard let chat = manager.chat(for: client, with: peer) ?? manager.createChat(for: client, with: peer) else { + guard + let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) + else { throw LumaXMPPError.cannotCreateChat } - let fallbackText = "/me удалил(а) сообщение. Обновите клиент, если оно всё ещё отображается." + let fallbackText = + "/me удалил(а) сообщение. Обновите клиент, если оно всё ещё отображается." let message = chat.createMessage(text: fallbackText, id: retractionID) let retract = Element(name: "retract", xmlns: Self.retractionNamespace) retract.setAttribute("id", value: targetID) @@ -796,7 +820,8 @@ final class XMPPService { let roomJID = BareJID(recipient.lowercased()) let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: roomJID), - room.state == .joined else { + room.state == .joined + else { throw LumaXMPPError.roomNotJoined } let message = room.createMessage(id: reactionMessageID, type: .groupchat) @@ -808,8 +833,10 @@ final class XMPPService { let peer = BareJID(recipient.lowercased()) let manager = client.module(.message).chatManager - guard let chat = manager.chat(for: client, with: peer) - ?? manager.createChat(for: client, with: peer) else { + guard + let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) + else { throw LumaXMPPError.cannotCreateChat } let message = chat.createMessage(id: reactionMessageID, type: .chat) @@ -824,15 +851,17 @@ final class XMPPService { isGroup: Bool ) async throws { guard chatStatesEnabled, - let client, - client.state == .connected() else { return } + let client, + client.state == .connected() + else { return } let martinState = Self.martinChatState(state) if isGroup { let roomJID = BareJID(recipient.lowercased()) let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: roomJID), - room.state == .joined else { return } + room.state == .joined + else { return } let message = room.createMessage(id: UUID().uuidString, type: .groupchat) message.chatState = martinState try await room.send(message: message) @@ -841,13 +870,16 @@ final class XMPPService { let peer = BareJID(recipient.lowercased()) let chatStateModule = client.module(.chatStateNotifications) - let hasSupport = knownChatStatePeers.contains(peer.stringValue.lowercased()) + let hasSupport = + knownChatStatePeers.contains(peer.stringValue.lowercased()) || chatStateModule.hasSupport(jid: peer) guard hasSupport else { return } let manager = client.module(.message).chatManager - guard let chat = manager.chat(for: client, with: peer) - ?? manager.createChat(for: client, with: peer) else { return } + guard + let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) + else { return } let message = chat.createMessage(id: UUID().uuidString, type: .chat) message.chatState = martinState try await chat.send(message: message) @@ -906,9 +938,12 @@ final class XMPPService { } catch { throw LumaXMPPError.uploadDiscoveryFailed(error.localizedDescription) } - guard let component = components - .filter({ $0.maxSize >= uploadData.count }) - .max(by: { $0.maxSize < $1.maxSize }) else { + guard + let component = + components + .filter({ $0.maxSize >= uploadData.count }) + .max(by: { $0.maxSize < $1.maxSize }) + else { throw LumaXMPPError.uploadUnavailable(size: uploadData.count) } @@ -924,7 +959,8 @@ final class XMPPService { throw LumaXMPPError.uploadSlotFailed(error.localizedDescription) } guard slot.putUri.scheme?.lowercased() == "https", - slot.getUri.scheme?.lowercased() == "https" else { + slot.getUri.scheme?.lowercased() == "https" + else { throw LumaXMPPError.insecureUploadURL } @@ -943,7 +979,8 @@ final class XMPPService { let uploadSession = URLSession(configuration: uploadConfiguration) defer { uploadSession.finishTasksAndInvalidate() } do { - let (_, receivedResponse) = try await uploadSession.upload(for: request, from: uploadData) + let (_, receivedResponse) = try await uploadSession.upload( + for: request, from: uploadData) response = receivedResponse } catch { throw LumaXMPPError.uploadTransportFailed(error.localizedDescription) @@ -955,7 +992,8 @@ final class XMPPService { throw LumaXMPPError.uploadFailed(statusCode: http.statusCode) } - guard var urlComponents = URLComponents(url: slot.getUri, resolvingAgainstBaseURL: false) else { + guard var urlComponents = URLComponents(url: slot.getUri, resolvingAgainstBaseURL: false) + else { throw LumaXMPPError.invalidUploadURL } if let decryptionFragment { @@ -1014,13 +1052,19 @@ final class XMPPService { } func setDeviceVerified(_ verified: Bool, jid: String, deviceID: Int32) { - guard omemoStorage?.setVerified(verified, jid: jid, deviceID: deviceID) == true else { return } - eventHandler?(.omemo(ready: client?.moduleOrNil(.omemo)?.isReady ?? false, ownFingerprint: omemoStorage?.ownFingerprint)) + guard omemoStorage?.setVerified(verified, jid: jid, deviceID: deviceID) == true else { + return + } + eventHandler?( + .omemo( + ready: client?.moduleOrNil(.omemo)?.isReady ?? false, + ownFingerprint: omemoStorage?.ownFingerprint)) } func addToRoster(jid: String, name: String?) { guard let client, client.state == .connected() else { return } - client.module(.roster).addItem(jid: JID(jid), name: name, groups: [], completionHandler: nil) + client.module(.roster).addItem( + jid: JID(jid), name: name, groups: [], completionHandler: nil) client.module(.presence).subscribe(to: JID(jid)) fetchAvatar(for: jid) } @@ -1085,11 +1129,12 @@ final class XMPPService { DiscoveryModule(identity: .init(category: "client", type: "phone", name: "Luma")) ) _ = client.modulesManager.register( - SoftwareVersionModule(version: .init(name: "Luma", version: "0.10.6", os: Self.platformName)) + SoftwareVersionModule( + version: .init(name: "Luma", version: "0.10.6", os: Self.platformName)) ) _ = client.modulesManager.register(PingModule()) _ = client.modulesManager.register(ClientStateIndicationModule()) -// _ = client.modulesManager.register(ClientStateIndicationModule()) + // _ = client.modulesManager.register(ClientStateIndicationModule()) _ = client.modulesManager.register( RosterModule(rosterManager: RosterManagerBase(store: LumaRosterStore())) ) @@ -1102,7 +1147,7 @@ final class XMPPService { .init(rawValue: Self.retractionNamespace), .init(rawValue: Self.reactionsNamespace), .init(rawValue: ChatStateNotificationsModule.XMLNS), - .init(rawValue: Self.stanzaIDNamespace) + .init(rawValue: Self.stanzaIDNamespace), ]) ) _ = client.modulesManager.register(PubSubModule()) @@ -1126,7 +1171,7 @@ final class XMPPService { transport: Jingle.Transport.ICEUDPTransport.self, features: [ Jingle.Transport.ICEUDPTransport.XMLNS, - "urn:xmpp:jingle:apps:dtls:0" + "urn:xmpp:jingle:apps:dtls:0", ] ) jingle.register( @@ -1134,7 +1179,7 @@ final class XMPPService { features: [ "urn:xmpp:jingle:apps:rtp:1", "urn:xmpp:jingle:apps:rtp:audio", - "urn:xmpp:jingle:apps:rtp:video" + "urn:xmpp:jingle:apps:rtp:video", ] ) jingle.supportsMessageInitiation = true @@ -1155,14 +1200,16 @@ final class XMPPService { ) { client.connectionConfiguration.userJid = BareJID(account.normalizedJID) client.connectionConfiguration.resource = account.effectiveResource - client.connectionConfiguration.nickname = account.displayName.isEmpty ? nil : account.displayName + client.connectionConfiguration.nickname = + account.displayName.isEmpty ? nil : account.displayName client.connectionConfiguration.credentials = .password( password: password, authenticationName: nil, cache: nil ) let expectedDomain = account.domain ?? account.normalizedJID - client.connectionConfiguration.modifyConnectorOptions(type: SocketConnector.Options.self) { options in + client.connectionConfiguration.modifyConnectorOptions(type: SocketConnector.Options.self) { + options in options.conntectionTimeout = 20 options.sslCertificateValidation = .customValidator { trust in CertificateTrustEvaluator.evaluateServerTrust( @@ -1213,10 +1260,11 @@ final class XMPPService { client.module(.presence).presencePublisher .receive(on: DispatchQueue.main) .sink { [weak self] change in - self?.eventHandler?(.presence( - jid: change.jid.bareJid.stringValue, - online: change.presence.type != .unavailable - )) + self?.eventHandler?( + .presence( + jid: change.jid.bareJid.stringValue, + online: change.presence.type != .unavailable + )) } .store(in: &cancellables) @@ -1237,12 +1285,14 @@ final class XMPPService { client.module(.muc).inivitationsPublisher .receive(on: DispatchQueue.main) .sink { [weak self] invitation in - self?.eventHandler?(.roomInvitation(RoomInvitationEnvelope( - roomJID: invitation.roomJid.stringValue, - inviterJID: invitation.inviter?.stringValue, - reason: invitation.reason, - password: invitation.password - ))) + self?.eventHandler?( + .roomInvitation( + RoomInvitationEnvelope( + roomJID: invitation.roomJid.stringValue, + inviterJID: invitation.inviter?.stringValue, + reason: invitation.reason, + password: invitation.password + ))) } .store(in: &cancellables) @@ -1299,7 +1349,8 @@ final class XMPPService { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] ready in - self?.eventHandler?(.omemo(ready: ready, ownFingerprint: omemoStorage.ownFingerprint)) + self?.eventHandler?( + .omemo(ready: ready, ownFingerprint: omemoStorage.ownFingerprint)) } .store(in: &cancellables) } @@ -1324,18 +1375,27 @@ final class XMPPService { isArchived: Bool = false ) { guard message.type != .error, - message.type != .groupchat, - let client, - let sender = message.from?.bareJid else { return } + message.type != .groupchat, + let client, + let sender = message.from?.bareJid + else { return } let ownJID = client.userBareJid let outgoing = sender == ownJID - guard let peer = outgoing ? message.to?.bareJid : message.from?.bareJid else { return } + // guard let peer = outgoing ? message.to?.bareJid : message.from?.bareJid else { return } + let peer: BareJID? + if outgoing { + peer = message.to?.bareJid ?? message.from?.bareJid + } else { + peer = message.from?.bareJid + } + guard let peer else { return } let security: ChatMessage.Security let fingerprint: String? let contentMessage: Message? - switch client.module(.omemo).decode(message: message, from: sender, serverMsgId: archiveID) { + switch client.module(.omemo).decode(message: message, from: sender, serverMsgId: archiveID) + { case .successMessage(let decodedMessage, let value): security = .omemo fingerprint = value @@ -1359,23 +1419,27 @@ final class XMPPService { let payload = contentMessage ?? message if !isArchived, - let state = Self.chatTypingState(payload.chatState ?? message.chatState) { + let state = Self.chatTypingState(payload.chatState ?? message.chatState) + { knownChatStatePeers.insert(peer.stringValue.lowercased()) if !outgoing { - eventHandler?(.chatState(ChatStateEnvelope( - peerJID: peer.stringValue, - senderJID: sender.stringValue, - senderDisplayName: nil, - state: state, - isGroupMessage: false - ))) + eventHandler?( + .chatState( + ChatStateEnvelope( + peerJID: peer.stringValue, + senderJID: sender.stringValue, + senderDisplayName: nil, + state: state, + isGroupMessage: false + ))) } } if let reactions = payload.firstChild(name: "reactions", xmlns: Self.reactionsNamespace) ?? message.firstChild(name: "reactions", xmlns: Self.reactionsNamespace), - let targetID = reactions.getAttribute("id"), - !targetID.isEmpty { + let targetID = reactions.getAttribute("id"), + !targetID.isEmpty + { emitReaction( ReactionEnvelope( peerJID: peer.stringValue, @@ -1396,8 +1460,9 @@ final class XMPPService { if let retract = payload.firstChild(name: "retract", xmlns: Self.retractionNamespace) ?? message.firstChild(name: "retract", xmlns: Self.retractionNamespace), - let targetID = retract.getAttribute("id"), - !targetID.isEmpty { + let targetID = retract.getAttribute("id"), + !targetID.isEmpty + { emitRetraction( peer: peer, sender: sender, @@ -1411,10 +1476,12 @@ final class XMPPService { } if payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil - || message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil { + || message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil + { guard let targetID = message.originId ?? message.id ?? archiveID else { return } - let tombstone = payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) - ?? message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) + let tombstone = + payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) + ?? message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) emitRetraction( peer: peer, sender: sender, @@ -1427,21 +1494,25 @@ final class XMPPService { return } - let rawBody = contentMessage?.body + let rawBody = + contentMessage?.body ?? (security == .decryptionFailed ? "Не удалось расшифровать сообщение" : nil) guard let body = rawBody, !body.isEmpty else { return } - let reply = payload.firstChild(name: "reply", xmlns: Self.replyNamespace) - ?? message.firstChild(name: "reply", xmlns: Self.replyNamespace) - let replyFallback = payload.children.first { child in - child.name == "fallback" - && child.xmlns == Self.fallbackNamespace - && child.getAttribute("for") == Self.replyNamespace - } ?? message.children.first { child in - child.name == "fallback" - && child.xmlns == Self.fallbackNamespace - && child.getAttribute("for") == Self.replyNamespace - } + let reply = + payload.firstChild(name: "reply", xmlns: Self.replyNamespace) + ?? message.firstChild(name: "reply", xmlns: Self.replyNamespace) + let replyFallback = + payload.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } + ?? message.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } let fallbackBody = replyFallback?.findChild(name: "body") let parsedReply = MessageReplyFallback.parse( body: body, @@ -1450,7 +1521,8 @@ final class XMPPService { ) let visibleBody = parsedReply.body - let isEncryptedAttachment = visibleBody.hasPrefix("aesgcm://") + let isEncryptedAttachment = + visibleBody.hasPrefix("aesgcm://") && visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil && URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm" let oob = payload.oob ?? message.oob @@ -1466,41 +1538,45 @@ final class XMPPService { } let inferred = transportFilename.map(MediaMetadata.infer(from:)) let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location) - let displayBody = attachmentURL == nil + let displayBody = + attachmentURL == nil ? visibleBody : Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение") let id = message.originId ?? message.id ?? archiveID ?? UUID().uuidString - emitMessage(MessageEnvelope( - id: id, - peerJID: peer.stringValue, - senderJID: sender.stringValue, - body: displayBody, - timestamp: timestamp, - isOutgoing: outgoing, - security: security, - kind: kind, - remoteAttachmentURL: attachmentURL, - localFilename: transportFilename, - mimeType: inferred?.mimeType, - duration: inferred?.duration, - byteCount: nil, - fingerprint: fingerprint, - correctionTargetID: payload.lastMessageCorrectionId ?? message.lastMessageCorrectionId, - replyToID: reply?.getAttribute("id"), - replyToJID: reply?.getAttribute("to"), - replyPreview: parsedReply.preview, - stanzaID: nil, - senderDisplayName: nil, - isGroupMessage: false, - isArchived: isArchived - ), isArchived: isArchived) + emitMessage( + MessageEnvelope( + id: id, + peerJID: peer.stringValue, + senderJID: sender.stringValue, + body: displayBody, + timestamp: timestamp, + isOutgoing: outgoing, + security: security, + kind: kind, + remoteAttachmentURL: attachmentURL, + localFilename: transportFilename, + mimeType: inferred?.mimeType, + duration: inferred?.duration, + byteCount: nil, + fingerprint: fingerprint, + correctionTargetID: payload.lastMessageCorrectionId + ?? message.lastMessageCorrectionId, + replyToID: reply?.getAttribute("id"), + replyToJID: reply?.getAttribute("to"), + replyPreview: parsedReply.preview, + stanzaID: nil, + senderDisplayName: nil, + isGroupMessage: false, + isArchived: isArchived + ), isArchived: isArchived) } private func handle(groupMessage message: Message, room: RoomProtocol) { guard message.type != .error, - let client, - let from = message.from else { return } + let client, + let from = message.from + else { return } let nickname = from.resource ?? "Участник" let outgoing = nickname == room.nickname @@ -1509,7 +1585,8 @@ final class XMPPService { // JID (room@service/nickname), not the participant's disclosed real JID. let senderJID = from.stringValue let stanzaID = Self.roomStanzaID(in: message, roomJID: room.jid) - let id = outgoing + let id = + outgoing ? (message.originId ?? message.id ?? stanzaID ?? UUID().uuidString) : (stanzaID ?? message.originId ?? message.id ?? UUID().uuidString) @@ -1588,21 +1665,25 @@ final class XMPPService { if let state = Self.chatTypingState(payload.chatState ?? message.chatState) { knownChatStatePeers.insert(roomJID) if !outgoing { - eventHandler?(.chatState(ChatStateEnvelope( - peerJID: roomJID, - senderJID: senderJID, - senderDisplayName: nickname, - state: state, - isGroupMessage: true - ))) + eventHandler?( + .chatState( + ChatStateEnvelope( + peerJID: roomJID, + senderJID: senderJID, + senderDisplayName: nickname, + state: state, + isGroupMessage: true + ))) } } if let reactions = payload.firstChild(name: "reactions", xmlns: Self.reactionsNamespace) ?? message.firstChild(name: "reactions", xmlns: Self.reactionsNamespace), - let targetID = reactions.getAttribute("id"), - !targetID.isEmpty { - let reactionSender = outgoing + let targetID = reactions.getAttribute("id"), + !targetID.isEmpty + { + let reactionSender = + outgoing ? client.userBareJid.stringValue : (realSender?.stringValue ?? senderJID) emitReaction( @@ -1623,20 +1704,24 @@ final class XMPPService { return } - let rawBody = contentMessage?.body + let rawBody = + contentMessage?.body ?? (security == .decryptionFailed ? "Не удалось расшифровать групповое сообщение" : nil) guard let rawBody, !rawBody.isEmpty else { return } - let reply = payload.firstChild(name: "reply", xmlns: Self.replyNamespace) - ?? message.firstChild(name: "reply", xmlns: Self.replyNamespace) - let replyFallback = payload.children.first { child in - child.name == "fallback" - && child.xmlns == Self.fallbackNamespace - && child.getAttribute("for") == Self.replyNamespace - } ?? message.children.first { child in - child.name == "fallback" - && child.xmlns == Self.fallbackNamespace - && child.getAttribute("for") == Self.replyNamespace - } + let reply = + payload.firstChild(name: "reply", xmlns: Self.replyNamespace) + ?? message.firstChild(name: "reply", xmlns: Self.replyNamespace) + let replyFallback = + payload.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } + ?? message.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } let fallbackBody = replyFallback?.findChild(name: "body") let parsedReply = MessageReplyFallback.parse( body: rawBody, @@ -1645,7 +1730,8 @@ final class XMPPService { ) let visibleBody = parsedReply.body - let isEncryptedAttachment = visibleBody.hasPrefix("aesgcm://") + let isEncryptedAttachment = + visibleBody.hasPrefix("aesgcm://") && visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil && URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm" let oob = payload.oob ?? message.oob @@ -1661,33 +1747,37 @@ final class XMPPService { } let inferred = transportFilename.map(MediaMetadata.infer(from:)) let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location) - let displayBody = attachmentURL == nil + let displayBody = + attachmentURL == nil ? visibleBody : Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение") - eventHandler?(.message(MessageEnvelope( - id: id, - peerJID: room.jid.stringValue, - senderJID: senderJID, - body: displayBody, - timestamp: message.delay?.stamp ?? Date(), - isOutgoing: outgoing, - security: security, - kind: kind, - remoteAttachmentURL: attachmentURL, - localFilename: transportFilename, - mimeType: inferred?.mimeType, - duration: inferred?.duration, - byteCount: nil, - fingerprint: fingerprint, - correctionTargetID: payload.lastMessageCorrectionId ?? message.lastMessageCorrectionId, - replyToID: reply?.getAttribute("id"), - replyToJID: reply?.getAttribute("to"), - replyPreview: parsedReply.preview, - stanzaID: stanzaID, - senderDisplayName: nickname, - isGroupMessage: true, - isArchived: false - ))) + eventHandler?( + .message( + MessageEnvelope( + id: id, + peerJID: room.jid.stringValue, + senderJID: senderJID, + body: displayBody, + timestamp: message.delay?.stamp ?? Date(), + isOutgoing: outgoing, + security: security, + kind: kind, + remoteAttachmentURL: attachmentURL, + localFilename: transportFilename, + mimeType: inferred?.mimeType, + duration: inferred?.duration, + byteCount: nil, + fingerprint: fingerprint, + correctionTargetID: payload.lastMessageCorrectionId + ?? message.lastMessageCorrectionId, + replyToID: reply?.getAttribute("id"), + replyToJID: reply?.getAttribute("to"), + replyPreview: parsedReply.preview, + stanzaID: stanzaID, + senderDisplayName: nickname, + isGroupMessage: true, + isArchived: false + ))) } private func observe(room: RoomProtocol) { @@ -1698,12 +1788,14 @@ final class XMPPService { .receive(on: DispatchQueue.main) .sink { [weak self, weak room] state in guard let room else { return } - self?.eventHandler?(.roomState(RoomStateEnvelope( - roomJID: roomJID, - nickname: room.nickname, - joined: state == .joined, - occupantCount: 0 - ))) + self?.eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: roomJID, + nickname: room.nickname, + joined: state == .joined, + occupantCount: 0 + ))) } .store(in: &cancellables) @@ -1719,12 +1811,14 @@ final class XMPPService { } } self.roomRealJIDByNickname[roomJID] = knownAuthors - self.eventHandler?(.roomState(RoomStateEnvelope( - roomJID: roomJID, - nickname: room.nickname, - joined: room.state == .joined, - occupantCount: occupants.count - ))) + self.eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: roomJID, + nickname: room.nickname, + joined: room.state == .joined, + occupantCount: occupants.count + ))) } .store(in: &cancellables) } @@ -1744,7 +1838,8 @@ final class XMPPService { makeMembersOnly: Bool ) async throws -> Bool { let roomJID = JID(room.jid) - let configuration: JabberDataElement = try await withCheckedThrowingContinuation { continuation in + let configuration: JabberDataElement = try await withCheckedThrowingContinuation { + continuation in muc.getRoomConfiguration(roomJid: roomJID) { result in switch result { case .success(let value): continuation.resume(returning: value) @@ -1757,23 +1852,28 @@ final class XMPPService { if let field: ListSingleField = configuration.getField(named: "muc#roomconfig_whois") { field.value = "anyone" supportsNonAnonymousRooms = true - } else if let field: TextSingleField = configuration.getField(named: "muc#roomconfig_whois") { + } else if let field: TextSingleField = configuration.getField(named: "muc#roomconfig_whois") + { field.value = "anyone" supportsNonAnonymousRooms = true } - if let field: ListMultiField = configuration.getField(named: "muc#roomconfig_getmemberlist") { + if let field: ListMultiField = configuration.getField(named: "muc#roomconfig_getmemberlist") + { field.value = Array(Set(field.value + ["moderator", "participant"])).sorted() } if makeMembersOnly, - let field: BooleanField = configuration.getField(named: "muc#roomconfig_membersonly") { + let field: BooleanField = configuration.getField(named: "muc#roomconfig_membersonly") + { field.value = true } if makeMembersOnly, - let field: BooleanField = configuration.getField(named: "muc#roomconfig_persistentroom") { + let field: BooleanField = configuration.getField(named: "muc#roomconfig_persistentroom") + { field.value = true } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in muc.setRoomConfiguration(roomJid: roomJID, configuration: configuration) { result in switch result { case .success(let value): continuation.resume(returning: value) @@ -1875,9 +1975,10 @@ final class XMPPService { guard configured else { throw LumaXMPPError.groupEncryptionConfigurationUnsupported } - eventHandler?(.recoverableError( - "Комната переведена в неанонимный режим, необходимый для группового OMEMO." - )) + eventHandler?( + .recoverableError( + "Комната переведена в неанонимный режим, необходимый для группового OMEMO." + )) return } @@ -1920,22 +2021,26 @@ final class XMPPService { private static func roomStanzaID(in message: Message, roomJID: BareJID) -> String? { message.children.first { child in - guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else { return false } + guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else { + return false + } guard let by = child.getAttribute("by")?.lowercased() else { return false } return by == roomJID.stringValue.lowercased() }?.getAttribute("id") } private static func originalSenderJID(in message: Message) -> BareJID? { - guard let address = message.firstChild( - name: "addresses", - xmlns: Self.extendedAddressingNamespace - )?.children.first(where: { child in - child.name == "address" - && child.getAttribute("type") == "ofrom" - && child.getAttribute("jid") != nil - }), - let value = address.getAttribute("jid") else { return nil } + guard + let address = message.firstChild( + name: "addresses", + xmlns: Self.extendedAddressingNamespace + )?.children.first(where: { child in + child.name == "address" + && child.getAttribute("type") == "ofrom" + && child.getAttribute("jid") != nil + }), + let value = address.getAttribute("jid") + else { return nil } return JID(value).bareJid } @@ -1967,12 +2072,13 @@ final class XMPPService { senderJID: String ) { guard let stanzaID, !stanzaID.isEmpty else { return } - eventHandler?(.groupMessageEcho( - roomJID: roomJID, - messageID: messageID, - stanzaID: stanzaID, - senderJID: senderJID - )) + eventHandler?( + .groupMessageEcho( + roomJID: roomJID, + messageID: messageID, + stanzaID: stanzaID, + senderJID: senderJID + )) } private func emitRetraction( @@ -2026,7 +2132,8 @@ final class XMPPService { fireEvents: false ) guard info.size > 0, info.size <= 2 * 1_024 * 1_024 else { return nil } - let (_, data) = try await avatarModule.retrieveAvatar(from: BareJID(jid), itemId: info.id) + let (_, data) = try await avatarModule.retrieveAvatar( + from: BareJID(jid), itemId: info.id) return data.count <= 2 * 1_024 * 1_024 ? data : nil } catch { return nil @@ -2037,8 +2144,9 @@ final class XMPPService { do { let card = try await client.module(.vcardTemp).retrieveVCard(from: JID(jid)) guard let encoded = card.photos.first(where: { $0.binval != nil })?.binval, - let data = Data(base64Encoded: encoded), - data.count <= 2 * 1_024 * 1_024 else { return nil } + let data = Data(base64Encoded: encoded), + data.count <= 2 * 1_024 * 1_024 + else { return nil } return data } catch { return nil @@ -2064,14 +2172,17 @@ final class XMPPService { } } - private func encrypt(_ message: Message, using omemo: OMEMOModule) async throws -> (message: Message, fingerprint: String?) { + private func encrypt(_ message: Message, using omemo: OMEMOModule) async throws -> ( + message: Message, fingerprint: String? + ) { try await withCheckedThrowingContinuation { continuation in omemo.encode(message: message, withStoreHint: true) { result in switch result { case .successMessage(let message, let fingerprint): continuation.resume(returning: (message, fingerprint)) case .failure(let error): - continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + continuation.resume( + throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) } } } @@ -2082,20 +2193,23 @@ final class XMPPService { forGroupRecipients recipients: [BareJID], using omemo: OMEMOModule ) async throws -> (message: Message, fingerprint: String?) { - let fetchedAddresses: [SignalAddress] = try await withCheckedThrowingContinuation { continuation in + let fetchedAddresses: [SignalAddress] = try await withCheckedThrowingContinuation { + continuation in omemo.addresses(for: recipients) { result in switch result { case .success(let addresses): continuation.resume(returning: addresses) case .failure(let error): - continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + continuation.resume( + throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) } } } let addresses = Array(Set(fetchedAddresses)) let availableJIDs = Set(addresses.map { $0.name.lowercased() }) - let missingJIDs = recipients + let missingJIDs = + recipients .map(\.stringValue) .filter { !availableJIDs.contains($0.lowercased()) } guard missingJIDs.isEmpty else { @@ -2113,16 +2227,19 @@ final class XMPPService { ) } - let encryptedMessage: (message: Message, fingerprint: String?) = try await withCheckedThrowingContinuation { continuation in - omemo.encode(message: message, forAddresses: addresses, withStoreHint: true) { result in - switch result { - case .successMessage(let message, let fingerprint): - continuation.resume(returning: (message, fingerprint)) - case .failure(let error): - continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + let encryptedMessage: (message: Message, fingerprint: String?) = + try await withCheckedThrowingContinuation { continuation in + omemo.encode(message: message, forAddresses: addresses, withStoreHint: true) { + result in + switch result { + case .successMessage(let message, let fingerprint): + continuation.resume(returning: (message, fingerprint)) + case .failure(let error): + continuation.resume( + throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + } } } - } var encryptedKeyCounts: [Int32: Int] = [:] encryptedMessage.message @@ -2150,19 +2267,21 @@ final class XMPPService { private func startArchiveSyncIfAvailable() { guard let client, - client.state == .connected(), - !archiveSyncSuspended, - !archiveRetrySuppressedUntilActivation, - !client.module(.mam).availableVersions.isEmpty else { return } + client.state == .connected(), + !archiveSyncSuspended, + !archiveRetrySuppressedUntilActivation, + !client.module(.mam).availableVersions.isEmpty + else { return } startArchiveSync(client: client) } private func startArchiveSync(client: XMPPClient) { guard !archiveSyncStarted, - !archiveSyncCompletedForConnection, - !archiveSyncSuspended, - !archiveRetrySuppressedUntilActivation, - self.client === client else { return } + !archiveSyncCompletedForConnection, + !archiveSyncSuspended, + !archiveRetrySuppressedUntilActivation, + self.client === client + else { return } archiveSyncStarted = true if archiveSyncQueryStartedAt == nil { archiveSyncQueryStartedAt = Date() @@ -2202,8 +2321,9 @@ final class XMPPService { private func queryArchive(client: XMPPClient, after: String?, retry: Int) { guard archiveSyncStarted, - self.client === client, - client.state == .connected() else { + self.client === client, + client.state == .connected() + else { if archiveSyncStarted, self.client === client { finishArchiveSync(client: client, succeeded: false) } else { @@ -2220,16 +2340,19 @@ final class XMPPService { queryID: queryID, allowedSources: [ client.userBareJid.stringValue, - client.userBareJid.domain + client.userBareJid.domain, ] ) scheduleArchiveQueryTimeout(client: client, queryID: queryID) - let start = archiveIsBootstrapQuery + let start = + archiveIsBootstrapQuery ? nil - : (after == nil ? archiveSyncCheckpoint?.timestamp.addingTimeInterval( - -ArchiveSyncRecoveryPolicy.incrementalOverlap - ) : nil) - let resultSet = archiveIsBootstrapQuery + : (after == nil + ? archiveSyncCheckpoint?.timestamp.addingTimeInterval( + -ArchiveSyncRecoveryPolicy.incrementalOverlap + ) : nil) + let resultSet = + archiveIsBootstrapQuery ? RSM.Query(lastItems: archiveBootstrapMessageLimit) : RSM.Query(after: after, max: archivePageSize) client.module(.mam).queryItems( @@ -2242,10 +2365,11 @@ final class XMPPService { // actor hand-off runs. DispatchQueue.main.async { guard let self, - let client, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + let client, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client + else { return } self.archiveQueryTimeoutTask?.cancel() self.archiveQueryTimeoutTask = nil let page = self.archiveStanzaInbox.take(queryID: queryID) @@ -2254,8 +2378,9 @@ final class XMPPService { self.archiveRejectedSource = page.rejectedSource guard case .success = result, - !page.overflowed, - !page.rejectedSource else { + !page.overflowed, + !page.rejectedSource + else { self.archiveActiveQueryID = nil self.completeArchivePage( result, @@ -2272,10 +2397,11 @@ final class XMPPService { guard let self, let client else { return } let applied = await self.drainArchiveStanzas(queryID: queryID) guard !Task.isCancelled, - applied, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + applied, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client + else { return } self.archiveQueryTimeoutTask?.cancel() self.archiveQueryTimeoutTask = nil self.archiveQueryCompletionTask = nil @@ -2293,8 +2419,9 @@ final class XMPPService { private func drainArchiveStanzas(queryID: String) async -> Bool { while archiveSyncStarted, - archiveActiveQueryID == queryID, - !Task.isCancelled { + archiveActiveQueryID == queryID, + !Task.isCancelled + { guard !archiveStanzaBuffer.isEmpty else { return true } let count = min(archiveApplyBatchSize, archiveStanzaBuffer.count) let batch = Array(archiveStanzaBuffer.prefix(count)) @@ -2334,9 +2461,10 @@ final class XMPPService { archiveBufferOverflowed = false archiveStanzaBuffer.removeAll(keepingCapacity: true) archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "Сервер вернул слишком большую страницу MAM; синхронизация остановлена без потери контрольной точки." - )) + eventHandler?( + .recoverableError( + "Сервер вернул слишком большую страницу MAM; синхронизация остановлена без потери контрольной точки." + )) finishArchiveSync(client: client, succeeded: false) return } @@ -2344,9 +2472,10 @@ final class XMPPService { archiveRejectedSource = false archiveStanzaBuffer.removeAll(keepingCapacity: true) archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "MAM вернул результат от неожиданного отправителя; страница отклонена." - )) + eventHandler?( + .recoverableError( + "MAM вернул результат от неожиданного отправителя; страница отклонена." + )) finishArchiveSync(client: client, succeeded: false) return } @@ -2402,9 +2531,10 @@ final class XMPPService { ) } else { archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "MAM не продвинул контрольную точку; синхронизация приостановлена." - )) + eventHandler?( + .recoverableError( + "MAM не продвинул контрольную точку; синхронизация приостановлена." + )) finishArchiveSync(client: client, succeeded: false) } } else { @@ -2415,9 +2545,10 @@ final class XMPPService { // atomically. Drop its decoded mutations so the next attempt // can safely request the same page again. archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "MAM вернул некорректный курсор; страница отклонена без изменения истории." - )) + eventHandler?( + .recoverableError( + "MAM вернул некорректный курсор; страница отклонена без изменения истории." + )) finishArchiveSync(client: client, succeeded: false) } case .failure(let error): @@ -2443,9 +2574,10 @@ final class XMPPService { archivePassMutations.removeAll(keepingCapacity: true) queryArchive(client: client, after: nil, retry: 0) } else { - eventHandler?(.recoverableError( - "MAM: \(error.localizedDescription). Продолжим после следующего открытия приложения." - )) + eventHandler?( + .recoverableError( + "MAM: \(error.localizedDescription). Продолжим после следующего открытия приложения." + )) finishArchiveSync(client: client, succeeded: false) } } @@ -2474,10 +2606,11 @@ final class XMPPService { let delaySeconds = min(8, 1 << max(0, retry - 1)) try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.client === client else { return } + let self, + let client, + self.archiveSyncStarted, + self.client === client + else { return } self.queryArchive(client: client, after: after, retry: retry) } } @@ -2492,10 +2625,11 @@ final class XMPPService { nanoseconds: ArchiveSyncRecoveryPolicy.interPageDelayNanoseconds ) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.client === client else { return } + let self, + let client, + self.archiveSyncStarted, + self.client === client + else { return } self.queryArchive(client: client, after: after, retry: 0) } } @@ -2507,11 +2641,12 @@ final class XMPPService { nanoseconds: ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds ) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + let self, + let client, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client + else { return } self.archiveQueryCompletionTask?.cancel() self.archiveQueryCompletionTask = nil self.archiveStanzaInbox.cancel(queryID: queryID) @@ -2521,9 +2656,10 @@ final class XMPPService { self.archiveBufferOverflowed = false self.archiveRejectedSource = false self.archivePassMutations.removeAll(keepingCapacity: true) - self.eventHandler?(.recoverableError( - "MAM не ответил вовремя; синхронизация остановлена до следующего открытия приложения." - )) + self.eventHandler?( + .recoverableError( + "MAM не ответил вовремя; синхронизация остановлена до следующего открытия приложения." + )) self.finishArchiveSync(client: client, succeeded: false) } } @@ -2535,11 +2671,12 @@ final class XMPPService { nanoseconds: ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds ) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + let self, + let client, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client + else { return } self.archiveQueryCompletionTask?.cancel() self.archiveQueryCompletionTask = nil self.archiveStanzaInbox.cancel(queryID: queryID) @@ -2549,9 +2686,10 @@ final class XMPPService { self.archiveBufferOverflowed = false self.archiveRejectedSource = false self.archivePassMutations.removeAll(keepingCapacity: true) - self.eventHandler?(.recoverableError( - "Обработка MAM заняла слишком много времени; синхронизация приостановлена." - )) + self.eventHandler?( + .recoverableError( + "Обработка MAM заняла слишком много времени; синхронизация приостановлена." + )) self.finishArchiveSync(client: client, succeeded: false) } } @@ -2595,10 +2733,12 @@ final class XMPPService { archiveCursorFallbackUsed = false if succeeded { - let resolvedCheckpoint = checkpoint ?? ArchiveSyncCheckpoint( - timestamp: archiveSyncQueryStartedAt ?? Date(), - cursor: archiveLastCompletedCursor ?? archiveSyncCheckpoint?.cursor - ) + let resolvedCheckpoint = + checkpoint + ?? ArchiveSyncCheckpoint( + timestamp: archiveSyncQueryStartedAt ?? Date(), + cursor: archiveLastCompletedCursor ?? archiveSyncCheckpoint?.cursor + ) archiveSyncCheckpoint = resolvedCheckpoint archiveLastCompletedCursor = resolvedCheckpoint.cursor archiveSyncQueryStartedAt = nil @@ -2614,22 +2754,26 @@ final class XMPPService { archiveResumeAfter = archiveSyncCheckpoint?.cursor archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor archiveRetrySuppressedUntilActivation = true - eventHandler?(.recoverableError( - "Синхронизация истории приостановлена. Luma продолжит после возврата в приложение." - )) + eventHandler?( + .recoverableError( + "Синхронизация истории приостановлена. Luma продолжит после возврата в приложение." + )) } private func refreshArchiveIfNeeded(client: XMPPClient) { guard !archiveSyncStarted, - !archiveSyncSuspended, - self.client === client else { return } + !archiveSyncSuspended, + self.client === client + else { return } if !archiveSyncCompletedForConnection { archiveRetrySuppressedUntilActivation = false startArchiveSync(client: client) return } let lastCheckpoint = archiveSyncCheckpoint?.timestamp ?? .distantPast - guard Date().timeIntervalSince(lastCheckpoint) >= archiveForegroundRefreshInterval else { return } + guard Date().timeIntervalSince(lastCheckpoint) >= archiveForegroundRefreshInterval else { + return + } archiveSyncCompletedForConnection = false archiveResumeAfter = nil archiveSyncQueryStartedAt = nil @@ -2712,11 +2856,11 @@ final class XMPPService { } private static var platformName: String { -#if os(macOS) - return "macOS" -#else - return "iOS/iPadOS" -#endif + #if os(macOS) + return "macOS" + #else + return "iOS/iPadOS" + #endif } private static func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? { @@ -2778,17 +2922,23 @@ enum LumaXMPPError: LocalizedError { case .roomNotJoined: return "Сначала подключитесь к групповой комнате." case .groupEncryptionRequiresNonAnonymousRoom: - return "Групповое OMEMO требует неанонимную MUC-комнату. Попросите владельца включить показ реальных JID (muc#roomconfig_whois = anyone)." + return + "Групповое OMEMO требует неанонимную MUC-комнату. Попросите владельца включить показ реальных JID (muc#roomconfig_whois = anyone)." case .groupEncryptionConfigurationUnsupported: - return "Сервер не предлагает настройку неанонимной MUC-комнаты, необходимую для группового OMEMO." + return + "Сервер не предлагает настройку неанонимной MUC-комнаты, необходимую для группового OMEMO." case .groupEncryptionParticipantsHidden(let nicknames): - return "Нельзя безопасно зашифровать сообщение: комната скрывает JID участников \(nicknames.joined(separator: ", ")). Переподключитесь после включения неанонимного режима." + return + "Нельзя безопасно зашифровать сообщение: комната скрывает JID участников \(nicknames.joined(separator: ", ")). Переподключитесь после включения неанонимного режима." case .groupEncryptionMemberListUnavailable(let message): - return "Сообщение не отправлено: сервер не отдал полный список участников комнаты для OMEMO (\(message))." + return + "Сообщение не отправлено: сервер не отдал полный список участников комнаты для OMEMO (\(message))." case .groupOMEMODevicesUnavailable(let jids): - return "Сообщение не отправлено: OMEMO-устройства отсутствуют у \(jids.joined(separator: ", "))." + return + "Сообщение не отправлено: OMEMO-устройства отсутствуют у \(jids.joined(separator: ", "))." case .groupOMEMOSessionsUnavailable(let devices): - return "Сообщение не отправлено: не удалось создать OMEMO-сессию для \(devices.joined(separator: ", "))." + return + "Сообщение не отправлено: не удалось создать OMEMO-сессию для \(devices.joined(separator: ", "))." case .fileEncryptionFailed: return "Не удалось зашифровать вложение." case .fileDecryptionFailed: @@ -2796,7 +2946,8 @@ enum LumaXMPPError: LocalizedError { case .uploadDiscoveryFailed(let message): return "Не удалось найти XEP-0363 HTTP Upload на сервере: \(message)" case .uploadUnavailable(let size): - return "Сервер не предлагает HTTP Upload для файла размером \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))." + return + "Сервер не предлагает HTTP Upload для файла размером \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))." case .uploadSlotFailed(let message): return "Prosody не выдал слот для загрузки: \(message)" case .uploadTransportFailed(let message):