import SwiftUI import VoiceCatCore // ChannelNode wraps Channel for OutlineGroup; childrenOrNil must be nil (not empty []) // for leaf channels so OutlineGroup doesn't render expand buttons. struct ChannelNode: Identifiable { let channel: Channel let children: [ChannelNode]? var id: UInt32 { channel.id } } struct ChannelTreeView: View { @Bindable var session: SessionState @State private var showCreateChannel = false @State private var editChannel: Channel? @State private var channelPassword = "" @State private var passwordChannelId: UInt32? var body: some View { List(channelTree, children: \.children) { node in ChannelRowView(node: node, session: session) .onTapGesture { if node.channel.passwordProtected { passwordChannelId = node.channel.id } else { session.joinChannel(node.channel.id) } } .swipeActions(edge: .trailing) { if session.permissions.isAdmin { Button(role: .destructive) { session.deleteChannel(node.channel.id) } label: { Label("Delete", systemImage: "trash") } } } .swipeActions(edge: .leading) { if session.permissions.isAdmin { Button { editChannel = node.channel } label: { Label("Edit", systemImage: "pencil") } .tint(.blue) } } } .listStyle(.sidebar) .toolbar { if session.permissions.canCreateTempChannel || session.permissions.isAdmin { ToolbarItem(placement: .primaryAction) { Button { showCreateChannel = true } label: { Image(systemName: "plus") } .accessibilityLabel("Create channel") } } if session.currentChannelId != 0 { ToolbarItem(placement: .topBarLeading) { Button("Leave", systemImage: "arrow.left.circle") { session.leaveChannel() } .accessibilityLabel("Leave current channel") } } } .sheet(isPresented: $showCreateChannel) { ChannelEditView(channelId: nil, session: session) } .sheet(item: $editChannel) { ch in ChannelEditView(channelId: ch.id, session: session) } .alert("Channel Password", isPresented: Binding( get: { passwordChannelId != nil }, set: { if !$0 { passwordChannelId = nil; channelPassword = "" } } )) { SecureField("Password", text: $channelPassword) .accessibilityLabel("Channel password") Button("Join") { if let cid = passwordChannelId { session.joinChannel(cid, password: channelPassword) } passwordChannelId = nil channelPassword = "" } Button("Cancel", role: .cancel) { passwordChannelId = nil channelPassword = "" } } } private var channelTree: [ChannelNode] { buildTree(parentId: 0, channels: session.channels) } private func buildTree(parentId: UInt32, channels: [Channel]) -> [ChannelNode] { channels .filter { $0.parentId == parentId } .map { ch in let kids = buildTree(parentId: ch.id, channels: channels) return ChannelNode(channel: ch, children: kids.isEmpty ? nil : kids) } .sorted { $0.channel.name < $1.channel.name } } } private struct ChannelRowView: View { let node: ChannelNode let session: SessionState var body: some View { let ch = node.channel let isCurrent = session.currentChannelId == ch.id let usersHere = session.users.filter { $0.channelId == ch.id } HStack(spacing: 8) { Image(systemName: ch.passwordProtected ? "lock.fill" : "number") .foregroundStyle(isCurrent ? .blue : .secondary) .imageScale(.small) VStack(alignment: .leading, spacing: 1) { Text(ch.name) .fontWeight(isCurrent ? .semibold : .regular) if !ch.topic.isEmpty { Text(ch.topic) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() if !usersHere.isEmpty { Text("\(usersHere.count)") .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel("\(usersHere.count) users") } } .accessibilityElement(children: .combine) .accessibilityLabel("\(ch.name)\(isCurrent ? ", current" : "")\(ch.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")") } }