this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add profile picture & banner to user account menu

+101 -15
+2
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add user banner to profile dropdown and cache profile images in IndexedDB [#288](https://issues.opake.app/issues/288.html) 16 + - Add split-panel preview, UI cleanup, and fix document name flicker [#286](https://issues.opake.app/issues/286.html) 15 17 - Add markdown renderer for document preview [#206](https://issues.opake.app/issues/206.html) 16 18 - Add encrypted image viewer with client-side decryption [#204](https://issues.opake.app/issues/204.html) 17 19 - Add README.md header sections for all crates [#264](https://issues.opake.app/issues/264.html)
+14 -4
web/src/components/cabinet/TopBar.tsx
··· 26 26 const handle = session.status === "active" ? session.handle : null; 27 27 const did = session.status === "active" ? session.did : null; 28 28 const avatarUrl = session.status === "active" ? session.avatarUrl : null; 29 + const bannerUrl = session.status === "active" ? session.bannerUrl : null; 29 30 const initial = handle?.[0]?.toUpperCase() ?? "?"; 30 31 31 32 const [menuOpen, setMenuOpen] = useState(false); ··· 111 112 className="border-base-300/50 bg-base-100 shadow-panel-lg fixed top-12 right-4 z-9999 w-52.5 rounded-xl border" 112 113 > 113 114 {handle && did && ( 114 - <div className="border-base-300/50 border-b px-3.5 py-2.5"> 115 - <div className="text-ui text-base-content font-medium">{handle}</div> 116 - <div className="text-caption text-text-faint mt-0.5">{truncateDid(did)}</div> 117 - </div> 115 + <> 116 + <div className="relative h-20 w-full overflow-hidden rounded-t-xl"> 117 + <div 118 + className="bg-base-300/60 absolute inset-0 bg-cover bg-center" 119 + style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined} 120 + /> 121 + <div className="from-base-100 absolute inset-0 bg-gradient-to-t to-transparent" /> 122 + </div> 123 + <div className="border-base-300/50 border-b px-3.5 pb-2.5 pt-2"> 124 + <div className="text-ui text-base-content font-medium">{handle}</div> 125 + <div className="text-caption text-text-faint mt-0.5">{truncateDid(did)}</div> 126 + </div> 127 + </> 118 128 )} 119 129 <ul className="menu w-full p-1"> 120 130 {[
+31 -1
web/src/lib/indexeddbStorage.ts
··· 22 22 value: Session; 23 23 } 24 24 25 + export interface CachedProfile { 26 + readonly avatarUrl: string | null; 27 + readonly bannerUrl: string | null; 28 + readonly fetchedAt: number; 29 + } 30 + 31 + interface ProfileRow { 32 + did: string; 33 + value: CachedProfile; 34 + } 35 + 25 36 class OpakeDatabase extends Dexie { 26 37 readonly configs!: Readonly<EntityTable<ConfigRow, "key">>; 27 38 readonly identities!: Readonly<EntityTable<IdentityRow, "did">>; 28 39 readonly sessions!: Readonly<EntityTable<SessionRow, "did">>; 40 + readonly profiles!: Readonly<EntityTable<ProfileRow, "did">>; 29 41 30 42 constructor(name = "opake") { 31 43 super(name); ··· 34 46 identities: "did", 35 47 sessions: "did", 36 48 }); 49 + this.version(2).stores({ 50 + configs: "key", 51 + identities: "did", 52 + sessions: "did", 53 + profiles: "did", 54 + }); 37 55 } 38 56 } 39 57 ··· 84 102 await this.db.sessions.put({ did: key, value: session }); 85 103 } 86 104 105 + async loadProfile(did: string): Promise<CachedProfile | null> { 106 + const key = sanitizeDid(did); 107 + const row = await this.db.profiles.get(key); 108 + return row?.value ?? null; 109 + } 110 + 111 + async saveProfile(did: string, profile: CachedProfile): Promise<void> { 112 + const key = sanitizeDid(did); 113 + await this.db.profiles.put({ did: key, value: profile }); 114 + } 115 + 87 116 async removeAccount(did: string): Promise<void> { 88 117 const config = await this.loadConfig(); 89 118 const remainingAccounts = Object.fromEntries( ··· 103 132 const key = sanitizeDid(did); 104 133 await this.db.transaction( 105 134 "rw", 106 - [this.db.configs, this.db.identities, this.db.sessions], 135 + [this.db.configs, this.db.identities, this.db.sessions, this.db.profiles], 107 136 async () => { 108 137 await this.db.configs.put({ key: CONFIG_KEY, value: updatedConfig }); 109 138 await this.db.identities.delete(key); 110 139 await this.db.sessions.delete(key); 140 + await this.db.profiles.delete(key); 111 141 }, 112 142 ); 113 143 }
+54 -10
web/src/stores/auth.ts
··· 37 37 | { status: "initializing" } 38 38 | { status: "none" } 39 39 | { status: "authenticating" } 40 - | { status: "active"; did: string; handle: string; pdsUrl: string; avatarUrl: string | null } 40 + | { 41 + status: "active"; 42 + did: string; 43 + handle: string; 44 + pdsUrl: string; 45 + avatarUrl: string | null; 46 + bannerUrl: string | null; 47 + } 41 48 | { status: "error"; message: string }; 42 49 43 50 export type IdentityState = ··· 78 85 // Helpers 79 86 // --------------------------------------------------------------------------- 80 87 81 - /** Fetch the user's Bluesky profile avatar URL via the PDS proxy. */ 82 - async function fetchAvatarUrl(pdsUrl: string, did: string): Promise<string | null> { 88 + interface ProfileUrls { 89 + readonly avatarUrl: string | null; 90 + readonly bannerUrl: string | null; 91 + } 92 + 93 + /** Fetch the user's Bluesky profile avatar + banner URLs via the PDS proxy. */ 94 + async function fetchProfileUrls(pdsUrl: string, did: string): Promise<ProfileUrls> { 83 95 try { 84 96 const res = await fetch( 85 97 `${pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 86 98 { headers: { "atproto-proxy": "did:web:api.bsky.app#bsky_appview" } }, 87 99 ); 88 - if (!res.ok) return null; 89 - const profile = (await res.json()) as { avatar?: string }; 90 - return profile.avatar ?? null; 100 + if (!res.ok) return { avatarUrl: null, bannerUrl: null }; 101 + const profile = (await res.json()) as { avatar?: string; banner?: string }; 102 + return { 103 + avatarUrl: profile.avatar ?? null, 104 + bannerUrl: profile.banner ?? null, 105 + }; 91 106 } catch { 92 - return null; 107 + return { avatarUrl: null, bannerUrl: null }; 93 108 } 94 109 } 95 110 111 + /** 112 + * Load cached profile, then refresh from the network in the background. 113 + * Calls `applyProfile` immediately with cached data (if any) and again after the fetch. 114 + */ 115 + function loadAndRefreshProfile( 116 + pdsUrl: string, 117 + did: string, 118 + applyProfile: (urls: ProfileUrls) => void, 119 + ): void { 120 + // Show cached data instantly 121 + void storage.loadProfile(did).then((cached) => { 122 + if (cached) applyProfile({ avatarUrl: cached.avatarUrl, bannerUrl: cached.bannerUrl }); 123 + }); 124 + 125 + // Then refresh from the network 126 + void fetchProfileUrls(pdsUrl, did).then((urls) => { 127 + applyProfile(urls); 128 + void storage.saveProfile(did, { 129 + avatarUrl: urls.avatarUrl, 130 + bannerUrl: urls.bannerUrl, 131 + fetchedAt: Date.now(), 132 + }); 133 + }); 134 + } 135 + 96 136 /** Check if publicKey/self already exists on the PDS. */ 97 137 async function fetchUpstreamPublicKey( 98 138 pdsUrl: string, ··· 161 201 handle: account.handle, 162 202 pdsUrl: account.pdsUrl, 163 203 avatarUrl: null, 204 + bannerUrl: null, 164 205 }; 165 206 }); 166 207 167 - // Fire-and-forget — don't block boot on a profile picture 168 - void fetchAvatarUrl(account.pdsUrl, did).then((avatarUrl) => { 208 + // Fire-and-forget — don't block boot on profile images 209 + loadAndRefreshProfile(account.pdsUrl, did, ({ avatarUrl, bannerUrl }) => { 169 210 set((draft) => { 170 211 if (draft.session.status === "active") { 171 212 draft.session.avatarUrl = avatarUrl; 213 + draft.session.bannerUrl = bannerUrl; 172 214 } 173 215 }); 174 216 }); ··· 396 438 handle: pending.handle, 397 439 pdsUrl: pending.pdsUrl, 398 440 avatarUrl: null, 441 + bannerUrl: null, 399 442 }; 400 443 draft.identity = { status: "unchecked" }; 401 444 }); 402 445 403 - void fetchAvatarUrl(pending.pdsUrl, did).then((avatarUrl) => { 446 + loadAndRefreshProfile(pending.pdsUrl, did, ({ avatarUrl, bannerUrl }) => { 404 447 set((draft) => { 405 448 if (draft.session.status === "active") { 406 449 draft.session.avatarUrl = avatarUrl; 450 + draft.session.bannerUrl = bannerUrl; 407 451 } 408 452 }); 409 453 });