From 13f8b0c63618caf71529297a850a25786ff0ad55 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:38:44 +0200 Subject: [PATCH] Fix/geohash work (#475) * Scroll UX: auto-scroll only when last item visible; preserve user position when scrolled up for mesh/geohash/DM; reduce blanking after very long messages * iOS: re-enable keyboard autocomplete and default capitalization for message input * Styling: stop blue/underline styling for #hashtags; render as normal text color (self=orange, others=green) * Geo UI: ensure self shows teleported (face.dashed) if either per-session tag or manager flag is true * Geo teleported: publish UI updates by assigning @Published Set instead of in-place insert; update on tag receipt and channel switch --------- Co-authored-by: jack --- bitchat/ViewModels/ChatViewModel.swift | 35 +++++++++++++++----------- bitchat/Views/ContentView.swift | 21 +++++++++++++--- bitchat/Views/GeohashPeopleList.swift | 3 ++- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 33734926..1ebaf7ee 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1179,8 +1179,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.recordGeoParticipant(pubkeyHex: id.publicKeyHex) #if os(iOS) if LocationChannelManager.shared.teleported { - teleportedGeo.insert(id.publicKeyHex.lowercased()) - objectWillChange.send() + let key = id.publicKeyHex.lowercased() + teleportedGeo = teleportedGeo.union([key]) } #endif } @@ -1198,9 +1198,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Track teleport tag for participants if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, teleTag[1] == "teleport" { let key = event.pubkey.lowercased() - if !self.teleportedGeo.contains(key) { - self.teleportedGeo.insert(key) - DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } + Task { @MainActor in + self.teleportedGeo = self.teleportedGeo.union([key]) } } // Skip our own events (we already locally echoed) @@ -2826,17 +2825,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate { result.append(AttributedString(mSuffix).mergingAttributes(light)) } } else { - var matchStyle = AttributeContainer() - matchStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) - // Self messages use orange for all tokens; others keep blue for links/hashtags + // Style non-mention matches if type == "hashtag" { - matchStyle.foregroundColor = isSelf ? .orange : .blue - matchStyle.underlineStyle = .single - } else if type == "url" { - matchStyle.foregroundColor = isSelf ? .orange : .blue - matchStyle.underlineStyle = .single + // Do NOT special-style hashtags: render like normal content (no blue, no underline) + var tagStyle = AttributeContainer() + tagStyle.font = isSelf + ? .system(size: 14, weight: .bold, design: .monospaced) + : .system(size: 14, design: .monospaced) + tagStyle.foregroundColor = baseColor + result.append(AttributedString(matchText).mergingAttributes(tagStyle)) + } else { + // Keep URL styling (blue + underline for non-self, orange for self) + var matchStyle = AttributeContainer() + matchStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) + if type == "url" { + matchStyle.foregroundColor = isSelf ? .orange : .blue + matchStyle.underlineStyle = .single + } + result.append(AttributedString(matchText).mergingAttributes(matchStyle)) } - result.append(AttributedString(matchText).mergingAttributes(matchStyle)) } lastEnd = nsRange.upperBound } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e13c28c2..2cedae5a 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -267,6 +267,7 @@ struct ContentView: View { private func messagesView(privatePeer: String?) -> some View { ScrollViewReader { proxy in + @State var isAtBottom: Bool = true ScrollView { LazyVStack(alignment: .leading, spacing: 0) { // Extract messages based on context (private or public chat) @@ -325,6 +326,17 @@ struct ContentView: View { } } .id(message.id) + .onAppear { + // Track if last item is visible to enable auto-scroll only when near bottom + if message.id == windowedMessages.last?.id { + isAtBottom = true + } + } + .onDisappear { + if message.id == windowedMessages.last?.id { + isAtBottom = false + } + } .contentShape(Rectangle()) .onTapGesture { // Only show actions for messages from other users (not system or self) @@ -347,6 +359,8 @@ struct ContentView: View { } .onChange(of: viewModel.messages.count) { _ in if privatePeer == nil && !viewModel.messages.isEmpty { + // Only autoscroll when user is at/near bottom + guard isAtBottom else { return } // Throttle scroll animations to prevent excessive UI updates let now = Date() if now.timeIntervalSince(lastScrollTime) > 0.5 { @@ -367,6 +381,8 @@ struct ContentView: View { if let peerID = privatePeer, let messages = viewModel.privateChats[peerID], !messages.isEmpty { + // Only autoscroll when user is at/near bottom + guard isAtBottom else { return } // Same throttling for private chats let now = Date() if now.timeIntervalSince(lastScrollTime) > 0.5 { @@ -508,10 +524,7 @@ struct ContentView: View { .foregroundColor(textColor) .focused($isTextFieldFocused) .padding(.leading, 12) - .autocorrectionDisabled(true) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif + // iOS keyboard autocomplete and capitalization enabled by default .onChange(of: messageText) { newValue in // Cancel previous debounce timer autocompleteDebounceTimer?.invalidate() diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index a875dfff..e8604b97 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -40,7 +40,8 @@ struct GeohashPeopleList: View { // For the local user, use a different face icon when teleported let isMe = (person.id == myHex) #if os(iOS) - let teleported = isMe ? LocationChannelManager.shared.teleported : viewModel.teleportedGeo.contains(person.id.lowercased()) + // Consider either the per-session tag (for any peer) or the manager flag for self + let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) #else let teleported = false #endif