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