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

+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)
}
}