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>
90 lines
3.7 KiB
Swift
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
|
|
}
|
|
}
|