import SwiftUI import VoiceCatCore /// A single user row with its admin context menu and the sheets those actions present. /// Self-contained (owns its own sheet state) so it can be reused both by the iPad /// `UserListView` middle column and by the iPhone `ChannelDetailView` drill-down. struct UserRow: View { let user: User @Bindable var session: SessionState @State private var activeSheet: ActiveSheet? private enum ActiveSheet: Identifiable { case tuning, ban, move, permissions var id: Int { hashValue } } var body: some View { UserRowView(user: user, isSelf: user.id == session.selfUserId) .contextMenu { contextMenu } // The context menu is long-press only, which VoiceOver doesn't surface — mirror the // same buttons as accessibility actions so VoiceOver users can reach per-user tuning // (and the admin actions) via the actions rotor on the focused row. .accessibilityActions { contextMenu } .sheet(item: $activeSheet) { sheet in switch sheet { case .tuning: PerUserTuningView(user: user, session: session) case .ban: BanUserView(user: user, session: session) case .move: MoveUserView(user: user, session: session) case .permissions: PermissionsView(user: user, session: session) } } } @ViewBuilder private var contextMenu: some View { if user.id != session.selfUserId { Button { activeSheet = .tuning } label: { Label("Volume / NR", systemImage: "speaker.wave.2") } if session.permissions.canKick || session.permissions.isAdmin { Divider() Button { session.kickUser(user.id, reason: "") } label: { Label("Kick", systemImage: "person.fill.xmark") } if session.permissions.canBan || session.permissions.isAdmin { Button(role: .destructive) { activeSheet = .ban } label: { Label("Ban…", systemImage: "nosign") } } } if session.permissions.canMoveUsers || session.permissions.isAdmin { Button { activeSheet = .move } label: { Label("Move to channel…", systemImage: "arrow.right.circle") } } if session.permissions.isAdmin { Divider() let muted = user.serverMuted Button { session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened) } label: { Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash") } Button { activeSheet = .permissions } label: { Label("Permissions…", systemImage: "lock.shield") } } } } } private struct UserRowView: View { let user: User let isSelf: Bool var body: some View { HStack(spacing: 10) { Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill") .foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green) .imageScale(.small) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Text(user.nickname) .fontWeight(isSelf ? .semibold : .regular) if isSelf { Text("(you)") .font(.caption2) .foregroundStyle(.secondary) } if user.isGuest { Text("guest") .font(.caption2) .foregroundStyle(.secondary) } } if user.serverMuted || user.serverDeafened { Text(user.serverDeafened ? "server deafened" : "server muted") .font(.caption2) .foregroundStyle(.orange) } } Spacer() if user.selfDeafened { Image(systemName: "headphones.slash") .imageScale(.small) .foregroundStyle(.secondary) .accessibilityHidden(true) } } .accessibilityElement(children: .combine) .accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")") } }