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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user