diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 662a4643..3124eda6 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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": { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 3f0abadc..81171866 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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: "#/@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