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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-22 11:38:44 +02:00
committed by GitHub
co-authored by jack
parent 2d0f9aff0a
commit 13f8b0c636
3 changed files with 40 additions and 19 deletions
+21 -14
View File
@@ -1179,8 +1179,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex) self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
#if os(iOS) #if os(iOS)
if LocationChannelManager.shared.teleported { if LocationChannelManager.shared.teleported {
teleportedGeo.insert(id.publicKeyHex.lowercased()) let key = id.publicKeyHex.lowercased()
objectWillChange.send() teleportedGeo = teleportedGeo.union([key])
} }
#endif #endif
} }
@@ -1198,9 +1198,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Track teleport tag for participants // Track teleport tag for participants
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, teleTag[1] == "teleport" { if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, teleTag[1] == "teleport" {
let key = event.pubkey.lowercased() let key = event.pubkey.lowercased()
if !self.teleportedGeo.contains(key) { Task { @MainActor in
self.teleportedGeo.insert(key) self.teleportedGeo = self.teleportedGeo.union([key])
DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() }
} }
} }
// Skip our own events (we already locally echoed) // Skip our own events (we already locally echoed)
@@ -2826,17 +2825,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(AttributedString(mSuffix).mergingAttributes(light)) result.append(AttributedString(mSuffix).mergingAttributes(light))
} }
} else { } else {
var matchStyle = AttributeContainer() // Style non-mention matches
matchStyle.font = .system(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
// Self messages use orange for all tokens; others keep blue for links/hashtags
if type == "hashtag" { if type == "hashtag" {
matchStyle.foregroundColor = isSelf ? .orange : .blue // Do NOT special-style hashtags: render like normal content (no blue, no underline)
matchStyle.underlineStyle = .single var tagStyle = AttributeContainer()
} else if type == "url" { tagStyle.font = isSelf
matchStyle.foregroundColor = isSelf ? .orange : .blue ? .system(size: 14, weight: .bold, design: .monospaced)
matchStyle.underlineStyle = .single : .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 lastEnd = nsRange.upperBound
} }
+17 -4
View File
@@ -267,6 +267,7 @@ struct ContentView: View {
private func messagesView(privatePeer: String?) -> some View { private func messagesView(privatePeer: String?) -> some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
@State var isAtBottom: Bool = true
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
// Extract messages based on context (private or public chat) // Extract messages based on context (private or public chat)
@@ -325,6 +326,17 @@ struct ContentView: View {
} }
} }
.id(message.id) .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()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
// Only show actions for messages from other users (not system or self) // 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 .onChange(of: viewModel.messages.count) { _ in
if privatePeer == nil && !viewModel.messages.isEmpty { 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 // Throttle scroll animations to prevent excessive UI updates
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > 0.5 {
@@ -367,6 +381,8 @@ struct ContentView: View {
if let peerID = privatePeer, if let peerID = privatePeer,
let messages = viewModel.privateChats[peerID], let messages = viewModel.privateChats[peerID],
!messages.isEmpty { !messages.isEmpty {
// Only autoscroll when user is at/near bottom
guard isAtBottom else { return }
// Same throttling for private chats // Same throttling for private chats
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > 0.5 {
@@ -508,10 +524,7 @@ struct ContentView: View {
.foregroundColor(textColor) .foregroundColor(textColor)
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.padding(.leading, 12) .padding(.leading, 12)
.autocorrectionDisabled(true) // iOS keyboard autocomplete and capitalization enabled by default
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Cancel previous debounce timer // Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
+2 -1
View File
@@ -40,7 +40,8 @@ struct GeohashPeopleList: View {
// For the local user, use a different face icon when teleported // For the local user, use a different face icon when teleported
let isMe = (person.id == myHex) let isMe = (person.id == myHex)
#if os(iOS) #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 #else
let teleported = false let teleported = false
#endif #endif