import LChatCore import SwiftUI // A circle for a chat or person. Shows initials over a name-hashed colour by // default; when handed an AvatarSource it fetches + decrypts that profile/group // photo (via BlobImageLoader, decoded off-main at avatar size) and swaps it in // once it loads — mirroring paintAvatar in web/static/chat.js (initials first, // the real photo when it resolves). The encrypted-blob fetch keeps E2E intact: // the server only ever serves ciphertext. struct Avatar: View { let name: String var size: CGFloat = 40 var source: AvatarSource? = nil // A ready, already-decoded image to show directly — e.g. an instance's plaintext // server photo (/api/server-avatar), which isn't an E2E blob so it doesn't go // through AvatarSource/BlobImageLoader. Wins over the fetched `source`/initials. var photo: UIImage? = nil // An app-thread's emoji glyph (⏰, ✓, …) — replaces the initial. var glyph: String? = nil // The app's identity mark (docs/icons.md): an SF Symbol name for // first-party apps, or a vendored brand template (GitHub, Linear, …). // Either wins over the glyph — emoji is content, never chrome. var symbol: String? = nil var mark: UIImage? = nil // Presence, iOS-style: a subtle green ring just outside the avatar edge. // Mobile has no room for the web/Mac dot+word status line, so online is // signalled here — and ONLY online; away/offline draw nothing (see // LChatCore/Presence.swift). Opt-in per call site; only the DM chat // header turns it on today. var onlineRing: Bool = false @State private var image: UIImage? var body: some View { ZStack { if let shown = photo ?? image { Image(uiImage: shown).resizable().scaledToFill() } else if let mark { Circle().fill(.primary.opacity(0.08)) Image(uiImage: mark) .renderingMode(.template) .resizable() .scaledToFit() .frame(width: size * 0.5, height: size * 0.5) .foregroundStyle(.primary) } else if let symbol { Circle().fill(.primary.opacity(0.08)) Image(systemName: symbol) .font(.system(size: size * 0.42, weight: .medium)) .foregroundStyle(.primary) } else if let glyph { Circle().fill(.primary.opacity(0.08)) Text(glyph).font(.system(size: size * 0.5)) } else { Circle().fill(color) Text(initial) .font(.system(size: size * 0.42, weight: .semibold)) .foregroundStyle(.white) } } .frame(width: size, height: size) .clipShape(Circle()) // The ring rides OUTSIDE the clip (overlay after clipShape isn't // clipped): a 2pt stroke inset -3 leaves a ~2pt breathing gap between // the avatar edge and the ring, so it reads as a halo, not a border. .overlay { if onlineRing { Circle() .inset(by: -3) .stroke(Self.onlineGreen, lineWidth: 2) } } .task(id: source) { await load() } } // The presence green (#34C759) — same as PresenceStatusLine's online dot. static let onlineGreen = Color(red: 0x34 / 255, green: 0xC7 / 255, blue: 0x59 / 255) private func load() async { guard let source else { image = nil; return } // A decoded photo shows instantly; otherwise fall to initials and fetch. if let cached = BlobImageLoader.shared.cached(source.blobID, maxPixel: avatarImageMaxPixel) { image = cached return } image = nil let img = await BlobImageLoader.shared.image(source, maxPixel: avatarImageMaxPixel) // The row may have been recycled onto another chat while we awaited; the // id-keyed task is cancelled in that case, so don't stomp the new photo. if !Task.isCancelled { image = img } } private var initial: String { let s = String(name.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased() return s.isEmpty ? "?" : s } private var color: Color { let palette: [Color] = [.blue, .purple, .pink, .orange, .green, .teal, .indigo, .red] var h = 5381 for b in name.utf8 { h = (h &* 33) &+ Int(b) } return palette[abs(h) % palette.count] } }