migrate persistence from JSON to SwiftData
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

Models:
- Convert Conversation and ChatMessage to SwiftData @Model classes with
  a
  one-to-many relationship and cascade delete.
- Rename ChatMessage.id to clientID and drop Conversation.id in favor of
  jid
  (the string id would clash with PersistentIdentifier).
- Keep MessageReaction as a Codable value stored in an array attribute.

Persistence:
- Add ArchiveStore, a per-account ModelContainer/ModelContext that
  replaces
  the JSON ChatArchive with load/save/erase.
- Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor,
  locally
  deleted message IDs and roster contact JIDs.
- Add LegacyArchiveImporter to import the old JSON snapshot once and
  delete
  the file afterwards, preserving existing history.

AppModel:
- Replace ChatArchive with ArchiveStore and persist via context.save();
  insert new models and delete the replaced object on upsert merges so
  SwiftData never keeps an orphaned duplicate.

Tests:
- Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and
  legacy import) and drop the now-obsolete Codable round-trip
  assertions.
This commit is contained in:
wt committed 2026-08-18 22:58:18 +07:00
1 parent 0317277d46
commit cbb820a86e
30 files changed
+726 -665

No files matched your search

+127 -128
View File
@@ -72,7 +72,7 @@ final class AppModel: ObservableObject {
private let avatarCache: AvatarCache
private let mediaPreviewProcessor = MediaPreviewProcessor()
private let mediaFileIO = MediaFileIO()
private var archive: ChatArchive?
private var store: ArchiveStore?
private var bootstrapTask: Task<Void, Never>?
private var persistTask: Task<Void, Never>?
private var watchSyncTask: Task<Void, Never>?
@@ -131,7 +131,7 @@ final class AppModel: ObservableObject {
watchBridge.onReply = { [weak self] jid, text in
Task { @MainActor in
guard let self else { return }
if !self.conversations.contains(where: { $0.id == jid.lowercased() }) {
if !self.conversations.contains(where: { $0.jid == jid.lowercased() }) {
self.upsertConversation(jid: jid, name: nil)
}
await self.sendText(text, to: jid)
@@ -171,7 +171,7 @@ final class AppModel: ObservableObject {
return
}
if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) {
if !self.conversations.contains(where: { $0.jid == voice.jid.lowercased() }) {
self.upsertConversation(jid: voice.jid, name: nil)
}
let messageID = await self.sendMedia(
@@ -218,7 +218,7 @@ final class AppModel: ObservableObject {
var selectedConversation: Conversation? {
guard let selectedConversationID else { return nil }
return conversations.first { $0.id == selectedConversationID }
return conversations.first { $0.jid == selectedConversationID }
}
private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? {
@@ -238,7 +238,7 @@ final class AppModel: ObservableObject {
messages
.filter { $0.conversationID == selectedConversationID }
.sorted { lhs, rhs in
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
return lhs.timestamp < rhs.timestamp
}
selectedMessagesCacheConversationID = selectedConversationID
@@ -269,7 +269,7 @@ final class AppModel: ObservableObject {
do {
guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
account = nil
archive = nil
store = nil
conversations = []
rosterContactJIDs = []
messages = []
@@ -280,7 +280,7 @@ final class AppModel: ObservableObject {
password = storedPassword
} catch {
account = nil
archive = nil
store = nil
conversations = []
rosterContactJIDs = []
messages = []
@@ -340,7 +340,7 @@ final class AppModel: ObservableObject {
} catch {
await xmpp.disconnect()
self.account = nil
archive = nil
store = nil
conversations = []
rosterContactJIDs = []
messages = []
@@ -356,14 +356,14 @@ final class AppModel: ObservableObject {
if let oldAccount {
try? credentials.deletePassword(for: oldAccount.normalizedJID)
if forgetHistory {
try? await archive?.erase()
try? store?.erase()
}
}
persistTask?.cancel()
resetArchiveBatchState()
watchSyncTask?.cancel()
watchSyncTask = nil
archive = nil
store = nil
account = nil
conversations = []
rosterContactJIDs = []
@@ -429,7 +429,7 @@ final class AppModel: ObservableObject {
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 {
guard conversations.first(where: { $0.jid == jid })?.isGroup != true else {
errorMessage = "Звонки в текущем MVP доступны только в личных чатах."
return
}
@@ -493,7 +493,7 @@ final class AppModel: ObservableObject {
}
upsertConversation(jid: normalized, name: name)
selectedConversationID = normalized
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
conversations[index].unreadCount = 0
}
if addToRoster {
@@ -508,7 +508,7 @@ final class AppModel: ObservableObject {
func selectConversation(id: String) {
let normalized = id.lowercased()
selectedConversationID = normalized
// if let index = conversations.firstIndex(where: { $0.id == normalized }) {
// if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
// conversations[index].unreadCount = 0
// }
// schedulePersist()
@@ -532,7 +532,7 @@ final class AppModel: ObservableObject {
// let oldestServerID = selectedMessages
// .sorted { lhs, rhs in
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
// return lhs.id < rhs.id
// return lhs.clientID < rhs.clientID
// }
// .compactMap(\.stanzaID)
// .first
@@ -540,12 +540,12 @@ final class AppModel: ObservableObject {
.filter { $0.stanzaID != nil }
.min { lhs, rhs in
if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
return lhs.id < rhs.id
return lhs.clientID < rhs.clientID
}?
.stanzaID
isLoadingOlderHistory = true
let conversationID = conversation.id.lowercased()
let conversationID = conversation.jid.lowercased()
xmpp.loadOlderHistory(
conversationJID: conversation.jid,
isGroup: conversation.isGroup,
@@ -603,7 +603,7 @@ final class AppModel: ObservableObject {
informationalMessage = "Приглашения отправлены: \(validInvitees.count)."
}
} catch {
if let index = conversations.firstIndex(where: { $0.id == roomJID }) {
if let index = conversations.firstIndex(where: { $0.jid == roomJID }) {
conversations[index].isGroupJoined = false
}
errorMessage = error.localizedDescription
@@ -613,7 +613,7 @@ 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 {
guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
return
}
guard !conversations[index].isGroupJoined,
@@ -647,7 +647,7 @@ 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 {
guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else {
return
}
conversations[index].isGroupJoined = false
@@ -685,7 +685,7 @@ final class AppModel: ObservableObject {
let account,
let peer = explicitJID ?? selectedConversation?.jid
else { return }
let conversation = conversations.first { $0.id == peer.lowercased() }
let conversation = conversations.first { $0.jid == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
let replyTarget = replyingTo.flatMap { message -> ChatMessage? in
@@ -750,7 +750,7 @@ final class AppModel: ObservableObject {
guard let account,
let peer = explicitJID ?? selectedConversation?.jid
else { return }
let conversation = conversations.first { $0.id == peer.lowercased() }
let conversation = conversations.first { $0.jid == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
let id = UUID().uuidString
@@ -791,7 +791,7 @@ 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 }),
let index = messages.firstIndex(where: { $0.clientID == id }),
messages[index].canBeEdited
else { return }
@@ -818,7 +818,7 @@ final class AppModel: ObservableObject {
to: previous.conversationID,
messageID: correctionID,
encrypted: encrypted,
replacingMessageID: previous.id
replacingMessageID: previous.clientID
)
updateMessage(id: id) {
$0.delivery = .sent
@@ -826,7 +826,7 @@ final class AppModel: ObservableObject {
}
} catch {
correctionReceiptTargets.removeValue(forKey: correctionID)
if let currentIndex = messages.firstIndex(where: { $0.id == id }),
if let currentIndex = messages.firstIndex(where: { $0.clientID == id }),
messages[currentIndex].delivery == .sending
{
messages[currentIndex] = previous
@@ -840,7 +840,7 @@ final class AppModel: ObservableObject {
func retractMessage(id: String) async {
guard let account,
let message = messages.first(where: { $0.id == id }),
let message = messages.first(where: { $0.clientID == id }),
message.canBeRetracted
else { return }
@@ -848,7 +848,7 @@ final class AppModel: ObservableObject {
do {
try await xmpp.sendRetraction(
to: message.conversationID,
targetID: message.id,
targetID: message.clientID,
retractionID: retractionID,
encrypted: message.security == .omemo
)
@@ -856,7 +856,7 @@ final class AppModel: ObservableObject {
XMPPService.RetractionEnvelope(
peerJID: message.conversationID,
senderJID: account.normalizedJID,
targetID: message.id,
targetID: message.clientID,
retractionID: retractionID,
timestamp: Date(),
isOutgoing: true
@@ -876,7 +876,7 @@ final class AppModel: ObservableObject {
guard !ids.isEmpty else { return }
let normalizedConversationID = conversationID?.lowercased()
let selected = messages.filter { message in
ids.contains(message.id)
ids.contains(message.clientID)
&& (normalizedConversationID == nil
|| message.conversationID == normalizedConversationID)
}
@@ -886,11 +886,11 @@ final class AppModel: ObservableObject {
for message in selected {
locallyDeletedMessageIDs.insert(
Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
))
let pendingKey = Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
)
pendingCorrections.removeValue(forKey: pendingKey)
@@ -899,7 +899,7 @@ final class AppModel: ObservableObject {
guard envelope.peerJID.lowercased() == message.conversationID else {
return true
}
if envelope.targetID == message.id { return false }
if envelope.targetID == message.clientID { return false }
if let stanzaID = message.stanzaID,
envelope.targetID == stanzaID
{
@@ -907,19 +907,19 @@ final class AppModel: ObservableObject {
}
return true
}
removeCachedMedia(for: message.id)
removeCachedMedia(for: message.clientID)
}
let selectedKeys = Set(
selected.map {
Self.localDeletionKey(
messageID: $0.id,
messageID: $0.clientID,
conversationID: $0.conversationID
)
})
messages.removeAll { message in
selectedKeys.contains(
Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
))
}
@@ -946,7 +946,7 @@ final class AppModel: ObservableObject {
selectedMessages
.filter(\.canBeForwarded)
.sorted { lhs, rhs in
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
return lhs.timestamp < rhs.timestamp
}
guard !forwardableMessages.isEmpty else { return false }
@@ -995,7 +995,7 @@ final class AppModel: ObservableObject {
case .attachment, .photo, .video, .audio, .voice, .videoNote:
do {
let url: URL
if let cached = mediaPreviewURLs[message.id] {
if let cached = mediaPreviewURLs[message.clientID] {
url = cached
} else {
url = try await localAttachmentURL(
@@ -1031,7 +1031,7 @@ final class AppModel: ObservableObject {
guard let conversationID else {
if let index = firstMessageIndexByID[id],
messages.indices.contains(index),
messages[index].id == id
messages[index].clientID == id
{
return messages[index]
}
@@ -1115,7 +1115,7 @@ final class AppModel: ObservableObject {
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 conversation = conversations.first { $0.jid == peer.lowercased() }
let isGroup = conversation?.isGroup == true
let encrypted = encryptionEnabled(for: peer)
var duration = suppliedDuration
@@ -1191,12 +1191,12 @@ final class AppModel: ObservableObject {
&& message.delivery == .failed
&& message.kind.isMedia
&& message.remoteAttachmentURL == nil
&& mediaPreviewURLs[message.id] != nil
&& mediaPreviewURLs[message.clientID] != nil
}
func retryMediaMessage(_ message: ChatMessage) async {
guard canRetryMediaMessage(message),
let localURL = mediaPreviewURLs[message.id]
let localURL = mediaPreviewURLs[message.clientID]
else { return }
let activityToken = beginMediaSendActivity()
defer { endMediaSendActivity(activityToken) }
@@ -1211,7 +1211,7 @@ final class AppModel: ObservableObject {
from: localURL,
preferredKind: message.kind
).data
updateMessage(id: message.id) { $0.delivery = .sending }
updateMessage(id: message.clientID) { $0.delivery = .sending }
let result = try await uploadAndSendMediaWithRetry(
data: data,
filename: message.localFilename ?? localURL.lastPathComponent,
@@ -1219,17 +1219,17 @@ final class AppModel: ObservableObject {
kind: message.kind,
duration: message.duration,
to: message.conversationID,
messageID: message.id,
messageID: message.clientID,
encrypted: message.security == .omemo,
isGroup: message.isGroupMessage
)
updateMessage(id: message.id) {
updateMessage(id: message.clientID) {
$0.delivery = .sent
$0.remoteAttachmentURL = result.remoteURL
$0.encryptionFingerprint = result.fingerprint
}
} catch {
updateMessage(id: message.id) { $0.delivery = .failed }
updateMessage(id: message.clientID) { $0.delivery = .failed }
errorMessage = error.localizedDescription
}
schedulePersist()
@@ -1364,7 +1364,7 @@ final class AppModel: ObservableObject {
func previewAttachment(_ message: ChatMessage) async {
do {
let output: URL
if let cached = mediaPreviewURLs[message.id] {
if let cached = mediaPreviewURLs[message.clientID] {
output = cached
} else {
guard message.remoteAttachmentURL != nil else { return }
@@ -1377,36 +1377,36 @@ final class AppModel: ObservableObject {
}
func mediaPreviewURL(for message: ChatMessage) -> URL? {
mediaPreviewURLs[message.id]
mediaPreviewURLs[message.clientID]
}
func mediaThumbnail(for message: ChatMessage) -> Data? {
mediaThumbnailData[message.id]
mediaThumbnailData[message.clientID]
}
func audioWaveform(for message: ChatMessage) -> [Float] {
audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform
audioWaveformSamples[message.clientID] ?? MediaPreviewProcessor.placeholderWaveform
}
func isMediaPreviewLoading(_ message: ChatMessage) -> Bool {
loadingMediaIDs.contains(message.id)
loadingMediaIDs.contains(message.clientID)
}
func prepareMediaPreview(_ message: ChatMessage) async {
guard Self.supportsInlinePreview(message.kind),
message.remoteAttachmentURL != nil,
mediaPreviewURLs[message.id] == nil,
loadingMediaIDs.insert(message.id).inserted
mediaPreviewURLs[message.clientID] == nil,
loadingMediaIDs.insert(message.clientID).inserted
else { return }
defer { loadingMediaIDs.remove(message.id) }
defer { loadingMediaIDs.remove(message.clientID) }
do {
let output = try await localAttachmentURL(
for: message, directoryName: "LumaMediaPreviews")
mediaPreviewURLs[message.id] = output
mediaPreviewURLs[message.clientID] = output
await analyzeMediaPreview(
at: output,
messageID: message.id,
messageID: message.clientID,
kind: message.kind,
mimeType: message.mimeType
)
@@ -1421,20 +1421,20 @@ final class AppModel: ObservableObject {
do {
let output: URL
if let cached = mediaPreviewURLs[message.id] {
if let cached = mediaPreviewURLs[message.clientID] {
output = cached
} else {
guard message.remoteAttachmentURL != nil else { return }
loadingMediaIDs.insert(message.id)
defer { loadingMediaIDs.remove(message.id) }
loadingMediaIDs.insert(message.clientID)
defer { loadingMediaIDs.remove(message.clientID) }
output = try await localAttachmentURL(
for: message,
directoryName: "LumaMediaPreviews"
)
mediaPreviewURLs[message.id] = output
mediaPreviewURLs[message.clientID] = output
await analyzeMediaPreview(
at: output,
messageID: message.id,
messageID: message.clientID,
kind: message.kind,
mimeType: message.mimeType
)
@@ -1443,10 +1443,10 @@ final class AppModel: ObservableObject {
audioPlayback.stop()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: message.id
object: message.clientID
)
mediaViewerItem = MediaViewerItem(
id: message.id,
id: message.clientID,
url: output,
kind: message.kind,
title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео")
@@ -1466,7 +1466,7 @@ final class AppModel: ObservableObject {
func encryptionPreference(for jid: String) -> EncryptionPreference {
conversations
.first(where: { $0.id == jid.lowercased() })?
.first(where: { $0.jid == jid.lowercased() })?
.encryptionPreference ?? .inheritGlobal
}
@@ -1490,13 +1490,13 @@ final class AppModel: ObservableObject {
func typingText(for conversation: Conversation) -> String? {
let names =
typingParticipantsByConversation[conversation.id.lowercased()]
typingParticipantsByConversation[conversation.jid.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()
let conversationID = conversation.jid.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
guard typingIndicatorsEnabled, appIsActive else { return }
@@ -1523,7 +1523,7 @@ final class AppModel: ObservableObject {
}
func endComposerActivity(in conversation: Conversation) {
let conversationID = conversation.id.lowercased()
let conversationID = conversation.jid.lowercased()
localTypingPauseTasks[conversationID]?.cancel()
localTypingPauseTasks[conversationID] = nil
transitionLocalChatState(.inactive, in: conversation)
@@ -1532,7 +1532,7 @@ final class AppModel: ObservableObject {
func toggleReaction(_ emoji: String, on sourceMessage: ChatMessage) async {
guard let account,
let message = message(
withID: sourceMessage.id,
withID: sourceMessage.clientID,
in: sourceMessage.conversationID
),
message.canBeReactedTo,
@@ -1586,7 +1586,7 @@ final class AppModel: ObservableObject {
}
func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) {
guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else {
guard let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) else {
return
}
conversations[index].encryptionPreference = preference
@@ -1797,7 +1797,7 @@ final class AppModel: ObservableObject {
// 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() }) {
if let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) {
conversations[index].isOnline = online
}
case .message(let envelope):
@@ -1835,7 +1835,7 @@ final class AppModel: ObservableObject {
shouldAutojoin: envelope.joined
)
if let index = conversations.firstIndex(where: {
$0.id == envelope.roomJID.lowercased()
$0.jid == envelope.roomJID.lowercased()
}) {
conversations[index].isGroupJoined = envelope.joined
conversations[index].occupantCount = envelope.occupantCount
@@ -1885,17 +1885,14 @@ final class AppModel: ObservableObject {
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,
guard let self, let store = self.store else { return }
try? store.save(
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
rosterContactJIDs: self.rosterContactJIDs,
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
mamCheckpoints: self.mamCheckpoints
)
try? await archive.save(snapshot)
}
case .mucArchiveSyncCompleted(let archiveKey, let checkpoint):
mamCheckpoints[archiveKey] = checkpoint
@@ -2030,7 +2027,7 @@ final class AppModel: ObservableObject {
incrementUnread: envelope.isArchived ? false : nil
)
let pendingKey = Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
)
if let correction = pendingCorrections.removeValue(forKey: pendingKey) {
@@ -2113,14 +2110,16 @@ final class AppModel: ObservableObject {
private func upsertConversation(jid: String, name: String?) {
let normalized = jid.lowercased()
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
conversations[index].displayName = name
}
} else {
conversations.append(Conversation(jid: normalized, displayName: name))
let conversation = Conversation(jid: normalized, displayName: name)
store?.context.insert(conversation)
conversations.append(conversation)
}
if conversations.first(where: { $0.id == normalized })?.isGroup != true {
if conversations.first(where: { $0.jid == normalized })?.isGroup != true {
if !isApplyingArchiveBatch {
xmpp.prepareDirectChat(with: normalized)
}
@@ -2147,7 +2146,7 @@ final class AppModel: ObservableObject {
} else {
initialDisplayName = fallbackName
}
if let index = conversations.firstIndex(where: { $0.id == normalized }) {
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
conversations[index].kind = .group
if let resolvedName, !resolvedName.isEmpty {
conversations[index].displayName = resolvedName
@@ -2161,16 +2160,17 @@ final class AppModel: ObservableObject {
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
))
let conversation = Conversation(
jid: normalized,
displayName: initialDisplayName,
encryptionPreference: .inheritGlobal,
kind: .group,
groupNickname: nickname,
shouldAutojoin: shouldAutojoin,
invitedBy: invitedBy
)
store?.context.insert(conversation)
conversations.append(conversation)
}
if !isApplyingArchiveBatch {
sortConversations()
@@ -2310,7 +2310,7 @@ final class AppModel: ObservableObject {
}
private func applyPendingReactions(for message: ChatMessage) {
let identifiers = Set([message.id, message.stanzaID].compactMap { $0 })
let identifiers = Set([message.clientID, message.stanzaID].compactMap { $0 })
guard !identifiers.isEmpty else { return }
let matches = pendingReactions.filter { _, envelope in
envelope.peerJID.lowercased() == message.conversationID
@@ -2394,7 +2394,7 @@ final class AppModel: ObservableObject {
in conversation: Conversation
) {
guard typingIndicatorsEnabled, connectionStatus == .connected else { return }
let conversationID = conversation.id.lowercased()
let conversationID = conversation.jid.lowercased()
let previous = localChatStateByConversation[conversationID]
if state == .paused, previous != .composing { return }
if state == .inactive, previous == nil { return }
@@ -2450,7 +2450,7 @@ final class AppModel: ObservableObject {
original.retractedAt == nil || envelope.timestamp >= original.retractedAt!
else { return }
removeCachedMedia(for: original.id)
removeCachedMedia(for: original.clientID)
messages[index].body = "Сообщение удалено"
messages[index].kind = .system
messages[index].remoteAttachmentURL = nil
@@ -2471,7 +2471,7 @@ final class AppModel: ObservableObject {
}
pendingCorrections.removeValue(
forKey: Self.localDeletionKey(
messageID: original.id,
messageID: original.clientID,
conversationID: original.conversationID
))
updateConversationPreview(for: messages[index], incrementUnread: false)
@@ -2485,13 +2485,13 @@ final class AppModel: ObservableObject {
guard
!locallyDeletedMessageIDs.contains(
Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
))
else { return false }
upsertConversation(jid: message.conversationID, name: nil)
if let index = messageIndex(
id: message.id,
id: message.clientID,
conversationID: message.conversationID
) {
let previous = messages[index]
@@ -2551,6 +2551,13 @@ final class AppModel: ObservableObject {
merged.byteCount = previous.byteCount
}
messages[index] = merged
if previous !== merged {
// `merged` is the freshly-created incoming message; replace the
// previously managed object with it so SwiftData does not keep
// an orphaned duplicate of the same clientID.
store?.context.delete(previous)
store?.context.insert(merged)
}
if let stanzaID = merged.stanzaID {
messageIndexByStanzaKey[
Self.localDeletionKey(
@@ -2568,15 +2575,16 @@ final class AppModel: ObservableObject {
updateConversationPreview(for: merged, incrementUnread: false)
return false
}
store?.context.insert(message)
messages.append(message)
let insertedIndex = messages.index(before: messages.endIndex)
messageIndexByStorageKey[
Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
)] = insertedIndex
if firstMessageIndexByID[message.id] == nil {
firstMessageIndexByID[message.id] = insertedIndex
if firstMessageIndexByID[message.clientID] == nil {
firstMessageIndexByID[message.clientID] = insertedIndex
}
if let stanzaID = message.stanzaID {
messageIndexByStanzaKey[
@@ -2602,14 +2610,14 @@ 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
messages[index].clientID == 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 })
guard let index = conversations.firstIndex(where: { $0.jid == message.conversationID })
else { return }
if message.timestamp >= conversations[index].lastActivity {
conversations[index].lastActivity = message.timestamp
@@ -2624,14 +2632,14 @@ final class AppModel: ObservableObject {
}
private func rebuildConversationPreview(for conversationID: String) {
guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else {
guard let index = conversations.firstIndex(where: { $0.jid == conversationID }) else {
return
}
let latest =
messages
.filter { $0.conversationID == conversationID }
.max { lhs, rhs in
if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id }
if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID }
return lhs.timestamp < rhs.timestamp
}
conversations[index].lastActivity = latest?.timestamp ?? .distantPast
@@ -2640,7 +2648,7 @@ final class AppModel: ObservableObject {
}
private func conversationName(for jid: String) -> String {
conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid
conversations.first(where: { $0.jid == jid.lowercased() })?.displayName ?? jid
}
private func sortConversations() {
@@ -2660,7 +2668,7 @@ final class AppModel: ObservableObject {
)
guard let index = messageIndexByStorageKey[key] else { return nil }
if messages.indices.contains(index),
messages[index].id == id,
messages[index].clientID == id,
messages[index].conversationID == normalizedConversationID
{
return index
@@ -2716,14 +2724,14 @@ final class AppModel: ObservableObject {
firstMessageIndexByID.removeAll(keepingCapacity: true)
for (index, message) in messages.enumerated() {
let key = Self.localDeletionKey(
messageID: message.id,
messageID: message.clientID,
conversationID: message.conversationID
)
if messageIndexByStorageKey[key] == nil {
messageIndexByStorageKey[key] = index
}
if firstMessageIndexByID[message.id] == nil {
firstMessageIndexByID[message.id] = index
if firstMessageIndexByID[message.clientID] == nil {
firstMessageIndexByID[message.clientID] = index
}
if let stanzaID = message.stanzaID {
let stanzaKey = Self.localDeletionKey(
@@ -2749,22 +2757,16 @@ final class AppModel: ObservableObject {
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
guard let store = try? ArchiveStore(accountJID: jid) else {
self.store = nil
return
}
self.store = store
let snapshot = store.load()
conversations = snapshot.conversations.map { conversation in
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
}
@@ -2782,7 +2784,7 @@ final class AppModel: ObservableObject {
messages = snapshot.messages.filter {
!locallyDeletedMessageIDs.contains(
Self.localDeletionKey(
messageID: $0.id,
messageID: $0.clientID,
conversationID: $0.conversationID
))
}
@@ -2798,21 +2800,18 @@ final class AppModel: ObservableObject {
}
private func schedulePersist() {
guard let archive else { return }
guard let store 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,
try? store.save(
locallyDeletedMessageIDs: self.locallyDeletedMessageIDs,
rosterContactJIDs: self.rosterContactJIDs,
lastSuccessfulMAMSync: self.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor,
mamCheckpoints: self.mamCheckpoints
)
try? await archive.save(snapshot)
}
}
@@ -2950,7 +2949,7 @@ final class AppModel: ObservableObject {
if let duration = analysis.duration,
let index = firstMessageIndexByID[messageID],
messages.indices.contains(index),
messages[index].id == messageID,
messages[index].clientID == messageID,
messages[index].duration == nil
{
messages[index].duration = duration
@@ -2966,7 +2965,7 @@ final class AppModel: ObservableObject {
throw AttachmentError.missingRemoteURL
}
let output = try Self.previewFileURL(
messageID: message.id,
messageID: message.clientID,
filename: message.localFilename ?? "attachment",
directoryName: directoryName
)
+12 -69
View File
@@ -1,25 +1,27 @@
import Foundation
import SwiftData
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
enum Direction: String, Codable, Sendable {
@Model
final class ChatMessage {
enum Direction: String, Codable {
case incoming
case outgoing
}
enum Delivery: String, Codable, Sendable {
enum Delivery: String, Codable {
case sending
case sent
case delivered
case failed
}
enum Security: String, Codable, Sendable {
enum Security: String, Codable {
case omemo
case plaintext
case decryptionFailed
}
enum Kind: String, Codable, Sendable {
enum Kind: String, Codable {
case text
case attachment
case photo
@@ -40,7 +42,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
}
}
let id: String
@Attribute(.unique) var clientID: String
var conversationID: String
var senderJID: String
var body: String
@@ -68,6 +70,8 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
var conversation: Conversation?
init(
id: String = UUID().uuidString,
conversationID: String,
@@ -97,7 +101,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
self.id = id
self.clientID = id
self.conversationID = conversationID.lowercased()
self.senderJID = Self.normalizedSenderJID(senderJID)
self.body = body
@@ -126,67 +130,6 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
self.reactions = reactions
}
private enum CodingKeys: String, CodingKey {
case id
case conversationID
case senderJID
case body
case timestamp
case direction
case delivery
case security
case kind
case remoteAttachmentURL
case localFilename
case mimeType
case duration
case byteCount
case encryptionFingerprint
case editedAt
case replyToID
case replyToJID
case replyPreview
case forwardedFrom
case retractedAt
case originID
case stanzaID
case senderDisplayName
case isGroupMessage
case callHistory
case reactions
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
body = try values.decode(String.self, forKey: .body)
timestamp = try values.decode(Date.self, forKey: .timestamp)
direction = try values.decode(Direction.self, forKey: .direction)
delivery = try values.decode(Delivery.self, forKey: .delivery)
security = try values.decode(Security.self, forKey: .security)
kind = try values.decode(Kind.self, forKey: .kind)
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
originID = try values.decodeIfPresent(String.self, forKey: .originID)
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
}
var canBeEdited: Bool {
!isRetracted
&& !isGroupMessage
@@ -218,7 +161,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
}
var replyIdentifier: String? {
isGroupMessage ? stanzaID : id
isGroupMessage ? stanzaID : clientID
}
var quotePreview: String {
+12 -2
View File
@@ -3,11 +3,21 @@ import Foundation
/// A cached presentation row for the selected chat. Building day boundaries
/// once per message mutation avoids walking the full history during every
/// scroll-position or composer-state update.
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
struct ChatTimelineEntry: Identifiable, Hashable {
let message: ChatMessage
let startsNewDay: Bool
var id: String { message.id }
var id: String { message.clientID }
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message.clientID == rhs.message.clientID
&& lhs.startsNewDay == rhs.startsNewDay
}
func hash(into hasher: inout Hasher) {
hasher.combine(message.clientID)
hasher.combine(startsNewDay)
}
static func make(
from messages: [ChatMessage],
+8 -45
View File
@@ -1,13 +1,14 @@
import Foundation
import SwiftData
struct Conversation: Codable, Identifiable, Hashable, Sendable {
enum Kind: String, Codable, Sendable {
@Model
final class Conversation {
enum Kind: String, Codable {
case direct
case group
}
let id: String
var jid: String
@Attribute(.unique) var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
@@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
var occupantCount: Int
var invitedBy: String?
@Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation)
var messages: [ChatMessage] = []
init(
jid: String,
displayName: String? = nil,
@@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
invitedBy: String? = nil
) {
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self.id = normalized
self.jid = normalized
self.displayName = displayName?.nilIfBlank ?? normalized
self.lastMessage = lastMessage
@@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable {
self.invitedBy = invitedBy?.nilIfBlank
}
private enum CodingKeys: String, CodingKey {
case id
case jid
case displayName
case lastMessage
case lastActivity
case unreadCount
case isOnline
case isPinned
case encryptionPreference
case kind
case groupNickname
case isGroupJoined
case shouldAutojoin
case occupantCount
case invitedBy
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
jid = try values.decode(String.self, forKey: .jid)
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
displayName = try values.decode(String.self, forKey: .displayName)
lastMessage = try values.decode(String.self, forKey: .lastMessage)
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
isOnline = try values.decode(Bool.self, forKey: .isOnline)
isPinned = try values.decode(Bool.self, forKey: .isPinned)
encryptionPreference = try values.decodeIfPresent(
EncryptionPreference.self,
forKey: .encryptionPreference
) ?? .inheritGlobal
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
}
var isGroup: Bool { kind == .group }
var initials: String {
+1 -1
View File
@@ -80,7 +80,7 @@ enum MessageReactionPolicy {
extension ChatMessage {
var reactionIdentifier: String? {
isGroupMessage ? stanzaID : id
isGroupMessage ? stanzaID : clientID
}
var canBeReactedTo: Bool {