mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 02:45:19 +00:00
Merge feature/peer-colors: per-peer colors, mention action, teleport persistence + logs, geohash count consistency, scroll gating, hashtag styling, copy message context menu
This commit is contained in:
@@ -1473,6 +1473,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// MARK: - Public helpers (iOS)
|
// MARK: - Public helpers (iOS)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
/// Return the current, pruned, sorted people list for the active geohash without mutating state.
|
||||||
|
@MainActor
|
||||||
|
func visibleGeohashPeople() -> [GeoPerson] {
|
||||||
|
guard let gh = currentGeohash else { return [] }
|
||||||
|
let cutoff = Date().addingTimeInterval(-5 * 60)
|
||||||
|
let map = (geoParticipants[gh] ?? [:]).filter { $0.value >= cutoff }
|
||||||
|
let people = map
|
||||||
|
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
|
||||||
|
.sorted { $0.lastSeen > $1.lastSeen }
|
||||||
|
return people
|
||||||
|
}
|
||||||
/// Returns the current participant count for a specific geohash, using the 5-minute activity window.
|
/// Returns the current participant count for a specific geohash, using the 5-minute activity window.
|
||||||
@MainActor
|
@MainActor
|
||||||
func geohashParticipantCount(for geohash: String) -> Int {
|
func geohashParticipantCount(for geohash: String) -> Int {
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ struct ContentView: View {
|
|||||||
@State private var selectedMessageSender: String?
|
@State private var selectedMessageSender: String?
|
||||||
@State private var selectedMessageSenderID: String?
|
@State private var selectedMessageSenderID: String?
|
||||||
@FocusState private var isNicknameFieldFocused: Bool
|
@FocusState private var isNicknameFieldFocused: Bool
|
||||||
|
@State private var isAtBottomPublic: Bool = true
|
||||||
|
@State private var isAtBottomPrivate: Bool = true
|
||||||
@State private var lastScrollTime: Date = .distantPast
|
@State private var lastScrollTime: Date = .distantPast
|
||||||
@State private var scrollThrottleTimer: Timer?
|
@State private var scrollThrottleTimer: Timer?
|
||||||
@State private var autocompleteDebounceTimer: Timer?
|
@State private var autocompleteDebounceTimer: Timer?
|
||||||
@@ -273,9 +275,8 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Message List View
|
// MARK: - Message List View
|
||||||
|
|
||||||
private func messagesView(privatePeer: String?) -> some View {
|
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> 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)
|
||||||
@@ -337,12 +338,12 @@ struct ContentView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
// Track if last item is visible to enable auto-scroll only when near bottom
|
// Track if last item is visible to enable auto-scroll only when near bottom
|
||||||
if message.id == windowedMessages.last?.id {
|
if message.id == windowedMessages.last?.id {
|
||||||
isAtBottom = true
|
isAtBottom.wrappedValue = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
if message.id == windowedMessages.last?.id {
|
if message.id == windowedMessages.last?.id {
|
||||||
isAtBottom = false
|
isAtBottom.wrappedValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -379,7 +380,7 @@ 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
|
// Only autoscroll when user is at/near bottom
|
||||||
guard isAtBottom else { return }
|
guard isAtBottom.wrappedValue 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 {
|
||||||
@@ -401,7 +402,7 @@ struct ContentView: View {
|
|||||||
let messages = viewModel.privateChats[peerID],
|
let messages = viewModel.privateChats[peerID],
|
||||||
!messages.isEmpty {
|
!messages.isEmpty {
|
||||||
// Only autoscroll when user is at/near bottom
|
// Only autoscroll when user is at/near bottom
|
||||||
guard isAtBottom else { return }
|
guard isAtBottom.wrappedValue 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 {
|
||||||
@@ -727,7 +728,7 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
mainHeaderView
|
mainHeaderView
|
||||||
Divider()
|
Divider()
|
||||||
messagesView(privatePeer: nil)
|
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||||
Divider()
|
Divider()
|
||||||
inputView
|
inputView
|
||||||
}
|
}
|
||||||
@@ -777,7 +778,7 @@ struct ContentView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
privateHeaderView
|
privateHeaderView
|
||||||
Divider()
|
Divider()
|
||||||
messagesView(privatePeer: viewModel.selectedPrivateChatPeer)
|
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
|
||||||
Divider()
|
Divider()
|
||||||
inputView
|
inputView
|
||||||
}
|
}
|
||||||
@@ -867,24 +868,31 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Channel badge + dynamic spacing + people counter
|
// Channel badge + dynamic spacing + people counter
|
||||||
|
// Precompute header count and color outside the ViewBuilder expressions
|
||||||
|
#if os(iOS)
|
||||||
|
let cc = channelPeopleCountAndColor()
|
||||||
|
let headerCountColor: Color = cc.1
|
||||||
|
let headerOtherPeersCount: Int = {
|
||||||
|
if case .location = locationManager.selectedChannel {
|
||||||
|
return viewModel.visibleGeohashPeople().count
|
||||||
|
}
|
||||||
|
return cc.0
|
||||||
|
}()
|
||||||
|
#else
|
||||||
|
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||||
|
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||||
|
let isMeshConnected = peer.isConnected
|
||||||
|
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||||
|
else if peer.isMutualFavorite { counts.others += 1 }
|
||||||
|
}
|
||||||
|
let headerOtherPeersCount = peerCounts.others
|
||||||
|
// Darker, more neutral blue (less purple hue)
|
||||||
|
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||||
|
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
|
||||||
|
#endif
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
// Unread icon immediately to the left of the channel badge (independent from channel button)
|
||||||
#if os(iOS)
|
|
||||||
let cc = channelPeopleCountAndColor()
|
|
||||||
let otherPeersCount = cc.0
|
|
||||||
let countColor = cc.1
|
|
||||||
#else
|
|
||||||
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
|
||||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
|
||||||
let isMeshConnected = peer.isConnected
|
|
||||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
|
||||||
else if peer.isMutualFavorite { counts.others += 1 }
|
|
||||||
}
|
|
||||||
let otherPeersCount = peerCounts.others
|
|
||||||
// Darker, more neutral blue (less purple hue)
|
|
||||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
|
||||||
let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Unread indicator
|
// Unread indicator
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -928,12 +936,12 @@ struct ContentView: View {
|
|||||||
// People icon with count
|
// People icon with count
|
||||||
Image(systemName: "person.2.fill")
|
Image(systemName: "person.2.fill")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.accessibilityLabel("\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")")
|
.accessibilityLabel("\(headerOtherPeersCount) people")
|
||||||
Text("\(otherPeersCount)")
|
Text("\(headerOtherPeersCount)")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(countColor)
|
.foregroundColor(headerCountColor)
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct GeohashPeopleList: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.geohashPeople.isEmpty {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
Text("nobody around...")
|
Text("nobody around...")
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.system(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
@@ -24,7 +24,7 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
let ordered = viewModel.geohashPeople.sorted { a, b in
|
let ordered = viewModel.visibleGeohashPeople().sorted { a, b in
|
||||||
if let me = myHex {
|
if let me = myHex {
|
||||||
if a.id == me && b.id != me { return true }
|
if a.id == me && b.id != me { return true }
|
||||||
if b.id == me && a.id != me { return false }
|
if b.id == me && a.id != me { return false }
|
||||||
|
|||||||
Reference in New Issue
Block a user