diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 386686a2..7516c858 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -129,7 +129,7 @@ class ChatViewModel: ObservableObject { } // Show welcome message after delay if still no peers - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in guard let self = self else { return } if self.connectedPeers.isEmpty && self.messages.isEmpty { let welcomeMessage = BitchatMessage( @@ -2107,7 +2107,6 @@ class ChatViewModel: ObservableObject { } } else { // System message - let content = AttributedString("* \(message.content) *") var contentStyle = AttributeContainer() // Check for welcome message if message.content.contains("get people around you to download bitchat") { @@ -2115,6 +2114,7 @@ class ChatViewModel: ObservableObject { } else { contentStyle.foregroundColor = Color.gray } + let content = AttributedString("* \(message.content) *") contentStyle.font = .system(size: 12, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 80341dc0..1868422b 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -27,6 +27,9 @@ struct ContentView: View { @State private var showCommandSuggestions = false @State private var commandSuggestions: [String] = [] @State private var showLeaveChannelAlert = false + @State private var backSwipeOffset: CGFloat = 0 + @State private var showPrivateChat = false + @State private var showChannel = false private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white @@ -41,82 +44,119 @@ struct ContentView: View { } var body: some View { - ZStack { - // Main content - GeometryReader { geometry in - ZStack { - VStack(spacing: 0) { - headerView - Divider() - messagesView - Divider() - inputView - } - .background(backgroundColor) - .foregroundColor(textColor) - .gesture( - DragGesture() - .onChanged { value in - // Only respond to leftward swipes when sidebar is closed - // or rightward swipes when sidebar is open - if !showSidebar && value.translation.width < 0 { - sidebarDragOffset = max(value.translation.width, -geometry.size.width * 0.7) - } else if showSidebar && value.translation.width > 0 { - sidebarDragOffset = min(-geometry.size.width * 0.7 + value.translation.width, 0) + GeometryReader { geometry in + ZStack { + // Base layer - Main public chat (always visible) + mainChatView + + // Private chat slide-over + 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 ? 0 : geometry.size.width) + .offset(x: backSwipeOffset) + .gesture( + DragGesture() + .onChanged { value in + if value.translation.width > 0 { + backSwipeOffset = min(value.translation.width, geometry.size.width) + } } - } - .onEnded { value in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - if !showSidebar { - // Opening gesture (swipe left) - if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) { - showSidebar = true - sidebarDragOffset = 0 - } else { - sidebarDragOffset = 0 + .onEnded { value in + if value.translation.width > 50 || (value.translation.width > 30 && value.velocity.width > 300) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showPrivateChat = false + backSwipeOffset = 0 + viewModel.endPrivateChat() } } else { - // Closing gesture (swipe right) - if value.translation.width > 100 || (value.translation.width > 50 && value.velocity.width > 500) { - showSidebar = false - sidebarDragOffset = 0 - } else { - sidebarDragOffset = 0 + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + backSwipeOffset = 0 } } } - } - ) - - // Sidebar overlay - HStack(spacing: 0) { - // Tap to dismiss area - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSidebar = false - sidebarDragOffset = 0 - } - } - - sidebarView - #if os(macOS) - .frame(width: min(300, geometry.size.width * 0.4)) - #else - .frame(width: geometry.size.width * 0.7) - #endif - .transition(.move(edge: .trailing)) - } - .offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset) + ) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPrivateChat) } + + // Channel slide-over + if viewModel.currentChannel != nil { + channelView + .frame(width: geometry.size.width) + .background(backgroundColor) + .transition(.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .trailing) + )) + .offset(x: showChannel ? 0 : geometry.size.width) + .offset(x: backSwipeOffset) + .gesture( + DragGesture() + .onChanged { value in + if value.translation.width > 0 { + backSwipeOffset = min(value.translation.width, geometry.size.width) + } + } + .onEnded { value in + if value.translation.width > 50 || (value.translation.width > 30 && value.velocity.width > 300) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showChannel = false + backSwipeOffset = 0 + viewModel.switchToChannel(nil) + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + backSwipeOffset = 0 + } + } + } + ) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showChannel) + } + + // Sidebar overlay + HStack(spacing: 0) { + // Tap to dismiss area + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSidebar = false + sidebarDragOffset = 0 + } + } + + sidebarView + #if os(macOS) + .frame(width: min(300, geometry.size.width * 0.4)) + #else + .frame(width: geometry.size.width * 0.7) + #endif + .transition(.move(edge: .trailing)) + } + .offset(x: showSidebar ? -sidebarDragOffset : geometry.size.width - sidebarDragOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSidebar) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: sidebarDragOffset) } } #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif + .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showPrivateChat = newValue != nil + } + } + .onChange(of: viewModel.currentChannel) { newValue in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showChannel = newValue != nil + } + } .sheet(isPresented: $showAppInfo) { AppInfoView() } @@ -175,295 +215,16 @@ struct ContentView: View { } } - private var headerView: some View { - HStack { - if let privatePeerID = viewModel.selectedPrivateChatPeer, - let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] { - // Private chat header - Button(action: { - viewModel.endPrivateChat() - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12)) - Text("back") - .font(.system(size: 14, design: .monospaced)) - } - .foregroundColor(textColor) - } - .buttonStyle(.plain) - .accessibilityLabel("Back to main chat") - - Spacer() - - Button(action: { - viewModel.showFingerprint(for: privatePeerID) - }) { - HStack(spacing: 6) { - // Dynamic encryption status icon - let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID) - Image(systemName: encryptionStatus.icon) - .font(.system(size: 14)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : - encryptionStatus == .noiseSecured ? Color.orange : - Color.red) - .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") - Text("\(privatePeerNick)") - .font(.system(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(Color.orange) - } - .frame(maxWidth: .infinity) - .accessibilityLabel("Private chat with \(privatePeerNick)") - .accessibilityHint("Tap to view encryption fingerprint") - } - .buttonStyle(.plain) - - Spacer() - - // Favorite button - Button(action: { - viewModel.toggleFavorite(peerID: privatePeerID) - }) { - Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star") - .font(.system(size: 16)) - .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) - } - .buttonStyle(.plain) - .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites") - .accessibilityHint("Double tap to toggle favorite status") - } else if let currentChannel = viewModel.currentChannel { - // Channel header - Button(action: { - viewModel.switchToChannel(nil) - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.system(size: 12)) - Text("back") - .font(.system(size: 14, design: .monospaced)) - } - .foregroundColor(textColor) - } - .buttonStyle(.plain) - .accessibilityLabel("Back to main chat") - - Spacer() - - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSidebar.toggle() - sidebarDragOffset = 0 - } - }) { - HStack(spacing: 4) { - if viewModel.passwordProtectedChannels.contains(currentChannel) { - Image(systemName: "lock.fill") - .font(.system(size: 14)) - .foregroundColor(Color.orange) - .accessibilityLabel("Password protected channel") - } - - Text(currentChannel) - .font(.system(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue) - - // Verification status indicator after channel name - if viewModel.passwordProtectedChannels.contains(currentChannel), - let status = viewModel.channelVerificationStatus[currentChannel] { - switch status { - case .verifying: - ProgressView() - .scaleEffect(0.5) - .frame(width: 12, height: 12) - case .verified: - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 12)) - .foregroundColor(Color.green) - case .failed: - Image(systemName: "xmark.circle.fill") - .font(.system(size: 12)) - .foregroundColor(Color.red) - case .unverified: - Image(systemName: "questionmark.circle") - .font(.system(size: 12)) - .foregroundColor(Color.gray) - .help("Password verification pending") - } - } else if viewModel.passwordProtectedChannels.contains(currentChannel) { - } - } - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity) - - Spacer() - - HStack(spacing: 8) { - // Show retention indicator for all users - if viewModel.retentionEnabledChannels.contains(currentChannel) { - Image(systemName: "bookmark.fill") - .font(.system(size: 16)) - .foregroundColor(Color.yellow) - .help("Messages in this channel are being saved locally") - .accessibilityLabel("Message retention enabled") - } - - // Save button - only for channel owner - if viewModel.isChannelOwner(currentChannel) { - Button(action: { - viewModel.sendMessage("/save") - }) { - Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark") - .font(.system(size: 16)) - .foregroundColor(textColor) - } - .buttonStyle(.plain) - .help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention") - .accessibilityLabel(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention") - } - - // Password button for channel creator only - if viewModel.isChannelOwner(currentChannel) { - Button(action: { - // Toggle password protection - if viewModel.passwordProtectedChannels.contains(currentChannel) { - viewModel.removeChannelPassword(for: currentChannel) - } else { - // Show password input - showPasswordInput = true - passwordInputChannel = currentChannel - } - }) { - Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock") - .font(.system(size: 16)) - .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor) - } - .buttonStyle(.plain) - .accessibilityLabel(viewModel.passwordProtectedChannels.contains(currentChannel) ? "Remove channel password" : "Set channel password") - } - - // Leave channel button - Button(action: { - showLeaveChannelAlert = true - }) { - Image(systemName: "xmark.circle") - .font(.system(size: 16)) - .foregroundColor(Color.red.opacity(0.8)) - } - .buttonStyle(.plain) - .alert("leave channel?", isPresented: $showLeaveChannelAlert) { - Button("cancel", role: .cancel) { } - Button("leave", role: .destructive) { - viewModel.leaveChannel(currentChannel) - } - } message: { - Text("sure you want to leave \(currentChannel)?") - } - } - } else { - // Public chat header - HStack(spacing: 4) { - Text("bitchat*") - .font(.system(size: 18, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - .onTapGesture(count: 3) { - // PANIC: Triple-tap to clear all data - viewModel.panicClearAllData() - } - .onTapGesture(count: 1) { - // Single tap for app info - showAppInfo = true - } - - HStack(spacing: 0) { - Text("@") - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - - TextField("nickname", text: $viewModel.nickname) - .textFieldStyle(.plain) - .font(.system(size: 14, design: .monospaced)) - .frame(maxWidth: 100) - .foregroundColor(textColor) - .onChange(of: viewModel.nickname) { _ in - viewModel.saveNickname() - } - .onSubmit { - viewModel.saveNickname() - } - } - } - - Spacer() - - // People counter with unread indicator - HStack(spacing: 4) { - // Check for any unread channel messages - let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 } - - if hasUnreadChannelMessages { - Image(systemName: "number") - .font(.system(size: 12)) - .foregroundColor(Color.blue) - .accessibilityLabel("Unread channel messages") - } - - if !viewModel.unreadPrivateMessages.isEmpty { - Image(systemName: "envelope.fill") - .font(.system(size: 12)) - .foregroundColor(Color.orange) - .accessibilityLabel("Unread private messages") - } - - let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count - let channelCount = viewModel.joinedChannels.count - - HStack(spacing: 4) { - // People icon with count - Image(systemName: "person.2.fill") - .font(.system(size: 11)) - .accessibilityLabel("\(otherPeersCount) connected \(otherPeersCount == 1 ? "person" : "people")") - Text("\(otherPeersCount)") - .font(.system(size: 12, design: .monospaced)) - .accessibilityHidden(true) - - // Channels icon with count (only if there are channels) - if channelCount > 0 { - Text("·") - .font(.system(size: 12, design: .monospaced)) - Image(systemName: "square.split.2x2") - .font(.system(size: 11)) - .accessibilityLabel("\(channelCount) active \(channelCount == 1 ? "channel" : "channels")") - Text("\(channelCount)") - .font(.system(size: 12, design: .monospaced)) - .accessibilityHidden(true) - } - } - .foregroundColor(viewModel.isConnected ? textColor : Color.red) - } - .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - showSidebar.toggle() - sidebarDragOffset = 0 - } - } - } - } - .frame(height: 44) // Fixed height to prevent bouncing - .padding(.horizontal, 12) - .background(backgroundColor.opacity(0.95)) - } - - private var messagesView: some View { + private func messagesView(for channel: String?, privatePeer: String?) -> some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 2) { let messages: [BitchatMessage] = { - if let privatePeer = viewModel.selectedPrivateChatPeer { + if let privatePeer = privatePeer { let msgs = viewModel.getPrivateChatMessages(for: privatePeer) return msgs - } else if let currentChannel = viewModel.currentChannel { - let msgs = viewModel.getChannelMessages(currentChannel) + } else if let channel = channel { + let msgs = viewModel.getChannelMessages(channel) return msgs } else { return viewModel.messages @@ -527,14 +288,14 @@ struct ContentView: View { } .background(backgroundColor) .onChange(of: viewModel.messages.count) { _ in - if viewModel.selectedPrivateChatPeer == nil && !viewModel.messages.isEmpty { + if channel == nil && privatePeer == nil && !viewModel.messages.isEmpty { withAnimation { proxy.scrollTo(viewModel.messages.last?.id, anchor: .bottom) } } } .onChange(of: viewModel.privateChats) { _ in - if let peerID = viewModel.selectedPrivateChatPeer, + if let peerID = privatePeer, let messages = viewModel.privateChats[peerID], !messages.isEmpty { withAnimation { @@ -542,18 +303,18 @@ struct ContentView: View { } } } - .onChange(of: viewModel.selectedPrivateChatPeer) { newPeerID in - // When switching to a private chat, send read receipts - if let peerID = newPeerID { - // Small delay to ensure messages are loaded - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - viewModel.markPrivateMessagesAsRead(from: peerID) + .onChange(of: viewModel.channelMessages) { _ in + if let channelName = channel, + let messages = viewModel.channelMessages[channelName], + !messages.isEmpty { + withAnimation { + proxy.scrollTo(messages.last?.id, anchor: .bottom) } } } .onAppear { // Also check when view appears - if let peerID = viewModel.selectedPrivateChatPeer { + if let peerID = privatePeer { // Try multiple times to ensure read receipts are sent viewModel.markPrivateMessagesAsRead(from: peerID) @@ -973,14 +734,14 @@ struct ContentView: View { } if viewModel.connectedPeers.isEmpty { - Text("No one connected") + Text("no one connected...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) } else if let currentChannel = viewModel.currentChannel, let channelMemberIDs = viewModel.channelMembers[currentChannel], channelMemberIDs.isEmpty { - Text("No one in this channel yet") + Text("no one in this channel yet...") .font(.system(size: 14, design: .monospaced)) .foregroundColor(secondaryTextColor) .padding(.horizontal) @@ -1049,18 +810,6 @@ struct ContentView: View { .accessibilityLabel("Signal strength: \(rssi > -60 ? "excellent" : rssi > -70 ? "good" : rssi > -80 ? "fair" : "poor")") } - // Encryption status icon (between connection dot and name) - if !isMe { - let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) - Image(systemName: encryptionStatus.icon) - .font(.system(size: 10)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : - encryptionStatus == .noiseSecured ? textColor : - encryptionStatus == .noiseHandshaking ? Color.orange : - Color.red) - .accessibilityLabel("Encryption: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : encryptionStatus == .noiseHandshaking ? "establishing" : "none")") - } - // Peer name if isMe { HStack { @@ -1091,6 +840,16 @@ struct ContentView: View { viewModel.showFingerprint(for: peerID) } + // Encryption status icon (after peer name) + let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) + Image(systemName: encryptionStatus.icon) + .font(.system(size: 10)) + .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : + encryptionStatus == .noiseSecured ? textColor : + encryptionStatus == .noiseHandshaking ? Color.orange : + Color.red) + .accessibilityLabel("Encryption: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : encryptionStatus == .noiseHandshaking ? "establishing" : "none")") + Spacer() // Favorite star @@ -1119,6 +878,390 @@ struct ContentView: View { .background(backgroundColor) } } + + // MARK: - View Components + + private var mainChatView: some View { + VStack(spacing: 0) { + mainHeaderView + Divider() + messagesView(for: nil, privatePeer: nil) + Divider() + inputView + } + .background(backgroundColor) + .foregroundColor(textColor) + .gesture( + DragGesture() + .onChanged { value in + if !showSidebar && value.translation.width < 0 { + sidebarDragOffset = max(value.translation.width, -300) + } else if showSidebar && value.translation.width > 0 { + sidebarDragOffset = min(-300 + value.translation.width, 0) + } + } + .onEnded { value in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + if !showSidebar { + if value.translation.width < -100 || (value.translation.width < -50 && value.velocity.width < -500) { + showSidebar = true + sidebarDragOffset = 0 + } else { + sidebarDragOffset = 0 + } + } else { + if value.translation.width > 100 || (value.translation.width > 50 && value.velocity.width > 500) { + showSidebar = false + sidebarDragOffset = 0 + } else { + sidebarDragOffset = 0 + } + } + } + } + ) + } + + 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(for: nil, privatePeer: viewModel.selectedPrivateChatPeer) + Divider() + inputView + } + .background(backgroundColor) + .foregroundColor(textColor) + } + } + + private var channelView: some View { + HStack(spacing: 0) { + // Vertical separator bar + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 1) + + VStack(spacing: 0) { + channelHeaderView + Divider() + messagesView(for: viewModel.currentChannel, privatePeer: nil) + Divider() + inputView + } + .background(backgroundColor) + .foregroundColor(textColor) + } + } + + private var mainHeaderView: some View { + HStack(spacing: 4) { + Text("bitchat*") + .font(.system(size: 18, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + .onTapGesture(count: 3) { + // PANIC: Triple-tap to clear all data + viewModel.panicClearAllData() + } + .onTapGesture(count: 1) { + // Single tap for app info + showAppInfo = true + } + + HStack(spacing: 0) { + Text("@") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + + TextField("nickname", text: $viewModel.nickname) + .textFieldStyle(.plain) + .font(.system(size: 14, design: .monospaced)) + .frame(maxWidth: 100) + .foregroundColor(textColor) + .onChange(of: viewModel.nickname) { _ in + viewModel.saveNickname() + } + .onSubmit { + viewModel.saveNickname() + } + } + + Spacer() + + // People counter with unread indicator + HStack(spacing: 4) { + // Check for any unread channel messages + let hasUnreadChannelMessages = viewModel.unreadChannelMessages.values.contains { $0 > 0 } + + if hasUnreadChannelMessages { + Image(systemName: "number") + .font(.system(size: 12)) + .foregroundColor(Color.blue) + .accessibilityLabel("Unread channel messages") + } + + if !viewModel.unreadPrivateMessages.isEmpty { + Image(systemName: "envelope.fill") + .font(.system(size: 12)) + .foregroundColor(Color.orange) + .accessibilityLabel("Unread private messages") + } + + let otherPeersCount = viewModel.connectedPeers.filter { $0 != viewModel.meshService.myPeerID }.count + let channelCount = viewModel.joinedChannels.count + + HStack(spacing: 4) { + // People icon with count + Image(systemName: "person.2.fill") + .font(.system(size: 11)) + .accessibilityLabel("\(otherPeersCount) connected \(otherPeersCount == 1 ? "person" : "people")") + Text("\(otherPeersCount)") + .font(.system(size: 12, design: .monospaced)) + .accessibilityHidden(true) + + // Channels icon with count (only if there are channels) + if channelCount > 0 { + Text("·") + .font(.system(size: 12, design: .monospaced)) + Image(systemName: "square.split.2x2") + .font(.system(size: 11)) + .accessibilityLabel("\(channelCount) active \(channelCount == 1 ? "channel" : "channels")") + Text("\(channelCount)") + .font(.system(size: 12, design: .monospaced)) + .accessibilityHidden(true) + } + } + .foregroundColor(viewModel.isConnected ? textColor : Color.red) + } + .onTapGesture { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSidebar.toggle() + sidebarDragOffset = 0 + } + } + } + .frame(height: 44) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + } + + private var privateHeaderView: some View { + Group { + if let privatePeerID = viewModel.selectedPrivateChatPeer, + let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] { + HStack { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showPrivateChat = false + viewModel.endPrivateChat() + } + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12)) + Text("back") + .font(.system(size: 14, design: .monospaced)) + } + .foregroundColor(textColor) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to main chat") + + Spacer() + + Button(action: { + viewModel.showFingerprint(for: privatePeerID) + }) { + HStack(spacing: 6) { + Text("\(privatePeerNick)") + .font(.system(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(Color.orange) + // Dynamic encryption status icon + let encryptionStatus = viewModel.getEncryptionStatus(for: privatePeerID) + Image(systemName: encryptionStatus.icon) + .font(.system(size: 14)) + .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : + encryptionStatus == .noiseSecured ? Color.orange : + Color.red) + .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") + } + .frame(maxWidth: .infinity) + .accessibilityLabel("Private chat with \(privatePeerNick)") + .accessibilityHint("Tap to view encryption fingerprint") + } + .buttonStyle(.plain) + + Spacer() + + // Favorite button + Button(action: { + viewModel.toggleFavorite(peerID: privatePeerID) + }) { + Image(systemName: viewModel.isFavorite(peerID: privatePeerID) ? "star.fill" : "star") + .font(.system(size: 16)) + .foregroundColor(viewModel.isFavorite(peerID: privatePeerID) ? Color.yellow : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites") + .accessibilityHint("Double tap to toggle favorite status") + } + .frame(height: 44) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + } else { + EmptyView() + } + } + } + + private var channelHeaderView: some View { + Group { + if let currentChannel = viewModel.currentChannel { + HStack { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showChannel = false + viewModel.switchToChannel(nil) + } + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12)) + Text("back") + .font(.system(size: 14, design: .monospaced)) + } + .foregroundColor(textColor) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to main chat") + + Spacer() + + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showSidebar.toggle() + sidebarDragOffset = 0 + } + }) { + HStack(spacing: 4) { + if viewModel.passwordProtectedChannels.contains(currentChannel) { + Image(systemName: "lock.fill") + .font(.system(size: 14)) + .foregroundColor(Color.orange) + .accessibilityLabel("Password protected channel") + } + + Text(currentChannel) + .font(.system(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.orange : Color.blue) + + // Verification status indicator after channel name + if viewModel.passwordProtectedChannels.contains(currentChannel), + let status = viewModel.channelVerificationStatus[currentChannel] { + switch status { + case .verifying: + ProgressView() + .scaleEffect(0.5) + .frame(width: 12, height: 12) + case .verified: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(Color.green) + case .failed: + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12)) + .foregroundColor(Color.red) + case .unverified: + Image(systemName: "questionmark.circle") + .font(.system(size: 12)) + .foregroundColor(Color.gray) + .help("Password verification pending") + } + } + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + + Spacer() + + HStack(spacing: 8) { + // Show retention indicator for all users + if viewModel.retentionEnabledChannels.contains(currentChannel) { + Image(systemName: "bookmark.fill") + .font(.system(size: 16)) + .foregroundColor(Color.yellow) + .help("Messages in this channel are being saved locally") + .accessibilityLabel("Message retention enabled") + } + + // Save button - only for channel owner + if viewModel.isChannelOwner(currentChannel) { + Button(action: { + viewModel.sendMessage("/save") + }) { + Image(systemName: viewModel.retentionEnabledChannels.contains(currentChannel) ? "bookmark.slash" : "bookmark") + .font(.system(size: 16)) + .foregroundColor(textColor) + } + .buttonStyle(.plain) + .help(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention") + .accessibilityLabel(viewModel.retentionEnabledChannels.contains(currentChannel) ? "Disable message retention" : "Enable message retention") + } + + // Password button for channel creator only + if viewModel.isChannelOwner(currentChannel) { + Button(action: { + // Toggle password protection + if viewModel.passwordProtectedChannels.contains(currentChannel) { + viewModel.removeChannelPassword(for: currentChannel) + } else { + // Show password input + showPasswordInput = true + passwordInputChannel = currentChannel + } + }) { + Image(systemName: viewModel.passwordProtectedChannels.contains(currentChannel) ? "lock.fill" : "lock") + .font(.system(size: 16)) + .foregroundColor(viewModel.passwordProtectedChannels.contains(currentChannel) ? Color.yellow : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel(viewModel.passwordProtectedChannels.contains(currentChannel) ? "Remove channel password" : "Set channel password") + } + + // Leave channel button + Button(action: { + showLeaveChannelAlert = true + }) { + Image(systemName: "xmark.circle") + .font(.system(size: 16)) + .foregroundColor(Color.red.opacity(0.8)) + } + .buttonStyle(.plain) + .alert("leave channel?", isPresented: $showLeaveChannelAlert) { + Button("cancel", role: .cancel) { } + Button("leave", role: .destructive) { + viewModel.leaveChannel(currentChannel) + } + } message: { + Text("sure you want to leave \(currentChannel)?") + } + } + } + .frame(height: 44) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + } else { + EmptyView() + } + } + } } // Helper view for rendering message content with clickable hashtags