From 9a37e4112ec97cb19b6ab42a8b485c49775d76d0 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Sun, 12 Jul 2026 04:47:03 -0700 Subject: [PATCH] feat: iOS blue-bubble chat feed anchored to the bottom The chat feed now reads like a chat app: the ScrollView default-anchors to the bottom (.defaultScrollAnchor(.bottom)) so the newest message sits at the bottom of the screen on first paint and stays pinned as messages stream in. Each incoming message renders through a new MessageBubble: a secondary author + platform caption above a content-hugging systemBlue bubble (18pt continuous corners, white body text), left-aligned with a trailing Spacer so short messages stay short and long ones wrap without truncating. The row folds into a single VoiceOver element (" on : "). The previous unconditional scroll-to-last is replaced with an isAtBottom-gated animated follow (tracked via .onScrollGeometryChange), so a viewer scrolled up reading history is no longer yanked to the bottom on every new message. The empty-state ContentUnavailable overlay and per-row .id are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- Stream/ChatView.swift | 86 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/Stream/ChatView.swift b/Stream/ChatView.swift index 271e82b..c49e389 100644 --- a/Stream/ChatView.swift +++ b/Stream/ChatView.swift @@ -6,6 +6,12 @@ import SwiftUI struct ChatFeedView: View { var chat: RestreamChat + /// Whether the feed is currently parked at the bottom. Gates the animated + /// auto-follow so new messages only pull the view down when the user is already + /// there — a viewer scrolled up reading history is never yanked. Seeded `true` + /// so the first burst of messages sticks to the bottom. + @State private var isAtBottom = true + var body: some View { Group { switch chat.status { @@ -41,28 +47,36 @@ struct ChatFeedView: View { } } + // The feed default-anchors to the BOTTOM, so the newest line sits at the bottom + // of the screen on first paint and stays pinned there as chat streams in + // (classic chat-app behavior). private var messageList: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(chat.messages) { message in - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(message.author).font(.caption.bold()) - if let platform = message.platform { - Text(platform).font(.caption2).foregroundStyle(.secondary) - } - } - Text(message.text) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) + // Incoming-only feed: every bubble hugs the LEADING edge. The + // trailing Spacer caps width so a bubble never spans edge-to-edge. + HStack(spacing: 0) { + MessageBubble(message: message) + Spacer(minLength: 40) } - .frame(maxWidth: .infinity, alignment: .leading) - .id(message.id) + .id(message.id) // per-row id for scrollTo(_:) } } .padding() } + // Pin to the bottom on first layout and keep it pinned as rows append. + .defaultScrollAnchor(.bottom) + // Track "parked at the bottom" so the animated follow below only fires + // there, never yanking a viewer scrolling up through history. 24pt slack + // absorbs rounding and the bottom content inset. + .onScrollGeometryChange(for: Bool.self) { geo in + geo.contentOffset.y + geo.containerSize.height + >= geo.contentSize.height - geo.contentInsets.bottom - 24 + } action: { _, atBottom in + isAtBottom = atBottom + } .overlay { if chat.messages.isEmpty { ContentUnavailableView("Waiting for chat…", @@ -70,11 +84,55 @@ struct ChatFeedView: View { description: Text("Messages from your connected platforms will appear here.")) } } + // Subtle animated follow — gated so it only fires when already at the bottom. .onChange(of: chat.messages.count) { - if let last = chat.messages.last { - withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(last.id, anchor: .bottom) } + guard isAtBottom, let last = chat.messages.last else { return } + withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } +} + +/// One incoming chat line, styled like an iMessage group bubble: a small secondary +/// caption (author + platform) sits ABOVE a content-hugging blue bubble. The body +/// wraps freely and never truncates; white-on-blue stays legible in light + dark, +/// and the whole line reads as a single VoiceOver element. +private struct MessageBubble: View { + let message: ChatMessage + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + // Sender caption above the bubble, like the name label over a group bubble. + HStack(spacing: 4) { + Text(message.author).fontWeight(.semibold) + if let platform = message.platform, !platform.isEmpty { + Text(platform.capitalized) // "youtube" -> "Youtube" } } + .font(.caption) + .foregroundStyle(.secondary) // adapts to light/dark + .lineLimit(1) // long names/handles truncate here + .padding(.leading, 4) // nudge under the bubble's corner + + // The blue bubble: hugs short text, wraps long text, never truncates. + Text(message.text) + .font(.callout) // scales with Dynamic Type + .foregroundStyle(.white) // legible on blue, both modes + .fixedSize(horizontal: false, vertical: true) // wrap to full height, no clip + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.blue, in: .rect(cornerRadius: 18, style: .continuous)) + } + // Fold the whole line into ONE VoiceOver stop: "Ada on twitch: hello there". + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + } + + // "Ada on twitch: hello there" — platform woven in only when present. + private var accessibilityLabel: String { + if let platform = message.platform, !platform.isEmpty { + return "\(message.author) on \(platform): \(message.text)" } + return "\(message.author): \(message.text)" } }