Files
Luma/Sources/Shared/Models/AppModel.swift
T
2026-07-30 20:07:51 +07:00

2772 lines
106 KiB
Swift

import Combine
import AVFoundation
import Foundation
import UniformTypeIdentifiers
import WebRTC
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var account: AccountConfiguration?
@Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected(reason: nil)
private(set) var conversations: [Conversation] = [] {
willSet {
if !isApplyingArchiveBatch {
objectWillChange.send()
}
}
}
@Published private(set) var rosterContactJIDs: Set<String> = []
private(set) var messages: [ChatMessage] = [] {
willSet {
if !isApplyingArchiveBatch {
objectWillChange.send()
}
}
didSet {
if !isApplyingArchiveBatch {
selectedMessagesCacheConversationID = nil
selectedTimelineEntriesCacheConversationID = nil
}
}
}
@Published var selectedConversationID: String?
@Published private(set) var isOMEMOReady = false
@Published private(set) var ownFingerprint: String?
@Published private(set) var isArchiveSyncing = false
@Published private(set) var isAuthenticating = false
@Published private(set) var isSendingAttachment = false
@Published private(set) var isUpdatingAvatar = false
@Published private(set) var avatarDataByJID: [String: Data] = [:]
@Published private(set) var globalEncryptionEnabled = true
@Published private(set) var typingIndicatorsEnabled = true
@Published private(set) var typingParticipantsByConversation: [String: [String: String]] = [:]
@Published var errorMessage: String?
@Published var informationalMessage: String?
@Published var previewURL: URL?
@Published private(set) var mediaViewerItem: MediaViewerItem?
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
@Published private(set) var audioWaveformSamples: [String: [Float]] = [:]
@Published private(set) var loadingMediaIDs: Set<String> = []
@Published private(set) var activeCall: CallSnapshot?
let audioPlayback = MediaPlaybackCoordinator()
private let xmpp: XMPPService
private let credentials: CredentialVault
private let preferences: AccountPreferences
private let notifications: NotificationCoordinator
private let watchBridge: PhoneWatchBridge
private let avatarCache: AvatarCache
private let mediaPreviewProcessor = MediaPreviewProcessor()
private let mediaFileIO = MediaFileIO()
private var archive: ChatArchive?
private var bootstrapTask: Task<Void, Never>?
private var persistTask: Task<Void, Never>?
private var watchSyncTask: Task<Void, Never>?
private var isApplyingArchiveBatch = false
private var messageIndexByStorageKey: [String: Int] = [:]
private var messageIndexByStanzaKey: [String: Int] = [:]
private var firstMessageIndexByID: [String: Int] = [:]
private var selectedMessagesCacheConversationID: String?
private var selectedMessagesCache: [ChatMessage] = []
private var selectedTimelineEntriesCacheConversationID: String?
private var selectedTimelineEntriesCache: [ChatTimelineEntry] = []
private var appIsActive = true
private var avatarCacheRequests: Set<String> = []
private var pendingCorrections: [String: XMPPService.MessageEnvelope] = [:]
private var pendingRetractions: [String: XMPPService.RetractionEnvelope] = [:]
private var pendingReactions: [String: XMPPService.ReactionEnvelope] = [:]
private var correctionReceiptTargets: [String: String] = [:]
private var localChatStateByConversation: [String: ChatTypingState] = [:]
private var localTypingPauseTasks: [String: Task<Void, Never>] = [:]
private var remoteTypingExpiryTasks: [String: Task<Void, Never>] = [:]
private var locallyDeletedMessageIDs: Set<String> = []
private var lastSuccessfulMAMSync: Date?
private var lastSuccessfulMAMCursor: String?
private var pendingRoomPasswords: [String: String] = [:]
private var joiningRoomJIDs: Set<String> = []
private var mediaSendActivity = MediaSendActivityTracker()
private var mediaPreparationTokens: Set<UUID> = []
private var videoNoteCaptureIsActive = false
private var archiveSyncIsSuspendedForMedia = false
init(
xmpp: XMPPService? = nil,
credentials: CredentialVault = CredentialVault(),
preferences: AccountPreferences = AccountPreferences(),
notifications: NotificationCoordinator = NotificationCoordinator(),
watchBridge: PhoneWatchBridge = PhoneWatchBridge(),
avatarCache: AvatarCache = AvatarCache()
) {
// Default arguments are evaluated at the call site, which may be
// nonisolated. Construct the @MainActor service inside this
// @MainActor initializer instead.
let xmpp = xmpp ?? XMPPService()
self.xmpp = xmpp
self.credentials = credentials
self.preferences = preferences
self.notifications = notifications
self.watchBridge = watchBridge
self.avatarCache = avatarCache
xmpp.eventHandler = { [weak self] event in
self?.consume(event)
}
watchBridge.onReply = { [weak self] jid, text in
Task { @MainActor in
guard let self else { return }
if !self.conversations.contains(where: { $0.id == jid.lowercased() }) {
self.upsertConversation(jid: jid, name: nil)
}
await self.sendText(text, to: jid)
}
}
watchBridge.onVoiceMessage = { [weak self] voice in
Task { @MainActor in
guard let self else { return }
self.errorMessage = nil
if let bootstrapTask = self.bootstrapTask {
await bootstrapTask.value
}
guard self.account != nil else {
let error = "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch."
self.errorMessage = error
self.watchBridge.reportVoiceResult(
transferID: voice.transferID,
success: false,
error: error
)
return
}
if self.connectionStatus != .connected {
await self.reconnect()
}
guard self.connectionStatus == .connected else {
let error = self.errorMessage ?? "iPhone не подключён к XMPP-серверу."
self.errorMessage = error
self.watchBridge.reportVoiceResult(
transferID: voice.transferID,
success: false,
error: error
)
return
}
if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) {
self.upsertConversation(jid: voice.jid, name: nil)
}
let messageID = await self.sendMedia(
data: voice.data,
filename: voice.filename,
mimeType: "audio/mp4",
kind: .voice,
duration: voice.duration,
to: voice.jid,
messageID: voice.stableMessageID
)
let success = messageID != nil
let failure = success ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.")
if success {
self.informationalMessage = "Голосовое сообщение с Apple Watch отправлено."
} else if self.errorMessage == nil {
self.errorMessage = failure
}
self.watchBridge.reportVoiceResult(
transferID: voice.transferID,
success: success,
error: failure
)
}
}
bootstrapTask = Task { [weak self] in
guard let self else { return }
await self.bootstrap()
}
}
deinit {
bootstrapTask?.cancel()
persistTask?.cancel()
watchSyncTask?.cancel()
localTypingPauseTasks.values.forEach { $0.cancel() }
remoteTypingExpiryTasks.values.forEach { $0.cancel() }
}
var selectedConversation: Conversation? {
guard let selectedConversationID else { return nil }
return conversations.first { $0.id == selectedConversationID }
}
private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? {
guard let lastSuccessfulMAMSync else { return nil }
return ArchiveSyncCheckpoint(
timestamp: lastSuccessfulMAMSync,
cursor: lastSuccessfulMAMCursor
)
}
var selectedMessages: [ChatMessage] {
guard let selectedConversationID else { return [] }
if selectedMessagesCacheConversationID == selectedConversationID {
return selectedMessagesCache
}
let sorted = messages
.filter { $0.conversationID == selectedConversationID }
.sorted { lhs, rhs in
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
return lhs.timestamp < rhs.timestamp
}
selectedMessagesCacheConversationID = selectedConversationID
selectedMessagesCache = sorted
return sorted
}
var selectedTimelineEntries: [ChatTimelineEntry] {
guard let selectedConversationID else { return [] }
if selectedTimelineEntriesCacheConversationID == selectedConversationID {
return selectedTimelineEntriesCache
}
let entries = ChatTimelineEntry.make(from: selectedMessages)
selectedTimelineEntriesCacheConversationID = selectedConversationID
selectedTimelineEntriesCache = entries
return entries
}
func bootstrap() async {
guard let saved = preferences.load() else { return }
account = saved
globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID)
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: saved.normalizedJID)
xmpp.setChatStatesEnabled(typingIndicatorsEnabled)
await loadArchive(for: saved.normalizedJID)
let password: String
do {
guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
account = nil
archive = nil
conversations = []
rosterContactJIDs = []
messages = []
rebuildMessageIndex()
informationalMessage = "Введите пароль заново: запись Keychain не найдена."
return
}
password = storedPassword
} catch {
account = nil
archive = nil
conversations = []
rosterContactJIDs = []
messages = []
rebuildMessageIndex()
errorMessage = error.localizedDescription
return
}
do {
try await xmpp.connect(
account: saved,
password: password,
archiveCheckpoint: durableArchiveSyncCheckpoint
)
await notifications.requestAuthorization()
} catch {
errorMessage = error.localizedDescription
}
}
func signIn(account: AccountConfiguration, password: String) async {
isAuthenticating = true
errorMessage = nil
defer { isAuthenticating = false }
do {
let account = try account.validated()
guard !password.isEmpty else { throw SignInError.emptyPassword }
resetArchiveBatchState()
avatarDataByJID = [:]
avatarCacheRequests = []
pendingCorrections = [:]
pendingRetractions = [:]
pendingReactions = [:]
correctionReceiptTargets = [:]
locallyDeletedMessageIDs = []
pendingRoomPasswords = [:]
joiningRoomJIDs = []
rosterContactJIDs = []
resetMediaSendActivity()
resetMediaPreviews()
self.account = account
globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID)
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID)
xmpp.setChatStatesEnabled(typingIndicatorsEnabled)
await loadArchive(for: account.normalizedJID)
try await xmpp.connect(
account: account,
password: password,
archiveCheckpoint: durableArchiveSyncCheckpoint
)
try credentials.save(password: password, for: account.normalizedJID)
try preferences.save(account)
await notifications.requestAuthorization()
} catch {
await xmpp.disconnect()
self.account = nil
archive = nil
conversations = []
rosterContactJIDs = []
messages = []
rebuildMessageIndex()
errorMessage = error.localizedDescription
}
}
func signOut(forgetHistory: Bool = false) async {
let oldAccount = account
await xmpp.disconnect()
preferences.clear()
if let oldAccount {
try? credentials.deletePassword(for: oldAccount.normalizedJID)
if forgetHistory {
try? await archive?.erase()
}
}
persistTask?.cancel()
resetArchiveBatchState()
watchSyncTask?.cancel()
watchSyncTask = nil
archive = nil
account = nil
conversations = []
rosterContactJIDs = []
messages = []
rebuildMessageIndex()
selectedConversationID = nil
isOMEMOReady = false
ownFingerprint = nil
avatarDataByJID = [:]
avatarCacheRequests = []
globalEncryptionEnabled = true
typingIndicatorsEnabled = true
isUpdatingAvatar = false
errorMessage = nil
previewURL = nil
pendingCorrections = [:]
pendingRetractions = [:]
pendingReactions = [:]
correctionReceiptTargets = [:]
locallyDeletedMessageIDs = []
lastSuccessfulMAMSync = nil
lastSuccessfulMAMCursor = nil
pendingRoomPasswords = [:]
joiningRoomJIDs = []
activeCall = nil
resetTypingState()
resetMediaSendActivity()
resetMediaPreviews()
}
func reconnect() async {
guard let account else { return }
do {
guard let password = try credentials.password(for: account.normalizedJID) else {
throw ReconnectError.passwordMissing
}
try await xmpp.reconnectIfNeeded(password: password)
} catch {
errorMessage = error.localizedDescription
}
}
func setApplicationActive(_ active: Bool) {
appIsActive = active
xmpp.setApplicationActive(active)
if !active {
resetTypingState()
syncWatch(immediate: true)
}
if active, case .disconnected(_) = connectionStatus {
Task { await reconnect() }
}
}
func setVideoNoteCaptureActive(_ active: Bool) {
videoNoteCaptureIsActive = active
updateArchiveSyncSuspensionForMedia()
}
func startCall(to rawJID: String, withVideo: Bool) async {
let jid = rawJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !jid.isEmpty else { return }
guard conversations.first(where: { $0.id == jid })?.isGroup != true else {
errorMessage = "Звонки в текущем MVP доступны только в личных чатах."
return
}
guard await requestCallPermissions(includeVideo: withVideo) else { return }
audioPlayback.stop()
do {
try await xmpp.startCall(to: jid, withVideo: withVideo)
} catch {
errorMessage = error.localizedDescription
}
}
func answerCall() async {
guard let activeCall else { return }
guard await requestCallPermissions(includeVideo: activeCall.isVideoCall) else { return }
audioPlayback.stop()
do {
try await xmpp.answerCall()
} catch {
errorMessage = error.localizedDescription
}
}
func rejectCall() {
xmpp.rejectCall()
}
func endCall() {
xmpp.endCall()
}
func setCallMuted(_ muted: Bool) {
xmpp.setCallMuted(muted)
}
func setCallCameraEnabled(_ enabled: Bool) {
xmpp.setCallCameraEnabled(enabled)
}
func setCallSpeakerEnabled(_ enabled: Bool) {
xmpp.setCallSpeakerEnabled(enabled)
}
func switchCallCamera() {
xmpp.switchCallCamera()
}
func localCallVideoTrack(for callID: UUID) -> RTCVideoTrack? {
xmpp.localCallVideoTrack(for: callID)
}
func remoteCallVideoTrack(for callID: UUID) -> RTCVideoTrack? {
xmpp.remoteCallVideoTrack(for: callID)
}
func openConversation(jid: String, name: String? = nil, addToRoster: Bool = false) {
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard Self.isValidJID(normalized) else {
errorMessage = "Введите JID в формате user@example.org."
return
}
upsertConversation(jid: normalized, name: name)
selectedConversationID = normalized
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
conversations[index].unreadCount = 0
}
if addToRoster {
rosterContactJIDs.insert(normalized)
xmpp.addToRoster(jid: normalized, name: name)
}
xmpp.fetchAvatar(for: normalized)
schedulePersist()
syncWatch()
}
func selectConversation(id: String) {
let normalized = id.lowercased()
selectedConversationID = normalized
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
conversations[index].unreadCount = 0
}
schedulePersist()
syncWatch()
}
func createOrJoinGroup(
roomJID rawRoomJID: String,
name: String?,
nickname rawNickname: String,
invitees: [String]
) async {
let roomJID = rawRoomJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard Self.isValidJID(roomJID) else {
errorMessage = "Введите адрес комнаты в формате room@conference.example.org."
return
}
let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nickname.isEmpty else {
errorMessage = "Введите псевдоним для группового чата."
return
}
let validInvitees = invitees
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter(Self.isValidJID)
upsertGroupConversation(
jid: roomJID,
name: name,
nickname: nickname,
shouldAutojoin: true
)
selectConversation(id: roomJID)
guard joiningRoomJIDs.insert(roomJID).inserted else { return }
defer { joiningRoomJIDs.remove(roomJID) }
do {
try await xmpp.joinRoom(roomJID: roomJID, nickname: nickname)
if !validInvitees.isEmpty {
try await xmpp.inviteMembers(validInvitees, to: roomJID)
informationalMessage = "Приглашения отправлены: \(validInvitees.count)."
}
} catch {
if let index = conversations.firstIndex(where: { $0.id == roomJID }) {
conversations[index].isGroupJoined = false
}
errorMessage = error.localizedDescription
}
schedulePersist()
}
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 !conversations[index].isGroupJoined,
joiningRoomJIDs.insert(jid).inserted else { return }
defer { joiningRoomJIDs.remove(jid) }
let trimmedNickname = preferredNickname?.trimmingCharacters(in: .whitespacesAndNewlines)
let nickname: String
if let trimmedNickname, !trimmedNickname.isEmpty {
nickname = trimmedNickname
} else {
nickname = conversations[index].groupNickname ?? defaultGroupNickname
}
do {
try await xmpp.joinRoom(
roomJID: jid,
nickname: nickname,
password: pendingRoomPasswords[jid]
)
conversations[index].groupNickname = nickname
conversations[index].shouldAutojoin = true
conversations[index].invitedBy = nil
pendingRoomPasswords.removeValue(forKey: jid)
} catch {
conversations[index].isGroupJoined = false
errorMessage = error.localizedDescription
}
schedulePersist()
}
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 }
conversations[index].isGroupJoined = false
conversations[index].shouldAutojoin = false
conversations[index].occupantCount = 0
schedulePersist()
}
func inviteMembers(_ rawJIDs: [String], to roomJID: String) async {
let jids = rawJIDs
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter(Self.isValidJID)
guard !jids.isEmpty else {
errorMessage = "Введите хотя бы один корректный JID участника."
return
}
do {
try await xmpp.inviteMembers(jids, to: roomJID)
informationalMessage = "Приглашения отправлены: \(jids.count)."
} catch {
errorMessage = error.localizedDescription
}
}
var suggestedGroupNickname: String { defaultGroupNickname }
func sendText(
_ rawText: String,
to explicitJID: String? = nil,
replyingTo: ChatMessage? = nil
) async {
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty,
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 }
return message
}
let id = UUID().uuidString
let pending = ChatMessage(
id: id,
conversationID: peer,
senderJID: account.normalizedJID,
body: text,
direction: .outgoing,
delivery: .sending,
security: encrypted ? .omemo : .plaintext,
replyToID: replyTarget?.replyIdentifier,
replyToJID: replyTarget?.senderJID,
replyPreview: replyTarget?.quotePreview,
senderDisplayName: isGroup ? conversation?.groupNickname : nil,
isGroupMessage: isGroup
)
upsertMessage(pending)
do {
let fingerprint = try await xmpp.sendText(
text,
to: peer,
messageID: id,
encrypted: encrypted,
replyTo: replyTarget.flatMap { target in
guard let replyID = target.replyIdentifier else { return nil }
return XMPPService.ReplyReference(
id: replyID,
authorJID: target.senderJID,
fallbackAuthor: target.senderDisplayName ?? displayName(for: target.senderJID),
preview: target.quotePreview
)
},
isGroup: isGroup
)
updateMessage(id: id) {
$0.delivery = .sent
$0.encryptionFingerprint = fingerprint
}
let conversationID = peer.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
localChatStateByConversation[conversationID] = .active
} catch {
updateMessage(id: id) { $0.delivery = .failed }
errorMessage = error.localizedDescription
}
schedulePersist()
syncWatch()
}
func sendLocation(_ location: GeoLocation, to explicitJID: String? = nil) async {
guard 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 id = UUID().uuidString
let pending = ChatMessage(
id: id,
conversationID: peer,
senderJID: account.normalizedJID,
body: location.uriString,
direction: .outgoing,
delivery: .sending,
security: encrypted ? .omemo : .plaintext,
kind: .location,
senderDisplayName: isGroup ? conversation?.groupNickname : nil,
isGroupMessage: isGroup
)
upsertMessage(pending)
do {
let fingerprint = try await xmpp.sendText(
location.uriString,
to: peer,
messageID: id,
encrypted: encrypted,
isGroup: isGroup
)
updateMessage(id: id) {
$0.delivery = .sent
$0.encryptionFingerprint = fingerprint
}
} catch {
updateMessage(id: id) { $0.delivery = .failed }
errorMessage = error.localizedDescription
}
schedulePersist()
syncWatch()
}
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 previous = messages[index]
guard text != previous.body else { return }
let correctionID = UUID().uuidString
let encrypted = previous.security == .omemo
correctionReceiptTargets[correctionID] = id
if correctionReceiptTargets.count > 100,
let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) {
correctionReceiptTargets.removeValue(forKey: staleID)
}
updateMessage(id: id) {
$0.body = text
$0.editedAt = Date()
$0.delivery = .sending
}
do {
let fingerprint = try await xmpp.sendText(
text,
to: previous.conversationID,
messageID: correctionID,
encrypted: encrypted,
replacingMessageID: previous.id
)
updateMessage(id: id) {
$0.delivery = .sent
$0.encryptionFingerprint = fingerprint
}
} catch {
correctionReceiptTargets.removeValue(forKey: correctionID)
if let currentIndex = messages.firstIndex(where: { $0.id == id }),
messages[currentIndex].delivery == .sending {
messages[currentIndex] = previous
updateConversationPreview(for: previous, incrementUnread: false)
}
errorMessage = error.localizedDescription
}
schedulePersist()
syncWatch()
}
func retractMessage(id: String) async {
guard let account,
let message = messages.first(where: { $0.id == id }),
message.canBeRetracted else { return }
let retractionID = UUID().uuidString
do {
try await xmpp.sendRetraction(
to: message.conversationID,
targetID: message.id,
retractionID: retractionID,
encrypted: message.security == .omemo
)
applyRetraction(XMPPService.RetractionEnvelope(
peerJID: message.conversationID,
senderJID: account.normalizedJID,
targetID: message.id,
retractionID: retractionID,
timestamp: Date(),
isOutgoing: true
))
} catch {
errorMessage = error.localizedDescription
}
schedulePersist()
syncWatch()
}
func deleteMessageLocally(id: String) {
deleteMessagesLocally(ids: [id])
}
func deleteMessagesLocally(ids: Set<String>, in conversationID: String? = nil) {
guard !ids.isEmpty else { return }
let normalizedConversationID = conversationID?.lowercased()
let selected = messages.filter { message in
ids.contains(message.id)
&& (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
))
let pendingKey = Self.localDeletionKey(
messageID: message.id,
conversationID: message.conversationID
)
pendingCorrections.removeValue(forKey: pendingKey)
pendingRetractions.removeValue(forKey: pendingKey)
pendingReactions = pendingReactions.filter { _, envelope in
guard envelope.peerJID.lowercased() == message.conversationID else {
return true
}
if envelope.targetID == message.id { return false }
if let stanzaID = message.stanzaID,
envelope.targetID == stanzaID { return false }
return true
}
removeCachedMedia(for: message.id)
}
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
))
}
rebuildMessageIndex()
for conversationID in affectedConversations {
rebuildConversationPreview(for: conversationID)
}
schedulePersist()
syncWatch()
}
@discardableResult
func forwardMessage(_ message: ChatMessage, to rawRecipient: String) async -> Bool {
await forwardMessages([message], to: rawRecipient)
}
@discardableResult
func forwardMessages(_ selectedMessages: [ChatMessage], to rawRecipient: String) async -> Bool {
let recipient = rawRecipient
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
let forwardableMessages = selectedMessages
.filter(\.canBeForwarded)
.sorted { lhs, rhs in
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
return lhs.timestamp < rhs.timestamp
}
guard !forwardableMessages.isEmpty else { return false }
guard Self.isValidJID(recipient) else {
errorMessage = "Введите JID в формате user@example.org."
return false
}
errorMessage = nil
upsertConversation(jid: recipient, name: nil)
var forwardedCount = 0
for message in forwardableMessages {
guard await forwardMessageItem(message, to: recipient) else {
if forwardedCount > 0 {
informationalMessage = "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)."
}
return false
}
forwardedCount += 1
}
if forwardedCount > 1 {
informationalMessage = "Переслано сообщений: \(forwardedCount)."
}
return true
}
private func forwardMessageItem(_ message: ChatMessage, to recipient: String) async -> Bool {
let attribution = "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))"
switch message.kind {
case .text:
await sendText("\(attribution)\n\(message.body)", to: recipient)
return errorMessage == nil
case .location:
guard let location = GeoLocation(uri: message.body) else {
errorMessage = "Не удалось прочитать геопозицию из сообщения."
return false
}
await sendText(attribution, to: recipient)
guard errorMessage == nil else { return false }
await sendLocation(location, to: recipient)
return errorMessage == nil
case .attachment, .photo, .video, .audio, .voice, .videoNote:
do {
let url: URL
if let cached = mediaPreviewURLs[message.id] {
url = cached
} else {
url = try await localAttachmentURL(
for: message,
directoryName: "LumaForwarding"
)
}
let data = try await mediaFileIO.load(
from: url,
preferredKind: message.kind
).data
await sendText(attribution, to: recipient)
guard errorMessage == nil else { return false }
let sentID = await sendMedia(
data: data,
filename: message.localFilename ?? url.lastPathComponent,
mimeType: message.mimeType ?? "application/octet-stream",
kind: message.kind,
duration: message.duration,
to: recipient
)
return sentID != nil
} catch {
errorMessage = error.localizedDescription
return false
}
case .system:
return false
}
}
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 {
return messages[index]
}
return messages.first { $0.stanzaID == id }
}
let normalized = conversationID.lowercased()
guard let index = messageIndex(
referenceID: id,
conversationID: normalized
) else { return nil }
return messages[index]
}
func displayName(for jid: String) -> String {
conversationName(for: jid)
}
@discardableResult
func sendAttachment(
from url: URL,
preferredKind: ChatMessage.Kind? = nil,
duration: TimeInterval? = nil,
to explicitJID: String? = nil
) async -> String? {
guard let peer = explicitJID ?? selectedConversation?.jid else { return nil }
let preparationToken = beginMediaPreparationActivity()
defer { endMediaPreparationActivity(preparationToken) }
let accessed = url.startAccessingSecurityScopedResource()
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
do {
let loaded = try await mediaFileIO.load(
from: url,
preferredKind: preferredKind
)
let resolvedDuration: TimeInterval?
if let duration {
resolvedDuration = duration
} else {
resolvedDuration = await Self.mediaDuration(
of: url,
kind: loaded.inferredKind
)
}
return await sendMedia(
data: loaded.data,
filename: loaded.filename,
mimeType: loaded.mimeType,
kind: loaded.inferredKind,
duration: resolvedDuration,
to: peer
)
} catch {
errorMessage = error.localizedDescription
return nil
}
}
@discardableResult
func sendMedia(
data: Data,
filename rawFilename: String,
mimeType: String,
kind rawKind: ChatMessage.Kind,
duration suppliedDuration: TimeInterval? = nil,
to explicitJID: String? = nil,
messageID explicitMessageID: String? = nil,
thumbnailData preparedThumbnailData: Data? = nil
) async -> String? {
guard let account,
let peer = explicitJID ?? selectedConversation?.jid else { return nil }
errorMessage = nil
if connectionStatus != .connected {
await reconnect()
}
let activityToken = beginMediaSendActivity()
defer { endMediaSendActivity(activityToken) }
let id = explicitMessageID ?? UUID().uuidString
let filename = Self.safeFilename(rawFilename)
let kind = rawKind.isMedia ? rawKind : .attachment
let conversation = conversations.first { $0.id == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
var duration = suppliedDuration
var didInsertPending = false
var sentID: String?
do {
if let value = duration, !value.isFinite || value <= 0 {
duration = nil
}
if duration == nil {
duration = await Self.mediaDuration(data: data, filename: filename, kind: kind)
}
let pending = ChatMessage(
id: id,
conversationID: peer,
senderJID: account.normalizedJID,
body: Self.mediaTitle(kind: kind, filename: filename),
direction: .outgoing,
delivery: .sending,
security: encrypted ? .omemo : .plaintext,
kind: kind,
localFilename: filename,
mimeType: mimeType,
duration: duration,
byteCount: data.count,
senderDisplayName: isGroup ? conversation?.groupNickname : nil,
isGroupMessage: isGroup
)
upsertMessage(pending)
didInsertPending = true
if Self.supportsInlinePreview(kind) {
await cacheMediaPreview(
data: data,
messageID: id,
filename: filename,
kind: kind,
mimeType: mimeType,
preparedThumbnailData: preparedThumbnailData
)
}
let result = try await uploadAndSendMediaWithRetry(
data: data,
filename: filename,
mimeType: mimeType,
kind: kind,
duration: duration,
to: peer,
messageID: id,
encrypted: encrypted,
isGroup: isGroup
)
updateMessage(id: id) {
$0.delivery = .sent
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
sentID = id
} catch {
if didInsertPending {
updateMessage(id: id) { $0.delivery = .failed }
}
errorMessage = error.localizedDescription
}
schedulePersist()
syncWatch()
return sentID
}
func canRetryMediaMessage(_ message: ChatMessage) -> Bool {
message.direction == .outgoing
&& message.delivery == .failed
&& message.kind.isMedia
&& message.remoteAttachmentURL == nil
&& mediaPreviewURLs[message.id] != nil
}
func retryMediaMessage(_ message: ChatMessage) async {
guard canRetryMediaMessage(message),
let localURL = mediaPreviewURLs[message.id] else { return }
let activityToken = beginMediaSendActivity()
defer { endMediaSendActivity(activityToken) }
errorMessage = nil
if connectionStatus != .connected {
await reconnect()
}
do {
let data = try await mediaFileIO.load(
from: localURL,
preferredKind: message.kind
).data
updateMessage(id: message.id) { $0.delivery = .sending }
let result = try await uploadAndSendMediaWithRetry(
data: data,
filename: message.localFilename ?? localURL.lastPathComponent,
mimeType: message.mimeType ?? "application/octet-stream",
kind: message.kind,
duration: message.duration,
to: message.conversationID,
messageID: message.id,
encrypted: message.security == .omemo,
isGroup: message.isGroupMessage
)
updateMessage(id: message.id) {
$0.delivery = .sent
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
} catch {
updateMessage(id: message.id) { $0.delivery = .failed }
errorMessage = error.localizedDescription
}
schedulePersist()
syncWatch()
}
func prepareAttachmentDrafts(
from urls: [URL],
preferredKind: ChatMessage.Kind? = nil,
for peerJID: String? = nil
) async -> [AttachmentDraft] {
guard !urls.isEmpty else { return [] }
let preparationToken = beginMediaPreparationActivity()
defer { endMediaPreparationActivity(preparationToken) }
var drafts: [AttachmentDraft] = []
var lastPreparationError: Error?
for source in urls.prefix(20) {
do {
let staged = try await mediaFileIO.stageAttachmentDraft(from: source)
let filename = Self.safeFilename(staged.filename)
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"
let analysis = await mediaPreviewProcessor.analyze(
url: staged.url,
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
))
} catch {
lastPreparationError = error
}
}
if drafts.isEmpty, let lastPreparationError {
errorMessage = lastPreparationError.localizedDescription
}
return drafts
}
/// Extends the same finite MAM pause across item-provider/iCloud work that
/// happens before URLs enter `prepareAttachmentDrafts`.
func withMediaPreparationActivity<T>(
_ operation: () async -> T
) async -> T {
let token = beginMediaPreparationActivity()
defer { endMediaPreparationActivity(token) }
return await operation()
}
func sendAttachmentBatch(
_ drafts: [AttachmentDraft],
caption rawCaption: String,
to roomOrPeerJID: String
) async -> AttachmentBatchResult {
guard !drafts.isEmpty else {
return AttachmentBatchResult(sentCount: 0, failedDrafts: [], captionSent: false)
}
let activityToken = beginMediaSendActivity()
defer { endMediaSendActivity(activityToken) }
errorMessage = nil
var firstSentID: String?
var sentCount = 0
var failedDrafts: [AttachmentDraft] = []
for draft in drafts {
let data: Data
do {
data = try await mediaFileIO.load(
from: draft.url,
preferredKind: draft.kind
).data
} catch {
errorMessage = "Не удалось прочитать \(draft.filename)."
failedDrafts.append(draft)
continue
}
let sentID = await sendMedia(
data: data,
filename: draft.filename,
mimeType: draft.mimeType,
kind: draft.kind,
duration: draft.duration,
to: roomOrPeerJID,
thumbnailData: draft.thumbnailData
)
if let sentID {
if firstSentID == nil { firstSentID = sentID }
sentCount += 1
discardAttachmentDrafts([draft])
} else {
failedDrafts.append(draft)
}
}
let caption = rawCaption.trimmingCharacters(in: .whitespacesAndNewlines)
var captionSent = false
if failedDrafts.isEmpty, !caption.isEmpty {
let target = firstSentID.flatMap { message(withID: $0, in: roomOrPeerJID) }
errorMessage = nil
await sendText(
caption,
to: roomOrPeerJID,
replyingTo: target?.canBeRepliedTo == true ? target : nil
)
captionSent = errorMessage == nil
}
return AttachmentBatchResult(
sentCount: sentCount,
failedDrafts: failedDrafts,
captionSent: captionSent
)
}
func discardAttachmentDrafts(_ drafts: [AttachmentDraft]) {
for draft in drafts where draft.isTemporary {
try? FileManager.default.removeItem(at: draft.url)
}
}
func previewAttachment(_ message: ChatMessage) async {
do {
let output: URL
if let cached = mediaPreviewURLs[message.id] {
output = cached
} else {
guard message.remoteAttachmentURL != nil else { return }
output = try await localAttachmentURL(for: message, directoryName: "LumaPreview")
}
previewURL = output
} catch {
errorMessage = error.localizedDescription
}
}
func mediaPreviewURL(for message: ChatMessage) -> URL? {
mediaPreviewURLs[message.id]
}
func mediaThumbnail(for message: ChatMessage) -> Data? {
mediaThumbnailData[message.id]
}
func audioWaveform(for message: ChatMessage) -> [Float] {
audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform
}
func isMediaPreviewLoading(_ message: ChatMessage) -> Bool {
loadingMediaIDs.contains(message.id)
}
func prepareMediaPreview(_ message: ChatMessage) async {
guard Self.supportsInlinePreview(message.kind),
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")
mediaPreviewURLs[message.id] = output
await analyzeMediaPreview(
at: output,
messageID: message.id,
kind: message.kind,
mimeType: message.mimeType
)
} catch {
// Automatic previews are best-effort. An explicit tap still uses
// previewAttachment(_:) and presents the actionable error.
}
}
func presentMediaViewer(_ message: ChatMessage) async {
guard message.kind == .photo || message.kind == .video else { return }
do {
let output: URL
if let cached = mediaPreviewURLs[message.id] {
output = cached
} else {
guard message.remoteAttachmentURL != nil else { return }
loadingMediaIDs.insert(message.id)
defer { loadingMediaIDs.remove(message.id) }
output = try await localAttachmentURL(
for: message,
directoryName: "LumaMediaPreviews"
)
mediaPreviewURLs[message.id] = output
await analyzeMediaPreview(
at: output,
messageID: message.id,
kind: message.kind,
mimeType: message.mimeType
)
}
audioPlayback.stop()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: message.id
)
mediaViewerItem = MediaViewerItem(
id: message.id,
url: output,
kind: message.kind,
title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео")
)
} catch {
errorMessage = error.localizedDescription
}
}
func closeMediaViewer() {
mediaViewerItem = nil
}
func encryptionEnabled(for jid: String) -> Bool {
return encryptionPreference(for: jid).resolved(globalEnabled: globalEncryptionEnabled)
}
func encryptionPreference(for jid: String) -> EncryptionPreference {
conversations
.first(where: { $0.id == jid.lowercased() })?
.encryptionPreference ?? .inheritGlobal
}
func setGlobalEncryptionEnabled(_ enabled: Bool) {
globalEncryptionEnabled = enabled
if let account {
preferences.setEncryptionEnabled(enabled, for: account.normalizedJID)
}
}
func setTypingIndicatorsEnabled(_ enabled: Bool) {
typingIndicatorsEnabled = enabled
xmpp.setChatStatesEnabled(enabled)
if let account {
preferences.setChatStatesEnabled(enabled, for: account.normalizedJID)
}
if !enabled {
resetTypingState()
}
}
func typingText(for conversation: Conversation) -> String? {
let names = typingParticipantsByConversation[conversation.id.lowercased()]
.map { Array($0.values) } ?? []
return ChatTypingPolicy.displayText(names: names, isGroup: conversation.isGroup)
}
func updateComposerActivity(_ text: String, in conversation: Conversation) {
let conversationID = conversation.id.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
guard typingIndicatorsEnabled, appIsActive else { return }
let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
guard hasText else {
transitionLocalChatState(.paused, in: conversation)
return
}
transitionLocalChatState(.composing, in: conversation)
localTypingPauseTasks[conversationID] = Task { @MainActor [weak self] in
do {
try await Task.sleep(nanoseconds: ChatTypingPolicy.pauseDelayNanoseconds)
} catch {
return
}
guard let self,
self.localChatStateByConversation[conversationID] == .composing else { return }
self.transitionLocalChatState(.paused, in: conversation)
self.localTypingPauseTasks[conversationID] = nil
}
}
func endComposerActivity(in conversation: Conversation) {
let conversationID = conversation.id.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
transitionLocalChatState(.inactive, in: conversation)
}
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 previous = message.reactionEmojis(from: account.normalizedJID)
var updated = previous
if let index = updated.firstIndex(of: selectedEmoji) {
updated.remove(at: index)
} else {
updated.append(selectedEmoji)
}
updated = MessageReactionPolicy.sanitized(updated)
let optimisticTimestamp = Date()
let optimistic = XMPPService.ReactionEnvelope(
peerJID: message.conversationID,
senderJID: account.normalizedJID,
targetID: targetID,
emojis: updated,
timestamp: optimisticTimestamp,
isOutgoing: true,
isGroupMessage: message.isGroupMessage
)
applyReaction(optimistic)
do {
try await xmpp.sendReactions(
to: message.conversationID,
targetID: targetID,
emojis: updated,
reactionMessageID: UUID().uuidString,
isGroup: message.isGroupMessage
)
} catch {
let rollback = XMPPService.ReactionEnvelope(
peerJID: message.conversationID,
senderJID: account.normalizedJID,
targetID: targetID,
emojis: previous,
timestamp: optimisticTimestamp.addingTimeInterval(0.001),
isOutgoing: true,
isGroupMessage: message.isGroupMessage
)
applyReaction(rollback)
errorMessage = error.localizedDescription
}
schedulePersist()
}
func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) {
guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else { return }
conversations[index].encryptionPreference = preference
schedulePersist()
syncWatch()
}
func avatarData(for jid: String) -> Data? {
avatarDataByJID[jid.lowercased()]
}
func updateOwnAvatar(from sourceData: Data) async {
guard let account else { return }
isUpdatingAvatar = true
defer { isUpdatingAvatar = false }
do {
let pngData = try AvatarImageProcessor.pngData(from: sourceData)
try await xmpp.updateOwnAvatar(pngData: pngData)
avatarDataByJID[account.normalizedJID] = pngData
try? await avatarCache.store(pngData, for: account.normalizedJID)
informationalMessage = "Аватар обновлён."
} catch {
errorMessage = error.localizedDescription
}
}
func devices(for jid: String) -> [OMEMODevice] {
xmpp.devices(for: jid)
}
func setDeviceVerified(_ verified: Bool, jid: String, deviceID: Int32) {
xmpp.setDeviceVerified(verified, jid: jid, deviceID: deviceID)
objectWillChange.send()
}
func clearError() {
errorMessage = nil
}
private func beginMediaSendActivity() -> UUID {
let token = mediaSendActivity.begin()
isSendingAttachment = mediaSendActivity.isActive
updateArchiveSyncSuspensionForMedia()
return token
}
private func endMediaSendActivity(_ token: UUID) {
mediaSendActivity.end(token)
isSendingAttachment = mediaSendActivity.isActive
updateArchiveSyncSuspensionForMedia()
}
private func resetMediaSendActivity() {
mediaSendActivity.reset()
mediaPreparationTokens.removeAll(keepingCapacity: false)
videoNoteCaptureIsActive = false
isSendingAttachment = false
updateArchiveSyncSuspensionForMedia()
}
private func beginMediaPreparationActivity() -> UUID {
let token = UUID()
mediaPreparationTokens.insert(token)
updateArchiveSyncSuspensionForMedia()
return token
}
private func endMediaPreparationActivity(_ token: UUID) {
mediaPreparationTokens.remove(token)
updateArchiveSyncSuspensionForMedia()
}
private func updateArchiveSyncSuspensionForMedia() {
let shouldSuspend = videoNoteCaptureIsActive
|| mediaSendActivity.isActive
|| !mediaPreparationTokens.isEmpty
guard shouldSuspend != archiveSyncIsSuspendedForMedia else { return }
archiveSyncIsSuspendedForMedia = shouldSuspend
xmpp.setArchiveSyncSuspendedForMediaWork(shouldSuspend)
}
private func uploadAndSendMediaWithRetry(
data: Data,
filename: String,
mimeType: String,
kind: ChatMessage.Kind,
duration: TimeInterval?,
to recipient: String,
messageID: String,
encrypted: Bool,
isGroup: Bool
) async throws -> XMPPService.MediaSendResult {
let maximumAttempts = 2
for attempt in 1...maximumAttempts {
do {
return try await xmpp.uploadAndSendMedia(
data: data,
filename: filename,
mimeType: mimeType,
kind: kind,
duration: duration,
to: recipient,
messageID: messageID,
encrypted: encrypted,
isGroup: isGroup
)
} catch {
guard attempt < maximumAttempts,
Self.isRetryableMediaSendError(error) else { throw error }
if connectionStatus != .connected {
await reconnect()
}
try await Task.sleep(nanoseconds: 600_000_000)
}
}
throw LumaXMPPError.uploadFailed(statusCode: nil)
}
private static func isRetryableMediaSendError(_ error: Error) -> Bool {
guard let error = error as? LumaXMPPError else { return false }
switch error {
case .notConnected, .connection, .uploadDiscoveryFailed,
.uploadSlotFailed, .uploadTransportFailed:
return true
case .uploadFailed(let statusCode):
guard let statusCode else { return true }
return statusCode == 408 || statusCode == 429 || statusCode >= 500
default:
return false
}
}
private func requestCallPermissions(includeVideo: Bool) async -> Bool {
guard await Self.requestCaptureAccess(for: .audio) else {
errorMessage = "Разрешите Luma доступ к микрофону в системных настройках."
return false
}
if includeVideo {
guard await Self.requestCaptureAccess(for: .video) else {
errorMessage = "Разрешите Luma доступ к камере в системных настройках."
return false
}
}
return true
}
private static func requestCaptureAccess(for mediaType: AVMediaType) async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return true
case .notDetermined:
return await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: mediaType) { granted in
continuation.resume(returning: granted)
}
}
case .denied, .restricted:
return false
@unknown default:
return false
}
}
private func consume(_ event: XMPPService.Event) {
switch event {
case .connection(let status):
connectionStatus = status
if status == .connected {
if let account {
xmpp.fetchAvatar(for: account.normalizedJID)
}
conversations.filter { !$0.isGroup }.forEach {
xmpp.prepareDirectChat(with: $0.jid)
xmpp.fetchAvatar(for: $0.jid)
}
for room in conversations where room.isGroup && room.shouldAutojoin {
Task { await joinGroup(jid: room.jid) }
}
} else if case .disconnected = status {
// A transport disconnect is a hard boundary for MAM. Keep the
// UI from displaying a stale spinner even if the archive
// completion event was overtaken by a socket state change.
isArchiveSyncing = false
for index in conversations.indices where conversations[index].isGroup {
conversations[index].isGroupJoined = false
conversations[index].occupantCount = 0
}
resetTypingState()
}
case .rosterItem(let jid, let name):
let normalized = jid.lowercased()
rosterContactJIDs.insert(normalized)
upsertConversation(jid: normalized, name: name)
case .rosterRemoved(let jid):
// Keep the chat history but remove it from the server-backed
// contacts screen as soon as Prosody confirms the roster push.
rosterContactJIDs.remove(jid.lowercased())
case .presence(let jid, let online):
if let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) {
conversations[index].isOnline = online
}
case .message(let envelope):
consumeMessage(envelope)
case .retraction(let envelope):
applyRetraction(envelope)
case .reaction(let envelope):
applyReaction(envelope)
case .chatState(let envelope):
if typingIndicatorsEnabled {
applyRemoteChatState(envelope)
}
return
case .groupMessageEcho(let roomJID, let messageID, let stanzaID, let senderJID):
if let index = messageIndex(id: messageID, conversationID: roomJID)
?? messageIndex(referenceID: stanzaID, conversationID: roomJID) {
messages[index].stanzaID = stanzaID
messages[index].senderJID = senderJID
messageIndexByStanzaKey[Self.localDeletionKey(
messageID: stanzaID,
conversationID: roomJID
)] = index
if messages[index].delivery == .sending {
messages[index].delivery = .sent
}
applyPendingReactions(for: messages[index])
}
case .roomState(let envelope):
upsertGroupConversation(
jid: envelope.roomJID,
name: nil,
nickname: envelope.nickname,
shouldAutojoin: envelope.joined
)
if let index = conversations.firstIndex(where: { $0.id == envelope.roomJID.lowercased() }) {
conversations[index].isGroupJoined = envelope.joined
conversations[index].occupantCount = envelope.occupantCount
}
case .roomInvitation(let invitation):
let jid = invitation.roomJID.lowercased()
upsertGroupConversation(
jid: jid,
name: nil,
nickname: defaultGroupNickname,
shouldAutojoin: false,
invitedBy: invitation.inviterJID
)
if let password = invitation.password {
pendingRoomPasswords[jid] = password
}
if let reason = invitation.reason, !reason.isEmpty {
informationalMessage = "Приглашение в \(jid): \(reason)"
} else {
informationalMessage = "Получено приглашение в групповой чат \(jid)."
}
case .avatar(let jid, let data):
let normalized = jid.lowercased()
avatarDataByJID[normalized] = data
Task { try? await avatarCache.store(data, for: normalized) }
case .delivered(let messageID):
let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID
updateMessage(id: targetID) { $0.delivery = .delivered }
case .omemo(let ready, let fingerprint):
isOMEMOReady = ready
ownFingerprint = fingerprint
case .archiveBatch(let mutations):
applyArchiveBatch(mutations)
return
case .archiveSyncing(let syncing):
isArchiveSyncing = syncing
case .archiveSyncCompleted(let checkpoint):
lastSuccessfulMAMSync = checkpoint.timestamp
lastSuccessfulMAMCursor = checkpoint.cursor
case .call(let call):
activeCall = call
case .callHistory(let entry):
recordCallHistory(entry)
case .callError(let message):
errorMessage = message
case .recoverableError(let message):
informationalMessage = message
}
if !isApplyingArchiveBatch {
sortConversations()
}
if !isArchiveSyncing {
schedulePersist()
syncWatch()
}
}
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
}
}
} ?? false
isApplyingArchiveBatch = true
for mutation in mutations {
switch mutation {
case .message(let envelope):
consumeMessage(envelope)
case .retraction(let envelope):
applyRetraction(envelope)
case .reaction(let envelope):
applyReaction(envelope)
}
}
sortConversations()
isApplyingArchiveBatch = false
if selectedConversationWasMutated {
selectedMessagesCacheConversationID = nil
selectedTimelineEntriesCacheConversationID = nil
}
// Publish only after the complete pass is internally consistent. This
// prevents SwiftUI from reading half-merged arrays while MAM is busy.
objectWillChange.send()
}
private func resetArchiveBatchState() {
isApplyingArchiveBatch = false
}
private func consumeMessage(_ envelope: XMPPService.MessageEnvelope) {
if envelope.isGroupMessage {
upsertGroupConversation(
jid: envelope.peerJID,
name: nil,
nickname: nil,
shouldAutojoin: true
)
}
let deletionKey = Self.localDeletionKey(
messageID: envelope.id,
conversationID: envelope.peerJID
)
let correctionWasDeleted = envelope.correctionTargetID.map {
locallyDeletedMessageIDs.contains(Self.localDeletionKey(
messageID: $0,
conversationID: envelope.peerJID
))
} ?? false
guard !locallyDeletedMessageIDs.contains(deletionKey),
!correctionWasDeleted else { return }
if envelope.correctionTargetID != nil {
consumeCorrection(envelope)
return
}
let message = ChatMessage(
id: envelope.id,
conversationID: envelope.peerJID,
senderJID: envelope.senderJID,
body: envelope.body,
timestamp: envelope.timestamp,
direction: envelope.isOutgoing ? .outgoing : .incoming,
delivery: envelope.isOutgoing ? .sent : .delivered,
security: envelope.security,
kind: envelope.kind,
remoteAttachmentURL: envelope.remoteAttachmentURL,
localFilename: envelope.localFilename,
mimeType: envelope.mimeType,
duration: envelope.duration,
byteCount: envelope.byteCount,
encryptionFingerprint: envelope.fingerprint,
replyToID: envelope.replyToID,
replyToJID: envelope.replyToJID,
replyPreview: envelope.replyPreview,
stanzaID: envelope.stanzaID,
senderDisplayName: envelope.senderDisplayName,
isGroupMessage: envelope.isGroupMessage
)
let inserted = upsertMessage(
message,
incrementUnread: envelope.isArchived ? false : nil
)
let pendingKey = Self.localDeletionKey(
messageID: message.id,
conversationID: message.conversationID
)
if let correction = pendingCorrections.removeValue(forKey: pendingKey) {
consumeCorrection(correction)
}
if let retraction = pendingRetractions.removeValue(forKey: pendingKey) {
applyRetraction(retraction)
}
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
notifications.showIncomingMessage(
id: envelope.id,
sender: envelope.senderDisplayName ?? conversationName(for: envelope.peerJID),
body: notificationMessage.previewText,
conversationID: envelope.peerJID
)
}
}
private func recordCallHistory(_ entry: CallHistoryEntry) {
guard let account else { return }
let direction: ChatMessage.Direction = entry.direction == .outgoing
? .outgoing
: .incoming
let metadata = CallHistoryMetadata(
isVideo: entry.isVideo,
outcome: entry.outcome
)
let message = ChatMessage(
id: entry.id,
conversationID: entry.peerJID,
senderJID: direction == .outgoing ? account.normalizedJID : entry.peerJID,
body: entry.isVideo ? "Видеозвонок" : "Аудиозвонок",
timestamp: entry.startedAt,
direction: direction,
delivery: direction == .outgoing ? .sent : .delivered,
security: .plaintext,
kind: .system,
duration: entry.duration,
callHistory: metadata
)
let isUnreadMissedCall = direction == .incoming
&& entry.outcome == .missed
&& selectedConversationID != entry.peerJID.lowercased()
_ = upsertMessage(message, incrementUnread: isUnreadMissedCall)
}
private func upsertConversation(jid: String, name: String?) {
let normalized = jid.lowercased()
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
conversations[index].displayName = name
}
} else {
conversations.append(Conversation(jid: normalized, displayName: name))
}
if conversations.first(where: { $0.id == normalized })?.isGroup != true {
if !isApplyingArchiveBatch {
xmpp.prepareDirectChat(with: normalized)
}
loadCachedAvatarIfNeeded(for: normalized)
}
if !isApplyingArchiveBatch {
sortConversations()
}
}
private func upsertGroupConversation(
jid: String,
name: String?,
nickname: String?,
shouldAutojoin: Bool,
invitedBy: String? = nil
) {
let normalized = jid.lowercased()
let fallbackName = normalized.split(separator: "@").first.map(String.init) ?? normalized
let resolvedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)
let initialDisplayName: String
if let resolvedName, !resolvedName.isEmpty {
initialDisplayName = resolvedName
} else {
initialDisplayName = fallbackName
}
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
conversations[index].kind = .group
if let resolvedName, !resolvedName.isEmpty {
conversations[index].displayName = resolvedName
} else if conversations[index].displayName == normalized {
conversations[index].displayName = fallbackName
}
if let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
conversations[index].groupNickname = nickname
}
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
))
}
if !isApplyingArchiveBatch {
sortConversations()
}
}
private var defaultGroupNickname: String {
if let displayName = account?.displayName.trimmingCharacters(in: .whitespacesAndNewlines),
!displayName.isEmpty {
return displayName
}
return account?.normalizedJID.split(separator: "@").first.map(String.init) ?? "Luma"
}
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 }
guard let index = messageIndex(
id: targetID,
conversationID: envelope.peerJID
) else {
let pendingKey = Self.localDeletionKey(
messageID: targetID,
conversationID: envelope.peerJID
)
pendingCorrections[pendingKey] = envelope
if pendingCorrections.count > 100, let key = pendingCorrections.keys.first {
pendingCorrections.removeValue(forKey: key)
}
return
}
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 }
messages[index].body = envelope.body
messages[index].editedAt = envelope.timestamp
messages[index].security = envelope.security
messages[index].encryptionFingerprint = envelope.fingerprint
if envelope.isOutgoing {
messages[index].delivery = .sent
}
updateConversationPreview(for: messages[index], incrementUnread: false)
}
private func applyReaction(_ envelope: XMPPService.ReactionEnvelope) {
let conversationID = envelope.peerJID.lowercased()
let senderJID = envelope.senderJID.lowercased()
if !envelope.isGroupMessage {
var allowedSenders: Set<String> = [
MessageReactionPolicy.bareJID(conversationID)
]
if let account {
allowedSenders.insert(MessageReactionPolicy.bareJID(account.normalizedJID))
}
guard allowedSenders.contains(MessageReactionPolicy.bareJID(senderJID)) else { return }
}
guard let index = messageIndex(
referenceID: envelope.targetID,
conversationID: conversationID
) else {
let key = pendingReactionKey(envelope)
if let previous = pendingReactions[key],
previous.timestamp > envelope.timestamp { return }
pendingReactions[key] = envelope
if pendingReactions.count > 200,
let staleKey = pendingReactions.min(by: {
$0.value.timestamp < $1.value.timestamp
})?.key {
pendingReactions.removeValue(forKey: staleKey)
}
return
}
guard !messages[index].isRetracted else { return }
var updatedMessage = messages[index]
let existingIndex = updatedMessage.reactions.firstIndex { reaction in
reactionSenderMatches(
reaction.senderJID,
senderJID,
isGroup: envelope.isGroupMessage
)
}
if let existingIndex,
updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp {
return
}
let emojis = MessageReactionPolicy.sanitized(envelope.emojis)
if let existingIndex {
if emojis.isEmpty {
updatedMessage.reactions.remove(at: existingIndex)
} else {
updatedMessage.reactions[existingIndex] = MessageReaction(
senderJID: senderJID,
emojis: emojis,
updatedAt: envelope.timestamp
)
}
} else if !emojis.isEmpty {
updatedMessage.reactions.append(MessageReaction(
senderJID: senderJID,
emojis: emojis,
updatedAt: envelope.timestamp
))
} else {
return
}
updatedMessage.reactions.sort { $0.senderJID < $1.senderJID }
messages[index] = updatedMessage
}
private func applyPendingReactions(for message: ChatMessage) {
let identifiers = Set([message.id, message.stanzaID].compactMap { $0 })
guard !identifiers.isEmpty else { return }
let matches = pendingReactions.filter { _, envelope in
envelope.peerJID.lowercased() == message.conversationID
&& identifiers.contains(envelope.targetID)
}
for (key, envelope) in matches {
pendingReactions.removeValue(forKey: key)
applyReaction(envelope)
}
}
private func pendingReactionKey(_ envelope: XMPPService.ReactionEnvelope) -> String {
[
envelope.peerJID.lowercased(),
envelope.targetID,
envelope.senderJID.lowercased()
].joined(separator: "|")
}
private func reactionSenderMatches(
_ lhs: String,
_ rhs: String,
isGroup: Bool
) -> Bool {
if isGroup { return lhs.lowercased() == rhs.lowercased() }
return MessageReactionPolicy.bareJID(lhs) == MessageReactionPolicy.bareJID(rhs)
}
private func applyRemoteChatState(_ envelope: XMPPService.ChatStateEnvelope) {
let conversationID = envelope.peerJID.lowercased()
let senderKey = envelope.isGroupMessage
? envelope.senderJID.lowercased()
: MessageReactionPolicy.bareJID(envelope.senderJID)
let expiryKey = "\(conversationID)|\(senderKey)"
remoteTypingExpiryTasks[expiryKey]?.cancel()
remoteTypingExpiryTasks[expiryKey] = nil
guard envelope.state == .composing else {
removeRemoteTypingParticipant(
conversationID: conversationID,
senderKey: senderKey
)
return
}
var participants = typingParticipantsByConversation[conversationID] ?? [:]
participants[senderKey] = envelope.senderDisplayName
?? displayName(for: envelope.senderJID)
typingParticipantsByConversation[conversationID] = participants
remoteTypingExpiryTasks[expiryKey] = Task { @MainActor [weak self] in
do {
try await Task.sleep(nanoseconds: ChatTypingPolicy.remoteExpiryNanoseconds)
} catch {
return
}
self?.removeRemoteTypingParticipant(
conversationID: conversationID,
senderKey: senderKey
)
self?.remoteTypingExpiryTasks[expiryKey] = nil
}
}
private func removeRemoteTypingParticipant(
conversationID: String,
senderKey: String
) {
guard var participants = typingParticipantsByConversation[conversationID] else { return }
participants.removeValue(forKey: senderKey)
if participants.isEmpty {
typingParticipantsByConversation.removeValue(forKey: conversationID)
} else {
typingParticipantsByConversation[conversationID] = participants
}
}
private func transitionLocalChatState(
_ state: ChatTypingState,
in conversation: Conversation
) {
guard typingIndicatorsEnabled, connectionStatus == .connected else { return }
let conversationID = conversation.id.lowercased()
let previous = localChatStateByConversation[conversationID]
if state == .paused, previous != .composing { return }
if state == .inactive, previous == nil { return }
guard previous != state else { return }
localChatStateByConversation[conversationID] = state
Task { [weak self] in
guard let self else { return }
try? await self.xmpp.sendChatState(
state,
to: conversation.jid,
isGroup: conversation.isGroup
)
}
}
private func resetTypingState() {
localTypingPauseTasks.values.forEach { $0.cancel() }
remoteTypingExpiryTasks.values.forEach { $0.cancel() }
localTypingPauseTasks.removeAll(keepingCapacity: false)
remoteTypingExpiryTasks.removeAll(keepingCapacity: false)
localChatStateByConversation.removeAll(keepingCapacity: false)
typingParticipantsByConversation.removeAll(keepingCapacity: false)
}
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 {
let pendingKey = Self.localDeletionKey(
messageID: envelope.targetID,
conversationID: envelope.peerJID
)
pendingRetractions[pendingKey] = envelope
if pendingRetractions.count > 100, let staleID = pendingRetractions.keys.first {
pendingRetractions.removeValue(forKey: staleID)
}
return
}
let original = messages[index]
guard original.conversationID == envelope.peerJID.lowercased(),
original.senderJID == envelope.senderJID.lowercased(),
original.retractedAt == nil || envelope.timestamp >= original.retractedAt! else { return }
removeCachedMedia(for: original.id)
messages[index].body = "Сообщение удалено"
messages[index].kind = .system
messages[index].remoteAttachmentURL = nil
messages[index].localFilename = nil
messages[index].mimeType = nil
messages[index].duration = nil
messages[index].byteCount = nil
messages[index].editedAt = nil
messages[index].replyToID = nil
messages[index].replyToJID = nil
messages[index].replyPreview = nil
messages[index].forwardedFrom = nil
messages[index].callHistory = nil
messages[index].reactions = []
messages[index].retractedAt = envelope.timestamp
if envelope.isOutgoing, messages[index].delivery == .sending {
messages[index].delivery = .sent
}
pendingCorrections.removeValue(forKey: Self.localDeletionKey(
messageID: original.id,
conversationID: original.conversationID
))
updateConversationPreview(for: messages[index], incrementUnread: false)
}
@discardableResult
private func upsertMessage(
_ message: ChatMessage,
incrementUnread unreadOverride: Bool? = nil
) -> Bool {
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,
conversationID: message.conversationID
) {
let previous = messages[index]
if previous.isRetracted {
if message.delivery == .delivered {
messages[index].delivery = .delivered
}
updateConversationPreview(for: messages[index], incrementUnread: false)
return false
}
var merged = message
if previous.direction == .outgoing && message.direction == .outgoing {
merged.localFilename = previous.localFilename ?? message.localFilename
merged.mimeType = previous.mimeType ?? message.mimeType
merged.duration = previous.duration ?? message.duration
merged.byteCount = previous.byteCount ?? message.byteCount
if previous.delivery == .delivered {
merged.delivery = .delivered
}
}
merged.replyToID = message.replyToID ?? previous.replyToID
merged.replyToJID = message.replyToJID ?? previous.replyToJID
merged.replyPreview = message.replyPreview ?? previous.replyPreview
merged.forwardedFrom = message.forwardedFrom ?? previous.forwardedFrom
merged.stanzaID = message.stanzaID ?? previous.stanzaID
merged.senderDisplayName = message.senderDisplayName ?? previous.senderDisplayName
merged.isGroupMessage = message.isGroupMessage || previous.isGroupMessage
merged.callHistory = message.callHistory ?? previous.callHistory
if message.reactions.isEmpty {
merged.reactions = previous.reactions
}
if let editedAt = previous.editedAt,
message.editedAt == nil || editedAt > message.editedAt! {
merged.body = previous.body
merged.editedAt = editedAt
merged.security = previous.security
merged.encryptionFingerprint = previous.encryptionFingerprint
}
messages[index] = merged
if let stanzaID = merged.stanzaID {
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
if firstMessageIndexByID[message.id] == nil {
firstMessageIndexByID[message.id] = insertedIndex
}
if let stanzaID = message.stanzaID {
messageIndexByStanzaKey[Self.localDeletionKey(
messageID: stanzaID,
conversationID: message.conversationID
)] = insertedIndex
}
let shouldIncrement = unreadOverride
?? (message.direction == .incoming && selectedConversationID != message.conversationID)
updateConversationPreview(for: message, incrementUnread: shouldIncrement)
return true
}
private func updateMessage(id: String, mutation: (inout ChatMessage) -> Void) {
guard let index = firstMessageIndexByID[id],
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 }
if message.timestamp >= conversations[index].lastActivity {
conversations[index].lastActivity = message.timestamp
conversations[index].lastMessage = message.previewText
}
if incrementUnread {
conversations[index].unreadCount += 1
}
if !isApplyingArchiveBatch {
sortConversations()
}
}
private func rebuildConversationPreview(for conversationID: String) {
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 }
return lhs.timestamp < rhs.timestamp
}
conversations[index].lastActivity = latest?.timestamp ?? .distantPast
conversations[index].lastMessage = latest?.previewText ?? ""
sortConversations()
}
private func conversationName(for jid: String) -> String {
conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid
}
private func sortConversations() {
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
}
}
private func messageIndex(id: String, conversationID: String) -> Int? {
let normalizedConversationID = conversationID.lowercased()
let key = Self.localDeletionKey(
messageID: id,
conversationID: normalizedConversationID
)
guard let index = messageIndexByStorageKey[key] else { return nil }
if messages.indices.contains(index),
messages[index].id == id,
messages[index].conversationID == normalizedConversationID {
return index
}
rebuildMessageIndex()
return messageIndexByStorageKey[key]
}
private func messageIndex(referenceID: String, conversationID: String) -> Int? {
if let directIndex = messageIndex(id: referenceID, conversationID: conversationID) {
return directIndex
}
let normalizedConversationID = conversationID.lowercased()
let key = Self.localDeletionKey(
messageID: referenceID,
conversationID: normalizedConversationID
)
if let index = messageIndexByStanzaKey[key],
messages.indices.contains(index),
messages[index].conversationID == normalizedConversationID,
messages[index].stanzaID == referenceID {
return index
}
rebuildMessageIndex()
return messageIndexByStanzaKey[key]
}
private func rebuildMessageIndex() {
messageIndexByStorageKey.removeAll(keepingCapacity: true)
messageIndexByStanzaKey.removeAll(keepingCapacity: true)
firstMessageIndexByID.removeAll(keepingCapacity: true)
for (index, message) in messages.enumerated() {
let key = Self.localDeletionKey(
messageID: message.id,
conversationID: message.conversationID
)
if messageIndexByStorageKey[key] == nil {
messageIndexByStorageKey[key] = index
}
if firstMessageIndexByID[message.id] == nil {
firstMessageIndexByID[message.id] = index
}
if let stanzaID = message.stanzaID {
let stanzaKey = Self.localDeletionKey(
messageID: stanzaID,
conversationID: message.conversationID
)
if messageIndexByStanzaKey[stanzaKey] == nil {
messageIndexByStanzaKey[stanzaKey] = index
}
}
}
}
private func loadArchive(for jid: String) async {
resetArchiveBatchState()
resetMediaPreviews()
let archive = ChatArchive(accountJID: jid)
self.archive = archive
let snapshot = await archive.load()
let migrateLegacyGroupEncryption = snapshot.schemaVersion < 2
conversations = snapshot.conversations.map { stored in
var conversation = stored
if conversation.isGroup {
conversation.isGroupJoined = false
conversation.occupantCount = 0
if migrateLegacyGroupEncryption, conversation.encryptionPreference == .disabled {
// Older releases forced every MUC to disabled and had no
// group encryption control, so this value was not a user
// choice. Migrate it once, while preserving later explicit
// per-room opt-outs in schema v2 snapshots.
conversation.encryptionPreference = .inheritGlobal
}
}
return conversation
}
rosterContactJIDs = Set(snapshot.rosterContactJIDs.map { $0.lowercased() })
locallyDeletedMessageIDs = snapshot.locallyDeletedMessageIDs
lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync
lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor
messages = snapshot.messages.filter {
!locallyDeletedMessageIDs.contains(Self.localDeletionKey(
messageID: $0.id,
conversationID: $0.conversationID
))
}
rebuildMessageIndex()
let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid])
for avatarJID in avatarJIDs {
if let data = await avatarCache.data(for: avatarJID) {
avatarDataByJID[avatarJID.lowercased()] = data
}
}
sortConversations()
syncWatch()
}
private func schedulePersist() {
guard let archive else { return }
persistTask?.cancel()
persistTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled, let self 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)
}
}
private func syncWatch(immediate: Bool = false) {
watchSyncTask?.cancel()
watchSyncTask = nil
if immediate {
watchBridge.update(conversations: conversations, messages: messages)
return
}
watchSyncTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 250_000_000)
guard !Task.isCancelled, let self else { return }
self.watchSyncTask = nil
self.watchBridge.update(
conversations: self.conversations,
messages: self.messages
)
}
}
private func loadCachedAvatarIfNeeded(for jid: String) {
let normalized = jid.lowercased()
guard avatarDataByJID[normalized] == nil,
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
}
}
private func resetMediaPreviews() {
for directoryName in [
"LumaMediaPreviews",
"LumaPreview",
"LumaForwarding",
"LumaAttachmentDrafts",
"LumaPhotoPicker",
"LumaDuration"
] {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(directoryName, isDirectory: true)
try? FileManager.default.removeItem(at: directory)
}
mediaPreviewURLs = [:]
mediaThumbnailData = [:]
audioWaveformSamples = [:]
loadingMediaIDs = []
mediaViewerItem = nil
audioPlayback.stop()
}
private func removeCachedMedia(for messageID: String) {
if let url = mediaPreviewURLs.removeValue(forKey: messageID) {
try? FileManager.default.removeItem(at: url)
if previewURL == url { previewURL = nil }
}
let prefix = Self.safeFilename(messageID) + "-"
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
)) ?? []
for url in urls where url.lastPathComponent.hasPrefix(prefix) {
if previewURL == url { previewURL = nil }
try? FileManager.default.removeItem(at: url)
}
}
mediaThumbnailData.removeValue(forKey: messageID)
audioWaveformSamples.removeValue(forKey: messageID)
loadingMediaIDs.remove(messageID)
if mediaViewerItem?.id == messageID {
mediaViewerItem = nil
}
audioPlayback.stop()
}
private func cacheMediaPreview(
data: Data,
messageID: String,
filename: String,
kind: ChatMessage.Kind,
mimeType: String,
preparedThumbnailData: Data?
) async {
do {
let output = try Self.previewFileURL(
messageID: messageID,
filename: filename,
directoryName: "LumaMediaPreviews"
)
try await mediaFileIO.write(data, to: output)
mediaPreviewURLs[messageID] = output
if let preparedThumbnailData {
mediaThumbnailData[messageID] = preparedThumbnailData
}
let hasCompletePreparedPreview = preparedThumbnailData != nil
&& (kind == .photo || kind == .video)
guard !hasCompletePreparedPreview else { return }
Task { @MainActor [weak self] in
await self?.analyzeMediaPreview(
at: output,
messageID: messageID,
kind: kind,
mimeType: mimeType
)
}
} catch {
// Sending remains possible even if local preview caching fails.
}
}
private func analyzeMediaPreview(
at url: URL,
messageID: String,
kind: ChatMessage.Kind,
mimeType: String?
) async {
let analysis = await mediaPreviewProcessor.analyze(
url: url,
kind: kind,
mimeType: mimeType
)
if let thumbnailData = analysis.thumbnailData {
mediaThumbnailData[messageID] = thumbnailData
}
if let waveform = analysis.waveform {
audioWaveformSamples[messageID] = waveform
}
if let duration = analysis.duration,
let index = firstMessageIndexByID[messageID],
messages.indices.contains(index),
messages[index].id == messageID,
messages[index].duration == nil {
messages[index].duration = duration
schedulePersist()
}
}
private func localAttachmentURL(
for message: ChatMessage,
directoryName: String
) async throws -> URL {
guard let remoteURL = message.remoteAttachmentURL else {
throw AttachmentError.missingRemoteURL
}
let output = try Self.previewFileURL(
messageID: message.id,
filename: message.localFilename ?? "attachment",
directoryName: directoryName
)
if FileManager.default.fileExists(atPath: output.path) {
return output
}
let data = try await xmpp.downloadAttachment(remoteURL)
try await mediaFileIO.write(data, to: output)
return output
}
private static func previewFileURL(
messageID: String,
filename: String,
directoryName: String
) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(directoryName, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent(
"\(safeFilename(messageID))-\(safeFilename(filename))"
)
}
private static func supportsInlinePreview(_ kind: ChatMessage.Kind) -> Bool {
[.photo, .video, .audio, .voice, .videoNote].contains(kind)
}
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 })
}
private static func mediaKind(for contentType: UTType?, filename: String) -> ChatMessage.Kind {
if contentType?.conforms(to: .image) == true { return .photo }
if contentType?.conforms(to: .movie) == true { return .video }
if contentType?.conforms(to: .audio) == true { return .audio }
let fallback = UTType(filenameExtension: URL(fileURLWithPath: filename).pathExtension)
if fallback?.conforms(to: .image) == true { return .photo }
if fallback?.conforms(to: .movie) == true { return .video }
if fallback?.conforms(to: .audio) == true { return .audio }
return .attachment
}
private static func mediaTitle(kind: ChatMessage.Kind, filename: String) -> String {
switch kind {
case .photo: return "Фото"
case .video: return "Видео"
case .audio: return filename
case .voice: return "Голосовое сообщение"
case .videoNote: return "Видеосообщение"
case .attachment, .location, .text, .system: return filename
}
}
private static func mediaDuration(of url: URL, kind: ChatMessage.Kind) async -> TimeInterval? {
guard [.video, .audio, .voice, .videoNote].contains(kind) else { return nil }
let asset = AVURLAsset(url: url)
guard let time = try? await asset.load(.duration) else { return nil }
let seconds = time.seconds
return seconds.isFinite && seconds > 0 ? seconds : nil
}
private static func mediaDuration(
data: Data,
filename: String,
kind: ChatMessage.Kind
) async -> TimeInterval? {
guard [.video, .audio, .voice, .videoNote].contains(kind) else { return nil }
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaDuration", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("\(UUID().uuidString)-\(safeFilename(filename))")
do {
try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) }
return await mediaDuration(of: url, kind: kind)
} catch {
return nil
}
}
private static func safeFilename(_ value: String) -> String {
let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>").union(.controlCharacters)
let components = value.components(separatedBy: invalid)
let cleaned = components.filter { !$0.isEmpty }.joined(separator: "_")
return cleaned.isEmpty ? "attachment" : cleaned
}
private static func localDeletionKey(messageID: String, conversationID: String) -> String {
conversationID.lowercased() + "\u{1F}" + messageID
}
}
private enum SignInError: LocalizedError {
case emptyPassword
var errorDescription: String? {
"Введите пароль XMPP-аккаунта."
}
}
private enum AttachmentError: LocalizedError {
case missingRemoteURL
var errorDescription: String? {
switch self {
case .missingRemoteURL:
return "Ссылка на вложение ещё не получена."
}
}
}
private enum ReconnectError: LocalizedError {
case passwordMissing
var errorDescription: String? {
"Пароль не найден в Keychain. Выйдите из аккаунта и войдите снова."
}
}