eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// 🤖 The typing indicator: a them-side bubble with three dots, shown at the
4// bottom of the feed while someone's composing. The dots hold their position —
5// no bounce — and a brightness wave travels across them left to right. In a
6// group a small name line rides above it.
7public struct TypingBubble: View {
8 public let theme: ChatTheme
9 /// "Eleven is typing…" — shown as a quiet label above the bubble in groups.
10 public let label: String?
11
12 @Environment(\.colorScheme) private var scheme
13 @Environment(\.chatFontSize) private var fontSize
14
15 public init(theme: ChatTheme, label: String? = nil) {
16 self.theme = theme
17 self.label = label
18 }
19
20 public var body: some View {
21 VStack(alignment: .leading, spacing: 2) {
22 if let label, !label.isEmpty {
23 Text(label)
24 .font(.system(size: 11))
25 .foregroundStyle(.secondary)
26 .padding(.horizontal, 6)
27 }
28 ZStack {
29 // Invisible one-line message: same font + insets as a real text
30 // bubble (MessageBubble's 9/13), so this bubble is EXACTLY one
31 // message tall at every text size.
32 Text(verbatim: " ")
33 .font(.system(size: fontSize))
34 .opacity(0)
35 // Brightness comes from the CLOCK, not delay-staggered
36 // repeatForever animations: those re-sync into lockstep on any
37 // feed publish. A phase-offset sine can't drift or sync.
38 TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
39 let t = context.date.timeIntervalSinceReferenceDate
40 // 8pt dots, tight 4pt gaps (the web's .typing-bubble
41 // metrics): smaller dots read as an oversized, empty
42 // bubble even at the correct height.
43 HStack(spacing: 4) {
44 ForEach(0..<3, id: \.self) { i in
45 let phase = sin((t / 0.55 - Double(i) * 0.33) * .pi)
46 Circle()
47 .fill(.secondary)
48 .frame(width: 8, height: 8)
49 .opacity(0.35 + 0.6 * (phase + 1) / 2)
50 }
51 }
52 }
53 }
54 .padding(EdgeInsets(top: 9, leading: 13, bottom: 9, trailing: 13))
55 .background(theme.bubbleThem.resolved(scheme))
56 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
57 }
58 }
59}
60