| 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 | public var body: some View { |
| 31 | VStack(alignment: .leading, spacing: 1) { |
| 32 | ForEach(Array(items.enumerated()), id: \.element.id) { i, item in |
| 33 | Button { |
| 34 | onPick(item) |
| 35 | } label: { |
| 36 | row(item) |
| 37 | .padding(.horizontal, 10) |
| 38 | .padding(.vertical, 7) |
| 39 | .frame(maxWidth: .infinity, alignment: .leading) |
| 40 | .background(RoundedRectangle(cornerRadius: 8, style: .continuous) |
| 41 | .fill(i == selected ? theme.accent.resolved(scheme).opacity(0.18) : .clear)) |
| 42 | .contentShape(Rectangle()) |
| 43 | } |
| 44 | .buttonStyle(.plain) |
| 45 | } |
| 46 | } |
| 47 | .padding(6) |
| 48 | .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(.background)) |
| 49 | .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous) |
| 50 | .strokeBorder(.quaternary, lineWidth: 1)) |
| 51 | .shadow(color: .black.opacity(0.15), radius: 12, y: 4) |
| 52 | } |
| 53 | |
| 54 | @ViewBuilder private func row(_ item: TypeaheadItem) -> some View { |
| 55 | switch item { |
| 56 | // Row type sizes mirror the web's .ta-item (15px lines, 13px |
| 57 | // descriptions, 20px emoji in a 24px slot) — welcoming, not cramped. |
| 58 | case .command(let name, let desc): |
| 59 | HStack(spacing: 8) { |
| 60 | Text(name).font(.system(size: 15, weight: .semibold)) |
| 61 | Text(desc).font(.system(size: 13)).foregroundStyle(.secondary).lineLimit(1) |
| 62 | } |
| 63 | case .emoji(let glyph, let code): |
| 64 | HStack(spacing: 8) { |
| 65 | Text(glyph) |
| 66 | .font(.system(size: 20)) |
| 67 | .frame(width: 24) |
| 68 | Text(":" + code + ":").font(.system(size: 15)).foregroundStyle(.secondary) |
| 69 | } |
| 70 | case .mention(let name, let userId, let display): |
| 71 | HStack(spacing: 8) { |
| 72 | MentionAvatar(name: display, source: mentionAvatar?(userId)) |
| 73 | Text(display).font(.system(size: 15, weight: .medium)) |
| 74 | // I call them something else — say whose @name will insert. |
| 75 | if display != name { |
| 76 | Text(name).font(.system(size: 13)).foregroundStyle(.secondary) |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // A member's face for the row: their decrypted profile photo, or the |
| 83 | // coloured-initial circle (deterministic hue from the name, so it's the |
| 84 | // same colour every render — the web's avatar fallback). |
| 85 | private struct MentionAvatar: View { |
| 86 | let name: String |
| 87 | let source: AvatarSource? |
| 88 | |
| 89 | @State private var image: PlatformImage? |
| 90 | |
| 91 | var body: some View { |
| 92 | ZStack { |
| 93 | if let image { |
| 94 | Image(platform: image) |
| 95 | .resizable() |
| 96 | .scaledToFill() |
| 97 | } else { |
| 98 | Circle().fill(Color(hue: Self.hue(name), saturation: 0.55, brightness: 0.78)) |
| 99 | Text(name.first.map { String($0).uppercased() } ?? "?") |
| 100 | .font(.system(size: 12, weight: .semibold)) |
| 101 | .foregroundStyle(.white) |
| 102 | } |
| 103 | } |
| 104 | .frame(width: 26, height: 26) |
| 105 | .clipShape(Circle()) |
| 106 | .task(id: source?.blobID) { |
| 107 | guard let source else { return } |
| 108 | if let hit = BlobImageLoader.shared.cached(source.blobID, |
| 109 | maxPixel: avatarImageMaxPixel) { |
| 110 | image = hit |
| 111 | return |
| 112 | } |
| 113 | image = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | // djb2 % 360 — the same stable-hue trick the Mac rail uses. |
| 118 | private static func hue(_ s: String) -> Double { |
| 119 | var h: UInt64 = 5381 |
| 120 | for b in s.utf8 { h = (h &* 33) &+ UInt64(b) } |
| 121 | return Double(h % 360) / 360.0 |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |