~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
614 lines · 21.9 KB Raw History
//
//  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
        }
    }
}