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