fix MAM history loading, multi-device resource, and OMEMO self-session
Multi-device: - Add DeviceResource with a stable per-install UUID-based resource (Luma-<hex>) persisted in UserDefaults and migrated from legacy "Luma". - AccountConfiguration.effectiveResource falls back to DeviceResource.default for a blank or legacy resource; LoginView defaults resource to "" with an auto placeholder. - Prevents two devices from sharing the same full JID resource and kicking each other off the stream (the "xmpp stream error" conflict). MAM / history: - loadOlderHistory now always issues the RSM page request instead of deferring, so scrolling up reliably loads older messages. - Track interactive history mutations separately so live messages are not dropped while an older-history page is decoding. - ChatView: replace the unreliable GeometryReader/preference scroll trigger with onAppear/onDisappear on a top sentinel; auto-load the first page when the conversation is empty. - AppModel: reset hasMoreOlderHistory for empty conversations and clear isLoadingOlderHistory on disconnect. - Decode own/duplicate/notEncrypted messages to decryption-failed/plaintext placeholders instead of dropping them, so every MAM stanza is collected. OMEMO: - Remove the encrypt-to-self setup that built a Signal session with the local device (cryptographically invalid), which caused an endless "Bad MAC" loop and a stream of undecryptable messages in the self-chat. - Add LumaOMEMOStore.removeSessionWithOwnDevice() and call it on connect to purge any self-session persisted by the previous build. Tests: - Update AccountConfigurationTests for the unique resource and add coverage for legacy-resource migration and explicit-resource preservation.
This commit is contained in:
1 parent
0b4a552044
commit
9c8cff7168
9 files changed
+235
-141
No files matched your search
@@ -334,27 +334,13 @@ struct ChatView: View {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.accessibilityHidden(true)
|
||||
// .onAppear {
|
||||
//// guard !model.isLoadingOlderHistory else { return }
|
||||
// guard historyTopTriggerArmed,
|
||||
// !model.isLoadingOlderHistory
|
||||
// else {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// historyTopTriggerArmed = false
|
||||
// historyLoadAnchorID = timelineEntries.first?.id
|
||||
// model.loadOlderHistoryForSelectedConversation()
|
||||
// }
|
||||
.background {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: TimelineTopYPreferenceKey.self,
|
||||
value: geometry.frame(
|
||||
in: .named(Self.timelineCoordinateSpace)
|
||||
).minY
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
guard historyTopTriggerArmed,
|
||||
!model.isLoadingOlderHistory
|
||||
else { return }
|
||||
historyTopTriggerArmed = false
|
||||
historyLoadAnchorID = timelineEntries.first?.id
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
.onDisappear {
|
||||
historyTopTriggerArmed = true
|
||||
@@ -374,6 +360,14 @@ struct ChatView: View {
|
||||
description: Text(emptyChatDescription)
|
||||
)
|
||||
.padding(.top, 80)
|
||||
.onAppear {
|
||||
// Empty timeline has no scroll gesture, so load
|
||||
// the first page directly when it appears.
|
||||
guard model.hasMoreOlderHistory,
|
||||
!model.isLoadingOlderHistory
|
||||
else { return }
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
} else {
|
||||
ForEach(timelineEntries) { entry in
|
||||
let message = entry.message
|
||||
@@ -441,22 +435,6 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
#endif
|
||||
.onPreferenceChange(TimelineTopYPreferenceKey.self) { topY in
|
||||
if topY > 24 {
|
||||
historyTopTriggerArmed = true
|
||||
return
|
||||
}
|
||||
guard hasCompletedInitialScroll,
|
||||
!timelineEntries.isEmpty,
|
||||
model.hasMoreOlderHistory,
|
||||
!model.isLoadingOlderHistory,
|
||||
historyTopTriggerArmed
|
||||
// topY <= 24
|
||||
else { return }
|
||||
historyTopTriggerArmed = false
|
||||
historyLoadAnchorID = timelineEntries.first?.id
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
.task(id: timelineEntries.last?.id) {
|
||||
await performInitialScrollAfterLayout(using: proxy)
|
||||
}
|
||||
@@ -1571,8 +1549,16 @@ struct ChatView: View {
|
||||
}
|
||||
|
||||
private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async {
|
||||
guard !hasCompletedInitialScroll,
|
||||
!model.selectedMessages.isEmpty else { return }
|
||||
guard !hasCompletedInitialScroll else { return }
|
||||
guard !model.selectedMessages.isEmpty else {
|
||||
// Empty conversation: there is nothing to scroll to, so the
|
||||
// scroll-to-top gesture can never fire. Load the first page of
|
||||
// history directly so a newly-created or emptied chat still fills.
|
||||
if model.hasMoreOlderHistory, !model.isLoadingOlderHistory {
|
||||
model.loadOlderHistoryForSelectedConversation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ScrollViewReader can receive its first command before a long
|
||||
// LazyVStack has published the bottom anchor. Yield once for layout,
|
||||
@@ -1614,14 +1600,6 @@ private struct TimelineBottomYPreferenceKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineTopYPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = .greatestFiniteMagnitude
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Reads the real UIScrollView offset without replacing its delegate or
|
||||
/// installing another pan recognizer. SwiftUI geometry preferences can stop
|
||||
|
||||
Reference in new issue
Block a user