eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3
4// A circle for a chat or person. Shows initials over a name-hashed colour by
5// default; when handed an AvatarSource it fetches + decrypts that profile/group
6// photo (via BlobImageLoader, decoded off-main at avatar size) and swaps it in
7// once it loads — mirroring paintAvatar in web/static/chat.js (initials first,
8// the real photo when it resolves). The encrypted-blob fetch keeps E2E intact:
9// the server only ever serves ciphertext.
10struct Avatar: View {
11 let name: String
12 var size: CGFloat = 40
13 var source: AvatarSource? = nil
14 // A ready, already-decoded image to show directly — e.g. an instance's plaintext
15 // server photo (/api/server-avatar), which isn't an E2E blob so it doesn't go
16 // through AvatarSource/BlobImageLoader. Wins over the fetched `source`/initials.
17 var photo: UIImage? = nil
18 // An app-thread's emoji glyph (⏰, ✓, …) — replaces the initial.
19 var glyph: String? = nil
20 // The app's identity mark (docs/icons.md): an SF Symbol name for
21 // first-party apps, or a vendored brand template (GitHub, Linear, …).
22 // Either wins over the glyph — emoji is content, never chrome.
23 var symbol: String? = nil
24 var mark: UIImage? = nil
25 // Presence, iOS-style: a subtle green ring just outside the avatar edge.
26 // Mobile has no room for the web/Mac dot+word status line, so online is
27 // signalled here — and ONLY online; away/offline draw nothing (see
28 // LChatCore/Presence.swift). Opt-in per call site; only the DM chat
29 // header turns it on today.
30 var onlineRing: Bool = false
31
32 @State private var image: UIImage?
33
34 var body: some View {
35 ZStack {
36 if let shown = photo ?? image {
37 Image(uiImage: shown).resizable().scaledToFill()
38 } else if let mark {
39 Circle().fill(.primary.opacity(0.08))
40 Image(uiImage: mark)
41 .renderingMode(.template)
42 .resizable()
43 .scaledToFit()
44 .frame(width: size * 0.5, height: size * 0.5)
45 .foregroundStyle(.primary)
46 } else if let symbol {
47 Circle().fill(.primary.opacity(0.08))
48 Image(systemName: symbol)
49 .font(.system(size: size * 0.42, weight: .medium))
50 .foregroundStyle(.primary)
51 } else if let glyph {
52 Circle().fill(.primary.opacity(0.08))
53 Text(glyph).font(.system(size: size * 0.5))
54 } else {
55 Circle().fill(color)
56 Text(initial)
57 .font(.system(size: size * 0.42, weight: .semibold))
58 .foregroundStyle(.white)
59 }
60 }
61 .frame(width: size, height: size)
62 .clipShape(Circle())
63 // The ring rides OUTSIDE the clip (overlay after clipShape isn't
64 // clipped): a 2pt stroke inset -3 leaves a ~2pt breathing gap between
65 // the avatar edge and the ring, so it reads as a halo, not a border.
66 .overlay {
67 if onlineRing {
68 Circle()
69 .inset(by: -3)
70 .stroke(Self.onlineGreen, lineWidth: 2)
71 }
72 }
73 .task(id: source) { await load() }
74 }
75
76 // The presence green (#34C759) — same as PresenceStatusLine's online dot.
77 static let onlineGreen = Color(red: 0x34 / 255, green: 0xC7 / 255, blue: 0x59 / 255)
78
79 private func load() async {
80 guard let source else { image = nil; return }
81 // A decoded photo shows instantly; otherwise fall to initials and fetch.
82 if let cached = BlobImageLoader.shared.cached(source.blobID,
83 maxPixel: avatarImageMaxPixel) {
84 image = cached
85 return
86 }
87 image = nil
88 let img = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel)
89 // The row may have been recycled onto another chat while we awaited; the
90 // id-keyed task is cancelled in that case, so don't stomp the new photo.
91 if !Task.isCancelled { image = img }
92 }
93
94 private var initial: String {
95 let s = String(name.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased()
96 return s.isEmpty ? "?" : s
97 }
98
99 private var color: Color {
100 let palette: [Color] = [.blue, .purple, .pink, .orange, .green, .teal, .indigo, .red]
101 var h = 5381
102 for b in name.utf8 { h = (h &* 33) &+ Int(b) }
103 return palette[abs(h) % palette.count]
104 }
105}
106