eleven / messenger Public

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

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 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