Align DM sheet toolbar with people list (#795)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-10-12 19:53:08 +02:00
committed by GitHub
co-authored by jack
parent 4563c22d2d
commit 615273a63e
2 changed files with 397 additions and 441 deletions
+35 -1
View File
@@ -177,7 +177,41 @@
"state": "translated",
"value": "bitchat"
}
}
}
}
},
"%@ active": {
"comment": "A label at the bottom of the people list sheet showing the number of active users.",
"localizations": {
"ar": { "stringUnit": { "state": "translated", "value": "%@ نشطين" } },
"bn": { "stringUnit": { "state": "translated", "value": "%@ সক্রিয়" } },
"de": { "stringUnit": { "state": "translated", "value": "%@ aktiv" } },
"en": { "stringUnit": { "state": "translated", "value": "%@ active" } },
"es": { "stringUnit": { "state": "translated", "value": "%@ activos" } },
"fil": { "stringUnit": { "state": "translated", "value": "%@ aktibo" } },
"fr": { "stringUnit": { "state": "translated", "value": "%@ actifs" } },
"he": { "stringUnit": { "state": "translated", "value": "%@ פעילים" } },
"hi": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
"id": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
"it": { "stringUnit": { "state": "translated", "value": "%@ attivi" } },
"ja": { "stringUnit": { "state": "translated", "value": "%@ 人がアクティブ" } },
"ko": { "stringUnit": { "state": "translated", "value": "활성 사용자 %@명" } },
"ms": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
"ne": { "stringUnit": { "state": "translated", "value": "%@ सक्रिय" } },
"nl": { "stringUnit": { "state": "translated", "value": "%@ actief" } },
"pl": { "stringUnit": { "state": "translated", "value": "%@ aktywni" } },
"pt": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
"pt-BR": { "stringUnit": { "state": "translated", "value": "%@ ativos" } },
"ru": { "stringUnit": { "state": "translated", "value": "%@ активных" } },
"sv": { "stringUnit": { "state": "translated", "value": "%@ aktiva" } },
"ta": { "stringUnit": { "state": "translated", "value": "%@ செயலில்" } },
"th": { "stringUnit": { "state": "translated", "value": "%@ กำลังใช้งาน" } },
"tr": { "stringUnit": { "state": "translated", "value": "%@ aktif" } },
"uk": { "stringUnit": { "state": "translated", "value": "%@ активних" } },
"ur": { "stringUnit": { "state": "translated", "value": "%@ فعال" } },
"vi": { "stringUnit": { "state": "translated", "value": "%@ đang hoạt động" } },
"zh-Hans": { "stringUnit": { "state": "translated", "value": "活跃用户 %@ 名" } },
"zh-Hant": { "stringUnit": { "state": "translated", "value": "活躍使用者 %@ 位" } }
}
},
"app_info.close": {
+362 -440
View File
@@ -33,12 +33,9 @@ struct ContentView: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showPeerList = false
@State private var showSidebar = false
@State private var sidebarDragOffset: CGFloat = 0
@State private var showAppInfo = false
@State private var showCommandSuggestions = false
@State private var commandSuggestions: [String] = []
@State private var backSwipeOffset: CGFloat = 0
@State private var showPrivateChat = false
@State private var showMessageActions = false
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: String?
@@ -79,8 +76,38 @@ struct ContentView: View {
private var headerLineLimit: Int? {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
private var peopleSheetTitle: String {
String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased()
}
private var peopleSheetSubtitle: String? {
switch locationManager.selectedChannel {
case .mesh:
return "#mesh"
case .location(let channel):
return "#\(channel.geohash.lowercased())"
}
}
private var peopleSheetActiveCount: Int {
switch locationManager.selectedChannel {
case .mesh:
return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count
case .location:
return viewModel.visibleGeohashPeople().count
}
}
// MARK: - Body
private struct PrivateHeaderContext {
let headerPeerID: String
let peer: BitchatPeer?
let displayName: String
let isNostrAvailable: Bool
}
// MARK: - Body
var body: some View {
VStack(spacing: 0) {
@@ -92,116 +119,17 @@ struct ContentView: View {
Divider()
// Messages area with overlays
GeometryReader { geometry in
ZStack {
// Base layer - public messages
VStack(spacing: 0) {
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
.background(backgroundColor)
// Private chat slide-over (full screen with own header/input)
if viewModel.selectedPrivateChatPeer != nil {
privateChatView
.frame(width: geometry.size.width)
.background(backgroundColor)
.transition(.asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .trailing)
))
.offset(x: showPrivateChat ? -1 : max(0, geometry.size.width))
.offset(x: backSwipeOffset.isNaN ? 0 : backSwipeOffset)
.gesture(
DragGesture()
.onChanged { value in
if value.translation.width > 0 && !value.translation.width.isNaN {
let maxWidth = max(0, geometry.size.width)
backSwipeOffset = min(value.translation.width, maxWidth.isNaN ? 0 : maxWidth)
}
}
.onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = false
backSwipeOffset = 0
viewModel.endPrivateChat()
}
} else {
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
backSwipeOffset = 0
}
}
}
)
}
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
if !showSidebar && viewModel.selectedPrivateChatPeer == nil {
HStack {
Spacer()
Color.clear
.frame(width: 20)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 5)
.onChanged { value in
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
if translationWidth < 0 {
let newOffset = max(translationWidth, -300)
sidebarDragOffset = newOffset
}
}
.onEnded { value in
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
if translationWidth < -50 || velocity < -300 {
showSidebar = true
sidebarDragOffset = 0
} else {
sidebarDragOffset = 0
}
}
}
)
}
.allowsHitTesting(true)
}
// Sidebar overlay (only over messages area, not input)
if viewModel.selectedPrivateChatPeer == nil {
HStack(spacing: 0) {
// Tap to dismiss area
Color.clear
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false
sidebarDragOffset = 0
}
}
// Always render sidebar to avoid layout recalculation during drag
sidebarView
#if os(macOS)
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
#else
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
#endif
}
.offset(x: {
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
return showSidebar ? dragOffset : width + dragOffset
}())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
Divider()
// Input always accessible below sidebar (only in public chat)
if viewModel.selectedPrivateChatPeer == nil {
inputView
}
@@ -212,10 +140,27 @@ struct ContentView: View {
.frame(minWidth: 600, minHeight: 400)
#endif
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = newValue != nil
if newValue != nil {
showSidebar = true
}
}
.sheet(
isPresented: Binding(
get: { showSidebar || viewModel.selectedPrivateChatPeer != nil },
set: { isPresented in
if !isPresented {
showSidebar = false
viewModel.endPrivateChat()
}
}
)
) {
peopleSheetView
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
}
.sheet(isPresented: $showAppInfo) {
AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
@@ -252,8 +197,7 @@ struct ContentView: View {
viewModel.startPrivateChat(with: peerID)
}
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false
sidebarDragOffset = 0
showSidebar = true
}
}
}
@@ -889,23 +833,33 @@ struct ContentView: View {
messageText = ""
}
// MARK: - Sidebar View
// MARK: - Sheet Content
private var sidebarView: some View {
HStack(spacing: 0) {
// Grey vertical bar for visual continuity
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: 1)
VStack(alignment: .leading, spacing: 0) {
// Header - match main toolbar height
HStack {
Text("content.header.people")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
private var peopleSheetView: some View {
Group {
if viewModel.selectedPrivateChatPeer != nil {
privateChatSheetView
} else {
peopleListSheetView
}
}
.background(backgroundColor)
.foregroundColor(textColor)
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
}
// MARK: - People Sheet Views
private var peopleListSheetView: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(peopleSheetTitle)
.font(.bitchatSystem(size: 18, design: .monospaced))
.foregroundColor(textColor)
Spacer()
// Show QR in mesh on all platforms
if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
@@ -916,113 +870,304 @@ struct ContentView: View {
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
viewModel.endPrivateChat()
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight) // Match header height
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
Divider()
let activeText = String.localizedStringWithFormat(
String(localized: "%@ active", comment: "Count of active users in the people sheet"),
"\(peopleSheetActiveCount)"
)
if let subtitle = peopleSheetSubtitle {
let subtitleColor: Color = {
switch locationManager.selectedChannel {
case .mesh:
return Color.blue
case .location:
return Color.green
}
}()
HStack(spacing: 6) {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(.secondary)
}
.font(.bitchatSystem(size: 12, design: .monospaced))
} else {
Text(activeText)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
// Rooms and People list
ScrollView {
VStack(alignment: .leading, spacing: 6) {
// People section
VStack(alignment: .leading, spacing: 4) {
if case .location = locationManager.selectedChannel {
GeohashPeopleList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
})
} else {
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
}
if case .location = locationManager.selectedChannel {
GeohashPeopleList(
viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
showSidebar = true
}
)
} else {
MeshPeerList(
viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
showSidebar = true
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
}
)
}
}
.padding(.top, 4)
.id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined())
}
Spacer()
}
.background(backgroundColor)
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { value in
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
// Only handle horizontal drags on the sidebar
guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return }
if translationWidth > 0 {
let newOffset = min(translationWidth, 300)
sidebarDragOffset = newOffset
}
}
.onEnded { value in
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
// Only handle if drag is predominantly horizontal
guard abs(translationWidth) > abs(translationHeight) * 1.5 else {
sidebarDragOffset = 0
return
}
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
showSidebar = false
sidebarDragOffset = 0
} else {
sidebarDragOffset = 0
}
}
}
)
}
}
// MARK: - View Components
private var privateChatView: some View {
HStack(spacing: 0) {
// Vertical separator bar
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: 1)
VStack(spacing: 0) {
privateHeaderView
Divider()
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
Divider()
inputView
private var privateChatSheetView: some View {
VStack(spacing: 0) {
if let privatePeerID = viewModel.selectedPrivateChatPeer {
let headerContext = makePrivateHeaderContext(for: privatePeerID)
HStack(spacing: 12) {
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
viewModel.endPrivateChat()
}
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(textColor)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
)
Spacer(minLength: 0)
HStack(spacing: 8) {
privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID)
if !privatePeerID.hasPrefix("nostr_") {
Button(action: {
viewModel.toggleFavorite(peerID: headerContext.headerPeerID)
}) {
Image(systemName: viewModel.isFavorite(peerID: headerContext.headerPeerID) ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(viewModel.isFavorite(peerID: headerContext.headerPeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel(
viewModel.isFavorite(peerID: headerContext.headerPeerID)
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
}
}
.frame(maxWidth: .infinity)
Spacer(minLength: 0)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
viewModel.endPrivateChat()
showSidebar = true
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
.frame(height: headerHeight)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background(backgroundColor)
}
.background(backgroundColor)
.foregroundColor(textColor)
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
.background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
inputView
}
.background(backgroundColor)
.foregroundColor(textColor)
}
private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: String) -> some View {
Button(action: {
viewModel.showFingerprint(for: context.headerPeerID)
}) {
HStack(spacing: 6) {
if let connectionState = context.peer?.connectionState {
switch connectionState {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
EmptyView()
}
} else if viewModel.meshService.isPeerReachable(PeerID(str: context.headerPeerID)) {
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
} else if context.isNostrAvailable {
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
} else if viewModel.meshService.isPeerConnected(PeerID(str: context.headerPeerID)) || viewModel.connectedPeers.contains(context.headerPeerID) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
}
Text(context.displayName)
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
if !privatePeerID.hasPrefix("nostr_") {
let statusPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short
}
return context.headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor :
Color.red)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
locale: .current,
encryptionStatus.accessibilityDescription
)
)
}
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
locale: .current,
context.displayName
)
)
.accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(height: headerHeight)
}
private func makePrivateHeaderContext(for privatePeerID: String) -> PrivateHeaderContext {
let headerPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short
}
return privatePeerID
}()
let peer = viewModel.getPeer(byID: headerPeerID)
let displayName: String = {
if privatePeerID.hasPrefix("nostr_"), case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)"
}
if let name = peer?.displayName { return name }
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname }
if headerPeerID.count == 16 {
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
if let id = candidates.first,
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint()
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}()
let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else {
if let noiseKey = Data(hexString: headerPeerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return true
}
return false
}
return connectionState == .nostrAvailable
}()
return PrivateHeaderContext(
headerPeerID: headerPeerID,
peer: peer,
displayName: displayName,
isNostrAvailable: isNostrAvailable
)
}
// Compute channel-aware people count and color for toolbar (cross-platform)
@@ -1210,7 +1355,6 @@ struct ContentView: View {
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
sidebarDragOffset = 0
}
}
.sheet(isPresented: $showVerifySheet) {
@@ -1318,229 +1462,7 @@ struct ContentView: View {
}
.background(backgroundColor.opacity(0.95))
}
private var privateHeaderView: some View {
Group {
if let privatePeerID = viewModel.selectedPrivateChatPeer {
privateHeaderContent(for: privatePeerID)
}
}
}
@ViewBuilder
private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
let headerPeerID: String = {
if privatePeerID.count == 64 {
// Map stable Noise key to short ID if we know it (even if not directly connected)
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
}
return privatePeerID
}()
// Resolve peer object for header context (may be offline favorite)
let peer = viewModel.getPeer(byID: headerPeerID)
let privatePeerNick: String = {
if privatePeerID.hasPrefix("nostr_") {
// Build geohash DM header: "#<ghash>/@name#abcd"
if case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)"
}
}
// Try mesh/unified peer display
if let name = peer?.displayName { return name }
// Try direct mesh nickname (connected-only)
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: headerPeerID)) { return name }
// Try favorite nickname by stable Noise key
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 {
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: headerPeerID))
if let id = candidates.first,
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint()
if let social = viewModel.identityManager.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}()
let isNostrAvailable: Bool = {
guard let connectionState = peer?.connectionState else {
// Check if we can reach this peer via Nostr even if not in allPeers
if let noiseKey = Data(hexString: headerPeerID),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return true
}
return false
}
switch connectionState {
case .nostrAvailable:
return true
default:
return false
}
}()
ZStack {
// Center content - always perfectly centered
Button(action: {
viewModel.showFingerprint(for: headerPeerID)
}) {
HStack(spacing: 6) {
// Show transport icon based on connection state (like peer list)
if let connectionState = peer?.connectionState {
switch connectionState {
case .bluetoothConnected:
// Radio icon for mesh connection
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
)
case .meshReachable:
// point.3 filled icon for reachable via mesh (not directly connected)
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
)
case .nostrAvailable:
// Purple globe for Nostr
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
)
case .offline:
// Should not happen for PM header, but handle gracefully
EmptyView()
}
} else if viewModel.meshService.isPeerReachable(PeerID(str: headerPeerID)) {
// Fallback: reachable via mesh but not in current peer list
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(
String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
)
} else if isNostrAvailable {
// Fallback to Nostr if peer not in list but is mutual favorite
Image(systemName: "globe")
.font(.bitchatSystem(size: 14))
.foregroundColor(.purple)
.accessibilityLabel(
String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
)
} else if viewModel.meshService.isPeerConnected(PeerID(str: headerPeerID)) || viewModel.connectedPeers.contains(headerPeerID) {
// Fallback: if peer lookup is missing but mesh reports connected, show radio
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.accessibilityLabel(
String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
)
}
Text("\(privatePeerNick)")
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID)
let statusPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short
}
return headerPeerID
}()
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
if let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
encryptionStatus == .noiseSecured ? textColor :
Color.red)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"),
locale: .current,
encryptionStatus.accessibilityDescription
)
)
}
}
}
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"),
locale: .current,
privatePeerNick
)
)
.accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
}
.buttonStyle(.plain)
// Left and right buttons positioned with HStack
HStack {
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = false
viewModel.endPrivateChat()
}
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(textColor)
.frame(width: 44, height: 44, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat")
)
Spacer()
// Favorite button (hidden for geohash DMs)
if !(privatePeerID.hasPrefix("nostr_")) {
Button(action: {
viewModel.toggleFavorite(peerID: headerPeerID)
}) {
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
.font(.bitchatSystem(size: 16))
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
}
.buttonStyle(.plain)
.accessibilityLabel(
viewModel.isFavorite(peerID: privatePeerID)
? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
: String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
)
.accessibilityHint(
String(localized: "content.accessibility.toggle_favorite_hint", comment: "Accessibility hint for toggling favorite status")
)
}
}
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
}
}
// MARK: - Notes Counter Subscription Helper