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(
|
||||
|
||||
@@ -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