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:
2026-06-21 03:02:58 +02:00
parent c5ad080692
commit b07362e525
10 changed files with 419 additions and 187 deletions

View 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)
}
}
}