// // ChatView.swift // AT-IRC // import SwiftUI struct ChatView: View { @Bindable var store: ChatStore var kcp: KCPHost @Environment(\.scenePhase) private var scenePhase @State private var sidebarOpen = false @State private var asksOpen = false @State private var agentsOpen = false @State private var membersOpen = false @State private var wasBackgrounded = false @State private var lastMessageCount = 0 /// True while the reader is at (or within about one row of) the bottom. /// New messages only auto-scroll then; scrolled-up readers keep their /// place and see a jump pill instead (owner bug 3981). @State private var followsBottom = true @State private var unseenWhileScrolledUp = 0 @State private var requesterConfigJSON = "{}" @State private var oauthBridge = RequesterOAuthBridge() var body: some View { NavigationStack { ZStack(alignment: .leading) { chat if sidebarOpen { Color.black.opacity(0.25) .ignoresSafeArea() .onTapGesture(perform: closeSidebar) .transition(.opacity) ChannelSidebar( store: store, onSelect: selectChannel, onAgents: { agentsOpen = true closeSidebar() }, onClose: closeSidebar ) .frame(width: 300) .frame(maxHeight: .infinity) .background(.background) .shadow(color: .black.opacity(0.2), radius: 10, x: 4) .transition(.move(edge: .leading)) } } } .onAppear(perform: openAgentsForTestIfNeeded) .task { await configureRequester() #if DEBUG if ProcessInfo.processInfo.environment["ATIRC_TEST_DROP_STREAM"] == "1" { try? await Task.sleep(for: .seconds(3)) store.simulateStreamDropForTest() } #endif } } /// Scripted UI tests can open the Agents sheet: ATIRC_OPEN_AGENTS=1. private func openAgentsForTestIfNeeded() { #if DEBUG if ProcessInfo.processInfo.environment["ATIRC_OPEN_AGENTS"] == "1" { agentsOpen = true } #endif } private var chat: some View { VStack(spacing: 0) { if !streamStateText.isEmpty { HStack(spacing: 6) { Circle() .fill(statusColor) .frame(width: 6, height: 6) Text(streamStateText) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(2) } .frame(maxWidth: .infinity) .padding(.vertical, 3) .background(.bar) } messageList Divider() composer } .navigationTitle(store.channel) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { HStack(spacing: 12) { Button { toggleSidebar() } label: { Image(systemName: "line.3.horizontal") } HStack(spacing: 6) { Circle() .fill(statusColor) .frame(width: 8, height: 8) Text(store.account?.mint.nick ?? "") .font(.caption) .foregroundStyle(NickColor.color(for: store.account?.mint.nick ?? "")) } } } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 14) { // Separate view: presence updates re-render just this badge, // not the chat transcript body. MemberToolbarButton(store: store) { membersOpen = true } Button { asksOpen = true } label: { ZStack(alignment: .topTrailing) { Image(systemName: "questionmark.bubble") if store.pendingAskCount > 0 { Text("\(store.pendingAskCount)") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) .padding(3) .background(.red, in: Circle()) .offset(x: 9, y: -9) } } } .accessibilityLabel(store.pendingAskCount == 0 ? "Questions" : "Questions, \(store.pendingAskCount) pending") Button { store.signOut() } label: { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") } } } } .sheet(isPresented: $asksOpen) { AskView(store: store) } .sheet(isPresented: $membersOpen) { MembersView(store: store) } .sheet(isPresented: $agentsOpen) { AgentsView(kcp: kcp, requesterConfig: requesterConfigForAgents) } .onChange(of: scenePhase) { _, phase in switch phase { case .background: wasBackgrounded = true store.setBackgrounded(true) case .active: Task { await store.refreshAsks(force: true) } store.setBackgrounded(false) if wasBackgrounded { wasBackgrounded = false store.resumeStream() } default: break } } } /// Config for the in-process socialweb requester: the signed-in identity /// plus the app's OAuth client metadata and the loopback redirect the /// requester's own sign-in returns to. private func configureRequester() async { guard let account = store.account else { return } guard let redirect = try? await oauthBridge.start() else { return } let config: [String: Any] = [ "handle": account.mint.handle, "did": account.oauth.did, "pdsUrl": account.oauth.pdsURL, "oauthClientId": OAuthClient.clientID, "oauthRedirectUri": redirect, ] guard let data = try? JSONSerialization.data(withJSONObject: config), let raw = String(data: data, encoding: .utf8) else { return } requesterConfigJSON = raw } /// Agents waits for the requester config rather than racing the sheet. private func requesterConfigForAgents() async -> String { await configureRequester() return requesterConfigJSON } // MARK: - Sidebar private func toggleSidebar() { if sidebarOpen { closeSidebar() } else { withAnimation(.snappy(duration: 0.22)) { sidebarOpen = true } Task { await store.refreshChannels() } } } private func closeSidebar() { withAnimation(.snappy(duration: 0.22)) { sidebarOpen = false } } private func selectChannel(_ name: String) { store.openChannel(name) closeSidebar() } // MARK: - Status private var bannerText: String? { switch store.phase { case .failed(let message): return message case .connecting: return store.statusText.isEmpty ? "connecting…" : store.statusText case .signedOut, .signingIn, .connected: return nil } } /// Always-visible stream state: connected / reconnecting / last error. private var streamStateText: String { switch store.phase { case .connected: return "connected" case .connecting: return store.statusText.isEmpty ? "connecting…" : store.statusText case .failed(let message): return message case .signedOut, .signingIn: return "" } } private var statusColor: Color { switch store.phase { case .connected: return .green case .connecting: return .orange case .failed: return .red case .signedOut, .signingIn: return .gray } } // MARK: - Messages private var messageList: some View { GeometryReader { geo in ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(store.timeline) { row in switch row { case .message(let message): MessageRow(message: message) case .notice(let notice): NoticeRow(notice: notice) } } Color.clear .frame(height: 1) .id(Self.bottomAnchorID) } .padding() } .defaultScrollAnchor(.bottom) .scrollDismissesKeyboard(.interactively) .modifier(ScrollBottomTracking(followsBottom: $followsBottom)) .overlay(alignment: .bottomTrailing) { if !followsBottom && unseenWhileScrolledUp > 0 { Button { unseenWhileScrolledUp = 0 followsBottom = true scrollToBottom(proxy) } label: { Label( "\(unseenWhileScrolledUp) new message\(unseenWhileScrolledUp == 1 ? "" : "s")", systemImage: "arrow.down" ) .font(.footnote.weight(.semibold)) .padding(.horizontal, 12) .padding(.vertical, 8) } .buttonStyle(.borderedProminent) .padding(12) } } .overlay { if store.timeline.isEmpty { ContentUnavailableView { Label("No messages", systemImage: "bubble.left") } description: { Text("Say something to \(store.channel).") } } } .onAppear { lastMessageCount = store.messages.count } // The scroll view keeps its previous offset when the channel's // content is replaced. Jump to the newest message when opening // a channel, and again once its full scrollback has been merged. .onChange(of: store.selectedChannel, initial: true) { _, _ in followsBottom = true unseenWhileScrolledUp = 0 scrollToBottom(proxy) } .onChange(of: store.isLoadingHistory) { _, isLoading in if !isLoading, followsBottom { scrollToBottom(proxy) } } // A live message taller than the view should be read from its // start, not pinned by its bottom edge - and a reader scrolled // up must not be moved at all (owner bug 3981). .onChange(of: store.messages.last?.id) { _, _ in let count = store.messages.count defer { lastMessageCount = count } guard followsBottom else { if count == lastMessageCount + 1 { unseenWhileScrolledUp += 1 } return } unseenWhileScrolledUp = 0 guard count == lastMessageCount + 1, let newest = store.messages.last else { scrollToBottom(proxy) return } if estimatedHeight(of: newest, width: geo.size.width) > geo.size.height * 0.8 { proxy.scrollTo(newest.id, anchor: .top) } else { scrollToBottom(proxy) } } } } } private func estimatedHeight(of message: AtircMessage, width: CGFloat) -> CGFloat { let charsPerLine = max(20.0, width / 7.5) let lines = max(1.0, ceil(Double(message.text.count) / charsPerLine)) return CGFloat(lines) * 20 + 44 } private static let bottomAnchorID = "message-list-bottom" private func scrollToBottom(_ proxy: ScrollViewProxy) { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } // MARK: - Composer private var composer: some View { HStack(alignment: .bottom, spacing: 8) { ZStack(alignment: .topLeading) { if store.composerText.isEmpty { Text("Message") .font(.body) .foregroundStyle(.tertiary) .padding(.horizontal, 12) .padding(.vertical, 9) .allowsHitTesting(false) .accessibilityHidden(true) } ComposerTextView(text: $store.composerText) .padding(.horizontal, 12) .padding(.vertical, 9) } .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 18)) Button { Task { await store.send() } } label: { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 30)) } .disabled(store.composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding(12) .background(.bar) } } struct ChannelSidebar: View { let store: ChatStore let onSelect: (String) -> Void let onAgents: () -> Void let onClose: () -> Void var body: some View { VStack(spacing: 0) { HStack(spacing: 16) { Text("Channels") .font(.headline) Spacer() Button { Task { await store.refreshChannels(force: true) } } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) Button(action: onClose) { Image(systemName: "xmark") } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) Divider() List { Button(action: onAgents) { HStack(spacing: 8) { Image(systemName: "person.3.fill") Text("Agents") .foregroundStyle(.primary) Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } .contentShape(Rectangle()) } .buttonStyle(.plain) Divider() ForEach(store.channels) { info in Button { onSelect(info.name) } label: { HStack(spacing: 8) { Text(info.name) .lineLimit(1) .truncationMode(.middle) .foregroundStyle(.primary) Spacer(minLength: 4) if info.name == store.selectedChannel { Image(systemName: "checkmark") .font(.caption.weight(.semibold)) .foregroundStyle(.tint) } } .contentShape(Rectangle()) } .buttonStyle(.plain) .listRowBackground( info.name == store.selectedChannel ? Color.accentColor.opacity(0.12) : Color.clear ) } } .listStyle(.plain) .refreshable { await store.refreshChannels(force: true) } } .background(.background) } } struct MessageRow: View { let message: AtircMessage /// Server timestamps rendered in the reader's local timezone. private static let timestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = .autoupdatingCurrent formatter.dateFormat = "yyyy-MM-dd HH:mm zzz" return formatter }() var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(message.displayNick) .font(.caption.weight(.semibold)) .foregroundStyle(NickColor.color(for: message.displayNick)) if let date = message.date { Text(Self.timestampFormatter.string(from: date)) .font(.caption2) .foregroundStyle(.tertiary) } } Text(message.text) .font(.body) .textSelection(.enabled) } .frame(maxWidth: .infinity, alignment: .leading) } } /// WeeChat-style system line: "-- joe joined" / "-- joe left". struct NoticeRow: View { let notice: ChatNotice var body: some View { Text("-- \(notice.nick) \(notice.kind == .join ? "joined" : "left")") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } /// Header badge with the connected-member count. isolated so presence churn /// does not invalidate the whole chat view. private struct MemberToolbarButton: View { let store: ChatStore let action: () -> Void var body: some View { Button(action: action) { ZStack(alignment: .topTrailing) { Image(systemName: "person.2") if !store.members.isEmpty { Text("\(store.members.count)") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) .padding(3) .background(Color.accentColor, in: Circle()) .offset(x: 9, y: -9) } } } .accessibilityLabel(store.members.isEmpty ? "Members, no one connected" : "Members, \(store.members.count) connected") } } struct MembersView: View { let store: ChatStore @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List(store.members) { member in HStack(spacing: 8) { Text(member.nick) .foregroundStyle(NickColor.color(for: member.nick)) if isYou(member) { Text("you") .font(.caption2) .foregroundStyle(.secondary) } Spacer(minLength: 8) if let identity = member.identity { Text(identity) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } } } .overlay { if store.members.isEmpty { ContentUnavailableView("No one connected", systemImage: "person.2.slash") } } .navigationTitle("Members") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } .presentationDetents([.medium, .large]) } private func isYou(_ member: AtircMember) -> Bool { guard let nick = store.account?.mint.nick else { return false } return member.nick.caseInsensitiveCompare(nick) == .orderedSame } } /// Tracks whether the reader is at (or within about one row of) the bottom of /// the message list. On iOS 18+ the scroll geometry says so directly; older /// systems keep the previous always-follow behaviour. private struct ScrollBottomTracking: ViewModifier { @Binding var followsBottom: Bool func body(content: Content) -> some View { if #available(iOS 18.0, *) { content.onScrollGeometryChange(for: Bool.self) { geometry in geometry.contentOffset.y + geometry.containerSize.height >= geometry.contentSize.height - 80 } action: { _, atBottom in followsBottom = atBottom } } else { content } } }