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
|
## ▶ 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
|
- **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
|
(`ScreenAudioCapture.swift`) → `vc_stream_feed_pcm`; iOS uses a ReplayKit Broadcast Upload
|
||||||
Extension (`VoiceCatBroadcast`) that forwards captured `.audioApp` PCM through a shared App
|
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 */; };
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
|
||||||
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
|
||||||
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.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 */; };
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
|
||||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
|
||||||
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
|
||||||
@@ -176,7 +180,9 @@
|
|||||||
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
BBBB00000000000000000023 /* ChannelTreeView.swift */,
|
||||||
BBBB00000000000000000024 /* UserListView.swift */,
|
BBBB00000000000000000024 /* UserListView.swift */,
|
||||||
BBBB00000000000000000025 /* ChatView.swift */,
|
BBBB00000000000000000025 /* ChatView.swift */,
|
||||||
BBBB00000000000000000026 /* ActivityLogView.swift */,
|
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
|
||||||
|
BBBB00000000000000000061 /* ChannelDetailView.swift */,
|
||||||
|
BBBB00000000000000000062 /* UserRow.swift */,
|
||||||
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
BBBB00000000000000000027 /* VoiceControlsView.swift */,
|
||||||
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
BBBB00000000000000000028 /* PerUserTuningView.swift */,
|
||||||
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
BBBB00000000000000000029 /* ChannelEditView.swift */,
|
||||||
@@ -302,7 +308,9 @@
|
|||||||
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
|
||||||
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
BBBB0000000000000000003D /* UserListView.swift in Sources */,
|
||||||
BBBB0000000000000000003E /* ChatView.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 */,
|
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
|
||||||
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
|
||||||
BBBB00000000000000000042 /* ChannelEditView.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 SwiftUI
|
||||||
import VoiceCatCore
|
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 {
|
struct ChatView: View {
|
||||||
@Bindable var session: SessionState
|
@Bindable var session: SessionState
|
||||||
@State private var composeText = ""
|
@State private var composeText = ""
|
||||||
@State private var scope: VoiceCatTextScope = .channel
|
@State private var scope: VoiceCatTextScope = .channel
|
||||||
@State private var privateTargetId: UInt32 = 0
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Message list
|
// Combined chat + activity timeline
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(session.messages) { msg in
|
ForEach(timeline) { item in
|
||||||
ChatBubble(message: msg)
|
switch item {
|
||||||
.id(msg.id)
|
case .message(let msg):
|
||||||
|
ChatBubble(message: msg)
|
||||||
|
.id(item.id)
|
||||||
|
case .activity(let entry):
|
||||||
|
ActivityRow(entry: entry)
|
||||||
|
.id(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.onChange(of: session.messages.count) { _, _ in
|
.onChange(of: session.messages.count + session.activityLog.count) { _, _ in
|
||||||
if let last = session.messages.last {
|
if let last = timeline.last {
|
||||||
proxy.scrollTo(last.id, anchor: .bottom)
|
proxy.scrollTo(last.id, anchor: .bottom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,3 +126,34 @@ private struct ChatBubble: View {
|
|||||||
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
|
.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 {
|
var body: some View {
|
||||||
TabView {
|
TabView {
|
||||||
ChannelTreeView(session: session)
|
ChannelBrowserView(session: session)
|
||||||
|
.voiceControlsBar(session)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Channels", systemImage: "list.bullet.indent")
|
Label("Channels", systemImage: "list.bullet.indent")
|
||||||
}
|
}
|
||||||
ChatView(session: session)
|
ChatView(session: session)
|
||||||
|
.voiceControlsBar(session)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Chat", systemImage: "message")
|
Label("Chat", systemImage: "message")
|
||||||
}
|
}
|
||||||
ActivityLogView(session: session)
|
|
||||||
.tabItem {
|
|
||||||
Label("Activity", systemImage: "bell")
|
|
||||||
}
|
|
||||||
SettingsView(session: session)
|
SettingsView(session: session)
|
||||||
|
.voiceControlsBar(session)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Settings", systemImage: "gear")
|
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)
|
VoiceControlsView(session: session)
|
||||||
.padding(.bottom, 56) // above tab bar
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import VoiceCatCore
|
|||||||
|
|
||||||
struct UserListView: View {
|
struct UserListView: View {
|
||||||
@Bindable var session: SessionState
|
@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 {
|
var body: some View {
|
||||||
let channelUsers = session.currentChannelId == 0
|
let channelUsers = session.currentChannelId == 0
|
||||||
@@ -15,10 +10,7 @@ struct UserListView: View {
|
|||||||
: session.users.filter { $0.channelId == session.currentChannelId }
|
: session.users.filter { $0.channelId == session.currentChannelId }
|
||||||
|
|
||||||
List(channelUsers) { user in
|
List(channelUsers) { user in
|
||||||
UserRowView(user: user, isSelf: user.id == session.selfUserId)
|
UserRow(user: user, session: session)
|
||||||
.contextMenu {
|
|
||||||
userContextMenu(user: user)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.overlay {
|
.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.
|
// SCREEN_AUDIO stream. Tinted while sharing.
|
||||||
BroadcastPickerButton(isSharing: session.voiceState.screenSharing)
|
BroadcastPickerButton(isSharing: session.voiceState.screenSharing)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.accessibilityLabel(session.voiceState.screenSharing
|
|
||||||
? "Stop sharing screen audio" : "Share screen audio")
|
|
||||||
|
|
||||||
// Disconnect
|
// Disconnect
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
@@ -134,6 +132,13 @@ private struct BroadcastPickerButton: UIViewRepresentable {
|
|||||||
|
|
||||||
func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) {
|
func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) {
|
||||||
uiView.tintColor = isSharing ? .systemGreen : .label
|
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