Fix geochat timeline scrolling and cut per-row render work (#1790)

* Remove the public batching flag that never suppressed animations
* Stop republishing unchanged composer state to every message row
* Scan message rows once and cache media row headers
* Keep media rows to mesh senders and single-component filenames
* Keep the message list where the reader left it
* Retry the scroll to the bottom only when it did not land
* Republish media capability only on change; keep bridged text as text
* Land timeline switches in one change and shrink the window at the bottom
* Follow only a message of mine that landed since the mark

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-10-23 20:24:06 +02:00 committed by GitHub
parent 14c6b0b47d
commit 88ee1ae6db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1376 additions and 298 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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))
}
}

View File

@ -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 (`"<context>|<message 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
)
}
}

View File

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

View File

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

View File

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

View File

@ -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)")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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")

View File

@ -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<Int>) -> [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))
}
}

View File

@ -21,7 +21,6 @@ private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
/// Message IDs the commit rejects (simulates the store's ID dedup).
var rejectedMessageIDs: Set<String> = []
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

View File

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

View File

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