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

@@ -129,7 +129,7 @@ struct AudioMessagePlayer: View {
}
private var isCurrent: Bool {
playback.currentMessageID == message.id
playback.currentMessageID == message.clientID
}
private var isPlaying: Bool {
@@ -194,7 +194,7 @@ struct AudioMessagePlayer: View {
return
}
playback.toggle(
messageID: message.id,
messageID: message.clientID,
url: downloadedURL,
durationHint: message.duration,
isVoice: message.kind == .voice
@@ -203,7 +203,7 @@ struct AudioMessagePlayer: View {
return
}
playback.toggle(
messageID: message.id,
messageID: message.clientID,
url: localURL,
durationHint: message.duration,
isVoice: message.kind == .voice
@@ -213,7 +213,7 @@ struct AudioMessagePlayer: View {
private func seek(_ fraction: Double) {
guard let localURL else { return }
playback.seek(
messageID: message.id,
messageID: message.clientID,
url: localURL,
durationHint: message.duration,
isVoice: message.kind == .voice,
@@ -124,7 +124,7 @@ struct PhotoAttachmentPreview: View {
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.id,
if model.mediaViewerItem?.id != message.clientID,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
@@ -112,10 +112,10 @@ struct ReplyThreadOverlay: View {
model: model,
message: reply,
maximumBubbleWidth: maximumBubbleWidth,
isSelected: reply.id == selectedReplyID,
showsDeliveryStatus: reply.id == selectedReplyID
isSelected: reply.clientID == selectedReplyID,
showsDeliveryStatus: reply.clientID == selectedReplyID
)
.id(reply.id)
.id(reply.clientID)
}
}
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
@@ -142,7 +142,7 @@ struct VideoAttachmentPreview: View {
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.id,
if model.mediaViewerItem?.id != message.clientID,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
@@ -82,7 +82,7 @@ struct VideoNotePreview: View {
}
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
guard let activeID = notification.object as? String,
activeID != message.id else { return }
activeID != message.clientID else { return }
player?.pause()
isPlaying = false
}
@@ -146,7 +146,7 @@ struct VideoNotePreview: View {
model.audioPlayback.stop()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: message.id
object: message.clientID
)
player.play()
}