mana-swift-ui/Sources/ManaAuthUI/Account/ManaAvatarImageStore.swift
Till JS e956a05e3c ManaAccountAvatarButton: Ökosystem-Profilbild (Stufe 1)
Der geteilte Konto-Avatar lädt jetzt selbst das im Avatar-Baukasten
gebaute Profilbild (mana-me primary kind=avatar → öffentliche
mana-media-Thumb) und zeigt es rund beschnitten, mit Fallback auf
Initialen/Icon. Neuer ManaAvatarImageStore (prozessweiter Cache,
dedupliziert über alle Tab-Buttons). Null App-Code nötig — Defaults
zeigen auf Produktion; jede App mit manaAccountToolbar erbt es nach
Rebuild. Deckt die gesamte native Swift-Flotte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:13:14 +02:00

90 lines
3.7 KiB
Swift

import Foundation
import ManaCore
/// Löst das Ökosystem-Profilbild des angemeldeten Nutzers auf.
///
/// Das Bild wird im Avatar-Baukasten (`avatar.mana.how`) gebaut und in
/// **mana-me** als primary `kind='avatar'` abgelegt. Dieser Store fragt
/// `GET {meBaseURL}/api/v1/me/images/primary/avatar` mit dem User-JWT ab und
/// baut daraus die **öffentliche** mana-media-Thumb-URL
/// (`{mediaBaseURL}/api/v1/media/{mediaId}/file/thumb`, kein Auth nötig zum
/// Anzeigen). Identischer Server-Pfad wie die wordeck-Web-BFF-Route
/// `/api/v1/me/avatar`.
///
/// Prozessweiter `@MainActor`-Cache, damit nicht jeder
/// ``ManaAccountAvatarButton`` (typisch einer pro Tab) denselben Call feuert.
/// Schlüssel ist der angemeldete Account (E-Mail aus ``AuthClient/Status``);
/// Abmelden oder Konto-Wechsel verwirft den Cache automatisch.
@MainActor
public final class ManaAvatarImageStore {
/// Geteilte Instanz — der Cache lebt prozessweit über alle Buttons hinweg.
public static let shared = ManaAvatarImageStore()
/// Öffentliche mana-me-Basis-URL (Default für alle Apps).
public static let defaultMeURL = URL(string: "https://me.mana.how")!
/// Öffentliche mana-media-Basis-URL (Default für alle Apps).
public static let defaultMediaURL = URL(string: "https://media.mana.how")!
private var cache: [String: URL] = [:]
private var inFlight: [String: Task<URL?, Never>] = [:]
private init() {}
/// Liefert die Thumb-URL des Profilbilds, oder `nil` wenn ausgeloggt,
/// kein Bild gesetzt oder bei Netz-/Server-Fehler. Idempotent und über
/// parallele Aufrufer dedupliziert.
public func thumbURL(
auth: AuthClient,
meBaseURL: URL = ManaAvatarImageStore.defaultMeURL,
mediaBaseURL: URL = ManaAvatarImageStore.defaultMediaURL
) async -> URL? {
guard case let .signedIn(email) = auth.status else {
// Ausgeloggt: Cache verwerfen, kein Bild.
cache.removeAll()
inFlight.removeAll()
return nil
}
if let cached = cache[email] { return cached }
if let task = inFlight[email] { return await task.value }
let task = Task<URL?, Never> {
await Self.resolve(auth: auth, meBaseURL: meBaseURL, mediaBaseURL: mediaBaseURL)
}
inFlight[email] = task
let url = await task.value
inFlight[email] = nil
if let url { cache[email] = url }
return url
}
/// Cache leeren — z.B. nachdem der Nutzer sein Profilbild geändert oder
/// neu gesetzt hat, damit das nächste Laden die frische Version holt.
public func invalidate() {
cache.removeAll()
inFlight.removeAll()
}
private static func resolve(
auth: AuthClient,
meBaseURL: URL,
mediaBaseURL: URL
) async -> URL? {
guard let token = try? await auth.freshAccessToken() else { return nil }
let endpoint = meBaseURL.appendingPathComponent("api/v1/me/images/primary/avatar")
var request = URLRequest(url: endpoint)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
guard
let (data, response) = try? await URLSession.shared.data(for: request),
let http = response as? HTTPURLResponse, http.statusCode == 200,
let primary = try? JSONDecoder().decode(PrimaryRef.self, from: data),
!primary.mediaId.isEmpty
else { return nil }
return mediaBaseURL.appendingPathComponent("api/v1/media/\(primary.mediaId)/file/thumb")
}
/// Schlanker Mirror der mana-me `primary/:kind`-Antwort — uns interessiert
/// nur die `mediaId`.
private struct PrimaryRef: Decodable {
let mediaId: String
}
}