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.
This commit is contained in:
14
PROGRESS.md
14
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
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
|
||||
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
91
clients/apple/iOS/VoiceCatiOS/Views/ChannelBrowserView.swift
Normal file
@@ -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" : "")")
|
||||
}
|
||||
}
|
||||
88
clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift
Normal file
88
clients/apple/iOS/VoiceCatiOS/Views/ChannelDetailView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" : "")")
|
||||
}
|
||||
}
|
||||
|
||||
121
clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift
Normal file
121
clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift
Normal file
@@ -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" : "")")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user