| 1 | import 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. |
| 8 | public 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 | |