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) { // Combined chat + activity timeline ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 8) { 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 + session.activityLog.count) { _, _ in if let last = timeline.last { proxy.scrollTo(last.id, anchor: .bottom) } } } Divider() // Compose bar HStack(spacing: 8) { TextField("Message…", text: $composeText, axis: .vertical) .lineLimit(1...5) .textFieldStyle(.roundedBorder) .accessibilityLabel("Message text field") .onSubmit { sendMessage() } Button { sendMessage() } label: { Image(systemName: "arrow.up.circle.fill") .imageScale(.large) } .disabled(composeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || session.currentChannelId == 0) .accessibilityLabel("Send message") } .padding(.horizontal) .padding(.vertical, 8) } .navigationTitle("Chat") .navigationBarTitleDisplayMode(.inline) } private func sendMessage() { let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } session.sendText(text, scope: .channel, targetId: session.currentChannelId) composeText = "" } } private struct ChatBubble: View { let message: ChatMessage private var timeString: String { let fmt = DateFormatter() fmt.dateStyle = .none fmt.timeStyle = .short return fmt.string(from: message.timestamp) } var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(message.senderName) .font(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) Text(timeString) .font(.caption2) .foregroundStyle(.tertiary) } Text(message.text) .font(.body) .textSelection(.enabled) } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .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)") } }