mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
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:
parent
14c6b0b47d
commit
88ee1ae6db
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
236
bitchat/Views/MessageListScrollPolicy.swift
Normal file
236
bitchat/Views/MessageListScrollPolicy.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
90
bitchatTests/BitchatMessageMediaTests.swift
Normal file
90
bitchatTests/BitchatMessageMediaTests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
400
bitchatTests/MessageListScrollPolicyTests.swift
Normal file
400
bitchatTests/MessageListScrollPolicyTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user