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
@@ -22,7 +22,10 @@ struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendabl
|
||||
|
||||
var effectiveResource: String {
|
||||
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Luma" : trimmed
|
||||
if trimmed.isEmpty || trimmed == DeviceResource.legacyDefault {
|
||||
return DeviceResource.default
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
init(
|
||||
|
||||
@@ -513,6 +513,14 @@ final class AppModel: ObservableObject {
|
||||
// schedulePersist()
|
||||
// syncWatch()
|
||||
// hasMoreOlderHistory = true
|
||||
if hasMoreOlderHistoryByConversation[normalized] == false,
|
||||
selectedMessages.isEmpty
|
||||
{
|
||||
// An empty conversation must be allowed to retry a load after a
|
||||
// previous attempt returned zero results. Otherwise it stays empty
|
||||
// with `hasMore = false` and no way to trigger loading again.
|
||||
hasMoreOlderHistoryByConversation[normalized] = true
|
||||
}
|
||||
hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true
|
||||
}
|
||||
|
||||
@@ -520,13 +528,13 @@ final class AppModel: ObservableObject {
|
||||
guard !isLoadingOlderHistory,
|
||||
hasMoreOlderHistory,
|
||||
let conversation = selectedConversation else { return }
|
||||
// let oldestServerID = selectedMessages
|
||||
// .sorted { lhs, rhs in
|
||||
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
// return lhs.id < rhs.id
|
||||
// }
|
||||
// .compactMap(\.stanzaID)
|
||||
// .first
|
||||
// let oldestServerID = selectedMessages
|
||||
// .sorted { lhs, rhs in
|
||||
// if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }
|
||||
// return lhs.id < rhs.id
|
||||
// }
|
||||
// .compactMap(\.stanzaID)
|
||||
// .first
|
||||
let oldestServerID = selectedMessages
|
||||
.filter { $0.stanzaID != nil }
|
||||
.min { lhs, rhs in
|
||||
@@ -534,7 +542,7 @@ final class AppModel: ObservableObject {
|
||||
return lhs.id < rhs.id
|
||||
}?
|
||||
.stanzaID
|
||||
|
||||
|
||||
isLoadingOlderHistory = true
|
||||
let conversationID = conversation.id.lowercased()
|
||||
xmpp.loadOlderHistory(
|
||||
@@ -1763,6 +1771,7 @@ final class AppModel: ObservableObject {
|
||||
// UI from displaying a stale spinner even if the archive
|
||||
// completion event was overtaken by a socket state change.
|
||||
isArchiveSyncing = false
|
||||
isLoadingOlderHistory = false
|
||||
for index in conversations.indices where conversations[index].isGroup {
|
||||
conversations[index].isGroupJoined = false
|
||||
conversations[index].occupantCount = 0
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Stable, per-installation XMPP resource. Two devices that both fall back to
|
||||
/// the default must never share a resource: an XMPP server resolves a resource
|
||||
/// collision with a `conflict` stream error, which disconnects the older
|
||||
/// session. A UUID persisted in UserDefaults keeps the value stable across
|
||||
/// launches (so presence and stream-management resume stay consistent) while
|
||||
/// remaining distinct between macOS and iOS installs.
|
||||
enum DeviceResource {
|
||||
/// The legacy hard-coded default that older Luma builds sent from every
|
||||
/// device. Treated as "automatic" so existing accounts migrate without the
|
||||
/// user having to re-enter credentials.
|
||||
static let legacyDefault = "Luma"
|
||||
|
||||
private static let storageKey = "app.luma.xmpp.resource"
|
||||
|
||||
static var `default`: String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let existing = defaults.string(forKey: storageKey), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let suffix = UUID().uuidString
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.prefix(8)
|
||||
.lowercased()
|
||||
let resource = "Luma-\(suffix)"
|
||||
defaults.set(resource, forKey: storageKey)
|
||||
return resource
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ struct LoginView: View {
|
||||
@State private var jid = ""
|
||||
@State private var password = ""
|
||||
@State private var displayName = ""
|
||||
@State private var resource = "Luma"
|
||||
@State private var resource = ""
|
||||
@State private var showAdvanced = false
|
||||
@State private var manualHost = ""
|
||||
@State private var manualPort = ""
|
||||
@@ -91,8 +91,11 @@ struct LoginView: View {
|
||||
VStack(spacing: 14) {
|
||||
TextField("Отображаемое имя", text: $displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Ресурс устройства", text: $resource)
|
||||
TextField("Автоматически (Luma-XXXX)", text: $resource)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Text("Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Сервер вручную (необязательно)", text: $manualHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
|
||||
|
||||
@@ -103,6 +103,22 @@ final class LumaOMEMOStore: SignalStorage {
|
||||
sessionAdapter.containsSessionRecord(forAddress: address)
|
||||
}
|
||||
|
||||
/// Removes a session record with our own current device. A self-session is
|
||||
/// cryptographically invalid (Signal sessions exist between two distinct
|
||||
/// identities) and makes Martin's `_encode(..., forSelf: true)` encrypt to
|
||||
/// the local device itself, producing messages that later fail with
|
||||
/// "Bad MAC". Such a record can only be created by a bug; deleting it is
|
||||
/// always safe and idempotent.
|
||||
func removeSessionWithOwnDevice() {
|
||||
let registrationID = identityAdapter.localRegistrationId()
|
||||
guard registrationID != 0 else { return }
|
||||
let address = SignalAddress(
|
||||
name: repository.accountJID,
|
||||
deviceId: Int32(bitPattern: registrationID)
|
||||
)
|
||||
_ = sessionAdapter.deleteSessionRecord(forAddress: address)
|
||||
}
|
||||
|
||||
func flushPendingPersistence() {
|
||||
repository.flush()
|
||||
}
|
||||
|
||||
@@ -298,15 +298,13 @@ final class XMPPService {
|
||||
private var mucCatchupLastCursor: String?
|
||||
private var mucCursorFallbackArchives: Set<MAMArchiveKey> = []
|
||||
private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:]
|
||||
private struct PendingOlderHistoryRequest {
|
||||
let conversationJID: String
|
||||
let isGroup: Bool
|
||||
let before: String?
|
||||
let completion: (Result<Bool, Error>) -> Void
|
||||
}
|
||||
private var pendingOlderHistoryRequest: PendingOlderHistoryRequest?
|
||||
private var olderHistoryQueryID: String?
|
||||
private var olderHistoryCompletion: ((Result<Bool, Error>) -> Void)?
|
||||
private var olderHistoryTimeoutTask: Task<Void, Never>?
|
||||
/// When non-nil, archived mutations produced while applying an interactive
|
||||
/// backward-history page are collected here instead of the catch-up
|
||||
/// accumulator, so the two never interleave their mutations.
|
||||
private var interactiveHistoryMutations: [ArchiveMutation]?
|
||||
private let olderHistoryQueryTimeoutNanoseconds: UInt64 = 15_000_000_000
|
||||
/// Serial background queue for OMEMO decryption. `decode` performs the
|
||||
/// expensive Signal work (session decrypt + AES-GCM) and previously ran on
|
||||
@@ -422,6 +420,9 @@ final class XMPPService {
|
||||
guard let signalContext = SignalContext(withStorage: omemoStorage) else {
|
||||
throw LumaXMPPError.omemoInitializationFailed
|
||||
}
|
||||
// Clean up any invalid self-session left behind by a previous buggy
|
||||
// build so it cannot poison encrypt-to-self ("Bad MAC" loops).
|
||||
omemoStorage.removeSessionWithOwnDevice()
|
||||
|
||||
configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage)
|
||||
configureConnection(client: client, account: account, password: password)
|
||||
@@ -446,6 +447,7 @@ final class XMPPService {
|
||||
|
||||
func disconnect() async {
|
||||
callEngine.detach()
|
||||
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
archiveRetryTask?.cancel()
|
||||
archiveRetryTask = nil
|
||||
archiveQueryTimeoutTask?.cancel()
|
||||
@@ -855,17 +857,9 @@ final class XMPPService {
|
||||
completion(.failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
// guard !archiveSyncStarted, activeMUCCatchup == nil else {
|
||||
// completion(.success(false))
|
||||
if archiveSyncStarted || activeMUCCatchup != nil {
|
||||
pendingOlderHistoryRequest = PendingOlderHistoryRequest(
|
||||
conversationJID: conversationJID,
|
||||
isGroup: isGroup,
|
||||
before: before,
|
||||
completion: completion
|
||||
)
|
||||
return
|
||||
}
|
||||
// Interactive backward-history is fully independent from catch-up: it
|
||||
// owns a query-ID-scoped inbox entry and its own mutation collector, so
|
||||
// it must never wait for an account/MUC catch-up pass to settle.
|
||||
performOlderHistoryRequest(
|
||||
conversationJID: conversationJID,
|
||||
isGroup: isGroup,
|
||||
@@ -885,20 +879,26 @@ final class XMPPService {
|
||||
return
|
||||
}
|
||||
guard let version = client.module(.mam).availableVersions.first else {
|
||||
// completion(.success(false))
|
||||
completion(.failure(LumaXMPPError.connection("MAM недоступен")))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let archive = isGroup
|
||||
? MAMArchiveKey.muc(conversationJID)
|
||||
: MAMArchiveKey.account(client.userBareJid.stringValue)
|
||||
// Only one interactive backward-history query is allowed at a time.
|
||||
// This is independent from the normal account/MUC catch-up query.
|
||||
if let previous = olderHistoryCompletion {
|
||||
// AppModel normally serializes requests, but never leak the
|
||||
// previous completion if a request does slip through.
|
||||
previous(.failure(LumaXMPPError.connection("Запрос истории заменён более новым.")))
|
||||
}
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
interactiveHistoryMutations = nil
|
||||
let queryID = UUID().uuidString
|
||||
olderHistoryQueryID = queryID
|
||||
olderHistoryCompletion = completion
|
||||
archiveStanzaInbox.begin(
|
||||
queryID: queryID,
|
||||
allowedSources: allowedMAMSources(for: archive, client: client)
|
||||
@@ -906,10 +906,9 @@ final class XMPPService {
|
||||
scheduleOlderHistoryTimeout(
|
||||
client: client,
|
||||
queryID: queryID,
|
||||
conversationJID: conversationJID,
|
||||
completion: completion
|
||||
conversationJID: conversationJID
|
||||
)
|
||||
|
||||
|
||||
// Martin 3.2.4's convenience overload accidentally clears `with`.
|
||||
// Construct MAMQueryForm explicitly so direct-chat history is truly
|
||||
// scoped to this peer.
|
||||
@@ -921,7 +920,7 @@ final class XMPPService {
|
||||
let rsm: RSM.Query = before.map {
|
||||
RSM.Query(before: $0, max: archivePageSize)
|
||||
} ?? RSM.Query(lastItems: archivePageSize)
|
||||
|
||||
|
||||
client.module(.mam).queryItems(
|
||||
version: version,
|
||||
componentJid: componentJID,
|
||||
@@ -930,33 +929,39 @@ final class XMPPService {
|
||||
rsm: rsm
|
||||
) { [weak self, weak client] result in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, let client else {
|
||||
// completion(.success(false))
|
||||
guard let self else {
|
||||
completion(.failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
guard let client, self.client === client else {
|
||||
self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
guard self.olderHistoryQueryID == queryID else {
|
||||
// A newer request, a timeout, or a disconnect already owns
|
||||
// the slot and has released this completion.
|
||||
return
|
||||
}
|
||||
self.olderHistoryTimeoutTask?.cancel()
|
||||
self.olderHistoryTimeoutTask = nil
|
||||
self.olderHistoryQueryID = nil
|
||||
let page = self.archiveStanzaInbox.take(queryID: queryID)
|
||||
guard !page.overflowed, !page.rejectedSource else {
|
||||
completion(.failure(LumaXMPPError.connection("Некорректная MAM history page")))
|
||||
self.finishOlderHistory(
|
||||
result: .failure(LumaXMPPError.connection("Некорректная MAM history page"))
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Task { @MainActor [weak self, weak client] in
|
||||
guard let self, let client else {
|
||||
completion(.failure(LumaXMPPError.notConnected))
|
||||
guard let self else { return }
|
||||
guard let client, self.client === client else {
|
||||
self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
return
|
||||
}
|
||||
// History is only allowed while catch-up is idle, so the
|
||||
// legacy mutation accumulator can safely be used as a local
|
||||
// page collector here.
|
||||
let savedMutations = self.archivePassMutations
|
||||
self.archivePassMutations = []
|
||||
// Collect this page's mutations in a dedicated buffer so a
|
||||
// concurrently-running catch-up pass never interleaves its
|
||||
// mutations with the interactive page.
|
||||
self.interactiveHistoryMutations = []
|
||||
for stanza in page.stanzas {
|
||||
if stanza.message.type == .groupchat,
|
||||
let roomJID = stanza.message.from?.bareJid {
|
||||
@@ -977,29 +982,28 @@ final class XMPPService {
|
||||
)
|
||||
}
|
||||
}
|
||||
let historyMutations = self.archivePassMutations
|
||||
self.archivePassMutations = savedMutations
|
||||
let historyMutations = self.interactiveHistoryMutations ?? []
|
||||
self.interactiveHistoryMutations = nil
|
||||
if !historyMutations.isEmpty {
|
||||
self.eventHandler?(.archiveBatch(historyMutations))
|
||||
}
|
||||
switch result {
|
||||
case .success(let response):
|
||||
completion(.success(!response.complete))
|
||||
self.finishOlderHistory(result: .success(!response.complete))
|
||||
case .failure(let error):
|
||||
// Do not leave the UI in the loading state. A failed
|
||||
// interactive query is recoverable by the next scroll.
|
||||
completion(.failure(error))
|
||||
self.finishOlderHistory(result: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func scheduleOlderHistoryTimeout(
|
||||
client: XMPPClient,
|
||||
queryID: String,
|
||||
conversationJID: String,
|
||||
completion: @escaping (Result<Bool, Error>) -> Void
|
||||
conversationJID: String
|
||||
) {
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = Task { @MainActor [weak self, weak client] in
|
||||
@@ -1012,17 +1016,15 @@ final class XMPPService {
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
self.olderHistoryQueryID = nil
|
||||
self.olderHistoryTimeoutTask = nil
|
||||
|
||||
self.archiveStanzaInbox.cancel(queryID: queryID)
|
||||
self.eventHandler?(
|
||||
.recoverableError(
|
||||
"Не удалось загрузить более старую историю чата \(conversationJID): MAM не ответил за 15 секунд."
|
||||
)
|
||||
)
|
||||
completion(
|
||||
.failure(
|
||||
self.finishOlderHistory(
|
||||
result: .failure(
|
||||
LumaXMPPError.connection(
|
||||
"MAM не ответил вовремя. Повторите прокрутку вверх."
|
||||
)
|
||||
@@ -1030,19 +1032,20 @@ final class XMPPService {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPendingOlderHistoryIfPossible() {
|
||||
guard !archiveSyncStarted,
|
||||
activeMUCCatchup == nil,
|
||||
let pending = pendingOlderHistoryRequest
|
||||
else { return }
|
||||
pendingOlderHistoryRequest = nil
|
||||
performOlderHistoryRequest(
|
||||
conversationJID: pending.conversationJID,
|
||||
isGroup: pending.isGroup,
|
||||
before: pending.before,
|
||||
completion: pending.completion
|
||||
)
|
||||
|
||||
/// Releases the in-flight interactive backward-history request exactly once.
|
||||
/// Every terminal path (success, failure, timeout, disconnect, background)
|
||||
/// funnels through here so the UI's "loading older history" spinner can
|
||||
/// never be left stuck.
|
||||
private func finishOlderHistory(result: Result<Bool, Error>) {
|
||||
guard olderHistoryCompletion != nil else { return }
|
||||
let completion = olderHistoryCompletion
|
||||
olderHistoryCompletion = nil
|
||||
olderHistoryQueryID = nil
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
interactiveHistoryMutations = nil
|
||||
completion?(result)
|
||||
}
|
||||
|
||||
func sendRetraction(
|
||||
@@ -1650,7 +1653,7 @@ final class XMPPService {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
private func deliverOrDelayDirect(_ message: Message, timestamp: Date) async {
|
||||
guard let client else { return }
|
||||
let archive = MAMArchiveKey.account(client.userBareJid.stringValue)
|
||||
@@ -1766,9 +1769,13 @@ final class XMPPService {
|
||||
security = .plaintext
|
||||
fingerprint = nil
|
||||
contentMessage = message
|
||||
case .duplicateMessage:
|
||||
return
|
||||
default:
|
||||
// Includes `.duplicateMessage` (our own message without an
|
||||
// encrypt-to-self key, or a key already consumed by a previous
|
||||
// session): the body can no longer be recovered here. Still
|
||||
// emit the message (as an undecryptable bubble) instead of
|
||||
// dropping it, so history does not silently stop. Genuine
|
||||
// duplicates are filtered later in AppModel by stanza/origin ID.
|
||||
security = .decryptionFailed
|
||||
fingerprint = nil
|
||||
contentMessage = nil
|
||||
@@ -1901,6 +1908,10 @@ final class XMPPService {
|
||||
? visibleBody
|
||||
: Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение")
|
||||
|
||||
// Live 1:1 messages carry their server archive UID in a <stanza-id>
|
||||
// element (by = own bare JID). Surface it so the interactive MAM
|
||||
// "before" cursor and dedup match the IDs the archive later returns.
|
||||
let stanzaID = archiveID ?? Self.accountStanzaID(in: message, accountJID: ownJID)
|
||||
let id = message.originId ?? message.id ?? archiveID ?? UUID().uuidString
|
||||
emitMessage(
|
||||
MessageEnvelope(
|
||||
@@ -1924,7 +1935,7 @@ final class XMPPService {
|
||||
replyToJID: reply?.getAttribute("to"),
|
||||
replyPreview: parsedReply.preview,
|
||||
originID: message.originId,
|
||||
stanzaID: archiveID,
|
||||
stanzaID: stanzaID,
|
||||
senderDisplayName: nil,
|
||||
isGroupMessage: false,
|
||||
isArchived: isArchived
|
||||
@@ -2411,6 +2422,16 @@ final class XMPPService {
|
||||
}?.getAttribute("id")
|
||||
}
|
||||
|
||||
private static func accountStanzaID(in message: Message, accountJID: BareJID) -> String? {
|
||||
message.children.first { child in
|
||||
guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else {
|
||||
return false
|
||||
}
|
||||
guard let by = child.getAttribute("by")?.lowercased() else { return false }
|
||||
return by == accountJID.stringValue.lowercased()
|
||||
}?.getAttribute("id")
|
||||
}
|
||||
|
||||
private static func originalSenderJID(in message: Message) -> BareJID? {
|
||||
guard
|
||||
let address = message.firstChild(
|
||||
@@ -2481,7 +2502,7 @@ final class XMPPService {
|
||||
isOutgoing: outgoing
|
||||
)
|
||||
if isArchived {
|
||||
archivePassMutations.append(.retraction(envelope))
|
||||
appendArchiveMutation(.retraction(envelope))
|
||||
} else {
|
||||
eventHandler?(.retraction(envelope))
|
||||
}
|
||||
@@ -2492,7 +2513,7 @@ final class XMPPService {
|
||||
isArchived: Bool
|
||||
) {
|
||||
if isArchived {
|
||||
archivePassMutations.append(.reaction(envelope))
|
||||
appendArchiveMutation(.reaction(envelope))
|
||||
} else {
|
||||
eventHandler?(.reaction(envelope))
|
||||
}
|
||||
@@ -2500,12 +2521,24 @@ final class XMPPService {
|
||||
|
||||
private func emitMessage(_ envelope: MessageEnvelope, isArchived: Bool) {
|
||||
if isArchived {
|
||||
archivePassMutations.append(.message(envelope))
|
||||
appendArchiveMutation(.message(envelope))
|
||||
} else {
|
||||
eventHandler?(.message(envelope))
|
||||
}
|
||||
}
|
||||
|
||||
/// Archived mutations normally belong to the active account/MUC catch-up
|
||||
/// pass. While an interactive backward-history page is being applied, its
|
||||
/// mutations are collected in a dedicated buffer so the two flows never
|
||||
/// interleave and never steal each other's mutations.
|
||||
private func appendArchiveMutation(_ mutation: ArchiveMutation) {
|
||||
if interactiveHistoryMutations != nil {
|
||||
interactiveHistoryMutations?.append(mutation)
|
||||
} else {
|
||||
archivePassMutations.append(mutation)
|
||||
}
|
||||
}
|
||||
|
||||
private func retrievePEPAvatar(for jid: String, client: XMPPClient) async -> Data? {
|
||||
do {
|
||||
let avatarModule = client.module(.pepUserAvatar)
|
||||
@@ -2730,7 +2763,8 @@ final class XMPPService {
|
||||
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
||||
archiveBufferOverflowed = false
|
||||
archiveRejectedSource = false
|
||||
archiveStanzaInbox.cancel()
|
||||
// Do NOT blanket-cancel the inbox here: an interactive backward-history
|
||||
// query may be in flight and must keep its own query-ID-scoped buffer.
|
||||
// Show the visible "Синхронизация истории…" banner only for the
|
||||
// one-page bootstrap window. Incremental catch-up continues silently so
|
||||
// a large backlog never leaves the spinner visible for the whole pass.
|
||||
@@ -2878,7 +2912,6 @@ final class XMPPService {
|
||||
await self?.replayDelayedLive(for: archive)
|
||||
}
|
||||
startNextMUCCatchupIfPossible()
|
||||
startPendingOlderHistoryIfPossible()
|
||||
}
|
||||
// MUC catch-up decodes into archivePassMutations just like the account
|
||||
// pass. Publish them once the room catch-up settles, otherwise group
|
||||
@@ -3329,7 +3362,8 @@ final class XMPPService {
|
||||
archiveQueryTimeoutTask = nil
|
||||
archiveQueryCompletionTask?.cancel()
|
||||
archiveQueryCompletionTask = nil
|
||||
archiveStanzaInbox.cancel()
|
||||
// Do NOT blanket-cancel the inbox here: an interactive backward-history
|
||||
// query may be in flight and must keep its own query-ID-scoped buffer.
|
||||
archiveActiveQueryID = nil
|
||||
archiveStanzaBuffer.removeAll(keepingCapacity: true)
|
||||
archiveBufferOverflowed = false
|
||||
@@ -3345,7 +3379,6 @@ final class XMPPService {
|
||||
}
|
||||
setArchiveSyncIndicator(false)
|
||||
archiveSyncStarted = false
|
||||
startPendingOlderHistoryIfPossible()
|
||||
archiveIsBootstrapQuery = false
|
||||
archiveHighWatermark = nil
|
||||
archiveHasCompletedPage = false
|
||||
@@ -3435,14 +3468,8 @@ final class XMPPService {
|
||||
|
||||
private func suspendArchiveSyncForDisconnect() {
|
||||
setArchiveSyncIndicator(false)
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
olderHistoryQueryID = nil
|
||||
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
archiveStanzaInbox.cancel()
|
||||
if let pending = pendingOlderHistoryRequest {
|
||||
pendingOlderHistoryRequest = nil
|
||||
pending.completion(.failure(LumaXMPPError.notConnected))
|
||||
}
|
||||
guard let client else {
|
||||
archiveSyncStarted = false
|
||||
// archiveStanzaInbox.cancel()
|
||||
@@ -3485,6 +3512,10 @@ final class XMPPService {
|
||||
|
||||
private func pauseArchiveSync(client: XMPPClient) {
|
||||
setArchiveSyncIndicator(false)
|
||||
// An in-flight interactive history request must not survive into the
|
||||
// background with a dangling spinner; finish it before the catch-up
|
||||
// guard below, which may early-return when no catch-up is active.
|
||||
finishOlderHistory(result: .failure(LumaXMPPError.notConnected))
|
||||
guard archiveSyncStarted, self.client === client else { return }
|
||||
archiveRetryTask?.cancel()
|
||||
archiveRetryTask = nil
|
||||
@@ -3495,9 +3526,6 @@ final class XMPPService {
|
||||
archiveSyncRetryTask?.cancel()
|
||||
archiveSyncRetryTask = nil
|
||||
archiveStanzaInbox.cancel()
|
||||
olderHistoryTimeoutTask?.cancel()
|
||||
olderHistoryTimeoutTask = nil
|
||||
olderHistoryQueryID = nil
|
||||
archiveResumeAfter = archiveSyncCheckpoint?.cursor
|
||||
archiveActiveQueryID = nil
|
||||
archiveSyncStarted = false
|
||||
|
||||
Reference in new issue
Block a user