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(