| 1 | import LChatCore |
| 2 | import 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. |
| 10 | struct 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 | |