diff --git a/ui/atproto/feed.css b/ui/atproto/feed.css index 7920b3b..089782f 100644 --- a/ui/atproto/feed.css +++ b/ui/atproto/feed.css @@ -125,6 +125,65 @@ h1 { } } +/* Per-account profile header, shown above the empty / sync-offer state in the + ?did= view so an account we don't sync yet still shows who it is. */ +.profile { + text-align: center; + padding-bottom: var(--spacing-md, 16px); + margin-bottom: var(--spacing-md, 16px); + border-bottom: 1px solid var(--color-border, #e7e2da); +} + +.profile-banner { + height: 120px; + margin: calc(-1 * var(--spacing-sm, 8px)) calc(-1 * var(--spacing-md, 16px)) 0; + background-color: var(--bg-surface, #f3efe8); + background-size: cover; + background-position: center; +} + +.profile-avatar { + display: inline-block; + width: 76px; + height: 76px; + border-radius: 50%; + object-fit: cover; + border: 3px solid var(--bg-menu, #fffdf9); + background: var(--bg-icon, #e8e2d8); + margin-top: 12px; +} + +/* Overlap the banner with the avatar for the classic profile look. */ +.profile.has-banner .profile-avatar { + position: relative; + margin-top: -38px; +} + +.profile-name { + margin-top: 8px; + font-size: 1.3rem; + font-weight: var(--font-weight-bold, 600); + color: var(--color-text, #2a2520); +} + +.profile-handle { + margin-top: 2px; + font-size: 0.85rem; + color: var(--color-text-tertiary, #8a817a); + word-break: break-all; +} + +.profile-bio { + margin: 12px auto 0; + max-width: 460px; + text-align: left; + color: var(--color-text-secondary, #6b6259); + font-size: 0.95rem; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + /* Infinite-scroll tripwire below the feed; the IntersectionObserver watches it. A sliver of height keeps it reliably laid out for observation. */ #feed-sentinel { diff --git a/ui/atproto/feed.js b/ui/atproto/feed.js index e373725..90ad3d4 100644 --- a/ui/atproto/feed.js +++ b/ui/atproto/feed.js @@ -143,9 +143,85 @@ function showStatus(message, withSettingsLink = false) { feed.appendChild(box); } -// Empty per-account feed: tell "synced but nothing timestamped" apart from "we -// don't sync this account" (via repoStatus), and offer to sync in the latter. +// Fetch an account's profile record (app.bsky.actor.profile/self) for the +// per-account header. Carries displayName + description (bio) + avatar + banner +// blobs. Returns null if it can't be read. Served by the at:// handler. +async function fetchProfile(did) { + const encoded = encodeURIComponent(did); + const blobUrl = (blob) => { + const cid = blob?.ref?.$link; + return cid + ? `at://${encoded}/com.atproto.sync.blob/${encodeURIComponent(cid)}` + : null; + }; + try { + const resp = await fetch(`at://${encoded}/app.bsky.actor.profile/self`); + if (!resp.ok) { + return null; + } + const value = (await resp.json())?.value || {}; + return { + displayName: value.displayName || null, + description: value.description || null, + avatarUrl: blobUrl(value.avatar), + bannerUrl: blobUrl(value.banner), + }; + } catch { + return null; + } +} + +// A profile card (banner / avatar / name / DID / bio) so an account we have no +// local records for still shows who it is. +function renderProfileHeader(profile, did) { + const card = document.createElement("div"); + card.className = "profile"; + + if (profile?.bannerUrl) { + card.classList.add("has-banner"); + const banner = document.createElement("div"); + banner.className = "profile-banner"; + banner.style.backgroundImage = `url("${profile.bannerUrl}")`; + card.appendChild(banner); + } + + const avatar = document.createElement(profile?.avatarUrl ? "img" : "span"); + avatar.className = "profile-avatar"; + if (profile?.avatarUrl) { + avatar.src = profile.avatarUrl; + avatar.alt = ""; + avatar.loading = "lazy"; + } + card.appendChild(avatar); + + const name = document.createElement("div"); + name.className = "profile-name"; + name.textContent = profile?.displayName || shortenDid(did); + card.appendChild(name); + + // Only show the DID line separately when the name isn't already the DID. + if (profile?.displayName) { + const id = document.createElement("div"); + id.className = "profile-handle"; + id.textContent = shortenDid(did); + card.appendChild(id); + } + + if (profile?.description) { + const bio = document.createElement("div"); + bio.className = "profile-bio"; + bio.textContent = profile.description; + card.appendChild(bio); + } + + return card; +} + +// Empty per-account feed: show the account's profile (so you know who it is), +// then either "nothing yet" (we sync it but it has no timestamped records) or an +// offer to start syncing (no local data) — told apart via repoStatus. async function showFilteredEmpty(did) { + const profile = await fetchProfile(did); let synced = false; try { const status = await navigator.atproto.repoStatus(did); @@ -153,45 +229,39 @@ async function showFilteredEmpty(did) { } catch { // Treat a status error as "not synced" and offer to sync. } - if (synced) { - showStatus("Nothing from this account yet."); - } else { - showSyncOffer(did); - } -} -// Offer to add an un-synced account to our synced repos. syncRepo backfills it -// into the store; the background indexer then keeps it fresh (it tracks every -// repo present in the store). -function showSyncOffer(did) { feed.replaceChildren(); + feed.appendChild(renderProfileHeader(profile, did)); + const box = document.createElement("div"); box.className = "status"; - - const btn = document.createElement("button"); - btn.className = "sync-btn"; - btn.textContent = "Sync this account"; - btn.addEventListener("click", async () => { - btn.disabled = true; - btn.textContent = "Syncing…"; - try { - await navigator.atproto.syncRepo(did); - loadFeed(); - } catch (e) { - btn.disabled = false; - btn.textContent = "Sync this account"; - const err = document.createElement("div"); - err.className = "sync-error"; - err.textContent = `Sync failed: ${e?.message || e}`; - box.appendChild(err); - } - }); - - box.append( - "You're not syncing this account yet.", - document.createElement("br"), - btn, - ); + if (synced) { + box.textContent = "Nothing from this account yet."; + } else { + const btn = document.createElement("button"); + btn.className = "sync-btn"; + btn.textContent = "Sync this account"; + btn.addEventListener("click", async () => { + btn.disabled = true; + btn.textContent = "Syncing…"; + try { + await navigator.atproto.syncRepo(did); + loadFeed(); + } catch (e) { + btn.disabled = false; + btn.textContent = "Sync this account"; + const err = document.createElement("div"); + err.className = "sync-error"; + err.textContent = `Sync failed: ${e?.message || e}`; + box.appendChild(err); + } + }); + box.append( + "Not syncing this account yet — sync to gather its records here.", + document.createElement("br"), + btn, + ); + } feed.appendChild(box); }