diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 131177b1..a40a2290 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -11,7 +11,6 @@ final class ConversationUIModel: ObservableObject { @Published private(set) var autocompleteSuggestions: [String] = [] @Published private(set) var selectedAutocompleteIndex = 0 @Published private(set) var currentNickname: String - @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? /// Who is talking live in the public mesh channel right now (floor @@ -34,7 +33,6 @@ final class ConversationUIModel: ObservableObject { self.conversations = conversations self.activeChannel = conversations.activeChannel self.currentNickname = chatViewModel.nickname - self.isBatchingPublic = chatViewModel.isBatchingPublic self.showAutocomplete = chatViewModel.showAutocomplete self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex @@ -224,31 +222,35 @@ final class ConversationUIModel: ObservableObject { } private func bind() { + // Every message row observes this model, so each republish re-renders + // all of them: forward only real changes, never a re-sent equal value. chatViewModel.$nickname + .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$currentNickname) chatViewModel.$showAutocomplete + .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$showAutocomplete) chatViewModel.$autocompleteSuggestions + .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$autocompleteSuggestions) chatViewModel.$selectedAutocompleteIndex + .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$selectedAutocompleteIndex) - chatViewModel.$isBatchingPublic - .receive(on: DispatchQueue.main) - .assign(to: &$isBatchingPublic) - chatViewModel.$activePublicVoiceTalker + .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$activeLiveVoiceTalker) chatViewModel.$legacyPrivateMediaConsentRequest + .removeDuplicates() .receive(on: DispatchQueue.main) .assign(to: &$legacyPrivateMediaConsentRequest) @@ -278,18 +280,24 @@ final class ConversationUIModel: ObservableObject { } private func refreshComputedState() { + let canSendMedia: Bool if let selectedPeerID = privateConversationModel.selectedPeerID { // Media transfer is not wired for groups in v1; keep it off so the // composer can't strand a media placeholder that never sends. - canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup) - return + canSendMedia = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup) + } else { + switch activeChannel { + case .mesh: + canSendMedia = true + case .location: + canSendMedia = false + } } - - switch activeChannel { - case .mesh: - canSendMediaInCurrentContext = true - case .location: - canSendMediaInCurrentContext = false + // Every write republishes to every message row, and most triggers + // (each peer, favorite or status refresh of the selection) leave the + // value as it was. + if canSendMediaInCurrentContext != canSendMedia { + canSendMediaInCurrentContext = canSendMedia } } } diff --git a/bitchat/Models/BitchatMessage+Media.swift b/bitchat/Models/BitchatMessage+Media.swift index 16e35a79..6d1e9380 100644 --- a/bitchat/Models/BitchatMessage+Media.swift +++ b/bitchat/Models/BitchatMessage+Media.swift @@ -33,11 +33,19 @@ extension BitchatMessage { } func mediaAttachment(for nickname: String) -> Media? { + // Files only travel over the mesh radio; geochats, geo DMs and + // bridged messages (internet-relayed from a rendezvous) carry text + // alone. A "[image] " line from such a sender names no file it sent + // us, so it stays a text row. + if let senderPeerID, senderPeerID.isGeoChat || senderPeerID.isGeoDM || senderPeerID.isBridge { + return nil + } guard let baseDirectory = Cache.shared.filesDir else { return nil } func url(for category: MimeType.Category) -> URL? { guard content.hasPrefix(category.messagePrefix), - let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty + let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, + Self.isSafeMediaFilename(filename) else { return nil } @@ -59,4 +67,19 @@ extension BitchatMessage { } return nil } + + /// Whether a media filename taken from message text is one plain path + /// component. Anything else ("../outgoing/x.jpg", "a/b", "..") would + /// resolve outside the media directory, so it is rejected, not repaired: + /// sanitizing would still point the row at some other local file. + static func isSafeMediaFilename(_ filename: String) -> Bool { + guard !filename.isEmpty, + filename != ".", + filename != "..", + !filename.unicodeScalars.contains(where: { $0 == "/" || $0 == "\\" }) + else { + return false + } + return (filename as NSString).lastPathComponent == filename + } } diff --git a/bitchat/Services/AutocompleteService.swift b/bitchat/Services/AutocompleteService.swift index 3e739e74..dd588d24 100644 --- a/bitchat/Services/AutocompleteService.swift +++ b/bitchat/Services/AutocompleteService.swift @@ -12,6 +12,15 @@ import Foundation final class AutocompleteService { private typealias Patterns = MessageFormattingEngine.Patterns + /// Whether the text before the cursor ends in an `@` mention token: the + /// only case `getSuggestions` can return anything for, whatever the peers. + /// Lets callers skip assembling the peer list after every keystroke. + static func hasMentionToken(in text: String, cursorPosition: Int) -> Bool { + let textToPosition = String(text.prefix(cursorPosition)) + let range = NSRange(location: 0, length: (textToPosition as NSString).length) + return Patterns.mentionAutocomplete.firstMatch(in: textToPosition, options: [], range: range) != nil + } + /// Get autocomplete suggestions for current text func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) { let textToPosition = String(text.prefix(cursorPosition)) diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift index d0d347f2..84a7cf3d 100644 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -82,6 +82,14 @@ final class ChatComposerCoordinator { } func updateAutocomplete(for text: String, cursorPosition: Int) { + // The query can only suggest for an @-token ending at the cursor, so + // skip assembling the candidates (a pass over every mesh peer or + // geohash participant) after each typing pause in plain text. + guard AutocompleteService.hasMentionToken(in: text, cursorPosition: cursorPosition) else { + setAutocompleteState(suggestions: [], range: nil) + return + } + let peerCandidates = autocompleteCandidates() let (suggestions, range) = context.autocompleteQuery( for: text, @@ -89,17 +97,7 @@ final class ChatComposerCoordinator { cursorPosition: cursorPosition ) - if !suggestions.isEmpty { - context.autocompleteSuggestions = suggestions - context.autocompleteRange = range - context.showAutocomplete = true - context.selectedAutocompleteIndex = 0 - } else { - context.autocompleteSuggestions = [] - context.autocompleteRange = nil - context.showAutocomplete = false - context.selectedAutocompleteIndex = 0 - } + setAutocompleteState(suggestions: suggestions, range: suggestions.isEmpty ? nil : range) } func completeNickname(_ nickname: String, in text: inout String) -> Int { @@ -143,6 +141,25 @@ final class ChatComposerCoordinator { } private extension ChatComposerCoordinator { + /// Shows `suggestions` (hidden when empty) with the highlight reset to the + /// first one. Writes only the properties that change: each write + /// republishes the composer state, which re-renders every message row. + func setAutocompleteState(suggestions: [String], range: NSRange?) { + let isShown = !suggestions.isEmpty + if context.autocompleteSuggestions != suggestions { + context.autocompleteSuggestions = suggestions + } + if context.autocompleteRange != range { + context.autocompleteRange = range + } + if context.showAutocomplete != isShown { + context.showAutocomplete = isShown + } + if context.selectedAutocompleteIndex != 0 { + context.selectedAutocompleteIndex = 0 + } + } + func autocompleteCandidates() -> [String] { switch context.activeChannel { case .mesh: diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 2db03bce..8a5310c1 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -362,6 +362,10 @@ final class ChatMessageFormatter { return result } + /// Prefix of the header's formatted-text cache variants. No full-text + /// variant starts with it, so the two caches can't collide. + static let headerCacheNamespace = "hdr:" + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { let design = theme.bodyFontDesign let isSelf: Bool = { @@ -378,14 +382,23 @@ final class ChatMessageFormatter { }() let isDark = colorScheme == .dark - let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) let isVerifiedSender = !isSelf && isVerifiedSender(of: message) + // Same key ingredients as the full-text cache, in a namespace of its + // own so a header and a full text never answer each other's lookups. + let cacheVariant = Self.headerCacheNamespace + theme.formatCacheVariant + (isVerifiedSender ? "-vf" : "") + if let cachedHeader = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: cacheVariant) { + return cachedHeader + } + + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender == "system" { var style = AttributeContainer() style.foregroundColor = baseColor style.font = .bitchatSystem(size: 14, weight: .medium, design: design) - return AttributedString(message.sender).mergingAttributes(style) + let header = AttributedString(message.sender).mergingAttributes(style) + message.setCachedFormattedText(header, isDark: isDark, isSelf: isSelf, variant: cacheVariant) + return header } var result = AttributedString() @@ -409,6 +422,7 @@ final class ChatMessageFormatter { appendVerifiedSeal(to: &result, baseColor: baseColor, design: design) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) + message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: cacheVariant) return result } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index eead15d7..d0853b95 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -24,10 +24,6 @@ protocol ChatPublicConversationContext: AnyObject { var currentGeohash: String? { get } var nickname: String { get } var myPeerID: PeerID { get } - /// Publishes the public-timeline batching state (UI animation suppression). - /// (Single mutation path for the owner's `isBatchingPublic`; this - /// coordinator never reads it.) - func setPublicBatching(_ isBatching: Bool) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() @@ -588,10 +584,6 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) { context.prewarmMessageFormatting(message) } - - func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) { - context.setPublicBatching(isBatching) - } } private extension ChatPublicConversationCoordinator { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 29c3bc0f..b63c65c4 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -442,8 +442,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline - // Single-writer: mutate only via `setPublicBatching(_:)` below. - @Published private(set) var isBatchingPublic: Bool = false // Backing store for `sentReadReceipts` persistence. `.standard` in // production; injectable so tests can use a scratch suite that does not @@ -568,12 +566,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage return oldCount - sentReadReceipts.count } - /// Publishes the public-timeline batching state (UI animation suppression). - @MainActor - func setPublicBatching(_ isBatching: Bool) { - isBatchingPublic = isBatching - } - @MainActor func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id diff --git a/bitchat/ViewModels/PublicMessagePipeline.swift b/bitchat/ViewModels/PublicMessagePipeline.swift index 9d30f83e..92dd0412 100644 --- a/bitchat/ViewModels/PublicMessagePipeline.swift +++ b/bitchat/ViewModels/PublicMessagePipeline.swift @@ -22,7 +22,6 @@ protocol PublicMessagePipelineDelegate: AnyObject { @discardableResult func pipeline(_: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) - func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) } @MainActor @@ -100,8 +99,6 @@ private extension PublicMessagePipeline { return } - delegate.pipelineSetBatchingState(self, isBatching: true) - // Content-window dedup against recorded keys and within the batch; // ID dedup happens in the store at commit time. var pending: [(message: BitchatMessage, conversationID: ConversationID, contentKey: String)] = [] @@ -122,10 +119,7 @@ private extension PublicMessagePipeline { } buffer.removeAll(keepingCapacity: true) - guard !pending.isEmpty else { - delegate.pipelineSetBatchingState(self, isBatching: false) - return - } + guard !pending.isEmpty else { return } pending.sort { $0.message.timestamp < $1.message.timestamp } @@ -140,8 +134,6 @@ private extension PublicMessagePipeline { delegate.pipelinePrewarmMessage(self, message: item.message) } - delegate.pipelineSetBatchingState(self, isBatching: false) - if !buffer.isEmpty { scheduleFlush() } diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index ed376acf..c7f3ee36 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -34,14 +34,12 @@ struct TextMessageView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - // Precompute heavy token scans once per row - let cashuLinks = message.content.extractCashuLinks() - let lightningLinks = message.content.extractLightningLinks() + // Heavy token scans run once per message, not once per render + let scan = message.contentScan // Baseline alignment keeps the lock and delivery glyphs on the // first text line; a fixed top padding left the lock's solid body // hanging below the line's visual center. HStack(alignment: .firstTextBaseline, spacing: 0) { - let isLong = message.content.isLongForDisplay() let isExpanded = expandedMessageIDs.contains(message.id) if message.isPrivate { Image(systemName: "lock.fill") @@ -70,7 +68,7 @@ struct TextMessageView: View { } Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) - .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) + .lineLimit(scan.isLongForDisplay && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) // Delivery status indicator for private messages. Tappable: @@ -112,7 +110,7 @@ struct TextMessageView: View { } // Expand/Collapse for very long messages - if message.content.isLongForDisplay() { + if scan.isLongForDisplay { let isExpanded = expandedMessageIDs.contains(message.id) let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") Button(labelKey) { @@ -125,12 +123,12 @@ struct TextMessageView: View { } // Render payment chips (Lightning / Cashu) with rounded background - if !lightningLinks.isEmpty || !cashuLinks.isEmpty { + if !scan.lightningLinks.isEmpty || !scan.cashuLinks.isEmpty { HStack(spacing: 8) { - ForEach(lightningLinks, id: \.self) { link in + ForEach(scan.lightningLinks, id: \.self) { link in PaymentChipView(paymentType: .lightning(link)) } - ForEach(cashuLinks, id: \.self) { link in + ForEach(scan.cashuLinks, id: \.self) { link in PaymentChipView(paymentType: .cashu(link)) } } diff --git a/bitchat/Views/MessageListScrollPolicy.swift b/bitchat/Views/MessageListScrollPolicy.swift new file mode 100644 index 00000000..9ee6a4ff --- /dev/null +++ b/bitchat/Views/MessageListScrollPolicy.swift @@ -0,0 +1,236 @@ +// +// MessageListScrollPolicy.swift +// bitchat +// +// The pure rules behind MessageListView's scroll position: how many messages +// arrived since the list last looked, how wide the window must be to keep a +// scrolled-up reader's rows in place, and when the top row may expand the +// window. Kept free of SwiftUI so the rules are unit-testable. +// + +import BitFoundation +import Foundation + +enum MessageListScrollPolicy { + /// What the list saw at its last look at a timeline. + struct TimelineMark: Equatable { + /// Conversation the mark belongs to; a mark never carries across. + let contextKey: String + /// Newest message at the time (nil: the timeline was empty), and its + /// timestamp to fall back on if that message is gone by the next look. + let newestMessageID: String? + let newestTimestamp: Date? + /// First message of the window at the time, and the window size it + /// was taken at: a window resized since then voids it. + let windowFirstMessageID: String? + let windowCount: Int + /// Messages in the timeline at the time. + let count: Int + } + + /// Messages that landed after a mark's newest message. + struct Appended: Equatable { + /// Every such message: rows the window has to make room for. + var total = 0 + /// Those the jump-to-latest pill counts. + var unseen = 0 + } + + /// What the list does about a change to the timeline it shows. + enum TimelineReaction: Equatable { + /// A timeline the list was not showing: land at its newest message. + case land + /// Scroll to the newest row. + case follow + /// Keep a scrolled-up reader's rows in place and count what's new. + case keepPosition + /// Leave the scroll alone: the rendered rows did not change. + case hold + } + + /// The reaction to a timeline change. `mark` is the mark taken in this + /// timeline's context, if any; `appendedCount` what `appended(since:in:)` + /// counted after it; `windowCount` the window size the list renders. + static func reaction( + mark: TimelineMark?, + messages: [BitchatMessage], + newestIsOwn: Bool, + appendedCount: Int, + windowCount: Int, + isAtBottom: Bool + ) -> TimelineReaction { + // No mark here: the list arrives from another timeline (a channel or + // DM switch), whatever the old timeline's row callbacks did to + // isAtBottom on the way out. + guard let mark else { return .land } + // Emptied (e.g. /clear): nothing to scroll to. + guard !messages.isEmpty else { return .hold } + // A new message of mine takes the reader to it from anywhere. New + // means appended since the mark (the newest message is then the last + // one counted): an older message of mine that a removal left newest + // (a retracted bridge alias, a dropped empty voice burst) is not. + if newestIsOwn && appendedCount > 0 { + return .follow + } + if !isAtBottom { + return .keepPosition + } + // At the bottom, follow any change that can move the rendered rows: a + // late message landing among the visible rows pushes the newest one + // off screen. Only provably unchanged rows skip the scroll, which + // would be a no-op, and a no-op scrollTo stays queued until the next + // layout pass: the reader's next drag, which it snaps back down. + let isWindowUnchanged = isRenderedWindowUnchanged( + since: mark, + appendedCount: appendedCount, + windowCount: windowCount, + in: messages + ) + return isWindowUnchanged ? .hold : .follow + } + + /// Whether the window rendered at the bottom (the last `windowCount` + /// messages) holds exactly the rows it held at `mark`: nothing appended, + /// the same message count, a full window starting at the same message. + /// True for a late message landing above the window at the store cap + /// (it evicts the oldest message, also above the window). + static func isRenderedWindowUnchanged( + since mark: TimelineMark, + appendedCount: Int, + windowCount: Int, + in messages: [BitchatMessage] + ) -> Bool { + appendedCount == 0 + && mark.count == messages.count + && mark.windowCount == windowCount + && messages.count >= windowCount + && mark.windowFirstMessageID == messages.suffix(windowCount).first?.id + } + + /// Whether a row ID (`"|"`) belongs to the timeline + /// of `contextKey`. Row callbacks lag a switch, so the list checks them + /// against the context it has landed in. + static func isRow(_ rowID: String, inContext contextKey: String) -> Bool { + rowID.hasPrefix(contextKey + "|") + } + + /// Whether the retry of a scroll to the bottom has anything to do: not + /// once the newest row is the one confirmed on screen, where the + /// scrollTo would be a no-op that stays queued until the reader's next + /// drag. + static func needsScroll(toNewestRowID newestRowID: String?, bottomRowID: String?) -> Bool { + guard let newestRowID else { return false } + return newestRowID != bottomRowID + } + + /// Messages the unseen counters may book as "new": rows that render as + /// human messages. System lines render as narration and whitespace-only + /// content never renders at all, so neither belongs in the pill count. + static func isUnseenEligible(_ message: BitchatMessage) -> Bool { + message.sender != "system" && !message.content.trimmed.isEmpty + } + + /// Messages after `mark`'s newest one. Located by ID, not by count: at + /// the store cap every append evicts the oldest message, so the count + /// stops moving. If that message is gone (evicted by a burst larger than + /// the cap, or removed), everything newer than its timestamp counts. + static func appended(since mark: TimelineMark, in messages: [BitchatMessage]) -> Appended { + let start: Int + if let newestID = mark.newestMessageID, let newestTimestamp = mark.newestTimestamp { + if let index = messages.lastIndex(where: { $0.id == newestID }) { + start = index + 1 + } else { + // The timeline is timestamp-ordered. + start = (messages.lastIndex(where: { $0.timestamp <= newestTimestamp }) ?? -1) + 1 + } + } else { + start = 0 + } + + var appended = Appended() + for message in messages[start...] { + appended.total += 1 + if isUnseenEligible(message) { + appended.unseen += 1 + } + } + return appended + } + + /// Window size that keeps a scrolled-up reader's rows in place. The + /// window is a suffix of the timeline, so every message landing below its + /// first row (appended, or a late one inserted mid-timeline) would push + /// that row out of the top and shift everything on screen up a row. + /// While `mark` still describes the window, its first message stays + /// first; otherwise the window grows by `appendedCount`. Never shrinks, + /// never grows past the timeline. + static func windowCountKeepingFirstRow( + current: Int, + mark: TimelineMark?, + appendedCount: Int, + in messages: [BitchatMessage] + ) -> Int { + let needed: Int + if let mark, + mark.windowCount == current, + let firstID = mark.windowFirstMessageID, + let index = messages.lastIndex(where: { $0.id == firstID }) { + needed = messages.count - index + } else { + needed = current + appendedCount + } + return max(current, min(messages.count, needed)) + } + + /// Whether the window's first row coming on screen may grow the window. + /// Not while an earlier expansion's position restore is still queued: the + /// rows it inserted put a new first row on screen at once, and expanding + /// again cascades straight to the oldest message. And not before the list + /// has settled at the bottom of this context: the initial scroll down to + /// the newest row starts with the top rows on screen. + static func canExpandWindow( + isRestoringExpansion: Bool, + bottomSettledContextKey: String?, + contextKey: String + ) -> Bool { + !isRestoringExpansion && bottomSettledContextKey == contextKey + } + + /// Whether a throttled follow-scroll should still run when its timer + /// fires. The timer is held back while a drag is tracking, so by then the + /// reader may have scrolled away from the bottom or switched timelines. + static func shouldRunDeferredFollowScroll( + isAtBottom: Bool, + scheduledContextKey: String, + currentContextKey: String + ) -> Bool { + isAtBottom && scheduledContextKey == currentContextKey + } +} + +extension MessageListScrollPolicy.TimelineMark { + /// Marks `messages` as seen, with the window taken at `windowCount`. + init(contextKey: String, messages: [BitchatMessage], windowCount: Int) { + self.init( + contextKey: contextKey, + newestMessageID: messages.last?.id, + newestTimestamp: messages.last?.timestamp, + windowFirstMessageID: messages.suffix(windowCount).first?.id, + windowCount: windowCount, + count: messages.count + ) + } + + /// The same mark with the window re-taken at `windowCount` (after the + /// window was resized without anything new being seen). + func withWindowCount(_ windowCount: Int, in messages: [BitchatMessage]) -> Self { + Self( + contextKey: contextKey, + newestMessageID: newestMessageID, + newestTimestamp: newestTimestamp, + windowFirstMessageID: messages.suffix(windowCount).first?.id, + windowCount: windowCount, + count: count + ) + } +} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 1894a347..58722957 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -14,6 +14,34 @@ private struct MessageDisplayItem: Identifiable { let message: BitchatMessage } +/// What the list's change detection keys on. The count alone stops moving +/// once the store's cap evicts the oldest message on every append, so the IDs +/// at both ends ride along, and the context key makes a switch to another +/// timeline one change even between two empty ones. The DM peer rides along +/// because the deprecated `onChange(of:perform:)` runs the closure from the +/// previous render, whose `privatePeer` is still the old one right after a +/// DM switch. +private struct TimelineSignature: Equatable { + let privatePeer: PeerID? + let contextKey: String + // periphery:ignore - read only via the synthesized Equatable + // conformance (onChange's comparison), which the indexer cannot + // attribute. + let count: Int + // periphery:ignore - see `count`. + let firstMessageID: String? + // periphery:ignore - see `count`. + let lastMessageID: String? + + init(privatePeer: PeerID?, contextKey: String, messages: [BitchatMessage]) { + self.privatePeer = privatePeer + self.contextKey = contextKey + count = messages.count + firstMessageID = messages.first?.id + lastMessageID = messages.last?.id + } +} + struct MessageListView: View { @EnvironmentObject private var publicChatModel: PublicChatModel @EnvironmentObject private var privateInboxModel: PrivateInboxModel @@ -43,11 +71,24 @@ struct MessageListView: View { @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? @State private var unseenCount = 0 - @State private var lastSeenMessageCount = 0 - /// Context key the unseen counters were baselined against. Channel - /// switches swap the timeline wholesale, so a count delta is only a - /// "new messages" signal while the context is unchanged. - @State private var unseenBaselineKey = "" + /// The timeline as of the last change this list observed: its newest + /// message (the next change counts what landed after it) and the + /// window's first row (kept in place while the reader is scrolled up). + /// Marks are per context: channel switches swap the timeline wholesale, + /// so a difference only means "new messages" within one context. Its + /// context is the one the list has landed in; row callbacks from any + /// other timeline are ignored. + @State private var timelineMark: MessageListScrollPolicy.TimelineMark? + /// Row whose appearance last put the list at the bottom, cleared when it + /// leaves the screen. Its leaving means the reader scrolled up, even while + /// a newer row that landed below it has not appeared yet. + @State private var bottomRowID: String? + /// Set while an expansion's position-restoring scroll is still queued. + @State private var isRestoringExpansion = false + /// Context whose newest row has appeared since the list appeared or + /// landed in it, i.e. whose scroll to the bottom has landed. Window + /// expansion waits for it. + @State private var bottomSettledContextKey: String? /// Whether this instance holds the nearby-notes counter active (mesh /// public timeline only); balanced against activate/deactivate. @State private var holdsNotesCounter = false @@ -55,28 +96,31 @@ struct MessageListView: View { @ThemedPalette private var palette var body: some View { - let currentWindowCount: Int = { - if let peer = privatePeer { - return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate - } - return windowCountPublic - }() + let currentWindowCount = windowCount(for: privatePeer) let messages = conversationMessages(for: privatePeer) - let windowedMessages = Array(messages.suffix(currentWindowCount)) - - let contextKey: String = { - if let peer = privatePeer { - "dm:\(peer)" - } else { - locationChannelsModel.selectedChannel.contextKey - } - }() + let contextKey = timelineContextKey(for: privatePeer) + // While the reader is scrolled up, messages landing below the + // window's first row widen the window in this same render instead of + // pushing that row out of the top, which would shift what they are + // reading (onTimelineChange then records the wider window). + let renderedWindowCount = isAtBottom + ? currentWindowCount + : MessageListScrollPolicy.windowCountKeepingFirstRow( + current: currentWindowCount, + mark: contextMark(for: contextKey), + appendedCount: 0, + in: messages + ) + let windowedMessages = Array(messages.suffix(renderedWindowCount)) let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in guard !message.content.trimmed.isEmpty else { return nil } return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) } + // Top edge of the rendered window (whitespace-only messages get no + // row, so they could never report reaching it). + let firstRowID = messageItems.first?.id VStack(spacing: 0) { // Notes pinned to this place stay visible while chatting — a @@ -97,11 +141,25 @@ struct MessageListView: View { let message = item.message messageRow(for: message) .onAppear { - if message.id == windowedMessages.last?.id { + // Callbacks lag a timeline switch: the new + // rows appear before the old ones disappear, + // each with its own render's view of which row + // was last. Only rows of the timeline the list + // has landed in may move its state, checked + // against live state rather than the render's. + guard isLandedRow(item.id) else { return } + if item.id == newestRowID(for: privatePeer) { isAtBottom = true unseenCount = 0 + if bottomRowID != item.id { + bottomRowID = item.id + } + if bottomSettledContextKey != contextKey { + bottomSettledContextKey = contextKey + } + returnWindowToInitialSize(for: privatePeer, contextKey: contextKey, messages: messages) } - if message.id == windowedMessages.first?.id, + if item.id == firstRowID, messages.count > windowedMessages.count { expandWindow( ifNeededFor: message, @@ -112,8 +170,16 @@ struct MessageListView: View { } } .onDisappear { - if message.id == windowedMessages.last?.id { + // A row of the landed timeline shares this + // closure's peer, so newestRowID is live here. + guard isLandedRow(item.id) else { return } + if item.id == newestRowID(for: privatePeer) || item.id == bottomRowID { isAtBottom = false + bottomRowID = nil + // Leaving the bottom drops a follow-scroll + // still waiting on the throttle: it would + // yank the reader back down. + cancelFollowScroll() } } .contentShape(Rectangle()) @@ -170,7 +236,6 @@ struct MessageListView: View { .background(message.isArchivedEcho ? palette.secondary.opacity(0.08) : Color.clear) } } - .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 2) // Only carried history on screen: the ambient layer (radar, @@ -203,19 +268,18 @@ struct MessageListView: View { Button("common.cancel", role: .cancel) {} } .onAppear { - scrollToBottom(on: proxy) + // Runs before the rows' onAppear: lands the list (and sets the + // mark the row callbacks are checked against) before any row + // reports in. Expansion waits until the scroll below has + // brought the newest row on screen. + bottomSettledContextKey = nil + scrollToBottom(on: proxy, privatePeer: privatePeer) } - .onChange(of: privatePeer) { _ in - scrollToBottom(on: proxy) - } - .onChange(of: publicChatModel.messages.count) { _ in - onMessagesChange(proxy: proxy) - } - .onChange(of: privateMessageCount(for: privatePeer)) { _ in - onPrivateChatsChange(proxy: proxy) - } - .onChange(of: locationChannelsModel.selectedChannel) { newChannel in - onSelectedChannelChange(newChannel, proxy: proxy) + // The one change handler: a channel or DM switch changes the + // signature too, so landing in the new timeline happens here, + // ahead of that update's row callbacks. + .onChange(of: TimelineSignature(privatePeer: privatePeer, contextKey: contextKey, messages: messages)) { signature in + onTimelineChange(signature, proxy: proxy) } .confirmationDialog( selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"), @@ -271,7 +335,7 @@ struct MessageListView: View { } } .onDisappear { - scrollThrottleTimer?.invalidate() + cancelFollowScroll() } } } @@ -306,11 +370,61 @@ private extension MessageListView { } } - var currentContextKey: String { + /// Context key of the timeline shown for `privatePeer` (the DM, or the + /// public channel); also the prefix of its row IDs. The public key comes + /// from the channel PublicChatModel publishes together with its messages: + /// the selected channel switches one render or more before the timeline + /// does, and keying the old messages with the new channel re-keys (and + /// re-appears) every row. + func timelineContextKey(for privatePeer: PeerID?) -> String { if let peer = privatePeer { return "dm:\(peer)" } - return locationChannelsModel.selectedChannel.contextKey + return publicChatModel.activeChannel.contextKey + } + + func windowCount(for privatePeer: PeerID?) -> Int { + if let peer = privatePeer { + return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate + } + return windowCountPublic + } + + func setWindowCount(_ count: Int, for privatePeer: PeerID?) { + if let peer = privatePeer { + windowCountPrivate[peer] = count + } else { + windowCountPublic = count + } + } + + /// Returns the window to its initial size once the reader is back at the + /// bottom (the newest row has appeared): whatever it grew by while they + /// were scrolled up, or expanded into history, need not stay rendered, + /// and dropping rows above the bottom moves nothing on screen. Never in + /// the same update as a scroll: rows changing above the viewport make + /// SwiftUI hold its position, and a pending scrollTo never lands. + func returnWindowToInitialSize(for privatePeer: PeerID?, contextKey: String, messages: [BitchatMessage]) { + let initialCount = privatePeer == nil + ? TransportConfig.uiWindowInitialCountPublic + : TransportConfig.uiWindowInitialCountPrivate + guard windowCount(for: privatePeer) > initialCount else { return } + setWindowCount(initialCount, for: privatePeer) + timelineMark = contextMark(for: contextKey)?.withWindowCount(initialCount, in: messages) + } + + /// The timeline mark, if it was taken in `contextKey`. + func contextMark(for contextKey: String) -> MessageListScrollPolicy.TimelineMark? { + guard let timelineMark, timelineMark.contextKey == contextKey else { return nil } + return timelineMark + } + + /// Whether a row belongs to the timeline the list has landed in (the + /// mark's context: the change handler lands a switch before the rows of + /// that update report in). + func isLandedRow(_ rowID: String) -> Bool { + guard let landedContextKey = timelineMark?.contextKey else { return false } + return MessageListScrollPolicy.isRow(rowID, inContext: landedContextKey) } /// Tappable strip above the mesh timeline while notes are pinned at this @@ -412,30 +526,6 @@ private extension MessageListView { .fixedSize(horizontal: false, vertical: true) } - /// Messages the unseen counters may book as "new": rows that render as - /// human messages. System lines render as narration and whitespace-only - /// content never renders at all, so neither belongs in the pill count. - func unseenEligibleCount(in messages: [BitchatMessage]) -> Int { - messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count - } - - /// Updates the unseen-count baseline for the current context and returns - /// how many messages were appended since the last observation. A context - /// change (timeline swapped wholesale) re-baselines and reports zero, so - /// cross-channel count differences are never booked as "new" messages. - func rebaselinedAppendedCount(newCount: Int) -> Int { - let key = currentContextKey - if unseenBaselineKey != key { - unseenBaselineKey = key - unseenCount = 0 - lastSeenMessageCount = newCount - return 0 - } - let appended = max(0, newCount - lastSeenMessageCount) - lastSeenMessageCount = newCount - return appended - } - /// A failed private text message of our own can be resent through the /// normal send path (the context menu removes the failed original and /// re-submits its content). @@ -467,7 +557,7 @@ private extension MessageListView { /// message via the existing scrollToBottom helper. func jumpToLatestPill(proxy: ScrollViewProxy) -> some View { Button { - scrollToBottom(on: proxy) + scrollToBottom(on: proxy, privatePeer: privatePeer) } label: { HStack(spacing: 4) { Image(systemName: "arrow.down") @@ -533,32 +623,28 @@ private extension MessageListView { allMessages: [BitchatMessage], privatePeer: PeerID?, proxy: ScrollViewProxy) { - let step = TransportConfig.uiWindowStepCount - let contextKey: String = { - if let peer = privatePeer { - "dm:\(peer)" - } else { - locationChannelsModel.selectedChannel.contextKey - } - }() - let preserveID = "\(contextKey)|\(message.id)" + let contextKey = timelineContextKey(for: privatePeer) + guard MessageListScrollPolicy.canExpandWindow( + isRestoringExpansion: isRestoringExpansion, + bottomSettledContextKey: bottomSettledContextKey, + contextKey: contextKey + ) else { return } - if let peer = privatePeer { - let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate - let newCount = min(allMessages.count, current + step) - guard newCount != current else { return } - windowCountPrivate[peer] = newCount - DispatchQueue.main.async { - proxy.scrollTo(preserveID, anchor: .top) - } - } else { - let current = windowCountPublic - let newCount = min(allMessages.count, current + step) - guard newCount != current else { return } - windowCountPublic = newCount - DispatchQueue.main.async { - proxy.scrollTo(preserveID, anchor: .top) - } + let current = windowCount(for: privatePeer) + let newCount = min(allMessages.count, current + TransportConfig.uiWindowStepCount) + guard newCount != current else { return } + setWindowCount(newCount, for: privatePeer) + // The window's first row moved up with it: re-take the mark's window + // so a scrolled-up reader keeps holding the new first row. + timelineMark = contextMark(for: contextKey)?.withWindowCount(newCount, in: allMessages) + + // Until the restore below lands, the rows just inserted above leave a + // new first row on screen; it must not expand the window again. + let preserveID = "\(contextKey)|\(message.id)" + isRestoringExpansion = true + DispatchQueue.main.async { + proxy.scrollTo(preserveID, anchor: .top) + isRestoringExpansion = false } } @@ -593,155 +679,161 @@ private extension MessageListView { } } - func scrollToBottom(on proxy: ScrollViewProxy) { + func scrollToBottom(on proxy: ScrollViewProxy, privatePeer: PeerID?) { + // A follow-scroll still pending (possibly for a timeline being left) + // has nothing left to do. + cancelFollowScroll() isAtBottom = true unseenCount = 0 - lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer)) - unseenBaselineKey = currentContextKey - if let targetPeerID { - proxy.scrollTo(targetPeerID, anchor: .bottom) + timelineMark = MessageListScrollPolicy.TimelineMark( + contextKey: timelineContextKey(for: privatePeer), + messages: conversationMessages(for: privatePeer), + windowCount: windowCount(for: privatePeer) + ) + if let target = newestRowID(for: privatePeer) { + proxy.scrollTo(target, anchor: .bottom) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - if let secondTarget = self.targetPeerID { + // Retry only a scroll that has not landed. Once the newest row is + // on screen the retry would be a no-op, and a no-op scrollTo stays + // queued until the next layout pass: the reader's next drag. + if let secondTarget = self.newestRowID(for: privatePeer), + MessageListScrollPolicy.needsScroll(toNewestRowID: secondTarget, bottomRowID: bottomRowID) { proxy.scrollTo(secondTarget, anchor: .bottom) } } } - var targetPeerID: String? { - if let peer = privatePeer, - let last = privateInboxModel.messages(for: peer).last?.id { - return "dm:\(peer)|\(last)" - } - if let last = publicChatModel.messages.last?.id { - return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)" - } - return nil + /// Lands the list at the newest message of a timeline it was not showing + /// (a channel or DM switch): nothing about the old timeline's bottom + /// carries over. + func land(on proxy: ScrollViewProxy, privatePeer: PeerID?) { + bottomRowID = nil + bottomSettledContextKey = nil + scrollToBottom(on: proxy, privatePeer: privatePeer) } - func onMessagesChange(proxy: ScrollViewProxy) { - guard privatePeer == nil else { return } - let messages = publicChatModel.messages - let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) - guard let lastMsg = messages.last else { - // Timeline emptied (e.g. /clear): nothing below to jump to. - unseenCount = 0 + /// Row ID of the newest message that has a row (whitespace-only + /// messages render none). + func newestRowID(for privatePeer: PeerID?) -> String? { + guard let newest = conversationMessages(for: privatePeer).last(where: { !$0.content.trimmed.isEmpty }) else { + return nil + } + return "\(timelineContextKey(for: privatePeer))|\(newest.id)" + } + + /// Handles any change to the shown timeline: an append, a late message + /// inserted mid-timeline, an eviction at the cap, a removal, or a + /// different timeline altogether (a channel or DM switch). + func onTimelineChange(_ signature: TimelineSignature, proxy: ScrollViewProxy) { + // The deprecated onChange runs the previous render's closure, so the + // timeline comes from the new value rather than `self.privatePeer`. + let privatePeer = signature.privatePeer + let contextKey = signature.contextKey + let messages = conversationMessages(for: privatePeer) + + // No mark in this context means the timeline was swapped wholesale: + // nothing is booked as "new", the list lands instead. + let mark = contextMark(for: contextKey) + let appended = mark.map { MessageListScrollPolicy.appended(since: $0, in: messages) } + ?? MessageListScrollPolicy.Appended() + let currentWindowCount = windowCount(for: privatePeer) + let reaction = MessageListScrollPolicy.reaction( + mark: mark, + messages: messages, + newestIsOwn: messages.last.map { conversationUIModel.isSentByCurrentUser($0) } ?? false, + appendedCount: appended.total, + windowCount: currentWindowCount, + isAtBottom: isAtBottom + ) + + if reaction == .land { + land(on: proxy, privatePeer: privatePeer) return } - // If the newest message is from me, always scroll to bottom - let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) - if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom - unseenCount += appendedCount - return - } else { // Ensure we consider ourselves at bottom for subsequent messages + // Record the window this render kept: for a scrolled-up reader, + // widened over what landed below its first row instead of dropping + // that row (grown by the appended count if the mark lost track of + // it). A follow keeps it too: rows changing above the viewport in the + // same update as the scroll make SwiftUI hold its position instead. + let keptWindowCount = isAtBottom + ? currentWindowCount + : MessageListScrollPolicy.windowCountKeepingFirstRow( + current: currentWindowCount, + mark: mark, + appendedCount: appended.total, + in: messages + ) + if keptWindowCount != currentWindowCount { + setWindowCount(keptWindowCount, for: privatePeer) + } + timelineMark = MessageListScrollPolicy.TimelineMark(contextKey: contextKey, messages: messages, windowCount: keptWindowCount) + + switch reaction { + case .keepPosition: + unseenCount += appended.unseen + case .follow: + // Ensure we consider ourselves at bottom for subsequent messages isAtBottom = true unseenCount = 0 - } - - func scrollIfNeeded(date: Date) { - lastScrollTime = date - let contextKey = locationChannelsModel.selectedChannel.contextKey - if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) { - proxy.scrollTo(target, anchor: .bottom) + followToBottom(privatePeer: privatePeer, proxy: proxy) + case .hold: + if messages.isEmpty { + // Timeline emptied (e.g. /clear): nothing below to jump to. + unseenCount = 0 } + case .land: + break } + } - // Throttle scroll animations to prevent excessive UI updates + /// Scrolls to the newest row, at most once per throttle interval. A + /// throttled scroll decides when its timer fires: the timer is held back + /// while a drag is tracking, so by then the reader may have scrolled + /// away or switched timelines, and newer rows may have landed. + func followToBottom(privatePeer: PeerID?, proxy: ScrollViewProxy) { let now = Date() if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { // Immediate scroll if enough time has passed - scrollIfNeeded(date: now) - } else { - // Schedule a delayed scroll - scrollThrottleTimer?.invalidate() - scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in - Task { @MainActor in - scrollIfNeeded(date: Date()) - } + scrollToNewestRow(privatePeer: privatePeer, proxy: proxy, at: now) + return + } + // Schedule a delayed scroll + let scheduledContextKey = timelineContextKey(for: privatePeer) + scrollThrottleTimer?.invalidate() + scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in + Task { @MainActor in + guard MessageListScrollPolicy.shouldRunDeferredFollowScroll( + isAtBottom: isAtBottom, + scheduledContextKey: scheduledContextKey, + currentContextKey: timelineContextKey(for: privatePeer) + ) else { return } + scrollToNewestRow(privatePeer: privatePeer, proxy: proxy, at: Date()) } } } - func onPrivateChatsChange(proxy: ScrollViewProxy) { - guard let peerID = privatePeer else { return } - let messages = privateInboxModel.messages(for: peerID) - let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) - guard let lastMsg = messages.last else { - // Timeline emptied (e.g. /clear): nothing below to jump to. - unseenCount = 0 - return - } - - // If the newest private message is from me, always scroll - let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) - if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom - unseenCount += appendedCount - return - } else { - isAtBottom = true - unseenCount = 0 - } - - func scrollIfNeeded(date: Date) { - lastScrollTime = date - let contextKey = "dm:\(peerID)" - if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) { - proxy.scrollTo(target, anchor: .bottom) - } - } - - // Same throttling for private chats - let now = Date() - if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds { - scrollIfNeeded(date: now) - } else { - scrollThrottleTimer?.invalidate() - scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in - Task { @MainActor in - scrollIfNeeded(date: Date()) - } - } - } - } - - func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { - // When switching to a new geohash channel, scroll to the bottom - guard privatePeer == nil else { return } - // Invalidate the unseen baseline: the timeline is about to swap (or - // already has — the ordering of this onChange vs the count onChange - // is not guaranteed), so the next count observation re-baselines - // instead of booking the cross-channel difference as "new". - unseenCount = 0 - unseenBaselineKey = "" - // Entering any public channel shows its latest messages: a channel - // switch swaps the timeline wholesale, so the prior scroll offset is - // meaningless. Landing at the bottom keeps isAtBottom honest (no - // stale jump-to-latest pill) and matches standard chat behavior. - isAtBottom = true - windowCountPublic = TransportConfig.uiWindowInitialCountPublic - let contextKey: String - switch channel { - case .mesh: - contextKey = "mesh" - case .location(let ch): - contextKey = "geo:\(ch.geohash)" - } - if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { + func scrollToNewestRow(privatePeer: PeerID?, proxy: ScrollViewProxy, at date: Date) { + lastScrollTime = date + if let target = newestRowID(for: privatePeer) { proxy.scrollTo(target, anchor: .bottom) } } + /// Drops a follow-scroll still waiting on the throttle. + func cancelFollowScroll() { + guard let timer = scrollThrottleTimer else { return } + timer.invalidate() + scrollThrottleTimer = nil + } + func conversationMessages(for privatePeer: PeerID?) -> [BitchatMessage] { if let privatePeer { return privateInboxModel.messages(for: privatePeer) } return publicChatModel.messages } - - func privateMessageCount(for privatePeer: PeerID?) -> Int { - conversationMessages(for: privatePeer).count - } } private extension ChannelID { diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index fd438f10..f487d5ca 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -3,8 +3,27 @@ // Shared text parsing helpers for message rendering. // +import BitFoundation import Foundation +extension BitchatMessage { + /// The row's payment chips and collapse decision, scanned on first use + /// and cached on the message: a row re-renders far more often than its + /// immutable `content` could change. + var contentScan: ContentScan { + if let cached = getCachedContentScan() { + return cached + } + let scan = ContentScan( + cashuLinks: content.extractCashuLinks(), + lightningLinks: content.extractLightningLinks(), + isLongForDisplay: content.isLongForDisplay() + ) + setCachedContentScan(scan) + return scan + } +} + extension String { // Detect if there is an extremely long token (no whitespace/newlines) that could break layout func hasVeryLongToken(threshold: Int) -> Bool { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 574f66cc..ef5dfb92 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -711,6 +711,44 @@ struct AppArchitectureTests { #expect(transport.sentMessages.last?.content == "hello mesh") } + @Test("ConversationUIModel republishes only changed composer state") + @MainActor + func conversationUIModelSkipsUnchangedComposerState() async { + let viewModel = makeArchitectureViewModel() + let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) + let privateConversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversations: viewModel.conversations, + locationChannelsModel: locationChannelsModel + ) + let uiModel = ConversationUIModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel, + conversations: viewModel.conversations + ) + + // Bindings deliver on the main queue in FIFO order: once this change + // lands, every initial value delivered before it has landed too. + viewModel.selectedAutocompleteIndex = 1 + await waitUntil { uiModel.selectedAutocompleteIndex == 1 } + + var publishCount = 0 + let cancellable = uiModel.objectWillChange.sink { publishCount += 1 } + defer { cancellable.cancel() } + + // Re-sent equal values (what every typing pause used to produce) ... + viewModel.showAutocomplete = viewModel.showAutocomplete + viewModel.autocompleteSuggestions = viewModel.autocompleteSuggestions + viewModel.selectedAutocompleteIndex = 1 + viewModel.activePublicVoiceTalker = viewModel.activePublicVoiceTalker + // ... are dropped: only the real change queued behind them publishes. + viewModel.selectedAutocompleteIndex = 2 + await waitUntil { uiModel.selectedAutocompleteIndex == 2 } + + #expect(uiModel.selectedAutocompleteIndex == 2) + #expect(publishCount == 1) + } + @Test("VerificationModel bridges selected conversation and fingerprint actions") @MainActor func verificationModelBridgesSelectedConversationAndFingerprintActions() async { diff --git a/bitchatTests/BitchatMessageMediaTests.swift b/bitchatTests/BitchatMessageMediaTests.swift new file mode 100644 index 00000000..2703b525 --- /dev/null +++ b/bitchatTests/BitchatMessageMediaTests.swift @@ -0,0 +1,90 @@ +// +// BitchatMessageMediaTests.swift +// bitchatTests +// +// Which messages render as local media rows, and which file such a row +// loads. The filename comes from (possibly remote) message text, so it must +// never resolve outside the media directory it names. +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BitchatMessageMediaTests { + private static let meshPeerID = PeerID(str: "0011223344556677") + private static let nostrPubkey = String(repeating: "ab", count: 32) + + private func message(_ content: String, sender: String = "alice", senderPeerID: PeerID? = meshPeerID) -> BitchatMessage { + BitchatMessage(sender: sender, content: content, timestamp: Date(), isRelay: false, senderPeerID: senderPeerID) + } + + @Test + func meshMediaResolvesInsideItsCategoryDirectory() { + guard case .image(let imageURL)? = message("[image] img_1.jpg").mediaAttachment(for: "me"), + case .voice(let voiceURL)? = message("[voice] note 1.m4a").mediaAttachment(for: "me"), + case .image(let ownImageURL)? = message("[image] img_2.jpg", sender: "me").mediaAttachment(for: "me") + else { + Issue.record("Expected mesh media messages to resolve to media rows") + return + } + + #expect(imageURL.lastPathComponent == "img_1.jpg") + #expect(imageURL.deletingLastPathComponent().path.hasSuffix("/files/images/incoming")) + #expect(voiceURL.lastPathComponent == "note 1.m4a") + #expect(voiceURL.deletingLastPathComponent().path.hasSuffix("/files/voicenotes/incoming")) + #expect(ownImageURL.deletingLastPathComponent().path.hasSuffix("/files/images/outgoing")) + } + + @Test + func nostrSendersNeverGetMediaRows() { + // Geochats, geo DMs and bridged messages carry no files, so a + // media-looking line from a Nostr sender must stay text instead of + // loading some local file. + let geoChatSender = PeerID(nostr: Self.nostrPubkey) + let geoDMSender = PeerID(nostr_: Self.nostrPubkey) + let bridgedSender = PeerID(bridge: Self.nostrPubkey) + #expect(geoChatSender.isGeoChat) + #expect(geoDMSender.isGeoDM) + #expect(bridgedSender.isBridge) + + for content in ["[image] img_1.jpg", "[voice] note.m4a"] { + #expect(message(content, senderPeerID: geoChatSender).mediaAttachment(for: "me") == nil) + #expect(message(content, senderPeerID: geoDMSender).mediaAttachment(for: "me") == nil) + #expect(message(content, senderPeerID: bridgedSender).mediaAttachment(for: "me") == nil) + #expect(message(content, sender: "me", senderPeerID: geoChatSender).mediaAttachment(for: "me") == nil) + } + // A message without a sender ID keeps the mesh behavior. + #expect(message("[image] img_1.jpg", senderPeerID: nil).mediaAttachment(for: "me") != nil) + } + + @Test + func filenamesThatAreNotOnePlainComponentAreRejected() { + let unsafeNames = [ + "../outgoing/x.jpg", + "../../Library/secret.jpg", + "a/b.jpg", + "/etc/hosts", + "x.jpg/", + "..", + ".", + "a\\b.jpg", + "..\\outgoing\\x.jpg" + ] + for name in unsafeNames { + #expect(!BitchatMessage.isSafeMediaFilename(name), "\(name)") + #expect(message("[image] \(name)").mediaAttachment(for: "me") == nil, "\(name)") + #expect(message("[voice] \(name)").mediaAttachment(for: "me") == nil, "\(name)") + } + #expect(!BitchatMessage.isSafeMediaFilename("")) + #expect(message("[image] ").mediaAttachment(for: "me") == nil) + } + + @Test + func plainFilenamesAreAccepted() { + for name in ["img_20260101_120000.jpg", "note 1.m4a", "photo...jpg", "...", "émoji-😀.png"] { + #expect(BitchatMessage.isSafeMediaFilename(name), "\(name)") + } + } +} diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift index d38ddc8a..6c37e845 100644 --- a/bitchatTests/ChatComposerCoordinatorContextTests.swift +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -22,11 +22,13 @@ import BitFoundation /// `ChatComposerCoordinator` is testable without a `ChatViewModel`. @MainActor private final class MockChatComposerContext: ChatComposerContext { - // Autocomplete UI state - var autocompleteSuggestions: [String] = [] - var autocompleteRange: NSRange? - var showAutocomplete = false - var selectedAutocompleteIndex = -1 + // Autocomplete UI state. Every write is counted: each one republishes the + // composer state (and re-renders the message rows) in the real owner. + var autocompleteSuggestions: [String] = [] { didSet { autocompleteStateWrites += 1 } } + var autocompleteRange: NSRange? { didSet { autocompleteStateWrites += 1 } } + var showAutocomplete = false { didSet { autocompleteStateWrites += 1 } } + var selectedAutocompleteIndex = -1 { didSet { autocompleteStateWrites += 1 } } + private(set) var autocompleteStateWrites = 0 var queryResult: (suggestions: [String], range: NSRange?) = ([], nil) private(set) var queriedPeerCandidates: [[String]] = [] private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = [] @@ -158,6 +160,57 @@ struct ChatComposerCoordinatorContextTests { #expect(geoContext.queriedPeerCandidates == [["carol#dddd"]]) } + @Test @MainActor + func updateAutocomplete_withoutMentionTokenAtCursor_skipsCandidateQuery() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"] + context.queryResult = (["@alice"], NSRange(location: 3, length: 3)) + + // Plain text, and a mention that no longer ends at the cursor: the + // query could not suggest anything, so it never runs. + coordinator.updateAutocomplete(for: "plain text", cursorPosition: 10) + coordinator.updateAutocomplete(for: "hi @al there", cursorPosition: 12) + #expect(context.queriedPeerCandidates.isEmpty) + #expect(context.autocompleteSuggestions.isEmpty) + #expect(!context.showAutocomplete) + + // The same text with the cursor right after the token queries. + coordinator.updateAutocomplete(for: "hi @al there", cursorPosition: 6) + #expect(context.queriedPeerCandidates == [["alice"]]) + #expect(context.autocompleteSuggestions == ["@alice"]) + #expect(context.showAutocomplete) + } + + @Test @MainActor + func updateAutocomplete_rewritesNothingWhenStateIsUnchanged() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"] + + // The first clear only has to reset the highlight index. + coordinator.updateAutocomplete(for: "hello", cursorPosition: 5) + #expect(context.autocompleteStateWrites == 1) + #expect(context.selectedAutocompleteIndex == 0) + + // Typing pauses in plain text then leave the state untouched. + coordinator.updateAutocomplete(for: "hello there", cursorPosition: 11) + #expect(context.autocompleteStateWrites == 1) + + // Showing suggestions writes; re-querying the same result does not. + context.queryResult = (["@alice"], NSRange(location: 0, length: 2)) + coordinator.updateAutocomplete(for: "@a", cursorPosition: 2) + let writesAfterShowing = context.autocompleteStateWrites + #expect(writesAfterShowing == 4) + coordinator.updateAutocomplete(for: "@a", cursorPosition: 2) + #expect(context.autocompleteStateWrites == writesAfterShowing) + + // A moved highlight is still reset to the first suggestion. + context.selectedAutocompleteIndex = 2 + coordinator.updateAutocomplete(for: "@a", cursorPosition: 2) + #expect(context.selectedAutocompleteIndex == 0) + } + @Test @MainActor func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() { let context = MockChatComposerContext() diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 13424e0f..fb1b824b 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -30,13 +30,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon var currentGeohash: String? var nickname = "me" var myPeerID = PeerID(str: "0011223344556677") - private(set) var isBatchingPublic = false private(set) var notifyUIChangedCount = 0 - func setPublicBatching(_ isBatching: Bool) { - isBatchingPublic = isBatching - } - func notifyUIChanged() { notifyUIChangedCount += 1 } @@ -541,9 +536,6 @@ struct ChatPublicConversationCoordinatorContextTests { coordinator.pipelinePrewarmMessage(pipeline, message: message) #expect(context.prewarmedMessageIDs == ["pipeline-msg"]) - - coordinator.pipelineSetBatchingState(pipeline, isBatching: true) - #expect(context.isBatchingPublic) } @Test @MainActor diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 51e5f7fd..3c8bf357 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -823,8 +823,8 @@ struct ChatViewModelGeoDMTests { /// Contracts for the owner-side intent ops that are the sole mutation paths /// for `ChatViewModel`'s shared coordinator state (`nostrKeyMapping`, -/// `sentReadReceipts`, `sentGeoDeliveryAcks`, `isBatchingPublic`, the geo -/// subscription IDs, and the selected private chat hand-off). +/// `sentReadReceipts`, `sentGeoDeliveryAcks`, the geo subscription IDs, and +/// the selected private chat hand-off). struct ChatViewModelIntentOperationTests { @Test @MainActor @@ -890,17 +890,6 @@ struct ChatViewModelIntentOperationTests { #expect(viewModel.nostrKeyMapping[PeerID(nostr_: otherHex)] == otherHex) } - @Test @MainActor - func setPublicBatching_publishesBatchingState() async { - let (viewModel, _) = makeTestableViewModel() - - #expect(!viewModel.isBatchingPublic) - viewModel.setPublicBatching(true) - #expect(viewModel.isBatchingPublic) - viewModel.setPublicBatching(false) - #expect(!viewModel.isBatchingPublic) - } - @Test @MainActor func geoSubscriptionIntentOps_setClearAndDrainSubscriptions() async { let (viewModel, _) = makeTestableViewModel() diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 35ab2975..f4c8c9ce 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -691,6 +691,37 @@ struct ChatViewModelFormattingTests { #expect(String(header.characters) == "<@Alice#a1b2> ") } + + @Test @MainActor + func formatMessageHeader_isCachedApartFromTheFullText() async { + let (viewModel, _) = makeTestableViewModel() + let message = BitchatMessage( + id: "fmt-3", + sender: "Alice#a1b2", + content: "hello", + timestamp: Date(), + isRelay: false, + senderPeerID: PeerID(str: "00000000000000b3") + ) + + // The full text is cached first under the same scheme, theme and + // sender flags; the header lookup must not pick it up. + let fullText = viewModel.formatMessageAsText(message, colorScheme: .dark, theme: .matrix) + let header = viewModel.formatMessageHeader(message, colorScheme: .dark, theme: .matrix) + + #expect(String(header.characters) == "<@Alice#a1b2> ") + #expect(String(fullText.characters).hasPrefix("<@Alice#a1b2> hello")) + #expect( + message.getCachedFormattedText( + isDark: true, + isSelf: false, + variant: ChatMessageFormatter.headerCacheNamespace + ) == header + ) + // Later lookups are answered from each one's own cache entry. + #expect(viewModel.formatMessageHeader(message, colorScheme: .dark, theme: .matrix) == header) + #expect(viewModel.formatMessageAsText(message, colorScheme: .dark, theme: .matrix) == fullText) + } } // MARK: - Verification Tests diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 5de2adea..1a4e6493 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -332,6 +332,40 @@ struct MessageFormattingEngineTests { #expect(content.isLongForDisplay()) } + @Test func contentScan_matchesTheStringScansAndIsCachedOnTheMessage() { + let cashu = "cashuA" + String(repeating: "a", count: 40) + let invoice = "lnbc1" + String(repeating: "q", count: 60) + let long = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + let contents = [ + "plain words", + "pay \(cashu) or lightning:\(invoice) or \(invoice)", + "\(long) \(cashu)", + "same \(cashu) twice \(cashu)" + ] + + for content in contents { + let message = BitchatMessage(sender: "alice", content: content, timestamp: Date(), isRelay: false) + #expect(message.getCachedContentScan() == nil) + + let scan = message.contentScan + #expect(scan.cashuLinks == content.extractCashuLinks()) + #expect(scan.lightningLinks == content.extractLightningLinks()) + #expect(scan.isLongForDisplay == content.isLongForDisplay()) + #expect(message.getCachedContentScan() == scan) + } + } + + @Test func contentScan_servesTheCachedScanWithoutRescanning() { + let cashu = "cashuA" + String(repeating: "a", count: 40) + let message = BitchatMessage(sender: "alice", content: "pay \(cashu)", timestamp: Date(), isRelay: false) + // A sentinel no scan of this content could produce. + let sentinel = BitchatMessage.ContentScan(cashuLinks: [], lightningLinks: ["lightning:sentinel"], isLongForDisplay: true) + + message.setCachedContentScan(sentinel) + + #expect(message.contentScan == sentinel) + } + @MainActor @Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() { let context = MockMessageFormattingContext(nickname: "carol") diff --git a/bitchatTests/MessageListScrollPolicyTests.swift b/bitchatTests/MessageListScrollPolicyTests.swift new file mode 100644 index 00000000..9d6534d0 --- /dev/null +++ b/bitchatTests/MessageListScrollPolicyTests.swift @@ -0,0 +1,400 @@ +// +// MessageListScrollPolicyTests.swift +// bitchatTests +// +// The pure rules behind MessageListView's scroll position: appended-message +// counting (including at the store cap, where the count stops moving), the +// window size that keeps a scrolled-up reader's first row in place, the +// reaction to a timeline change (land, follow, keep position, hold), which +// row callbacks count, and the gates on expansion and follow-scrolls. +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +private typealias Policy = MessageListScrollPolicy + +private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + +private func message(_ index: Int, sender: String = "alice", content: String? = nil, at timestamp: Double? = nil) -> BitchatMessage { + BitchatMessage( + id: "m\(index)", + sender: sender, + content: content ?? "message \(index)", + timestamp: baseDate.addingTimeInterval(timestamp ?? Double(index)), + isRelay: false + ) +} + +private func timeline(_ range: Range) -> [BitchatMessage] { + range.map { message($0) } +} + +/// Inserts a late message (older timestamp) just before `index`. +private func inserting(_ late: BitchatMessage, before index: Int, in messages: [BitchatMessage]) -> [BitchatMessage] { + var copy = messages + copy.insert(late, at: index) + return copy +} + +struct MessageListScrollPolicyTests { + + // MARK: - Timeline marks + + @Test + func timelineMark_recordsTheNewestMessageAndTheWindowsFirstRow() { + let messages = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 4) + + #expect(mark.newestMessageID == "m9") + #expect(mark.newestTimestamp == messages[9].timestamp) + #expect(mark.windowFirstMessageID == "m6") + #expect(mark.windowCount == 4) + + // A window wider than the timeline starts at its oldest message. + #expect(Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 300).windowFirstMessageID == "m0") + // An empty timeline marks nothing. + let empty = Policy.TimelineMark(contextKey: "mesh", messages: [], windowCount: 300) + #expect(empty.newestMessageID == nil) + #expect(empty.windowFirstMessageID == nil) + } + + @Test + func timelineMark_withWindowCountRetakesOnlyTheWindow() { + let messages = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "geo:u4pruy", messages: Array(messages.prefix(8)), windowCount: 4) + + let expanded = mark.withWindowCount(7, in: messages) + + #expect(expanded.contextKey == "geo:u4pruy") + // What was seen stays as it was; the window is re-taken. + #expect(expanded.newestMessageID == "m7") + #expect(expanded.windowFirstMessageID == "m3") + #expect(expanded.windowCount == 7) + } + + // MARK: - Appended messages + + @Test + func appended_countsOnlyMessagesAfterTheMarkedNewest() { + let mark = Policy.TimelineMark(contextKey: "mesh", messages: timeline(0..<6), windowCount: 300) + var messages = timeline(0..<9) + messages.append(message(9, sender: "system")) + messages.append(message(10, content: " ")) + + let appended = Policy.appended(since: mark, in: messages) + + // m6, m7, m8, a system line and a whitespace-only message landed; the + // pill counts only the three human messages. + #expect(appended == Policy.Appended(total: 5, unseen: 3)) + } + + @Test + func appended_countsEverythingWhenTheTimelineWasEmpty() { + let mark = Policy.TimelineMark(contextKey: "mesh", messages: [], windowCount: 300) + + #expect(Policy.appended(since: mark, in: timeline(0..<3)) == Policy.Appended(total: 3, unseen: 3)) + #expect(Policy.appended(since: mark, in: []) == Policy.Appended()) + } + + @Test + func appended_keepsCountingAtTheStoreCap() { + // At the cap every append evicts the oldest message: the count never + // moves, but the appended messages are still found after the newest. + let capped = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: capped, windowCount: 300) + let afterThreeAppends = timeline(3..<13) + #expect(afterThreeAppends.count == capped.count) + + #expect(Policy.appended(since: mark, in: afterThreeAppends) == Policy.Appended(total: 3, unseen: 3)) + } + + @Test + func appended_doesNotCountLateMessagesLandingAboveTheNewest() { + let messages = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 300) + let late = message(100, at: 7.5) + + let withLate = inserting(late, before: 8, in: messages) + + #expect(Policy.appended(since: mark, in: withLate) == Policy.Appended()) + } + + @Test + func appended_fallsBackToTimestampsWhenTheNewestIsGone() { + let mark = Policy.TimelineMark(contextKey: "mesh", messages: timeline(0..<10), windowCount: 300) + + // The newest seen message was removed (e.g. a block purge); two + // newer ones landed since. + let removedNewest = timeline(0..<9) + timeline(10..<12) + #expect(Policy.appended(since: mark, in: removedNewest) == Policy.Appended(total: 2, unseen: 2)) + + // A burst larger than the cap evicted it: everything left is newer. + let evictedNewest = timeline(20..<30) + #expect(Policy.appended(since: mark, in: evictedNewest) == Policy.Appended(total: 10, unseen: 10)) + } + + // MARK: - Window growth + + @Test + func windowCountKeepingFirstRow_growsOverMessagesAppendedBelowIt() { + let messages = timeline(0..<20) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 10) + + let grown = Policy.windowCountKeepingFirstRow( + current: 10, + mark: mark, + appendedCount: 0, + in: messages + timeline(20..<23) + ) + + #expect(grown == 13) + #expect((messages + timeline(20..<23)).suffix(grown).first?.id == "m10") + } + + @Test + func windowCountKeepingFirstRow_absorbsLateMessagesInsertedBelowIt() { + let messages = timeline(0..<20) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 10) + + // Below the first row: the window grows to keep it. + let lateInside = inserting(message(100, at: 15.5), before: 16, in: messages) + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: mark, appendedCount: 0, in: lateInside) == 11) + + // Above the first row: the suffix already keeps it. + let lateAbove = inserting(message(101, at: 4.5), before: 5, in: messages) + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: mark, appendedCount: 0, in: lateAbove) == 10) + } + + @Test + func windowCountKeepingFirstRow_keepsTheFirstRowAtTheStoreCap() { + let capped = timeline(0..<20) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: capped, windowCount: 10) + // Two appends, two head evictions: same count, first row two places up. + let afterTwoAppends = timeline(2..<22) + + let grown = Policy.windowCountKeepingFirstRow(current: 10, mark: mark, appendedCount: 2, in: afterTwoAppends) + + #expect(grown == 12) + #expect(afterTwoAppends.suffix(grown).first?.id == "m10") + } + + @Test + func windowCountKeepingFirstRow_growsByTheAppendedCountWithoutAUsableMark() { + let messages = timeline(0..<30) + + // No mark (fresh context): grow by what was appended. + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: nil, appendedCount: 3, in: messages) == 13) + + // A mark taken at another window size (the window was expanded since). + let stale = Policy.TimelineMark(contextKey: "mesh", messages: timeline(0..<27), windowCount: 8) + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: stale, appendedCount: 3, in: messages) == 13) + + // The first row itself is gone (removed or evicted). + let mark = Policy.TimelineMark(contextKey: "mesh", messages: timeline(0..<27), windowCount: 10) + let withoutFirstRow = messages.filter { $0.id != "m17" } + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: mark, appendedCount: 3, in: withoutFirstRow) == 13) + + // Nothing appended and nothing to hold: the window stays. + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: nil, appendedCount: 0, in: messages) == 10) + } + + @Test + func windowCountKeepingFirstRow_neverShrinksNorOutgrowsTheTimeline() { + // A timeline shorter than the window keeps the window size. + let short = timeline(0..<5) + let shortMark = Policy.TimelineMark(contextKey: "mesh", messages: short, windowCount: 300) + #expect(Policy.windowCountKeepingFirstRow(current: 300, mark: shortMark, appendedCount: 2, in: short + timeline(5..<7)) == 300) + + // Growth stops at the timeline's length. + let messages = timeline(0..<12) + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: nil, appendedCount: 50, in: messages) == 12) + + // Removals below the first row never shrink the window. + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 10) + let removedBelow = messages.filter { $0.id != "m9" && $0.id != "m10" } + #expect(Policy.windowCountKeepingFirstRow(current: 10, mark: mark, appendedCount: 0, in: removedBelow) == 10) + } + + // MARK: - Gates + + @Test + func canExpandWindow_waitsForTheRestoreAndForTheBottomOfThisContext() { + #expect(Policy.canExpandWindow(isRestoringExpansion: false, bottomSettledContextKey: "mesh", contextKey: "mesh")) + // A previous expansion's position restore is still queued. + #expect(!Policy.canExpandWindow(isRestoringExpansion: true, bottomSettledContextKey: "mesh", contextKey: "mesh")) + // The initial scroll to the bottom has not landed yet. + #expect(!Policy.canExpandWindow(isRestoringExpansion: false, bottomSettledContextKey: nil, contextKey: "mesh")) + // It landed in another conversation. + #expect(!Policy.canExpandWindow(isRestoringExpansion: false, bottomSettledContextKey: "geo:u4pruy", contextKey: "mesh")) + } + + @Test + func shouldRunDeferredFollowScroll_requiresTheBottomOfTheSameTimeline() { + #expect(Policy.shouldRunDeferredFollowScroll(isAtBottom: true, scheduledContextKey: "mesh", currentContextKey: "mesh")) + // The reader dragged away while the timer was held back. + #expect(!Policy.shouldRunDeferredFollowScroll(isAtBottom: false, scheduledContextKey: "mesh", currentContextKey: "mesh")) + // The timeline was switched before the timer fired. + #expect(!Policy.shouldRunDeferredFollowScroll(isAtBottom: true, scheduledContextKey: "mesh", currentContextKey: "geo:u4pruy")) + } + + @Test + func needsScroll_skipsTheBottomRetryOnceTheNewestRowIsOnScreen() { + #expect(Policy.needsScroll(toNewestRowID: "mesh|m9", bottomRowID: "mesh|m8")) + #expect(Policy.needsScroll(toNewestRowID: "mesh|m9", bottomRowID: nil)) + // The first scroll landed: the retry would be a queued no-op. + #expect(!Policy.needsScroll(toNewestRowID: "mesh|m9", bottomRowID: "mesh|m9")) + // No row to scroll to. + #expect(!Policy.needsScroll(toNewestRowID: nil, bottomRowID: nil)) + } + + // MARK: - Row callbacks + + @Test + func isRow_matchesOnlyRowsOfItsOwnContext() { + #expect(Policy.isRow("mesh|m1", inContext: "mesh")) + #expect(Policy.isRow("geo:u4pruy|m1", inContext: "geo:u4pruy")) + #expect(Policy.isRow("dm:0011223344556677|m1", inContext: "dm:0011223344556677")) + // The old timeline's rows after a switch. + #expect(!Policy.isRow("geo:u4pruy|m1", inContext: "mesh")) + #expect(!Policy.isRow("dm:aaaa|m1", inContext: "dm:bbbb")) + // A context that is a prefix of another is not that context. + #expect(!Policy.isRow("geo:u4pruyd|m1", inContext: "geo:u4pruy")) + #expect(!Policy.isRow("mesh", inContext: "mesh")) + } + + // MARK: - Reactions + + private func reaction( + mark: Policy.TimelineMark?, + _ messages: [BitchatMessage], + newestIsOwn: Bool = false, + appended: Int = 0, + windowCount: Int = 10, + isAtBottom: Bool + ) -> Policy.TimelineReaction { + Policy.reaction( + mark: mark, + messages: messages, + newestIsOwn: newestIsOwn, + appendedCount: appended, + windowCount: windowCount, + isAtBottom: isAtBottom + ) + } + + @Test + func reaction_landsInATimelineWithoutAMarkInItsContext() { + // A channel or DM switch: whatever isAtBottom says (the old + // timeline's rows report on their way out), the list lands. + #expect(reaction(mark: nil, timeline(0..<10), isAtBottom: false) == .land) + #expect(reaction(mark: nil, timeline(0..<10), newestIsOwn: true, isAtBottom: true) == .land) + // Even into an empty timeline. + #expect(reaction(mark: nil, [], isAtBottom: false) == .land) + } + + @Test + func reaction_followsAtTheBottomWheneverTheRenderedRowsMayHaveMoved() { + let messages = timeline(0..<20) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 10) + + // Appended below the bottom. + #expect(reaction(mark: mark, messages + timeline(20..<22), appended: 2, isAtBottom: true) == .follow) + // A late message landing among the visible rows pushes the newest one + // down: nothing was appended, but the bottom moved. + let lateAmongVisibleRows = inserting(message(100, at: 18.5), before: 19, in: messages) + #expect(reaction(mark: mark, lateAmongVisibleRows, isAtBottom: true) == .follow) + // A removal changes the rows too. + #expect(reaction(mark: mark, messages.filter { $0.id != "m15" }, isAtBottom: true) == .follow) + } + + @Test + func reaction_holdsAtTheBottomWhenTheRenderedRowsAreUnchanged() { + // At the store cap a late message landing above the window evicts the + // oldest message, also above it: the rendered rows are the same, and + // a scroll would be a queued no-op that snaps the next drag back. + let capped = timeline(0..<20) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: capped, windowCount: 10) + let lateAboveTheWindow = Array(inserting(message(100, at: 4.5), before: 5, in: capped).dropFirst()) + + #expect(reaction(mark: mark, lateAboveTheWindow, isAtBottom: true) == .hold) + } + + @Test + func reaction_keepsAScrolledUpReaderInPlace() { + let messages = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 300) + + #expect(reaction(mark: mark, messages + timeline(10..<13), appended: 3, isAtBottom: false) == .keepPosition) + #expect(reaction(mark: mark, messages, isAtBottom: false) == .keepPosition) + } + + @Test + func reaction_followsTheReadersOwnNewMessageFromAnywhere() { + let messages = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 300) + + #expect(reaction(mark: mark, messages + [message(10)], newestIsOwn: true, appended: 1, isAtBottom: false) == .follow) + // A message of mine that was already the newest (m9) does not pull a + // scrolled-up reader down when late messages land above it. + let lateAboveMine = inserting(message(100, at: 7.5), before: 8, in: messages) + #expect(reaction(mark: mark, lateAboveMine, newestIsOwn: true, isAtBottom: false) == .keepPosition) + } + + @Test + func reaction_followsOnlyAMessageOfMineThatLandedSinceTheMark() { + // m8 is mine; m9 after it, the newest seen, is a bridge alias. + let messages = timeline(0..<10) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: messages, windowCount: 300) + + // The radio copy retracts the alias a batch before it lands itself: + // m8 is the newest again, but nothing landed since the mark. + let retracted = Array(messages.dropLast()) + let appended = Policy.appended(since: mark, in: retracted) + #expect(appended == Policy.Appended()) + #expect(reaction(mark: mark, retracted, newestIsOwn: true, appended: appended.total, isAtBottom: false) == .keepPosition) + // At the bottom the removal still moves the rows. + #expect(reaction(mark: mark, retracted, newestIsOwn: true, appended: appended.total, windowCount: 300, isAtBottom: true) == .follow) + + // A message of mine landing in the same change is new. + let retractedWithMine = retracted + [message(10)] + let appendedWithMine = Policy.appended(since: mark, in: retractedWithMine) + #expect(appendedWithMine == Policy.Appended(total: 1, unseen: 1)) + #expect(reaction(mark: mark, retractedWithMine, newestIsOwn: true, appended: appendedWithMine.total, isAtBottom: false) == .follow) + } + + @Test + func reaction_holdsWhenTheTimelineEmptied() { + let mark = Policy.TimelineMark(contextKey: "mesh", messages: timeline(0..<10), windowCount: 300) + + #expect(reaction(mark: mark, [], isAtBottom: false) == .hold) + #expect(reaction(mark: mark, [], isAtBottom: true) == .hold) + } + + @Test + func isRenderedWindowUnchanged_onlyForAFullWindowOfTheSameRows() { + let capped = timeline(0..<20) + let mark = Policy.TimelineMark(contextKey: "mesh", messages: capped, windowCount: 10) + func isUnchanged(_ messages: [BitchatMessage], appended: Int = 0, windowCount: Int = 10) -> Bool { + Policy.isRenderedWindowUnchanged(since: mark, appendedCount: appended, windowCount: windowCount, in: messages) + } + + // Late message above the window at the cap (the oldest evicted). + #expect(isUnchanged(Array(inserting(message(100, at: 4.5), before: 5, in: capped).dropFirst()))) + // Late message inside the window at the cap: its first row moves. + #expect(!isUnchanged(Array(inserting(message(101, at: 14.5), before: 15, in: capped).dropFirst()))) + // Below the cap any insert changes the count. + #expect(!isUnchanged(inserting(message(102, at: 4.5), before: 5, in: capped))) + // Something appended. + #expect(!isUnchanged(Array((capped + [message(20)]).dropFirst()), appended: 1)) + // The window was resized since the mark. + #expect(!isUnchanged(capped, windowCount: 12)) + + // A timeline shorter than its window renders every message. + let short = timeline(0..<5) + let shortMark = Policy.TimelineMark(contextKey: "mesh", messages: short, windowCount: 10) + #expect(!Policy.isRenderedWindowUnchanged(since: shortMark, appendedCount: 0, windowCount: 10, in: short)) + } +} diff --git a/bitchatTests/PublicMessagePipelineTests.swift b/bitchatTests/PublicMessagePipelineTests.swift index 4ab270a4..5b084b60 100644 --- a/bitchatTests/PublicMessagePipelineTests.swift +++ b/bitchatTests/PublicMessagePipelineTests.swift @@ -21,7 +21,6 @@ private final class TestPipelineDelegate: PublicMessagePipelineDelegate { /// Message IDs the commit rejects (simulates the store's ID dedup). var rejectedMessageIDs: Set = [] private(set) var recordedContentKeys: [String] = [] - private(set) var batchingStates: [Bool] = [] func messages(in conversationID: ConversationID) -> [BitchatMessage] { committed.filter { $0.conversationID == conversationID }.map(\.message) @@ -47,10 +46,6 @@ private final class TestPipelineDelegate: PublicMessagePipelineDelegate { } func pipelinePrewarmMessage(_: PublicMessagePipeline, message: BitchatMessage) {} - - func pipelineSetBatchingState(_: PublicMessagePipeline, isBatching: Bool) { - batchingStates.append(isBatching) - } } @MainActor @@ -80,8 +75,6 @@ struct PublicMessagePipelineTests { pipeline.flushIfNeeded() #expect(delegate.messages(in: .mesh).map { $0.id } == ["b", "a"]) - // Batching state wrapped the flush. - #expect(delegate.batchingStates == [true, false]) } @Test @MainActor diff --git a/bitchatTests/Services/AutocompleteServiceTests.swift b/bitchatTests/Services/AutocompleteServiceTests.swift index f12426bf..d35c1f75 100644 --- a/bitchatTests/Services/AutocompleteServiceTests.swift +++ b/bitchatTests/Services/AutocompleteServiceTests.swift @@ -35,6 +35,40 @@ struct AutocompleteServiceTests { #expect(result.range == nil) } + @Test("Mention token detection is true exactly where suggestions are possible") + func hasMentionTokenMatchesSuggestionContext() { + let service = AutocompleteService() + let samples: [(text: String, cursor: Int, hasToken: Bool)] = [ + ("", 0, false), + ("plain text", 10, false), + ("hi @al there", 12, false), + ("a @b-c", 6, false), + ("@", 1, true), + ("hi @al", 6, true), + ("hi @al there", 6, true), + ("@@al", 4, true), + ("salut @émi", 10, true), + ("@x_1", 4, true), + ("mail me@host", 12, true) + ] + + for sample in samples { + #expect( + AutocompleteService.hasMentionToken(in: sample.text, cursorPosition: sample.cursor) == sample.hasToken, + "\(sample.text) @ \(sample.cursor)" + ) + // A peer named after the token's own prefix always matches it, so + // suggestions appear exactly when a token is detected. + let prefix = String(sample.text.prefix(sample.cursor).split(separator: "@", omittingEmptySubsequences: false).last ?? "") + let result = service.getSuggestions( + for: sample.text, + peers: [prefix + "z"], + cursorPosition: sample.cursor + ) + #expect(result.suggestions.isEmpty == !sample.hasToken, "\(sample.text) @ \(sample.cursor)") + } + } + @Test("Applying suggestions replaces the range and adds command spacing only when needed") func applySuggestionReplacesRangeAndHandlesCommandSpacing() { let service = AutocompleteService() diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index 064cd5da..d07d815e 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -44,7 +44,19 @@ public final class BitchatMessage: Codable { public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") { _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text } - + + // Cached content scan for row rendering (not included in Codable). + // `content` is immutable, so a cached scan never goes stale. + private var _cachedContentScan: ContentScan? + + public func getCachedContentScan() -> ContentScan? { + _cachedContentScan + } + + public func setCachedContentScan(_ scan: ContentScan) { + _cachedContentScan = scan + } + // Codable implementation enum CodingKeys: String, CodingKey { case id, sender, content, timestamp, isRelay, originalSender @@ -100,6 +112,26 @@ public final class BitchatMessage: Codable { } } +// MARK: - Content Scan + +extension BitchatMessage { + /// What a message row derives from `content`: the payment links rendered + /// as chips and whether the text collapses behind "show more". Deriving + /// them takes several regex passes over the content, so the renderer + /// computes them once and caches the result on the message. + public struct ContentScan: Equatable { + public let cashuLinks: [String] + public let lightningLinks: [String] + public let isLongForDisplay: Bool + + public init(cashuLinks: [String], lightningLinks: [String], isLongForDisplay: Bool) { + self.cashuLinks = cashuLinks + self.lightningLinks = lightningLinks + self.isLongForDisplay = isLongForDisplay + } + } +} + // MARK: - Equatable Conformance extension BitchatMessage: Equatable {