mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
* 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>
746 lines
31 KiB
Swift
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()
|
|
// }
|