finish SwiftData migration with @Query views
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

- 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:
wt committed 2026-08-29 00:57:08 +07:00
1 parent 277d8654c3
commit 2884810ec4
16 files changed
+653 -65

No files matched your search

+46 -8
View File
@@ -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)
}
}
+52 -2
View File
@@ -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 {
+70 -1
View File
@@ -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(
+19 -2
View File
@@ -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
+24 -5
View File
@@ -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 {
+2
View File
@@ -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)
}
}