mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Extract message list into dedicated MessageListView (#1080)
* Extract message list into dedicated MessageListView * Fix broken smoke test Mounts two distinct fixtures to cover separate render branches: a truncatable message (>2000 chars, no payment tokens) and a payment message (lightning + cashu, private, partial delivery). Expectations guard that each fixture actually reaches its intended branch. --------- Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -1800,6 +1800,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
|
if let peerID {
|
||||||
|
return getPrivateChatMessages(for: peerID)
|
||||||
|
} else {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
var combined: [BitchatMessage] = []
|
var combined: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct TextMessageView: View {
|
|||||||
@EnvironmentObject private var viewModel: ChatViewModel
|
@EnvironmentObject private var viewModel: ChatViewModel
|
||||||
|
|
||||||
let message: BitchatMessage
|
let message: BitchatMessage
|
||||||
@Binding var expandedMessageIDs: Set<String>
|
@State private var expandedMessageIDs: Set<String> = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -66,14 +66,12 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 14, iOS 17, *)
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@Previewable @State var ids: Set<String> = []
|
|
||||||
let keychain = PreviewKeychainManager()
|
let keychain = PreviewKeychainManager()
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
List {
|
List {
|
||||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
TextMessageView(message: .preview)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(EmptyView())
|
.listRowBackground(EmptyView())
|
||||||
@@ -81,7 +79,7 @@ struct TextMessageView: View {
|
|||||||
.environment(\.colorScheme, .light)
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
List {
|
List {
|
||||||
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
TextMessageView(message: .preview)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(EmptyView())
|
.listRowBackground(EmptyView())
|
||||||
|
|||||||
+26
-454
@@ -16,17 +16,6 @@ import AppKit
|
|||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
//
|
|
||||||
|
|
||||||
private struct MessageDisplayItem: Identifiable {
|
|
||||||
let id: String
|
|
||||||
let message: BitchatMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On macOS 14+, disables the default system focus ring on TextFields.
|
/// On macOS 14+, disables the default system focus ring on TextFields.
|
||||||
/// On earlier macOS versions and on iOS this is a no-op.
|
/// On earlier macOS versions and on iOS this is a no-op.
|
||||||
private struct FocusEffectDisabledModifier: ViewModifier {
|
private struct FocusEffectDisabledModifier: ViewModifier {
|
||||||
@@ -59,18 +48,14 @@ struct ContentView: View {
|
|||||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@State private var showSidebar = false
|
@State private var showSidebar = false
|
||||||
@State private var showAppInfo = false
|
@State private var showAppInfo = false
|
||||||
@State private var showMessageActions = false
|
|
||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: PeerID?
|
@State private var selectedMessageSenderID: PeerID?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
@State private var isAtBottomPublic: Bool = true
|
@State private var isAtBottomPublic: Bool = true
|
||||||
@State private var isAtBottomPrivate: Bool = true
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@State private var lastScrollTime: Date = .distantPast
|
|
||||||
@State private var scrollThrottleTimer: Timer?
|
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@State private var showLocationChannelsSheet = false
|
@State private var showLocationChannelsSheet = false
|
||||||
@State private var showVerifySheet = false
|
@State private var showVerifySheet = false
|
||||||
@State private var expandedMessageIDs: Set<String> = []
|
|
||||||
@State private var showLocationNotes = false
|
@State private var showLocationNotes = false
|
||||||
@State private var notesGeohash: String? = nil
|
@State private var notesGeohash: String? = nil
|
||||||
@State private var imagePreviewURL: URL? = nil
|
@State private var imagePreviewURL: URL? = nil
|
||||||
@@ -159,7 +144,18 @@ struct ContentView: View {
|
|||||||
|
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
MessageListView(
|
||||||
|
privatePeer: nil,
|
||||||
|
isAtBottom: $isAtBottomPublic,
|
||||||
|
messageText: $messageText,
|
||||||
|
selectedMessageSender: $selectedMessageSender,
|
||||||
|
selectedMessageSenderID: $selectedMessageSenderID,
|
||||||
|
imagePreviewURL: $imagePreviewURL,
|
||||||
|
windowCountPublic: $windowCountPublic,
|
||||||
|
windowCountPrivate: $windowCountPrivate,
|
||||||
|
showSidebar: $showSidebar,
|
||||||
|
isTextFieldFocused: $isTextFieldFocused,
|
||||||
|
)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
@@ -265,59 +261,6 @@ struct ContentView: View {
|
|||||||
}, message: {
|
}, message: {
|
||||||
Text(voiceRecordingVM.state.alertMessage)
|
Text(voiceRecordingVM.state.alertMessage)
|
||||||
})
|
})
|
||||||
.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 {
|
|
||||||
// Pre-fill the input with an @mention and focus the field
|
|
||||||
messageText = "@\(sender) "
|
|
||||||
isTextFieldFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("content.actions.direct_message") {
|
|
||||||
if let peerID = selectedMessageSenderID {
|
|
||||||
if peerID.isGeoChat {
|
|
||||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
|
||||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
viewModel.startPrivateChat(with: peerID)
|
|
||||||
}
|
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
showSidebar = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("content.actions.hug") {
|
|
||||||
if let sender = selectedMessageSender {
|
|
||||||
viewModel.sendMessage("/hug @\(sender)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("content.actions.slap") {
|
|
||||||
if let sender = selectedMessageSender {
|
|
||||||
viewModel.sendMessage("/slap @\(sender)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("content.actions.block", role: .destructive) {
|
|
||||||
// Prefer direct geohash block when we have a Nostr sender ID
|
|
||||||
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
|
||||||
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
|
||||||
let sender = selectedMessageSender {
|
|
||||||
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
|
||||||
} else if let sender = selectedMessageSender {
|
|
||||||
viewModel.sendMessage("/block \(sender)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("common.cancel", role: .cancel) {}
|
|
||||||
}
|
|
||||||
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
.alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) {
|
||||||
Button("content.alert.bluetooth_required.settings") {
|
Button("content.alert.bluetooth_required.settings") {
|
||||||
SystemSettings.bluetooth.open()
|
SystemSettings.bluetooth.open()
|
||||||
@@ -327,239 +270,10 @@ struct ContentView: View {
|
|||||||
Text(viewModel.bluetoothAlertMessage)
|
Text(viewModel.bluetoothAlertMessage)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
// Clean up timers
|
|
||||||
scrollThrottleTimer?.invalidate()
|
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message List View
|
|
||||||
|
|
||||||
private func messagesView(privatePeer: PeerID?, isAtBottom: Binding<Bool>) -> some View {
|
|
||||||
let messages: [BitchatMessage] = {
|
|
||||||
if let peerID = privatePeer {
|
|
||||||
return viewModel.getPrivateChatMessages(for: peerID)
|
|
||||||
}
|
|
||||||
return viewModel.messages
|
|
||||||
}()
|
|
||||||
|
|
||||||
let currentWindowCount: Int = {
|
|
||||||
if let peer = privatePeer {
|
|
||||||
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
|
||||||
}
|
|
||||||
return windowCountPublic
|
|
||||||
}()
|
|
||||||
|
|
||||||
let windowedMessages: [BitchatMessage] = Array(messages.suffix(currentWindowCount))
|
|
||||||
|
|
||||||
let contextKey: String = {
|
|
||||||
if let peer = privatePeer { return "dm:\(peer)" }
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
|
|
||||||
guard let trimmed = message.content.trimmedOrNilIfEmpty else { return nil }
|
|
||||||
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ScrollViewReader { proxy in
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
|
||||||
ForEach(messageItems) { item in
|
|
||||||
let message = item.message
|
|
||||||
messageRow(for: message)
|
|
||||||
.onAppear {
|
|
||||||
if message.id == windowedMessages.last?.id {
|
|
||||||
isAtBottom.wrappedValue = true
|
|
||||||
}
|
|
||||||
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.wrappedValue = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
if message.sender != "system" {
|
|
||||||
messageText = "@\(message.sender) "
|
|
||||||
isTextFieldFocused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.onOpenURL { handleOpenURL($0) }
|
|
||||||
.onTapGesture(count: 3) {
|
|
||||||
viewModel.sendMessage("/clear")
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom)
|
|
||||||
}
|
|
||||||
.onChange(of: privatePeer) { _ in
|
|
||||||
scrollToBottom(on: proxy, privatePeer: privatePeer, isAtBottom: isAtBottom)
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.messages.count) { _ in
|
|
||||||
if privatePeer == nil && !viewModel.messages.isEmpty {
|
|
||||||
// If the newest message is from me, always scroll to bottom
|
|
||||||
let lastMsg = viewModel.messages.last!
|
|
||||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
|
||||||
if !isFromSelf {
|
|
||||||
// Only autoscroll when user is at/near bottom
|
|
||||||
guard isAtBottom.wrappedValue else { return }
|
|
||||||
} else {
|
|
||||||
// Ensure we consider ourselves at bottom for subsequent messages
|
|
||||||
isAtBottom.wrappedValue = true
|
|
||||||
}
|
|
||||||
// Throttle scroll animations to prevent excessive UI updates
|
|
||||||
let now = Date()
|
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
|
||||||
// Immediate scroll if enough time has passed
|
|
||||||
lastScrollTime = now
|
|
||||||
let contextKey: String = {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let count = windowCountPublic
|
|
||||||
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Schedule a delayed scroll
|
|
||||||
scrollThrottleTimer?.invalidate()
|
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { [weak viewModel] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
lastScrollTime = Date()
|
|
||||||
let contextKey: String = {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let count = windowCountPublic
|
|
||||||
let target = viewModel?.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: viewModel.privateChats) { _ in
|
|
||||||
if let peerID = privatePeer,
|
|
||||||
let messages = viewModel.privateChats[peerID],
|
|
||||||
!messages.isEmpty {
|
|
||||||
// If the newest private message is from me, always scroll
|
|
||||||
let lastMsg = messages.last!
|
|
||||||
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
|
||||||
if !isFromSelf {
|
|
||||||
// Only autoscroll when user is at/near bottom
|
|
||||||
guard isAtBottom.wrappedValue else { return }
|
|
||||||
} else {
|
|
||||||
isAtBottom.wrappedValue = true
|
|
||||||
}
|
|
||||||
// Same throttling for private chats
|
|
||||||
let now = Date()
|
|
||||||
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
|
|
||||||
lastScrollTime = now
|
|
||||||
let contextKey = "dm:\(peerID)"
|
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
|
||||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scrollThrottleTimer?.invalidate()
|
|
||||||
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
|
|
||||||
lastScrollTime = Date()
|
|
||||||
let contextKey = "dm:\(peerID)"
|
|
||||||
let count = windowCountPrivate[peerID] ?? 300
|
|
||||||
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: locationManager.selectedChannel) { newChannel in
|
|
||||||
// When switching to a new geohash channel, scroll to the bottom
|
|
||||||
guard privatePeer == nil else { return }
|
|
||||||
switch newChannel {
|
|
||||||
case .mesh:
|
|
||||||
break
|
|
||||||
case .location(let ch):
|
|
||||||
// Reset window size
|
|
||||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
|
||||||
let contextKey = "geo:\(ch.geohash)"
|
|
||||||
let last = viewModel.messages.suffix(windowCountPublic).last?.id
|
|
||||||
let target = last.map { "\(contextKey)|\($0)" }
|
|
||||||
isAtBottom.wrappedValue = true
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
// Also check when view appears
|
|
||||||
if let peerID = privatePeer {
|
|
||||||
// Try multiple times to ensure read receipts are sent
|
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
|
||||||
viewModel.markPrivateMessagesAsRead(from: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Input View
|
// MARK: - Input View
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -658,110 +372,6 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor.opacity(0.95))
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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
|
|
||||||
|
|
||||||
if peerID.isGeoDM || peerID.isGeoChat {
|
|
||||||
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
|
||||||
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
|
||||||
selectedMessageSender = name
|
|
||||||
} else {
|
|
||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel.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 }
|
|
||||||
|
|
||||||
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
|
||||||
switch len {
|
|
||||||
case 0...2: return .region
|
|
||||||
case 3...4: return .province
|
|
||||||
case 5: return .city
|
|
||||||
case 6: return .neighborhood
|
|
||||||
case 7: return .block
|
|
||||||
default: return .block
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let level = levelForLength(gh.count)
|
|
||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
|
||||||
|
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
|
||||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
|
||||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
|
||||||
}
|
|
||||||
LocationChannelManager.shared.select(ChannelID.location(channel))
|
|
||||||
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scrollToBottom(on proxy: ScrollViewProxy,
|
|
||||||
privatePeer: PeerID?,
|
|
||||||
isAtBottom: Binding<Bool>) {
|
|
||||||
let targetID: String? = {
|
|
||||||
if let peer = privatePeer,
|
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
|
||||||
return "dm:\(peer)|\(last)"
|
|
||||||
}
|
|
||||||
let contextKey: String = {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if let last = viewModel.messages.suffix(300).last?.id {
|
|
||||||
return "\(contextKey)|\(last)"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
isAtBottom.wrappedValue = true
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let targetID {
|
|
||||||
proxy.scrollTo(targetID, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
let secondTarget: String? = {
|
|
||||||
if let peer = privatePeer,
|
|
||||||
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
|
||||||
return "dm:\(peer)|\(last)"
|
|
||||||
}
|
|
||||||
let contextKey: String = {
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if let last = viewModel.messages.suffix(300).last?.id {
|
|
||||||
return "\(contextKey)|\(last)"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
|
|
||||||
if let secondTarget {
|
|
||||||
proxy.scrollTo(secondTarget, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func sendMessage() {
|
private func sendMessage() {
|
||||||
@@ -1008,10 +618,23 @@ struct ContentView: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
MessageListView(
|
||||||
|
privatePeer: viewModel.selectedPrivateChatPeer,
|
||||||
|
isAtBottom: $isAtBottomPrivate,
|
||||||
|
messageText: $messageText,
|
||||||
|
selectedMessageSender: $selectedMessageSender,
|
||||||
|
selectedMessageSenderID: $selectedMessageSenderID,
|
||||||
|
imagePreviewURL: $imagePreviewURL,
|
||||||
|
windowCountPublic: $windowCountPublic,
|
||||||
|
windowCountPrivate: $windowCountPrivate,
|
||||||
|
showSidebar: $showSidebar,
|
||||||
|
isTextFieldFocused: $isTextFieldFocused,
|
||||||
|
)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
inputView
|
inputView
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
@@ -1455,57 +1078,6 @@ struct ContentView: View {
|
|||||||
// MARK: - Helper Views
|
// MARK: - Helper Views
|
||||||
|
|
||||||
private extension ContentView {
|
private extension ContentView {
|
||||||
@ViewBuilder
|
|
||||||
private func messageRow(for message: BitchatMessage) -> some View {
|
|
||||||
if message.sender == "system" {
|
|
||||||
systemMessageRow(message)
|
|
||||||
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
|
|
||||||
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
|
|
||||||
} else {
|
|
||||||
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func systemMessageRow(_ message: BitchatMessage) -> some View {
|
|
||||||
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func expandWindow(ifNeededFor message: BitchatMessage,
|
|
||||||
allMessages: [BitchatMessage],
|
|
||||||
privatePeer: PeerID?,
|
|
||||||
proxy: ScrollViewProxy) {
|
|
||||||
let step = TransportConfig.uiWindowStepCount
|
|
||||||
let contextKey: String = {
|
|
||||||
if let peer = privatePeer { return "dm:\(peer)" }
|
|
||||||
switch locationManager.selectedChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var recordingIndicator: some View {
|
var recordingIndicator: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "waveform.circle.fill")
|
Image(systemName: "waveform.circle.fill")
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
//
|
||||||
|
// MessageListView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Created by Islam on 30/03/2026.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private struct MessageDisplayItem: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let message: BitchatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageListView: View {
|
||||||
|
@EnvironmentObject private var viewModel: ChatViewModel
|
||||||
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
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 lastScrollTime: Date = .distantPast
|
||||||
|
@State private var scrollThrottleTimer: Timer?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let currentWindowCount: Int = {
|
||||||
|
if let peer = privatePeer {
|
||||||
|
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
|
||||||
|
}
|
||||||
|
return windowCountPublic
|
||||||
|
}()
|
||||||
|
|
||||||
|
let messages = viewModel.getMessages(for: privatePeer)
|
||||||
|
let windowedMessages = Array(messages.suffix(currentWindowCount))
|
||||||
|
|
||||||
|
let contextKey: String = {
|
||||||
|
if let peer = privatePeer {
|
||||||
|
"dm:\(peer)"
|
||||||
|
} else {
|
||||||
|
locationManager.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
.onTapGesture {
|
||||||
|
if message.sender != "system" {
|
||||||
|
messageText = "@\(message.sender) "
|
||||||
|
isTextFieldFocused.wrappedValue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.onOpenURL(perform: handleOpenURL)
|
||||||
|
.onTapGesture(count: 3) {
|
||||||
|
viewModel.sendMessage("/clear")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
scrollToBottom(on: proxy)
|
||||||
|
}
|
||||||
|
.onChange(of: privatePeer) { _ in
|
||||||
|
scrollToBottom(on: proxy)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.messages.count) { _ in
|
||||||
|
onMessagesChange(proxy: proxy)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.privateChats) { _ in
|
||||||
|
onPrivateChatsChange(proxy: proxy)
|
||||||
|
}
|
||||||
|
.onChange(of: locationManager.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 {
|
||||||
|
// Pre-fill the input with an @mention and focus the field
|
||||||
|
messageText = "@\(sender) "
|
||||||
|
isTextFieldFocused.wrappedValue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("content.actions.direct_message") {
|
||||||
|
if let peerID = selectedMessageSenderID {
|
||||||
|
if peerID.isGeoChat {
|
||||||
|
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
|
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
viewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
showSidebar = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("content.actions.hug") {
|
||||||
|
if let sender = selectedMessageSender {
|
||||||
|
viewModel.sendMessage("/hug @\(sender)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("content.actions.slap") {
|
||||||
|
if let sender = selectedMessageSender {
|
||||||
|
viewModel.sendMessage("/slap @\(sender)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("content.actions.block", role: .destructive) {
|
||||||
|
// Prefer direct geohash block when we have a Nostr sender ID
|
||||||
|
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
|
||||||
|
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
|
||||||
|
let sender = selectedMessageSender {
|
||||||
|
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
|
||||||
|
} else if let sender = selectedMessageSender {
|
||||||
|
viewModel.sendMessage("/block \(sender)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("common.cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Also check when view appears
|
||||||
|
if let peerID = privatePeer {
|
||||||
|
// Try multiple times to ensure read receipts are sent
|
||||||
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
|
||||||
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
|
||||||
|
viewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
scrollThrottleTimer?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
@ViewBuilder
|
||||||
|
func messageRow(for message: BitchatMessage) -> some View {
|
||||||
|
Group {
|
||||||
|
if message.sender == "system" {
|
||||||
|
systemMessageRow(message)
|
||||||
|
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
|
||||||
|
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
|
||||||
|
} else {
|
||||||
|
TextMessageView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func systemMessageRow(_ message: BitchatMessage) -> some View {
|
||||||
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
|
.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 {
|
||||||
|
locationManager.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
|
||||||
|
|
||||||
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
|
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
|
||||||
|
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
|
selectedMessageSender = name
|
||||||
|
} else {
|
||||||
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.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 }
|
||||||
|
|
||||||
|
func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||||
|
switch len {
|
||||||
|
case 0...2: return .region
|
||||||
|
case 3...4: return .province
|
||||||
|
case 5: return .city
|
||||||
|
case 6: return .neighborhood
|
||||||
|
case 7: return .block
|
||||||
|
default: return .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = levelForLength(gh.count)
|
||||||
|
let channel = GeohashChannel(level: level, geohash: gh)
|
||||||
|
|
||||||
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||||
|
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||||
|
}
|
||||||
|
LocationChannelManager.shared.select(ChannelID.location(channel))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToBottom(on proxy: ScrollViewProxy) {
|
||||||
|
isAtBottom = true
|
||||||
|
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 = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
|
||||||
|
return "dm:\(peer)|\(last)"
|
||||||
|
}
|
||||||
|
if let last = viewModel.messages.suffix(300).last?.id {
|
||||||
|
return "\(locationManager.selectedChannel.contextKey)|\(last)"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||||
|
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
|
||||||
|
|
||||||
|
// If the newest message is from me, always scroll to bottom
|
||||||
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
|
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||||
|
return
|
||||||
|
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||||
|
isAtBottom = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollIfNeeded(date: Date) {
|
||||||
|
lastScrollTime = date
|
||||||
|
let contextKey = locationManager.selectedChannel.contextKey
|
||||||
|
if let target = viewModel.messages.suffix(windowCountPublic).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, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the newest private message is from me, always scroll
|
||||||
|
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
|
||||||
|
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
isAtBottom = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollIfNeeded(date: Date) {
|
||||||
|
lastScrollTime = date
|
||||||
|
let contextKey = "dm:\(peerID)"
|
||||||
|
let count = windowCountPrivate[peerID] ?? 300
|
||||||
|
if let target = messages.suffix(count).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 }
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
break
|
||||||
|
case .location(let ch):
|
||||||
|
// Reset window size
|
||||||
|
isAtBottom = true
|
||||||
|
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||||
|
let contextKey = "geo:\(ch.geohash)"
|
||||||
|
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||||
|
proxy.scrollTo(target, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChannelID {
|
||||||
|
var contextKey: String {
|
||||||
|
switch self {
|
||||||
|
case .mesh: "mesh"
|
||||||
|
case .location(let ch): "geo:\(ch.geohash)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//#Preview {
|
||||||
|
// MessageListView()
|
||||||
|
//}
|
||||||
@@ -358,10 +358,17 @@ struct ViewSmokeTests {
|
|||||||
secondaryTextColor: .gray,
|
secondaryTextColor: .gray,
|
||||||
onTapPerson: {}
|
onTapPerson: {}
|
||||||
)
|
)
|
||||||
var expandedMessageIDs: Set<String> = []
|
let truncatableMessage = BitchatMessage(
|
||||||
let longMessage = BitchatMessage(
|
|
||||||
sender: viewModel.nickname,
|
sender: viewModel.nickname,
|
||||||
content: String(repeating: "verylongtoken", count: 12) + " lightning:lnbc1test cashuA_test-token",
|
content: String(repeating: "verylongtoken ", count: 160),
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: false,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
let paymentMessage = BitchatMessage(
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
content: "lightning:lnbc1test cashuA_test-token",
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
@@ -371,18 +378,11 @@ struct ViewSmokeTests {
|
|||||||
|
|
||||||
_ = geohashPeopleList.body
|
_ = geohashPeopleList.body
|
||||||
_ = mount(geohashPeopleList)
|
_ = mount(geohashPeopleList)
|
||||||
_ = mount(
|
_ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel))
|
||||||
TextMessageView(
|
_ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel))
|
||||||
message: longMessage,
|
|
||||||
expandedMessageIDs: Binding(
|
|
||||||
get: { expandedMessageIDs },
|
|
||||||
set: { expandedMessageIDs = $0 }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.environmentObject(viewModel)
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(expandedMessageIDs.isEmpty)
|
#expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold)
|
||||||
|
#expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user