finish SwiftData migration with @Query views
- Drive chat list, timeline and forward picker from SwiftData @Query instead of AppModel's in-memory arrays; inject the per-account ModelContext from RootView with an in-memory fallback. - Physically delete locally deleted messages (context.delete) and purge rows marked deleted by older builds on store open, so @Query views never resurrect them. - Delete the legacy JSON snapshot only after a successful import save and restore the completeUntilFirstUserAuthentication data protection attribute on the store file. - Mirror the old ChatArchive tolerant decoding in LegacyArchiveImporter so snapshots from older schema versions (missing reactions, isGroupMessage, roster fields) still import. - Cover the new behaviour in ArchiveStoreTests and guard it with new verify.sh invariants; update AGENTS.md, ARCHITECTURE.md and SECURITY.md.
This commit is contained in:
1 parent
277d8654c3
commit
2884810ec4
16 files changed
+653
-65
No files matched your search
@@ -8,11 +8,17 @@ and the Martin / MartinOMEMO libraries. The Xcode project is generated from
|
||||
|
||||
- `Sources/App/` — iOS/macOS app entry point (`LumaApp.swift`).
|
||||
- `Sources/Shared/` — shared code:
|
||||
- `Models/` — value types and pure policy enums (`ChatMessage`,
|
||||
`ArchiveSyncPagination`, `ChatTypingPolicy`, …).
|
||||
- `UI/` — SwiftUI views and components.
|
||||
- `Models/` — value types and pure policy enums (`ArchiveSyncPagination`,
|
||||
`ChatTypingPolicy`, …) plus the SwiftData `@Model` classes
|
||||
(`ChatMessage`, `Conversation`).
|
||||
- `UI/` — SwiftUI views; chat list, timeline and forward picker read
|
||||
SwiftData through `@Query` (`MainChatView`, `ChatView`,
|
||||
`ForwardMessageView`).
|
||||
- `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store.
|
||||
- `Persistence/` — `ChatArchive` (JSON snapshot), preferences.
|
||||
- `Persistence/` — `ArchiveStore` (per-account SwiftData container),
|
||||
`ArchiveMetadataRecord` (durable MAM checkpoint metadata),
|
||||
`LegacyArchiveImporter` (one-time legacy JSON snapshot migration),
|
||||
preferences.
|
||||
- `Services/`, `Security/` — media, notifications, credentials.
|
||||
- `Sources/Watch/` — single-target watchOS app.
|
||||
- `Tests/` — XCTest unit tests.
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks;
|
||||
AES-GCM выполняется CryptoKit.
|
||||
4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live
|
||||
сообщения и сохраняет snapshot локально.
|
||||
сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`,
|
||||
отдельный `ModelContainer` на аккаунт); список чатов, лента и список
|
||||
получателей при пересылке читают данные напрямую через `@Query`.
|
||||
5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS.
|
||||
6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot,
|
||||
текстовые ответы возвращаются немедленно или через гарантированную очередь,
|
||||
@@ -60,7 +62,7 @@ Signal-криптография не блокирует главный пото
|
||||
автоматически повторяется ограниченное число раз. При уходе приложения с экрана
|
||||
и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы
|
||||
дешифрование истории не конкурировало с камерой и upload. Только после commit
|
||||
`AppModel` обновляет SwiftUI, локальный snapshot и Apple Watch; незавершённый
|
||||
`AppModel` обновляет SwiftUI, SwiftData-хранилище и Apple Watch; незавершённый
|
||||
проход при timeout/disconnect отбрасывается целиком.
|
||||
|
||||
Редактирование реализовано стандартным XEP-0308: исправление получает новый id и
|
||||
@@ -100,8 +102,9 @@ XEP-0424 retraction проходит через тот же plaintext/OMEMO-пу
|
||||
после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее
|
||||
редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM,
|
||||
временно удерживается в ограниченной очереди. Локальное удаление физически
|
||||
убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы
|
||||
последующая MAM-синхронизация не добавила её обратно.
|
||||
удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает
|
||||
строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор
|
||||
подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно.
|
||||
|
||||
Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не
|
||||
переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и
|
||||
@@ -205,6 +208,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры.
|
||||
- APNs provider + XEP-0357 registration;
|
||||
- современный `urn:xmpp:omemo:2` adapter и миграция устройств;
|
||||
- MIX и реакции;
|
||||
- SQLite/SQLCipher вместо JSON snapshot;
|
||||
- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится
|
||||
в SwiftData, а не в JSON snapshot);
|
||||
- PushKit/CallKit для фоновых входящих звонков и групповые звонки;
|
||||
- share extension и notification service extension.
|
||||
+13
-11
@@ -1,7 +1,7 @@
|
||||
# Безопасность и модель угроз MVP
|
||||
|
||||
- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или
|
||||
snapshot истории.
|
||||
локальное хранилище истории.
|
||||
- Проверка сертификата не отключается. Ручной host меняет маршрут подключения,
|
||||
но XMPP domain/JID остаётся исходной идентичностью.
|
||||
- TLS trust оценивается системным Apple Security framework как сертификат
|
||||
@@ -49,22 +49,24 @@
|
||||
STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже
|
||||
зашифрованный медиапоток. Если Prosody не публикует собственный сервис через
|
||||
XEP-0215, резервный публичный STUN получает запрос на определение адреса.
|
||||
- На iOS файлы OMEMO-состояния и локальной истории получают data-protection
|
||||
`completeUntilFirstUserAuthentication`.
|
||||
- На iOS файл OMEMO-состояния и база локальной истории (SwiftData
|
||||
`.store`) получают data-protection `completeUntilFirstUserAuthentication`.
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- На macOS история и OMEMO state лежат в sandbox Application Support без
|
||||
дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault
|
||||
пользователя. Для повышенной модели угроз нужна зашифрованная база.
|
||||
- На macOS история (SwiftData SQLite в Application Support) и OMEMO state
|
||||
лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает
|
||||
учётная запись и FileVault пользователя. Для повышенной модели угроз нужна
|
||||
зашифрованная база.
|
||||
- Локальная история содержит уже расшифрованные тексты, как и у большинства
|
||||
клиентов. Полное удаление требует выхода с опцией удаления истории.
|
||||
- «Удалить у меня» удаляет запись только из локального snapshot этого устройства
|
||||
и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив,
|
||||
другие собственные устройства или копию собеседника.
|
||||
- «Удалить у меня» удаляет запись только из локального SwiftData-хранилища
|
||||
этого устройства и запоминает id против повторной MAM-загрузки; оно не
|
||||
удаляет серверный архив, другие собственные устройства или копию собеседника.
|
||||
- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого
|
||||
JID, а переопределение чата — в локальном snapshot. Эти значения не являются
|
||||
секретами, но прямо влияют на конфиденциальность следующей отправки.
|
||||
JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения
|
||||
не являются секретами, но прямо влияют на конфиденциальность следующей
|
||||
отправки.
|
||||
- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется
|
||||
настройками PEP и roster на сервере; не используйте аватар как секретное фото.
|
||||
- Используемая реализация MartinOMEMO поддерживает распространённый legacy
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -199,6 +199,7 @@
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Watch-Notification-45mm@2x.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
@@ -278,6 +279,7 @@
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Watch-QuickLook-45mm@2x.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
@@ -285,6 +287,7 @@
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-Watch-QuickLook-49mm@2x.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
|
||||
+39
-1
@@ -80,6 +80,12 @@ required=(
|
||||
Tests/VideoNoteStopPolicyTests.swift
|
||||
Tests/MessageReplySwipeTests.swift
|
||||
Tests/WatchVoiceMessageTests.swift
|
||||
Tests/ArchiveStoreTests.swift
|
||||
Sources/Shared/Models/ChatMessage.swift
|
||||
Sources/Shared/Models/Conversation.swift
|
||||
Sources/Shared/Persistence/ArchiveStore.swift
|
||||
Sources/Shared/Persistence/ArchiveMetadataRecord.swift
|
||||
Sources/Shared/Persistence/LegacyArchiveImporter.swift
|
||||
Brand/LumaIcon-1024.png
|
||||
Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json
|
||||
Regenerate-Luma-Project.command
|
||||
@@ -257,6 +263,38 @@ grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift
|
||||
echo "The durable archive store must persist the XEP-0313 UID cursor"
|
||||
exit 1
|
||||
}
|
||||
if test -f Sources/Shared/Persistence/ChatArchive.swift; then
|
||||
echo "The legacy JSON ChatArchive actor must stay removed"
|
||||
exit 1
|
||||
fi
|
||||
grep -q '@Query' Sources/Shared/UI/MainChatView.swift || {
|
||||
echo "Chat list must be SwiftData @Query-driven"
|
||||
exit 1
|
||||
}
|
||||
grep -q '@Query' Sources/Shared/UI/ChatView.swift || {
|
||||
echo "Chat timeline must be SwiftData @Query-driven"
|
||||
exit 1
|
||||
}
|
||||
grep -q '@Query' Sources/Shared/UI/ForwardMessageView.swift || {
|
||||
echo "Forward destination list must be SwiftData @Query-driven"
|
||||
exit 1
|
||||
}
|
||||
grep -q '\.modelContext' Sources/Shared/UI/RootView.swift || {
|
||||
echo "RootView must inject the SwiftData ModelContext"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'modelContext.delete(message)' Sources/Shared/Models/AppModel.swift || {
|
||||
echo "Local message deletion must remove the SwiftData row"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'purgeLocallyDeletedMessages' Sources/Shared/Persistence/ArchiveStore.swift || {
|
||||
echo "ArchiveStore must purge locally deleted rows for @Query views"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'completeUntilFirstUserAuthentication' Sources/Shared/Persistence/ArchiveStore.swift || {
|
||||
echo "The SwiftData store must keep the data protection attribute"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || {
|
||||
echo "MAM reconnects must prefer the durable archive UID over timestamps"
|
||||
exit 1
|
||||
@@ -358,7 +396,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi
|
||||
echo "Interactive keyboard dismissal is missing"
|
||||
exit 1
|
||||
}
|
||||
grep -q 'selectedTimelineEntries' Sources/Shared/UI/ChatView.swift || {
|
||||
grep -q 'rebuildTimelineEntries' Sources/Shared/UI/ChatView.swift || {
|
||||
echo "Chat rows and day boundaries must use the cached stable timeline"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import UniformTypeIdentifiers
|
||||
import WebRTC
|
||||
|
||||
@@ -73,6 +74,8 @@ final class AppModel: ObservableObject {
|
||||
private let mediaPreviewProcessor = MediaPreviewProcessor()
|
||||
private let mediaFileIO = MediaFileIO()
|
||||
private var store: ArchiveStore?
|
||||
private var storeAccountJID: String?
|
||||
private var fallbackContext: ModelContext?
|
||||
private var bootstrapTask: Task<Void, Never>?
|
||||
private var persistTask: Task<Void, Never>?
|
||||
private var watchSyncTask: Task<Void, Never>?
|
||||
@@ -257,9 +260,37 @@ final class AppModel: ObservableObject {
|
||||
return entries
|
||||
}
|
||||
|
||||
/// The `ModelContext` injected into the SwiftUI environment so views
|
||||
/// can read conversations and messages through `@Query`. Falls back to
|
||||
/// an in-memory context when the on-disk store cannot be opened, keeping
|
||||
/// the session usable without persistence.
|
||||
var modelContext: ModelContext {
|
||||
if let store { return store.context }
|
||||
if let fallbackContext { return fallbackContext }
|
||||
let schema = Schema([
|
||||
Conversation.self,
|
||||
ChatMessage.self,
|
||||
ArchiveMetadata.self,
|
||||
])
|
||||
let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
let container = try! ModelContainer(for: schema, configurations: [configuration])
|
||||
let context = ModelContext(container)
|
||||
fallbackContext = context
|
||||
return context
|
||||
}
|
||||
|
||||
/// Opens the per-account SwiftData store before `account` is published so
|
||||
/// the `@Query`-backed screens never render without a usable context.
|
||||
private func prepareStore(for jid: String) {
|
||||
guard store == nil || storeAccountJID != jid else { return }
|
||||
store = try? ArchiveStore(accountJID: jid)
|
||||
storeAccountJID = jid
|
||||
}
|
||||
|
||||
func bootstrap() async {
|
||||
guard !RuntimeEnvironment.isRunningTests else { return }
|
||||
guard let saved = preferences.load() else { return }
|
||||
prepareStore(for: saved.normalizedJID)
|
||||
account = saved
|
||||
globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID)
|
||||
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: saved.normalizedJID)
|
||||
@@ -270,6 +301,7 @@ final class AppModel: ObservableObject {
|
||||
guard let storedPassword = try credentials.password(for: saved.normalizedJID) else {
|
||||
account = nil
|
||||
store = nil
|
||||
fallbackContext = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -281,6 +313,7 @@ final class AppModel: ObservableObject {
|
||||
} catch {
|
||||
account = nil
|
||||
store = nil
|
||||
fallbackContext = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -323,6 +356,7 @@ final class AppModel: ObservableObject {
|
||||
rosterContactJIDs = []
|
||||
resetMediaSendActivity()
|
||||
resetMediaPreviews()
|
||||
prepareStore(for: account.normalizedJID)
|
||||
self.account = account
|
||||
globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID)
|
||||
typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID)
|
||||
@@ -341,6 +375,7 @@ final class AppModel: ObservableObject {
|
||||
await xmpp.disconnect()
|
||||
self.account = nil
|
||||
store = nil
|
||||
fallbackContext = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
messages = []
|
||||
@@ -364,6 +399,7 @@ final class AppModel: ObservableObject {
|
||||
watchSyncTask?.cancel()
|
||||
watchSyncTask = nil
|
||||
store = nil
|
||||
fallbackContext = nil
|
||||
account = nil
|
||||
conversations = []
|
||||
rosterContactJIDs = []
|
||||
@@ -884,6 +920,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
let affectedConversations = Set(selected.map(\.conversationID))
|
||||
for message in selected {
|
||||
modelContext.delete(message)
|
||||
locallyDeletedMessageIDs.insert(
|
||||
Self.localDeletionKey(
|
||||
messageID: message.clientID,
|
||||
@@ -2116,7 +2153,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
} else {
|
||||
let conversation = Conversation(jid: normalized, displayName: name)
|
||||
store?.context.insert(conversation)
|
||||
modelContext.insert(conversation)
|
||||
conversations.append(conversation)
|
||||
}
|
||||
if conversations.first(where: { $0.jid == normalized })?.isGroup != true {
|
||||
@@ -2169,7 +2206,7 @@ final class AppModel: ObservableObject {
|
||||
shouldAutojoin: shouldAutojoin,
|
||||
invitedBy: invitedBy
|
||||
)
|
||||
store?.context.insert(conversation)
|
||||
modelContext.insert(conversation)
|
||||
conversations.append(conversation)
|
||||
}
|
||||
if !isApplyingArchiveBatch {
|
||||
@@ -2555,8 +2592,8 @@ final class AppModel: ObservableObject {
|
||||
// `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)
|
||||
modelContext.delete(previous)
|
||||
modelContext.insert(merged)
|
||||
}
|
||||
if let stanzaID = merged.stanzaID {
|
||||
messageIndexByStanzaKey[
|
||||
@@ -2575,7 +2612,7 @@ final class AppModel: ObservableObject {
|
||||
updateConversationPreview(for: merged, incrementUnread: false)
|
||||
return false
|
||||
}
|
||||
store?.context.insert(message)
|
||||
modelContext.insert(message)
|
||||
messages.append(message)
|
||||
let insertedIndex = messages.index(before: messages.endIndex)
|
||||
messageIndexByStorageKey[
|
||||
@@ -2757,8 +2794,9 @@ final class AppModel: ObservableObject {
|
||||
private func loadArchive(for jid: String) async {
|
||||
resetArchiveBatchState()
|
||||
resetMediaPreviews()
|
||||
guard let store = try? ArchiveStore(accountJID: jid) else {
|
||||
self.store = nil
|
||||
prepareStore(for: jid)
|
||||
guard let store else {
|
||||
fallbackContext = nil
|
||||
return
|
||||
}
|
||||
self.store = store
|
||||
@@ -3058,7 +3096,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
private static func localDeletionKey(messageID: String, conversationID: String) -> String {
|
||||
conversationID.lowercased() + "\u{1F}" + messageID
|
||||
ArchiveStore.deletionKey(messageID: messageID, conversationID: conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,10 @@ final class ArchiveStore {
|
||||
container = try ModelContainer(for: schema, configurations: [configuration])
|
||||
context = ModelContext(container)
|
||||
context.autosaveEnabled = false
|
||||
applyDataProtection()
|
||||
|
||||
migrateLegacyJSONIfNeeded()
|
||||
purgeLocallyDeletedMessages()
|
||||
}
|
||||
|
||||
func load() -> Loaded {
|
||||
@@ -86,6 +88,7 @@ final class ArchiveStore {
|
||||
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
|
||||
}
|
||||
try context.save()
|
||||
applyDataProtection()
|
||||
}
|
||||
|
||||
func erase() throws {
|
||||
@@ -121,11 +124,58 @@ final class ArchiveStore {
|
||||
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
|
||||
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
|
||||
metadata.mamCheckpoints = imported.mamCheckpoints
|
||||
try? context.save()
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
// The imported rows stay pending in memory so this session keeps
|
||||
// the history, but the legacy JSON remains on disk as the only
|
||||
// durable copy until a save actually succeeds.
|
||||
return
|
||||
}
|
||||
applyDataProtection()
|
||||
try? FileManager.default.removeItem(at: legacyJSONURL)
|
||||
}
|
||||
|
||||
private static func stableHash(_ value: String) -> String {
|
||||
/// Removes rows that older builds only marked as locally deleted (the
|
||||
/// legacy snapshot filtered them at load time). `@Query`-based views read
|
||||
/// the store directly, so these rows must physically disappear.
|
||||
func purgeLocallyDeletedMessages() {
|
||||
let metadata = fetchMetadata()
|
||||
let deletedKeys = Set(metadata.locallyDeletedMessageIDs)
|
||||
guard !deletedKeys.isEmpty else { return }
|
||||
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
|
||||
var removedAny = false
|
||||
for message in messages {
|
||||
let key = Self.deletionKey(
|
||||
messageID: message.clientID,
|
||||
conversationID: message.conversationID
|
||||
)
|
||||
guard deletedKeys.contains(key) else { continue }
|
||||
context.delete(message)
|
||||
removedAny = true
|
||||
}
|
||||
if removedAny {
|
||||
try? context.save()
|
||||
applyDataProtection()
|
||||
}
|
||||
}
|
||||
|
||||
/// Key format shared with `AppModel.localDeletionKey`:
|
||||
/// `<conversationID>\u{1F}<messageID>`.
|
||||
static func deletionKey(messageID: String, conversationID: String) -> String {
|
||||
conversationID.lowercased() + "\u{1F}" + messageID
|
||||
}
|
||||
|
||||
private func applyDataProtection() {
|
||||
#if os(iOS)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: storeURL.path
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func stableHash(_ value: String) -> String {
|
||||
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
|
||||
(partial ^ UInt64(byte)) &* 1_099_511_628_211
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import Foundation
|
||||
|
||||
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
|
||||
/// objects once, so existing history survives the migration from the JSON file.
|
||||
/// Decoding mirrors the tolerant decoders of the removed `ChatArchive`:
|
||||
/// every field that was ever optional or added later falls back to a default,
|
||||
/// so snapshots written by older schema versions still import.
|
||||
enum LegacyArchiveImporter {
|
||||
struct LegacyConversation: Codable {
|
||||
enum Kind: String, Codable { case direct, group }
|
||||
@@ -20,6 +23,80 @@ enum LegacyArchiveImporter {
|
||||
var shouldAutojoin: Bool
|
||||
var occupantCount: Int
|
||||
var invitedBy: String?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
jid: String,
|
||||
displayName: String,
|
||||
lastMessage: String,
|
||||
lastActivity: Date,
|
||||
unreadCount: Int,
|
||||
isOnline: Bool,
|
||||
isPinned: Bool,
|
||||
encryptionPreference: EncryptionPreference,
|
||||
kind: Kind,
|
||||
groupNickname: String? = nil,
|
||||
isGroupJoined: Bool,
|
||||
shouldAutojoin: Bool,
|
||||
occupantCount: Int,
|
||||
invitedBy: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.jid = jid
|
||||
self.displayName = displayName
|
||||
self.lastMessage = lastMessage
|
||||
self.lastActivity = lastActivity
|
||||
self.unreadCount = unreadCount
|
||||
self.isOnline = isOnline
|
||||
self.isPinned = isPinned
|
||||
self.encryptionPreference = encryptionPreference
|
||||
self.kind = kind
|
||||
self.groupNickname = groupNickname
|
||||
self.isGroupJoined = isGroupJoined
|
||||
self.shouldAutojoin = shouldAutojoin
|
||||
self.occupantCount = occupantCount
|
||||
self.invitedBy = invitedBy
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct LegacyMessage: Codable {
|
||||
@@ -56,9 +133,133 @@ enum LegacyArchiveImporter {
|
||||
var isGroupMessage: Bool
|
||||
var callHistory: CallHistoryMetadata?
|
||||
var reactions: [MessageReaction]
|
||||
|
||||
init(
|
||||
id: String,
|
||||
conversationID: String,
|
||||
senderJID: String,
|
||||
body: String,
|
||||
timestamp: Date,
|
||||
direction: Direction,
|
||||
delivery: Delivery,
|
||||
security: Security,
|
||||
kind: Kind,
|
||||
remoteAttachmentURL: String? = nil,
|
||||
localFilename: String? = nil,
|
||||
mimeType: String? = nil,
|
||||
duration: TimeInterval? = nil,
|
||||
byteCount: Int? = nil,
|
||||
encryptionFingerprint: String? = nil,
|
||||
editedAt: Date? = nil,
|
||||
replyToID: String? = nil,
|
||||
replyToJID: String? = nil,
|
||||
replyPreview: String? = nil,
|
||||
forwardedFrom: String? = nil,
|
||||
retractedAt: Date? = nil,
|
||||
originID: String? = nil,
|
||||
stanzaID: String? = nil,
|
||||
senderDisplayName: String? = nil,
|
||||
isGroupMessage: Bool = false,
|
||||
callHistory: CallHistoryMetadata? = nil,
|
||||
reactions: [MessageReaction] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.conversationID = conversationID
|
||||
self.senderJID = senderJID
|
||||
self.body = body
|
||||
self.timestamp = timestamp
|
||||
self.direction = direction
|
||||
self.delivery = delivery
|
||||
self.security = security
|
||||
self.kind = kind
|
||||
self.remoteAttachmentURL = remoteAttachmentURL
|
||||
self.localFilename = localFilename
|
||||
self.mimeType = mimeType
|
||||
self.duration = duration
|
||||
self.byteCount = byteCount
|
||||
self.encryptionFingerprint = encryptionFingerprint
|
||||
self.editedAt = editedAt
|
||||
self.replyToID = replyToID
|
||||
self.replyToJID = replyToJID
|
||||
self.replyPreview = replyPreview
|
||||
self.forwardedFrom = forwardedFrom
|
||||
self.retractedAt = retractedAt
|
||||
self.originID = originID
|
||||
self.stanzaID = stanzaID
|
||||
self.senderDisplayName = senderDisplayName
|
||||
self.isGroupMessage = isGroupMessage
|
||||
self.callHistory = callHistory
|
||||
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 = 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)
|
||||
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) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct Snapshot: Codable {
|
||||
static let currentSchemaVersion = 6
|
||||
|
||||
var schemaVersion: Int
|
||||
var conversations: [LegacyConversation]
|
||||
var messages: [LegacyMessage]
|
||||
@@ -67,6 +268,62 @@ enum LegacyArchiveImporter {
|
||||
var lastSuccessfulMAMSync: Date?
|
||||
var lastSuccessfulMAMCursor: String?
|
||||
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
|
||||
|
||||
init(
|
||||
conversations: [LegacyConversation],
|
||||
messages: [LegacyMessage],
|
||||
locallyDeletedMessageIDs: Set<String> = [],
|
||||
rosterContactJIDs: Set<String> = [],
|
||||
lastSuccessfulMAMSync: Date? = nil,
|
||||
lastSuccessfulMAMCursor: String? = nil,
|
||||
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
|
||||
) {
|
||||
self.schemaVersion = Self.currentSchemaVersion
|
||||
self.conversations = conversations
|
||||
self.messages = messages
|
||||
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
|
||||
self.rosterContactJIDs = rosterContactJIDs
|
||||
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
|
||||
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
|
||||
self.mamCheckpoints = mamCheckpoints
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case conversations
|
||||
case messages
|
||||
case locallyDeletedMessageIDs
|
||||
case rosterContactJIDs
|
||||
case lastSuccessfulMAMSync
|
||||
case lastSuccessfulMAMCursor
|
||||
case mamCheckpoints
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
conversations = try values.decode([LegacyConversation].self, forKey: .conversations)
|
||||
messages = try values.decode([LegacyMessage].self, forKey: .messages)
|
||||
locallyDeletedMessageIDs = try values.decodeIfPresent(
|
||||
Set<String>.self,
|
||||
forKey: .locallyDeletedMessageIDs
|
||||
) ?? []
|
||||
rosterContactJIDs = try values.decodeIfPresent(
|
||||
Set<String>.self,
|
||||
forKey: .rosterContactJIDs
|
||||
) ?? []
|
||||
lastSuccessfulMAMSync = try values.decodeIfPresent(
|
||||
Date.self,
|
||||
forKey: .lastSuccessfulMAMSync
|
||||
)
|
||||
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
|
||||
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
|
||||
)
|
||||
mamCheckpoints = try values.decodeIfPresent(
|
||||
[MAMArchiveKey: MAMArchiveCheckpoint].self,
|
||||
forKey: .mamCheckpoints
|
||||
) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
struct Imported {
|
||||
|
||||
@@ -2,6 +2,7 @@ import CoreTransferable
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@@ -67,6 +68,65 @@ struct ChatView: View {
|
||||
@State private var captureAttemptID = UUID()
|
||||
@State private var captureStartTask: Task<Void, Never>?
|
||||
@State private var captureSuspendsArchiveSync = false
|
||||
@State private var timelineEntries: [ChatTimelineEntry] = []
|
||||
|
||||
/// Messages of this conversation straight from the SwiftData store,
|
||||
/// ordered like `AppModel.selectedMessages`: timestamp, then clientID.
|
||||
@Query private var messages: [ChatMessage]
|
||||
|
||||
init(model: AppModel, conversation: Conversation) {
|
||||
self.model = model
|
||||
self.conversation = conversation
|
||||
let conversationJID = conversation.jid
|
||||
_messages = Query(
|
||||
filter: #Predicate<ChatMessage> { message in
|
||||
message.conversationID == conversationJID
|
||||
},
|
||||
sort: [
|
||||
SortDescriptor(\ChatMessage.timestamp, order: .forward),
|
||||
SortDescriptor(\ChatMessage.clientID, order: .forward),
|
||||
]
|
||||
)
|
||||
_audioRecorder = StateObject(wrappedValue: AudioMessageRecorder())
|
||||
_isComposerFocused = FocusState()
|
||||
_draft = State(initialValue: "")
|
||||
_showingFileImporter = State(initialValue: false)
|
||||
_fileImportMode = State(initialValue: FileImportMode.files)
|
||||
_showingEncryption = State(initialValue: false)
|
||||
_showingVideoNoteRecorder = State(initialValue: false)
|
||||
_videoNoteIsSending = State(initialValue: false)
|
||||
_showingMediaPicker = State(initialValue: false)
|
||||
_showingPhotoCamera = State(initialValue: false)
|
||||
_photoCameraIsPreparingResult = State(initialValue: false)
|
||||
_showingLocationPicker = State(initialValue: false)
|
||||
_showingGroupInfo = State(initialValue: false)
|
||||
_showingAttachmentPreview = State(initialValue: false)
|
||||
_attachmentPreviewPresentationPending = State(initialValue: false)
|
||||
_attachmentPreviewPresentationTask = State<Task<Void, Never>?>(initialValue: nil)
|
||||
_pickedMediaItems = State<[PhotosPickerItem]>(initialValue: [])
|
||||
_attachmentDrafts = State<[AttachmentDraft]>(initialValue: [])
|
||||
_isPreparingAttachments = State(initialValue: false)
|
||||
_editingMessageID = State<String?>(initialValue: nil)
|
||||
_replyingToMessageID = State<String?>(initialValue: nil)
|
||||
_forwardingSelection = State<MessageForwardSelection?>(initialValue: nil)
|
||||
_selectedMessageIDs = State<Set<String>>(initialValue: [])
|
||||
_destructiveAction = State<MessageDestructiveAction?>(initialValue: nil)
|
||||
_replyThreadSelection = State<ReplyThreadSelection?>(initialValue: nil)
|
||||
_emojiPickerPresentation = State<EmojiPickerPresentation?>(initialValue: nil)
|
||||
_hasCompletedInitialScroll = State(initialValue: false)
|
||||
_isNearTimelineBottom = State(initialValue: true)
|
||||
_historyLoadAnchorID = State<String?>(initialValue: nil)
|
||||
_historyTopTriggerArmed = State(initialValue: true)
|
||||
_activeCaptureMode = State<ComposerCaptureMode?>(initialValue: nil)
|
||||
_preparingCaptureMode = State<ComposerCaptureMode?>(initialValue: nil)
|
||||
_captureGestureIsActive = State(initialValue: false)
|
||||
_captureIsLocked = State(initialValue: false)
|
||||
_captureDragTranslation = State(initialValue: CGSize.zero)
|
||||
_captureAttemptID = State(initialValue: UUID())
|
||||
_captureStartTask = State<Task<Void, Never>?>(initialValue: nil)
|
||||
_captureSuspendsArchiveSync = State(initialValue: false)
|
||||
_timelineEntries = State<[ChatTimelineEntry]>(initialValue: [])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -352,13 +412,16 @@ struct ChatView: View {
|
||||
model.selectedMessages.filter { selectedMessageIDs.contains($0.clientID) }
|
||||
}
|
||||
|
||||
private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) {
|
||||
timelineEntries = ChatTimelineEntry.make(from: newMessages ?? messages)
|
||||
}
|
||||
|
||||
private var isSelectingMessages: Bool {
|
||||
!selectedMessageIDs.isEmpty
|
||||
}
|
||||
|
||||
private var messageTimeline: some View {
|
||||
ScrollViewReader { proxy in
|
||||
let timelineEntries = model.selectedTimelineEntries
|
||||
GeometryReader { viewport in
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ScrollView {
|
||||
@@ -468,6 +531,12 @@ struct ChatView: View {
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.coordinateSpace(name: Self.timelineCoordinateSpace)
|
||||
.onAppear {
|
||||
rebuildTimelineEntries()
|
||||
}
|
||||
.onChange(of: messages) { _, newMessages in
|
||||
rebuildTimelineEntries(from: newMessages)
|
||||
}
|
||||
#if os(macOS)
|
||||
.onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in
|
||||
updateTimelineBottomProximity(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@@ -10,6 +11,9 @@ struct ForwardMessageView: View {
|
||||
@State private var destinationJID = ""
|
||||
@State private var isForwarding = false
|
||||
|
||||
/// SwiftData-backed destination list, ordered like the main chat list.
|
||||
@Query private var conversations: [Conversation]
|
||||
|
||||
init(
|
||||
model: AppModel,
|
||||
messages: [ChatMessage],
|
||||
@@ -18,6 +22,11 @@ struct ForwardMessageView: View {
|
||||
_model = ObservedObject(wrappedValue: model)
|
||||
self.messages = messages.filter(\.canBeForwarded)
|
||||
self.onComplete = onComplete
|
||||
_conversations = Query(
|
||||
sort: [
|
||||
SortDescriptor(\Conversation.lastActivity, order: .reverse),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -39,9 +48,9 @@ struct ForwardMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !model.conversations.isEmpty {
|
||||
if !orderedConversations.isEmpty {
|
||||
Section("Чаты") {
|
||||
ForEach(model.conversations) { conversation in
|
||||
ForEach(orderedConversations) { conversation in
|
||||
Button {
|
||||
forward(to: conversation.jid)
|
||||
} label: {
|
||||
@@ -105,6 +114,14 @@ struct ForwardMessageView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Pinned-first ordering, matching the main chat list.
|
||||
private var orderedConversations: [Conversation] {
|
||||
conversations.sorted { lhs, rhs in
|
||||
if lhs.isPinned != rhs.isPinned { return lhs.isPinned }
|
||||
return lhs.lastActivity > rhs.lastActivity
|
||||
}
|
||||
}
|
||||
|
||||
private func forward(to jid: String) {
|
||||
guard !isForwarding, !messages.isEmpty else { return }
|
||||
isForwarding = true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct MainChatView: View {
|
||||
@@ -21,27 +22,45 @@ struct MainChatView: View {
|
||||
@State private var showingNewGroup = false
|
||||
@State private var showingSettings = false
|
||||
|
||||
/// SwiftData-backed chat list. The query orders by last activity;
|
||||
/// `filteredConversations` applies the pinned-first ordering that matches
|
||||
/// `AppModel.sortConversations` (`Bool` is not `Comparable`, so pinning
|
||||
/// cannot be a `SortDescriptor`).
|
||||
@Query(
|
||||
sort: [
|
||||
SortDescriptor(\Conversation.lastActivity, order: .reverse),
|
||||
]
|
||||
)
|
||||
private var conversations: [Conversation]
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
guard !searchText.isEmpty else { return model.conversations }
|
||||
return model.conversations.filter(matchesSearch)
|
||||
let source = searchText.isEmpty ? conversations : conversations.filter(matchesSearch)
|
||||
return source.sorted(by: conversationSort)
|
||||
}
|
||||
|
||||
private func conversationSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
|
||||
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 var filteredRosterContacts: [Conversation] {
|
||||
model.conversations
|
||||
conversations
|
||||
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) }
|
||||
.filter { searchText.isEmpty || matchesSearch($0) }
|
||||
.sorted(by: contactSort)
|
||||
}
|
||||
|
||||
private var filteredGroupContacts: [Conversation] {
|
||||
model.conversations
|
||||
conversations
|
||||
.filter { $0.isGroup }
|
||||
.filter { searchText.isEmpty || matchesSearch($0) }
|
||||
.sorted(by: contactSort)
|
||||
}
|
||||
|
||||
private var hasStoredContacts: Bool {
|
||||
!model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup }
|
||||
!model.rosterContactJIDs.isEmpty || conversations.contains { $0.isGroup }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QuickLook
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@@ -11,6 +12,7 @@ struct RootView: View {
|
||||
LoginView(model: model)
|
||||
} else {
|
||||
MainChatView(model: model)
|
||||
.environment(\.modelContext, model.modelContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+109
-26
@@ -45,7 +45,6 @@ final class ArchiveStoreTests: XCTestCase {
|
||||
func testLegacyImportMapsConversationAndMessage() throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let legacy = LegacyArchiveImporter.Snapshot(
|
||||
schemaVersion: 1,
|
||||
conversations: [
|
||||
LegacyArchiveImporter.LegacyConversation(
|
||||
id: "bob@example.org",
|
||||
@@ -75,32 +74,9 @@ final class ArchiveStoreTests: XCTestCase {
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo,
|
||||
kind: .text,
|
||||
remoteAttachmentURL: nil,
|
||||
localFilename: nil,
|
||||
mimeType: nil,
|
||||
duration: nil,
|
||||
byteCount: nil,
|
||||
encryptionFingerprint: nil,
|
||||
editedAt: nil,
|
||||
replyToID: nil,
|
||||
replyToJID: nil,
|
||||
replyPreview: nil,
|
||||
forwardedFrom: nil,
|
||||
retractedAt: nil,
|
||||
originID: nil,
|
||||
stanzaID: nil,
|
||||
senderDisplayName: nil,
|
||||
isGroupMessage: false,
|
||||
callHistory: nil,
|
||||
reactions: []
|
||||
kind: .text
|
||||
)
|
||||
],
|
||||
locallyDeletedMessageIDs: [],
|
||||
rosterContactJIDs: [],
|
||||
lastSuccessfulMAMSync: nil,
|
||||
lastSuccessfulMAMCursor: nil,
|
||||
mamCheckpoints: [:]
|
||||
]
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
@@ -117,4 +93,111 @@ final class ArchiveStoreTests: XCTestCase {
|
||||
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
|
||||
XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org")
|
||||
}
|
||||
|
||||
func testLegacyImportToleratesMissingOptionalFields() throws {
|
||||
// Snapshots written by older schema versions lack fields that were
|
||||
// added later (reactions, isGroupMessage, rosterContactJIDs, ...).
|
||||
// The importer must decode them with defaults, exactly like the
|
||||
// removed ChatArchive did.
|
||||
let raw: [String: Any] = [
|
||||
"schemaVersion": 2,
|
||||
"conversations": [
|
||||
[
|
||||
"id": "bob@example.org",
|
||||
"jid": "bob@example.org",
|
||||
"displayName": "Bob",
|
||||
"lastMessage": "",
|
||||
"lastActivity": 1_700_000_000_000.0,
|
||||
"unreadCount": 0,
|
||||
"isOnline": false,
|
||||
"isPinned": false,
|
||||
]
|
||||
],
|
||||
"messages": [
|
||||
[
|
||||
"id": "msg-1",
|
||||
"conversationID": "bob@example.org",
|
||||
"senderJID": "alice@example.org",
|
||||
"body": "Hello",
|
||||
"timestamp": 1_700_000_000_000.0,
|
||||
"direction": "outgoing",
|
||||
"delivery": "sent",
|
||||
"security": "omemo",
|
||||
"kind": "text",
|
||||
]
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: raw)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-old-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
try data.write(to: url)
|
||||
|
||||
let imported = try LegacyArchiveImporter.importIfNeeded(from: url)
|
||||
XCTAssertNotNil(imported)
|
||||
XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"])
|
||||
XCTAssertEqual(imported?.conversations.first?.kind, .direct)
|
||||
XCTAssertEqual(imported?.conversations.first?.isGroupJoined, false)
|
||||
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
|
||||
XCTAssertEqual(imported?.messages.first?.isGroupMessage, false)
|
||||
XCTAssertEqual(imported?.messages.first?.reactions ?? [], [])
|
||||
XCTAssertEqual(imported?.rosterContactJIDs ?? [], [])
|
||||
XCTAssertEqual(imported?.mamCheckpoints ?? [], [])
|
||||
XCTAssertNil(imported?.lastSuccessfulMAMSync)
|
||||
}
|
||||
|
||||
func testLocallyDeletedRowsArePurgedFromTheStore() throws {
|
||||
// @Query views read the store directly, so messages that were only
|
||||
// marked as locally deleted must be physically removed on open.
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let store = try ArchiveStore(accountJID: jid)
|
||||
defer { try? store.erase() }
|
||||
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let message = ChatMessage(
|
||||
conversationID: conversation.jid,
|
||||
senderJID: jid,
|
||||
body: "Will be deleted",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
store.context.insert(conversation)
|
||||
store.context.insert(message)
|
||||
let deletionKey = ArchiveStore.deletionKey(
|
||||
messageID: message.clientID,
|
||||
conversationID: conversation.jid
|
||||
)
|
||||
try store.save(
|
||||
locallyDeletedMessageIDs: [deletionKey],
|
||||
rosterContactJIDs: [],
|
||||
lastSuccessfulMAMSync: nil,
|
||||
lastSuccessfulMAMCursor: nil,
|
||||
mamCheckpoints: [:]
|
||||
)
|
||||
|
||||
store.purgeLocallyDeletedMessages()
|
||||
let loaded = store.load()
|
||||
XCTAssertTrue(loaded.messages.isEmpty)
|
||||
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set([deletionKey]))
|
||||
}
|
||||
|
||||
func testCorruptLegacyJSONIsKeptForRetry() throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let directory = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first!.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
let legacyURL = directory.appendingPathComponent("\(ArchiveStore.stableHash(jid)).json")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("not-json".utf8).write(to: legacyURL)
|
||||
defer { try? FileManager.default.removeItem(at: legacyURL) }
|
||||
|
||||
let store = try ArchiveStore(accountJID: jid)
|
||||
defer { try? store.erase() }
|
||||
XCTAssertTrue(store.load().conversations.isEmpty)
|
||||
// The unreadable snapshot must stay on disk instead of being deleted
|
||||
// together with the user's history.
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user