some fixes
This commit is contained in:
1 parent
cf2bda9c54
commit
e28a4043ee
13 files changed
+793
-119
No files matched your search
@@ -121,6 +121,11 @@ final class AppModel: ObservableObject {
|
||||
private var lastSuccessfulMAMCursor: String?
|
||||
private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
|
||||
private var hasMoreOlderHistoryByConversation: [String: Bool] = [:]
|
||||
/// Server-side cursor (RSM <first> of the last loaded page) for the next
|
||||
/// interactive backward-history request, per conversation. The cursor
|
||||
/// advances even when a page inserted nothing (reactions, duplicates), so
|
||||
/// scrolling always moves toward genuinely older history.
|
||||
private var olderHistoryCursorByConversation: [String: String] = [:]
|
||||
private var pendingRoomPasswords: [String: String] = [:]
|
||||
private var joiningRoomJIDs: Set<String> = []
|
||||
private var deletedGroupChatJIDs: Set<String> = []
|
||||
@@ -737,12 +742,13 @@ final class AppModel: ObservableObject {
|
||||
func selectConversation(id: String) {
|
||||
let normalized = id.lowercased()
|
||||
selectedConversationID = normalized
|
||||
// if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
// conversations[index].unreadCount = 0
|
||||
// }
|
||||
// schedulePersist()
|
||||
// syncWatch()
|
||||
// hasMoreOlderHistory = true
|
||||
// Opening the chat marks it as read, so the unread badge disappears
|
||||
// immediately and stays in sync with the watch.
|
||||
if let index = conversations.firstIndex(where: { $0.jid == normalized }) {
|
||||
conversations[index].unreadCount = 0
|
||||
}
|
||||
schedulePersist()
|
||||
syncWatch()
|
||||
if hasMoreOlderHistoryByConversation[normalized] == false,
|
||||
selectedMessages.isEmpty
|
||||
{
|
||||
@@ -753,6 +759,17 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true
|
||||
}
|
||||
|
||||
/// Called when the chat view disappears (back to the list, another chat
|
||||
/// selected, or a mode switch). Without this, `selectedConversationID`
|
||||
/// would keep pointing at the last viewed chat forever, and incoming
|
||||
/// messages for it would never increment the unread badge again.
|
||||
func endConversationViewing(jid: String) {
|
||||
let normalized = jid.lowercased()
|
||||
if selectedConversationID == normalized {
|
||||
selectedConversationID = nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadOlderHistoryForSelectedConversation() {
|
||||
// While disconnected the request would only fail with
|
||||
@@ -780,21 +797,37 @@ final class AppModel: ObservableObject {
|
||||
|
||||
isLoadingOlderHistory = true
|
||||
let conversationID = conversation.jid.lowercased()
|
||||
// Prefer the cursor the server returned with the previous page: it
|
||||
// always points at genuinely older history. Fall back to the oldest
|
||||
// local stanza id only when no server cursor is known yet (freshly
|
||||
// opened conversation in this session).
|
||||
let before =
|
||||
olderHistoryCursorByConversation[conversationID]
|
||||
?? oldestServerID
|
||||
xmpp.loadOlderHistory(
|
||||
conversationJID: conversation.jid,
|
||||
isGroup: conversation.isGroup,
|
||||
before: oldestServerID
|
||||
before: before
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
self.isLoadingOlderHistory = false
|
||||
switch result {
|
||||
case .success(let hasMore):
|
||||
// self.hasMoreOlderHistory = hasMore
|
||||
self.hasMoreOlderHistoryByConversation[conversationID] = hasMore
|
||||
case .success(let page):
|
||||
if let nextBefore = page.nextBefore {
|
||||
self.olderHistoryCursorByConversation[conversationID] =
|
||||
nextBefore
|
||||
} else {
|
||||
// Exhausted or the server stopped echoing RSM: never
|
||||
// re-request the same page in a loop.
|
||||
self.olderHistoryCursorByConversation[conversationID] = nil
|
||||
}
|
||||
self.hasMoreOlderHistoryByConversation[conversationID] =
|
||||
page.hasMore
|
||||
if self.selectedConversationID == conversationID {
|
||||
self.hasMoreOlderHistory = hasMore
|
||||
self.hasMoreOlderHistory = page.hasMore
|
||||
}
|
||||
case .failure(let error):
|
||||
// Keep the cursor: the next scroll retries the same page.
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
@@ -935,6 +968,7 @@ final class AppModel: ObservableObject {
|
||||
pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) }
|
||||
pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") }
|
||||
hasMoreOlderHistoryByConversation.removeValue(forKey: normalized)
|
||||
olderHistoryCursorByConversation.removeValue(forKey: normalized)
|
||||
pendingRoomPasswords.removeValue(forKey: normalized)
|
||||
joiningRoomJIDs.remove(normalized)
|
||||
localChatStateByConversation.removeValue(forKey: normalized)
|
||||
|
||||
@@ -4,14 +4,14 @@ import Foundation
|
||||
/// still published atomically; these values only bound network and decryption
|
||||
/// work performed before that publication.
|
||||
enum ArchiveMessageBatchPolicy {
|
||||
static let pageSize = 24
|
||||
static let pageSize = 40
|
||||
static let bootstrapMessageLimit = 40
|
||||
/// Decrypt this many archived stanzas per main-actor slice before yielding.
|
||||
/// One-by-one decryption turns a large MAM catch-up into hours of serialized
|
||||
/// work; a small batch keeps frames mostly intact while cutting the number
|
||||
/// of run-loop hand-offs by the same factor.
|
||||
static let decodeSliceSize = 8
|
||||
static let maximumBufferedStanzas = 48
|
||||
static let maximumBufferedStanzas = 96
|
||||
/// A short yield between archived decryption slices keeps touch handling
|
||||
/// responsive without the 8 ms per single message that previously dominated
|
||||
/// catch-up time.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// One interactive backward-history page handed to the UI: whether older
|
||||
/// history remains and the cursor the next request must use.
|
||||
struct OlderHistoryScrollPage: Equatable, Sendable {
|
||||
let hasMore: Bool
|
||||
let nextBefore: String?
|
||||
}
|
||||
|
||||
/// Interactive scroll-back policy. The cursor for the next page must come
|
||||
/// from the server's RSM <first> of the page that was just returned, not from
|
||||
/// the oldest local message: a page can consist entirely of reactions,
|
||||
/// retractions or already-known stanzas, which insert no new message rows, so
|
||||
/// a locally computed anchor would never advance and every subsequent scroll
|
||||
/// would re-request the same page forever (spinner spins, nothing loads).
|
||||
enum OlderHistoryScrollPolicy {
|
||||
/// - Parameter complete: the server's <fin complete="true"> flag.
|
||||
/// - Parameter pageFirstID: RSM <first> UID of the returned page — the
|
||||
/// oldest item in it, i.e. the anchor for the next, older page.
|
||||
static func page(
|
||||
complete: Bool,
|
||||
pageFirstID: String?
|
||||
) -> OlderHistoryScrollPage {
|
||||
let nextBefore = ArchiveSyncCheckpoint.normalizedCursor(pageFirstID)
|
||||
// An empty page (or one without RSM) cannot advance the cursor, so it
|
||||
// must never report "more" — otherwise the UI would loop on it.
|
||||
let hasMore = !complete && nextBefore != nil
|
||||
return OlderHistoryScrollPage(hasMore: hasMore, nextBefore: nextBefore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import CoreGraphics
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Clockwise rotation, in degrees, applied to the video-note capture so the
|
||||
/// recorded QuickTime track plays upright. The camera sensor is
|
||||
/// landscape-native: per Apple's videoOrientation<->videoRotationAngle
|
||||
/// equivalence a portrait recording needs a 90-degree clockwise rotation
|
||||
/// (a QuickTime track matrix), never 0 — 0 leaves the circle sideways.
|
||||
enum VideoNoteRotationPolicy {
|
||||
enum InterfaceOrientation: Equatable, Sendable {
|
||||
case portrait
|
||||
case portraitUpsideDown
|
||||
case landscapeLeft
|
||||
case landscapeRight
|
||||
}
|
||||
|
||||
static func angle(for orientation: InterfaceOrientation) -> CGFloat {
|
||||
switch orientation {
|
||||
case .portrait: return 90
|
||||
case .portraitUpsideDown: return 270
|
||||
case .landscapeLeft: return 180
|
||||
case .landscapeRight: return 0
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// The orientation of the interface the user actually sees. The device
|
||||
/// sensor (UIDevice.current.orientation) can report landscape or
|
||||
/// face-up while the app stays portrait, which is exactly how a
|
||||
/// portrait circle ended up encoded sideways.
|
||||
static var currentInterfaceOrientation: InterfaceOrientation {
|
||||
let orientation = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }?
|
||||
.interfaceOrientation
|
||||
switch orientation {
|
||||
case .portraitUpsideDown: return .portraitUpsideDown
|
||||
case .landscapeLeft: return .landscapeLeft
|
||||
case .landscapeRight: return .landscapeRight
|
||||
default: return .portrait
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in new issue
Block a user