Files
bitchat/bitchat/Views/MessageListView.swift
T
304460ee83 Nearby notes: tap-to-reveal (consent-gate the location subscription) + subscription hygiene (#1422)
* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool

The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.

Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
  main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
  sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
  both callers release-and-reacquire instead of retargeting in place, and the
  sheet releases its ref on dismissal.

The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only

Fixes the verified review findings on the tap-to-reveal PR:

- Permission dead-end: the "check for notes left here" hint now renders
  only when location permission is already authorized (it never prompts).
  Previously a location-denied install could tap it, flip the sticky
  revealed flag, and get nothing for the rest of the session because
  retarget() guards on authorization. Predicate lives on
  NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
  to the published permission state.

- Building-cell sampling: background geohash sampling subscribed
  geo-sample-<gh> for every regional level including the precision-8
  building cell, pre-reveal — contradicting the claim that nothing
  building-precision hits a relay before the explicit act.
  GeoChannelCoordinator now excludes the building level until
  NearbyNotesCounter.revealed (injectable publisher for tests); coarser
  levels keep the nearby-conversation hint and participant counts
  working, and bookmarks stay exempt (bookmarking is explicit).

- Implicit reveal: opening the notices sheet no longer reveals — the
  sheet auto-lands on the geo tab whenever a location channel is
  selected, so browsing a remote geohash and opening notices revealed
  the LOCAL building subscription. reveal() now fires only on the
  person actively picking the geo segment, and only when the sheet has
  a geo scope (the empty-mesh hint tap and /drop stay as before).

- Subscription hygiene: switching the sheet geo → mesh releases the
  pooled notes manager (the REQ was left streaming behind the mesh
  board); switching back re-acquires from the pool. The dismissal
  release stays balanced — liveGeoManager is nil after the tab-switch
  release.

- VoiceOver: the hint button exposes the plain localized action text
  instead of the decorated "* 📍 … *" label.

- Removed LocationNotesManager.setGeohash: zero callers, and calling it
  on a pooled instance would corrupt the pool's keying and refcounts.

New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline

CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.

Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:47:08 +02:00

746 lines
31 KiB
Swift

//
// MessageListView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import BitFoundation
import SwiftUI
private struct MessageDisplayItem: Identifiable {
let id: String
let message: BitchatMessage
}
struct MessageListView: View {
@EnvironmentObject private var publicChatModel: PublicChatModel
@EnvironmentObject private var privateInboxModel: PrivateInboxModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var appChromeModel: AppChromeModel
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
let privatePeer: PeerID?
@Binding var isAtBottom: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var showSidebar: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false
@State private var showClearConfirmation = false
@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 = ""
/// Whether this instance holds the nearby-notes counter active (mesh
/// public timeline only); balanced against activate/deactivate.
@State private var holdsNotesCounter = false
@ThemedPalette private var palette
var body: some View {
let currentWindowCount: Int = {
if let peer = privatePeer {
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
}
return windowCountPublic
}()
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 messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
guard !message.content.trimmed.isEmpty else { return nil }
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
VStack(spacing: 0) {
// Notes pinned to this place stay visible while chatting — a
// conversation starting must not hide what's left here.
if privatePeer == nil,
case .mesh = locationChannelsModel.selectedChannel,
nearbyNotes.noteCount > 0 {
notesHereStrip
}
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState(fillHeight: geometry.size.height)
}
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
messageRow(for: message)
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
unseenCount = 0
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
expandWindow(
ifNeededFor: message,
allMessages: messages,
privatePeer: privatePeer,
proxy: proxy
)
}
}
.onDisappear {
if message.id == windowedMessages.last?.id {
isAtBottom = false
}
}
.contentShape(Rectangle())
.contextMenu {
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
if showsUserActions {
// Mention and DM are redundant inside a 1:1 conversation:
// mentioning the only other participant is noise, and "DM"
// would just reopen the conversation that is already open.
if privatePeer == nil {
Button("content.actions.mention") {
insertMention(message.sender)
}
if let peerID = message.senderPeerID {
Button("content.actions.direct_message") {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
}
Button("content.actions.hug") {
conversationUIModel.sendHug(to: message.sender)
}
Button("content.actions.slap") {
conversationUIModel.sendSlap(to: message.sender)
}
}
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if showsUserActions {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
// Archived echoes read as one tinted block, not
// just faded rows.
.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,
// sightings, live hints) stays visible below it instead of
// vanishing the moment echoes exist.
if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) {
MeshEmptyStateView(compact: true)
.padding(.horizontal, 12)
.padding(.top, 20)
.padding(.bottom, 8)
}
}
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
showClearConfirmation = true
}
.confirmationDialog(
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
scrollToBottom(on: proxy)
}
.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)
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
insertMention(sender)
}
}
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
Button("content.actions.hug") {
if let sender = selectedMessageSender {
conversationUIModel.sendHug(to: sender)
}
}
Button("content.actions.slap") {
if let sender = selectedMessageSender {
conversationUIModel.sendSlap(to: sender)
}
}
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: selectedMessageSenderID, displayName: selectedMessageSender)
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
// Also check when view appears
if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent
privateConversationModel.markMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
privateConversationModel.markMessagesAsRead(from: peerID)
}
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
privateConversationModel.markMessagesAsRead(from: peerID)
}
}
}
.onDisappear {
scrollThrottleTimer?.invalidate()
}
}
}
}
.onAppear { updateNotesCounterHold() }
.onDisappear { releaseNotesCounterHold() }
.onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() }
.onChange(of: privatePeer) { _ in updateNotesCounterHold() }
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
#if os(iOS)
UIApplication.shared.open(url)
return .handled
#else
// On non-iOS platforms, let the system handle or ignore
return .systemAction
#endif
}
return .systemAction
})
}
}
private extension MessageListView {
var currentContextKey: String {
if let peer = privatePeer {
return "dm:\(peer)"
}
return locationChannelsModel.selectedChannel.contextKey
}
/// Tappable strip above the mesh timeline while notes are pinned at this
/// place: opens the notices sheet on the geo tab.
var notesHereStrip: some View {
let text: String = nearbyNotes.noteCount == 1
? String(localized: "content.empty.notes_one", comment: "Hint when exactly one note was left at this place")
: String(
format: String(localized: "content.empty.notes_many", comment: "Hint counting notes left at this place"),
locale: .current,
nearbyNotes.noteCount
)
return Button {
appChromeModel.presentNotices(geoTab: true)
} label: {
HStack(spacing: 6) {
Text(verbatim: "📍 \(text)")
.bitchatFont(size: 12)
.foregroundColor(palette.primary)
Spacer()
Image(systemName: "chevron.right")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(palette.secondary.opacity(0.08))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// The nearby-notes counter is held whenever the mesh public timeline is
/// showing — the strip needs a live count before it can decide to exist.
/// Holding is not subscribing: nothing hits the relays until an explicit
/// act reveals the counter (tap-to-reveal).
func updateNotesCounterHold() {
let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh
guard shouldHold != holdsNotesCounter else { return }
holdsNotesCounter = shouldHold
if shouldHold {
NearbyNotesCounter.shared.activate()
} else {
NearbyNotesCounter.shared.deactivate()
}
}
func releaseNotesCounterHold() {
guard holdsNotesCounter else { return }
holdsNotesCounter = false
NearbyNotesCounter.shared.deactivate()
}
/// True when the mesh timeline holds nothing but archived echoes and
/// system lines — no live conversation yet, so the ambient layer still
/// applies.
private func showsAmbientFooter(messageItems: [MessageDisplayItem]) -> Bool {
guard case .mesh = locationChannelsModel.selectedChannel,
!messageItems.isEmpty else { return false }
return messageItems.allSatisfy { $0.message.isArchivedEcho || $0.message.sender == "system" }
}
/// Terminal-styled narration for an empty public timeline: says which
/// channel this is, that the app is waiting for peers, and where to go
/// next. Rendered inside the ScrollView; disappears with the first row.
/// The mesh case fills the visible chat height so its radar can center
/// in the space below the text.
func publicEmptyState(fillHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
MeshEmptyStateView(fillHeight: max(0, fillHeight - 24))
case .location(let channel):
emptyStateLine(
String(
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
locale: .current,
channel.geohash
)
)
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
func emptyStateLine(_ text: String) -> some View {
// Non-breaking space before the closing asterisk so a tight wrap
// can't orphan a lone "*" onto its own line.
Text(verbatim: "* \(text)\u{00A0}*")
.bitchatFont(size: 13)
.foregroundColor(palette.secondary.opacity(0.9))
.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).
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
else { return false }
return true
}
/// Appends an @mention to the composer draft (never overwrites what the
/// user has already typed) and focuses the input field.
func insertMention(_ sender: String) {
let mention = "@\(sender) "
if messageText.isEmpty {
messageText = mention
} else if messageText.hasSuffix(" ") {
messageText += mention
} else {
messageText += " " + mention
}
isTextFieldFocused.wrappedValue = true
}
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
/// unseenCount state the view already tracks, and jumps to the newest
/// message via the existing scrollToBottom helper.
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(on: proxy)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down")
.font(.bitchatSystem(size: 11, weight: .semibold))
if unseenCount > 0 {
Text(
String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
)
.bitchatFont(size: 12, weight: .medium)
}
}
.foregroundColor(palette.primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.themedOverlayPanel()
.padding(.trailing, 12)
.padding(.bottom, 10)
.accessibilityLabel(jumpToLatestAccessibilityLabel)
}
var jumpToLatestAccessibilityLabel: String {
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
guard unseenCount > 0 else { return base }
let count = String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
return "\(base), \(count)"
}
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
if message.sender == "system" {
systemMessageRow(message)
} else if let media = conversationUIModel.mediaAttachment(for: message) {
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
} else {
TextMessageView(message: message)
}
}
// Archived echoes ("heard here earlier") render dimmed: real history,
// visually distinct from the live conversation.
.opacity(message.isArchivedEcho ? 0.55 : 1)
}
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
func expandWindow(ifNeededFor message: BitchatMessage,
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)"
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)
}
}
}
func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return }
switch url.host {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID
selectedMessageSender = conversationUIModel.senderDisplayName(
for: peerID,
fallbackMessages: conversationMessages(for: privatePeer)
)
if conversationUIModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
case "geohash":
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
locationChannelsModel.openLocationChannel(for: gh)
default:
return
}
}
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
if let secondTarget = self.targetPeerID {
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
}
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
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
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)
}
}
// Throttle scroll animations to prevent excessive UI updates
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())
}
}
}
}
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)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
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 {
var contextKey: String {
switch self {
case .mesh: "mesh"
case .location(let ch): "geo:\(ch.geohash)"
}
}
}
// #Preview {
// MessageListView()
// }