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 typeahead menu above the composer — a shared leaf view (like
4// MessageBubble) rendering the candidates ComposerTypeahead ranks: command
5// name + description, emoji glyph + :code:, or mention name. The container
6// owns the keyboard (↑↓ / Tab / Return / Esc) and the splice; rows also pick
7// on click. Mirrors the web's .ta-item rows.
8public struct TypeaheadStrip: View {
9 public let items: [TypeaheadItem]
10 public let selected: Int
11 public let theme: ChatTheme
12 /// Where a mention row's photo lives (the member's instance-key-encrypted
13 /// avatar blob) — nil/absent falls back to the coloured-initial circle.
14 public let mentionAvatar: ((_ userId: String) -> AvatarSource?)?
15 public let onPick: (TypeaheadItem) -> Void
16
17 @Environment(\.colorScheme) private var scheme
18
19 public init(items: [TypeaheadItem], selected: Int,
20 theme: ChatTheme = .classic,
21 mentionAvatar: ((String) -> AvatarSource?)? = nil,
22 onPick: @escaping (TypeaheadItem) -> Void) {
23 self.items = items
24 self.selected = selected
25 self.theme = theme
26 self.mentionAvatar = mentionAvatar
27 self.onPick = onPick
28 }
29
30 /// The tallest the card gets before it scrolls — the web's
31 /// `.typeahead { max-height: 216px }` (style.css). A bare ":" or "@" ranks
32 /// dozens of candidates, and an uncapped card covers the conversation it is
33 /// floating over.
34 private static let maxHeight: CGFloat = 216
35
36 public var body: some View {
37 // Capped BUT hugging: a ScrollView is greedy in its scroll axis, so
38 // `.frame(maxHeight:)` alone gives a full 216pt card for two
39 // candidates (measured off-screen: 228pt for 2 rows and for 10).
40 // `.fixedSize(vertical:)` sizes it to its content first, and the frame
41 // then clamps — 2 rows → 69pt, 10 rows → 228pt.
42 ScrollViewReader { proxy in
43 ScrollView {
44 VStack(alignment: .leading, spacing: 1) {
45 ForEach(Array(items.enumerated()), id: \.element.id) { i, item in
46 Button {
47 onPick(item)
48 } label: {
49 row(item)
50 .padding(.horizontal, 10)
51 .padding(.vertical, 7)
52 .frame(maxWidth: .infinity, alignment: .leading)
53 .background(RoundedRectangle(cornerRadius: 8, style: .continuous)
54 .fill(i == selected ? theme.accent.resolved(scheme).opacity(0.18) : .clear))
55 .contentShape(Rectangle())
56 }
57 .buttonStyle(.plain)
58 .id(item.id)
59 }
60 }
61 }
62 .frame(maxHeight: Self.maxHeight)
63 .fixedSize(horizontal: false, vertical: true)
64 // Keep the ↑↓ selection in view once the list scrolls — the web's
65 // scrollIntoView({block:"nearest"}) on every index move. A nil
66 // anchor is SwiftUI's "scroll the least that works".
67 .onChange(of: selected) { _, i in
68 guard items.indices.contains(i) else { return }
69 proxy.scrollTo(items[i].id, anchor: nil)
70 }
71 }
72 .padding(6)
73 .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(.background))
74 .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous)
75 .strokeBorder(.quaternary, lineWidth: 1))
76 .shadow(color: .black.opacity(0.15), radius: 12, y: 4)
77 }
78
79 @ViewBuilder private func row(_ item: TypeaheadItem) -> some View {
80 switch item {
81 // Row type sizes mirror the web's .ta-item (15px lines, 13px
82 // descriptions, 20px emoji in a 24px slot) — welcoming, not cramped.
83 case .command(let name, let desc):
84 HStack(spacing: 8) {
85 Text(name).font(.system(size: 15, weight: .semibold))
86 Text(desc).font(.system(size: 13)).foregroundStyle(.secondary).lineLimit(1)
87 }
88 case .emoji(let glyph, let code):
89 HStack(spacing: 8) {
90 Text(glyph)
91 .font(.system(size: 20))
92 .frame(width: 24)
93 Text(":" + code + ":").font(.system(size: 15)).foregroundStyle(.secondary)
94 }
95 case .mention(let name, let userId, let display):
96 HStack(spacing: 8) {
97 MentionAvatar(name: display, source: mentionAvatar?(userId))
98 Text(display).font(.system(size: 15, weight: .medium))
99 // I call them something else — say whose @name will insert.
100 if display != name {
101 Text(name).font(.system(size: 13)).foregroundStyle(.secondary)
102 }
103 }
104 }
105 }
106
107 // A member's face for the row: their decrypted profile photo, or the
108 // coloured-initial circle (deterministic hue from the name, so it's the
109 // same colour every render — the web's avatar fallback).
110 private struct MentionAvatar: View {
111 let name: String
112 let source: AvatarSource?
113
114 @State private var image: PlatformImage?
115
116 var body: some View {
117 ZStack {
118 if let image {
119 Image(platform: image)
120 .resizable()
121 .scaledToFill()
122 } else {
123 Circle().fill(Color(hue: Self.hue(name), saturation: 0.55, brightness: 0.78))
124 Text(name.first.map { String($0).uppercased() } ?? "?")
125 .font(.system(size: 12, weight: .semibold))
126 .foregroundStyle(.white)
127 }
128 }
129 .frame(width: 26, height: 26)
130 .clipShape(Circle())
131 .task(id: source?.blobID) {
132 guard let source else { return }
133 if let hit = BlobImageLoader.shared.cached(source.blobID,
134 maxPixel: avatarImageMaxPixel) {
135 image = hit
136 return
137 }
138 image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel)
139 }
140 }
141
142 // djb2 % 360 — the same stable-hue trick the Mac rail uses.
143 private static func hue(_ s: String) -> Double {
144 var h: UInt64 = 5381
145 for b in s.utf8 { h = (h &* 33) &+ UInt64(b) }
146 return Double(h % 360) / 360.0
147 }
148 }
149}
150