From b07362e525054ac292c540ac02d032938314d3a6 Mon Sep 17 00:00:00 2001 From: Talon Date: Sun, 21 Jun 2026 03:02:58 +0200 Subject: [PATCH] feat(ios): iPhone channel drill-down, fix chat compose box, unify chat+activity - Channels tab is now a drill-down on iPhone: ChannelBrowserView lists top-level channels; ChannelDetailView shows the people in a channel, its sub-channels, and an explicit Join button (with password prompt). iPad split view unchanged. - Extract self-contained UserRow (context menu + sheets) from UserListView so admin actions are reused in the drill-down. - Fix off-screen chat compose box: pin VoiceControlsView via per-tab .safeAreaInset(edge: .bottom) instead of a floating overlay, so it reserves layout space above the tab bar (keeping the compose box visible, cooperating with keyboard avoidance) without covering the tab bar buttons. - Collapse Activity into Chat like macOS/Windows: ChatView renders a merged, time-sorted timeline of messages + activity (activity rows in gray); remove the Activity tab and ActivityLogView. - Label the RPSystemBroadcastPickerView inner UIButton for VoiceOver ("Share/Stop sharing screen audio") instead of relying on an outer SwiftUI label. --- PROGRESS.md | 14 ++ .../iOS/VoiceCatiOS.xcodeproj/project.pbxproj | 16 ++- .../VoiceCatiOS/Views/ActivityLogView.swift | 49 ------- .../Views/ChannelBrowserView.swift | 91 +++++++++++++ .../VoiceCatiOS/Views/ChannelDetailView.swift | 88 +++++++++++++ .../iOS/VoiceCatiOS/Views/ChatView.swift | 77 ++++++++++- .../iOS/VoiceCatiOS/Views/MainView.swift | 21 ++- .../iOS/VoiceCatiOS/Views/UserListView.swift | 120 +---------------- .../apple/iOS/VoiceCatiOS/Views/UserRow.swift | 121 ++++++++++++++++++ .../VoiceCatiOS/Views/VoiceControlsView.swift | 9 +- 10 files changed, 419 insertions(+), 187 deletions(-) delete mode 100644 clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift create mode 100644 clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift create mode 100644 clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift create mode 100644 clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift diff --git a/PROGRESS.md b/PROGRESS.md index 109436a..5ae8f97 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,20 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-06-21):** **iOS iPhone-layout UX fixes.** (1) Channels are now a **drill-down** + on iPhone — new `ChannelBrowserView` (root list of top-level channels) → `ChannelDetailView` + (people in the channel + sub-channels + an explicit "Join Channel" button with password + prompt). The iPad 3-column `NavigationSplitView` is unchanged. (2) Extracted a self-contained + `UserRow` (context menu + sheets) from `UserListView` so admin actions are reused in the + drill-down. (3) Fixed the **off-screen chat compose box**: `MainView` now places + `VoiceControlsView` via `.safeAreaInset(edge: .bottom)` instead of a floating `.overlay`, so + it reserves layout space above the tab bar and cooperates with keyboard avoidance. (4) + **Collapsed Activity into Chat** like macOS/Windows: `ChatView` renders a merged, time-sorted + timeline of `messages` + `activityLog` (activity rows in gray); the separate Activity tab and + `ActivityLogView.swift` are removed. `xcodebuild` Debug for `generic/platform=iOS` BUILD + SUCCEEDED (sim slice still arm64-only → simulator run N/A). Next: on-device check of the + drill-down + compose box + unified timeline. + - **Done (2026-06-21):** **Screen-audio sharing on macOS + iOS.** macOS uses ScreenCaptureKit (`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App diff --git a/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj b/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj index a3cee86..4ab0a04 100644 --- a/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj +++ b/clients/apple/iOS/VoiceCatiOS.xcodeproj/project.pbxproj @@ -21,7 +21,9 @@ BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; }; BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; }; BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; }; - BBBB0000000000000000003F /* ActivityLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000026 /* ActivityLogView.swift */; }; + BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000060 /* ChannelBrowserView.swift */; }; + BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000061 /* ChannelDetailView.swift */; }; + BBBB00000000000000000072 /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000062 /* UserRow.swift */; }; BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; }; BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; }; BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; }; @@ -75,7 +77,9 @@ BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = ""; }; BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; - BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.swift; sourceTree = ""; }; + BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = ""; }; + BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = ""; }; + BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = ""; }; BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = ""; }; BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = ""; }; BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = ""; }; @@ -176,7 +180,9 @@ BBBB00000000000000000023 /* ChannelTreeView.swift */, BBBB00000000000000000024 /* UserListView.swift */, BBBB00000000000000000025 /* ChatView.swift */, - BBBB00000000000000000026 /* ActivityLogView.swift */, + BBBB00000000000000000060 /* ChannelBrowserView.swift */, + BBBB00000000000000000061 /* ChannelDetailView.swift */, + BBBB00000000000000000062 /* UserRow.swift */, BBBB00000000000000000027 /* VoiceControlsView.swift */, BBBB00000000000000000028 /* PerUserTuningView.swift */, BBBB00000000000000000029 /* ChannelEditView.swift */, @@ -302,7 +308,9 @@ BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */, BBBB0000000000000000003D /* UserListView.swift in Sources */, BBBB0000000000000000003E /* ChatView.swift in Sources */, - BBBB0000000000000000003F /* ActivityLogView.swift in Sources */, + BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */, + BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */, + BBBB00000000000000000072 /* UserRow.swift in Sources */, BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */, BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */, BBBB00000000000000000042 /* ChannelEditView.swift in Sources */, diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift deleted file mode 100644 index 3793558..0000000 --- a/clients/apple/iOS/VoiceCatiOS/Views/ActivityLogView.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI - -struct ActivityLogView: View { - @Bindable var session: SessionState - - private static let timeFormatter: DateFormatter = { - let fmt = DateFormatter() - fmt.dateStyle = .none - fmt.timeStyle = .medium - return fmt - }() - - var body: some View { - ScrollViewReader { proxy in - List(session.activityLog) { entry in - HStack(alignment: .top, spacing: 8) { - Text(Self.timeFormatter.string(from: entry.timestamp)) - .font(.caption2) - .foregroundStyle(.secondary) - .monospacedDigit() - .frame(width: 64, alignment: .leading) - Text(entry.text) - .font(.caption) - } - .id(entry.id) - .listRowSeparator(.hidden) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)") - } - .listStyle(.plain) - .onChange(of: session.activityLog.count) { _, _ in - if let last = session.activityLog.last { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - .navigationTitle("Activity") - .navigationBarTitleDisplayMode(.inline) - .overlay { - if session.activityLog.isEmpty { - ContentUnavailableView( - "No Activity", - systemImage: "bell.slash", - description: Text("Events will appear here as they happen.") - ) - } - } - } -} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift new file mode 100644 index 0000000..998de16 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import VoiceCatCore + +/// iPhone channels tab: a drill-down browser. The root list shows only top-level channels; +/// tapping (or VoiceOver-activating) a channel pushes `ChannelDetailView`, which shows the +/// people in it and any sub-channels. Joining is an explicit action inside the detail view. +struct ChannelBrowserView: View { + @Bindable var session: SessionState + @State private var showCreateChannel = false + + var body: some View { + NavigationStack { + List { + ForEach(rootChannels) { ch in + NavigationLink { + ChannelDetailView(channel: ch, session: session) + } label: { + ChannelRow(channel: ch, session: session) + } + .swipeActions(edge: .trailing) { + if session.permissions.isAdmin { + Button(role: .destructive) { + session.deleteChannel(ch.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + .navigationTitle("Channels") + .toolbar { + if session.permissions.canCreateTempChannel || session.permissions.isAdmin { + ToolbarItem(placement: .primaryAction) { + Button { + showCreateChannel = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Create channel") + } + } + } + .sheet(isPresented: $showCreateChannel) { + ChannelEditView(channelId: nil, session: session) + } + } + } + + private var rootChannels: [Channel] { + session.channels + .filter { $0.parentId == 0 } + .sorted { $0.name < $1.name } + } +} + +/// Shared channel row used by the iPhone browser and detail views. Mirrors the look of the +/// iPad `ChannelTreeView` row but keyed on a `Channel` rather than a tree `ChannelNode`. +struct ChannelRow: View { + let channel: Channel + let session: SessionState + + var body: some View { + let isCurrent = session.currentChannelId == channel.id + let usersHere = session.users.filter { $0.channelId == channel.id } + + HStack(spacing: 8) { + Image(systemName: channel.passwordProtected ? "lock.fill" : "number") + .foregroundStyle(isCurrent ? .blue : .secondary) + .imageScale(.small) + VStack(alignment: .leading, spacing: 1) { + Text(channel.name) + .fontWeight(isCurrent ? .semibold : .regular) + if !channel.topic.isEmpty { + Text(channel.topic) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer() + if !usersHere.isEmpty { + Text("\(usersHere.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(channel.name)\(isCurrent ? ", current" : "")\(channel.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")") + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift new file mode 100644 index 0000000..f0ad684 --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift @@ -0,0 +1,88 @@ +import SwiftUI +import VoiceCatCore + +/// The drilled-into view for a single channel: a Join control, the people currently in the +/// channel, and any sub-channels (each drilling deeper via a nested `ChannelDetailView`). +struct ChannelDetailView: View { + let channel: Channel + @Bindable var session: SessionState + + @State private var showPasswordPrompt = false + @State private var password = "" + + private var isCurrent: Bool { session.currentChannelId == channel.id } + private var people: [User] { session.users.filter { $0.channelId == channel.id } } + private var subchannels: [Channel] { + session.channels.filter { $0.parentId == channel.id }.sorted { $0.name < $1.name } + } + + var body: some View { + List { + Section { + if isCurrent { + Label("You're here", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityLabel("You are in this channel") + } else { + Button { + join() + } label: { + Label("Join Channel", systemImage: "arrow.right.circle.fill") + } + .accessibilityLabel("Join \(channel.name)") + } + } + + Section("People") { + if people.isEmpty { + Text("No one here yet.") + .foregroundStyle(.secondary) + } else { + ForEach(people) { user in + UserRow(user: user, session: session) + } + } + } + + if !subchannels.isEmpty { + Section("Channels") { + ForEach(subchannels) { sub in + NavigationLink { + ChannelDetailView(channel: sub, session: session) + } label: { + ChannelRow(channel: sub, session: session) + } + .swipeActions(edge: .trailing) { + if session.permissions.isAdmin { + Button(role: .destructive) { + session.deleteChannel(sub.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + } + } + .navigationTitle(channel.name) + .navigationBarTitleDisplayMode(.inline) + .alert("Channel Password", isPresented: $showPasswordPrompt) { + SecureField("Password", text: $password) + .accessibilityLabel("Channel password") + Button("Join") { + session.joinChannel(channel.id, password: password) + password = "" + } + Button("Cancel", role: .cancel) { password = "" } + } + } + + private func join() { + if channel.passwordProtected { + showPasswordPrompt = true + } else { + session.joinChannel(channel.id) + } + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift index 9332eb2..590f0d6 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift @@ -1,27 +1,61 @@ import SwiftUI import VoiceCatCore +/// One row of the combined chat/activity timeline. Mirrors the macOS/Windows clients, which +/// collapse chat messages and activity events into a single scrolling log (chat in normal +/// text, activity events in gray). +private enum TimelineItem: Identifiable { + case message(ChatMessage) + case activity(ActivityEntry) + + var id: UUID { + switch self { + case .message(let m): return m.id + case .activity(let a): return a.id + } + } + + var timestamp: Date { + switch self { + case .message(let m): return m.timestamp + case .activity(let a): return a.timestamp + } + } +} + struct ChatView: View { @Bindable var session: SessionState @State private var composeText = "" @State private var scope: VoiceCatTextScope = .channel @State private var privateTargetId: UInt32 = 0 + private var timeline: [TimelineItem] { + let merged = session.messages.map(TimelineItem.message) + + session.activityLog.map(TimelineItem.activity) + return merged.sorted { $0.timestamp < $1.timestamp } + } + var body: some View { VStack(spacing: 0) { - // Message list + // Combined chat + activity timeline ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { - ForEach(session.messages) { msg in - ChatBubble(message: msg) - .id(msg.id) + ForEach(timeline) { item in + switch item { + case .message(let msg): + ChatBubble(message: msg) + .id(item.id) + case .activity(let entry): + ActivityRow(entry: entry) + .id(item.id) + } } } .padding() } - .onChange(of: session.messages.count) { _, _ in - if let last = session.messages.last { + .onChange(of: session.messages.count + session.activityLog.count) { _, _ in + if let last = timeline.last { proxy.scrollTo(last.id, anchor: .bottom) } } @@ -92,3 +126,34 @@ private struct ChatBubble: View { .accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)") } } + +/// A compact, gray activity row interleaved into the chat timeline (joins/leaves, talk state, +/// streams, server mute, etc.). Matches the "activity = gray" convention of the macOS/Windows +/// unified logs. +private struct ActivityRow: View { + let entry: ActivityEntry + + private static let timeFormatter: DateFormatter = { + let fmt = DateFormatter() + fmt.dateStyle = .none + fmt.timeStyle = .short + return fmt + }() + + private var timeString: String { Self.timeFormatter.string(from: entry.timestamp) } + + var body: some View { + HStack(alignment: .top, spacing: 6) { + Text(timeString) + .font(.caption2) + .foregroundStyle(.tertiary) + .monospacedDigit() + Text(entry.text) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(timeString): \(entry.text)") + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift b/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift index 2656c22..ad6a0b2 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/MainView.swift @@ -24,26 +24,33 @@ private struct iPhoneMainView: View { var body: some View { TabView { - ChannelTreeView(session: session) + ChannelBrowserView(session: session) + .voiceControlsBar(session) .tabItem { Label("Channels", systemImage: "list.bullet.indent") } ChatView(session: session) + .voiceControlsBar(session) .tabItem { Label("Chat", systemImage: "message") } - ActivityLogView(session: session) - .tabItem { - Label("Activity", systemImage: "bell") - } SettingsView(session: session) + .voiceControlsBar(session) .tabItem { Label("Settings", systemImage: "gear") } } - .overlay(alignment: .bottom) { + } +} + +private extension View { + /// Pin the shared voice controls just above the tab bar, *inside each tab's content area*. + /// Applying this per-tab (rather than to the TabView itself) reserves layout space above + /// the tab bar — keeping ChatView's compose box visible and cooperating with keyboard + /// avoidance — without the bar covering the tab bar's buttons. + func voiceControlsBar(_ session: SessionState) -> some View { + safeAreaInset(edge: .bottom) { VoiceControlsView(session: session) - .padding(.bottom, 56) // above tab bar } } } diff --git a/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift b/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift index cb68154..73c040c 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/UserListView.swift @@ -3,11 +3,6 @@ import VoiceCatCore struct UserListView: View { @Bindable var session: SessionState - @State private var selectedUser: User? - @State private var showPerUserTuning = false - @State private var showBanUser = false - @State private var showMoveUser = false - @State private var showPermissions = false var body: some View { let channelUsers = session.currentChannelId == 0 @@ -15,10 +10,7 @@ struct UserListView: View { : session.users.filter { $0.channelId == session.currentChannelId } List(channelUsers) { user in - UserRowView(user: user, isSelf: user.id == session.selfUserId) - .contextMenu { - userContextMenu(user: user) - } + UserRow(user: user, session: session) } .listStyle(.plain) .overlay { @@ -30,115 +22,5 @@ struct UserListView: View { ) } } - .sheet(item: $selectedUser) { user in - if showPerUserTuning { - PerUserTuningView(user: user, session: session) - } else if showBanUser { - BanUserView(user: user, session: session) - } else if showMoveUser { - MoveUserView(user: user, session: session) - } else if showPermissions { - PermissionsView(user: user, session: session) - } - } - } - - @ViewBuilder - private func userContextMenu(user: User) -> some View { - if user.id != session.selfUserId { - Button { - selectedUser = user - showPerUserTuning = true - showBanUser = false; showMoveUser = false; showPermissions = false - } 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) { - selectedUser = user - showBanUser = true - showPerUserTuning = false; showMoveUser = false; showPermissions = false - } label: { - Label("Ban…", systemImage: "nosign") - } - } - } - if session.permissions.canMoveUsers || session.permissions.isAdmin { - Button { - selectedUser = user - showMoveUser = true - showPerUserTuning = false; showBanUser = false; showPermissions = false - } 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 { - selectedUser = user - showPermissions = true - showPerUserTuning = false; showBanUser = false; showMoveUser = false - } 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" : "")") } } diff --git a/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift b/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift new file mode 100644 index 0000000..907e7fe --- /dev/null +++ b/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift @@ -0,0 +1,121 @@ +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 } + .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" : "")") + } +} diff --git a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift index 4e63011..9f5b765 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift @@ -64,8 +64,6 @@ struct VoiceControlsView: View { // SCREEN_AUDIO stream. Tinted while sharing. BroadcastPickerButton(isSharing: session.voiceState.screenSharing) .frame(width: 32, height: 32) - .accessibilityLabel(session.voiceState.screenSharing - ? "Stop sharing screen audio" : "Share screen audio") // Disconnect Button(role: .destructive) { @@ -134,6 +132,13 @@ private struct BroadcastPickerButton: UIViewRepresentable { func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) { uiView.tintColor = isSharing ? .systemGreen : .label + // RPSystemBroadcastPickerView owns an inner UIButton that VoiceOver focuses; its default + // label is the picker's image name ("module icon"). Label the inner button directly — + // a SwiftUI .accessibilityLabel on the representable doesn't reach it. + let label = isSharing ? "Stop sharing screen audio" : "Share screen audio" + for case let button as UIButton in uiView.subviews { + button.accessibilityLabel = label + } } }