From 6fd22fd86a32fad191af12f499eb6ea0cd29ec67 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Sat, 26 Sep 2026 06:25:52 -0800 Subject: [PATCH] frontend fixes --- .../src/endpoints/authorize/login.rs | 20 +- .../src/endpoints/authorize/mod.rs | 2 + frontend/public/activity-taxonomy.js | 19 +- frontend/public/homepage-data.js | 247 +++++++++++++++--- frontend/public/homepage.css | 24 ++ frontend/public/homepage.js | 111 ++++++-- frontend/src/components/ui/ClientBadge.svelte | 104 ++++++++ frontend/src/locales/en.json | 2 +- frontend/src/locales/fi.json | 2 +- frontend/src/locales/fr.json | 2 +- frontend/src/routes/OAuthConsent.svelte | 16 +- frontend/src/routes/OAuthLogin.svelte | 18 +- frontend/src/routes/Register.svelte | 2 +- frontend/src/routes/RegisterSso.svelte | 2 +- frontend/src/styles/base.css | 16 +- frontend/src/styles/dashboard.css | 4 +- frontend/src/styles/design.css | 32 ++- frontend/src/styles/pages.css | 4 +- frontend/src/tests/homepage-data.test.ts | 80 +++++- 19 files changed, 600 insertions(+), 107 deletions(-) create mode 100644 frontend/src/components/ui/ClientBadge.svelte diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs index efafd9e..0ba25e5 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/login.rs @@ -81,15 +81,17 @@ pub async fn authorize_get( ); } let client_cache = &state.client_metadata_cache; - let client_name = client_cache + let client_metadata = client_cache .get(&request_data.parameters.client_id) .await - .ok() - .and_then(|m| m.client_name); + .ok(); if wants_json(&headers) { + let client_metadata = client_metadata.as_ref(); return Json(AuthorizeResponse { client_id: request_data.parameters.client_id.clone(), - client_name: client_name.clone(), + client_name: client_metadata.and_then(|m| m.client_name.clone()), + client_uri: client_metadata.and_then(|m| m.client_uri.clone()), + logo_uri: client_metadata.and_then(|m| m.logo_uri.clone()), scope: request_data.parameters.scope.clone(), redirect_uri: request_data.parameters.redirect_uri.clone(), state: request_data.parameters.state.clone(), @@ -213,9 +215,17 @@ pub async fn authorize_get_json( "request_uri has expired".to_string(), )); } + let client_metadata = state + .client_metadata_cache + .get(&request_data.parameters.client_id) + .await + .ok(); + let client_metadata = client_metadata.as_ref(); Ok(Json(AuthorizeResponse { client_id: request_data.parameters.client_id.clone(), - client_name: None, + client_name: client_metadata.and_then(|m| m.client_name.clone()), + client_uri: client_metadata.and_then(|m| m.client_uri.clone()), + logo_uri: client_metadata.and_then(|m| m.logo_uri.clone()), scope: request_data.parameters.scope.clone(), redirect_uri: request_data.parameters.redirect_uri.clone(), state: request_data.parameters.state.clone(), diff --git a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs index e16a1a2..f9da842 100644 --- a/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs +++ b/crates/tranquil-oauth-server/src/endpoints/authorize/mod.rs @@ -129,6 +129,8 @@ pub struct AuthorizeQuery { pub struct AuthorizeResponse { pub client_id: ClientId, pub client_name: Option, + pub client_uri: Option, + pub logo_uri: Option, pub scope: Option, pub redirect_uri: String, pub state: Option, diff --git a/frontend/public/activity-taxonomy.js b/frontend/public/activity-taxonomy.js index 485d9d5..79ea6f7 100644 --- a/frontend/public/activity-taxonomy.js +++ b/frontend/public/activity-taxonomy.js @@ -21,6 +21,8 @@ export const CATEGORY_ICONS = { "Q&A": 'M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z', "Places": 'M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z', "Research": 'M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z', + "Music": 'M212.92,17.69a8,8,0,0,0-6.86-1.45l-128,32A8,8,0,0,0,72,56V166.08A36,36,0,1,0,88,196V110.25l112-28v51.83A36,36,0,1,0,216,164V24A8,8,0,0,0,212.92,17.69ZM52,216a20,20,0,1,1,20-20A20,20,0,0,1,52,216ZM88,93.75V62.25l112-28v31.5ZM180,184a20,20,0,1,1,20-20A20,20,0,0,1,180,184Z', + "Sites": 'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z', }; // Apps, with a per-record link where the app has one. Placeholders: {did}, {handle}, {rkey}. @@ -51,6 +53,9 @@ export const APPS = { margin: { name: 'Margin', profile: 'https://margin.at' }, semble: { name: 'Semble', profile: 'https://semble.so/profile/{handle}' }, pckt: { name: 'pckt' }, + teal: { name: 'teal.fm' }, + atstore: { name: 'atstore' }, + wisp: { name: 'wisp.place' }, }; // Record type → app, category and how the action reads ("Wrote an article on WhiteWind"). @@ -70,6 +75,9 @@ export const LEXICONS = { 'blue.flashes.feed.post': { app: 'flashes', category: 'Photos', verb: 'Shared a photo' }, 'pics.pixl.image': { app: 'pixl', category: 'Photos', verb: 'Shared a photo' }, 'social.popfeed.feed.review': { app: 'popfeed', category: 'Reviews', verb: 'Wrote a review' }, + 'fyi.atstore.listing.review': { app: 'atstore', category: 'Reviews', verb: 'Reviewed an app', ratingMax: 5 }, + 'fm.teal.alpha.feed.play': { app: 'teal', category: 'Music', verb: 'Listened to a track' }, + 'place.wisp.fs': { app: 'wisp', category: 'Sites', verb: 'Published a site' }, 'buzz.bookhive.book': { app: 'bookhive', category: 'Reviews', verb: 'Logged a book' }, 'fyi.unravel.frontpage.post': { app: 'frontpage', category: 'Links', verb: 'Shared a link' }, 'com.shinolabs.pinksea.oekaki': { app: 'pinksea', category: 'Art', verb: 'Drew something' }, @@ -84,14 +92,21 @@ export const LEXICONS = { 'app.dropanchor.checkin': { app: 'dropanchor', category: 'Places', verb: 'Checked in' }, 'at.margin.note': { app: 'margin', category: 'Research', verb: 'Wrote a note' }, 'at.margin.annotation': { app: 'margin', category: 'Research', verb: 'Annotated a page' }, + 'at.margin.highlight': { app: 'margin', category: 'Research', verb: 'Highlighted a passage' }, 'app.sidetrail.trail': { app: 'semble', category: 'Research', verb: 'Made a trail' }, }; -export const CATEGORY_ORDER = ['Posts', 'Articles', 'Code', 'Photos', 'Events', 'Reviews', 'Links', 'Video', 'Art', 'Places', 'Recipes', 'Pastes', 'Q&A', 'Research', 'Verification']; +export const CATEGORY_ORDER = ['Posts', 'Articles', 'Photos', 'Code', 'Research', 'Music', 'Events', 'Reviews', 'Links', 'Video', 'Art', 'Sites', 'Places', 'Recipes', 'Pastes', 'Q&A', 'Verification']; + +const DID = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/; +const SEGMENT = /^[a-zA-Z0-9._~:-]+$/; export function appLink(appId, { did, handle, rkey }) { const app = APPS[appId]; const template = (rkey && app?.item) || app?.profile; if (!template || (template.includes('{handle}') && !handle)) return null; - return template.replace(/\{(did|handle|rkey)\}/g, (_, key) => encodeURIComponent({ did, handle, rkey }[key])); + // DIDs, handles and record keys are already URL-safe; apps expect `did:plc:…` verbatim. + const values = { did, handle, rkey }; + if (!DID.test(did || '') || Object.values(values).some(value => value !== undefined && !SEGMENT.test(value))) return null; + return template.replace(/\{(did|handle|rkey)\}/g, (_, key) => values[key]); } diff --git a/frontend/public/homepage-data.js b/frontend/public/homepage-data.js index 9c0be22..eaada07 100644 --- a/frontend/public/homepage-data.js +++ b/frontend/public/homepage-data.js @@ -10,6 +10,10 @@ export async function readPublic(method, params = {}, fetcher = fetch) { return response.json(); } +// Only accounts that are positively active. Deactivated, taken-down (suspended) or +// otherwise restricted repos, and entries missing the flag, are never read or shown. +export const isPublicRepo = repo => repo?.active === true && !repo.status; + export async function listLocalRepos(fetcher = fetch) { const repos = new Map(); const cursors = new Set(); @@ -56,41 +60,165 @@ export function selectActivity(items, { limit = 12, perAuthor = 2, includeReplie const clip = (text, max) => text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text; const firstString = (value, keys) => keys.map(key => value?.[key]).find(entry => typeof entry === 'string' && entry.trim()); +const httpUrl = value => { + try { if (typeof value === 'string' && /^https?:\/\//.test(value)) return new URL(value); } catch { /* Not a usable link. */ } + return undefined; +}; +const hostOf = url => url?.hostname.replace(/^www\./, ''); +const ratio = value => { + const { width, height } = value?.aspectRatio || {}; + return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : undefined; +}; +// Blobs are stored at full size (often megabytes). The timeline asks the public image CDN +// for a feed-sized thumbnail and falls back to the original on this PDS. +const thumbnailUrl = (did, blob) => imageBlobUrl(did, blob) && `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref?.$link || blob.cid}@jpeg`; +const image = (did, blob, alt, aspect) => ({ url: thumbnailUrl(did, blob), fallback: imageBlobUrl(did, blob), alt: typeof alt === 'string' ? alt : '', aspect }); // Long-form bodies are Markdown; an excerpt only needs readable words. const plain = text => text.replace(/^\s{0,3}#{1,6}\s.*$/gm, '').replace(/!\[[^\]]*]\([^)]*\)/g, '').replace(/\[([^\]]*)]\([^)]*\)/g, '$1').replace(/[#>*_`~]+/g, '').replace(/\s+/g, ' ').trim(); const RSVP_VERBS = { going: 'Going to an event', interested: 'Interested in an event', notgoing: 'Not going to an event' }; const BOOK_VERBS = { finished: 'Finished a book', reading: 'Started reading', wantToRead: 'Wants to read' }; +// Bluesky post embeds: images, a link card, a video, and/or a quoted record. +function readEmbed(did, embed) { + const media = embed?.$type === 'app.bsky.embed.recordWithMedia' ? embed.media : embed; + const quoted = embed?.$type === 'app.bsky.embed.recordWithMedia' ? embed.record?.record : embed?.$type === 'app.bsky.embed.record' ? embed.record : undefined; + const images = (Array.isArray(media?.images) ? media.images : []).slice(0, 4) + .map(entry => image(did, entry.image, entry.alt, ratio(entry))).filter(entry => entry.url); + const external = media?.$type === 'app.bsky.embed.external' && httpUrl(media.external?.uri) ? { + url: media.external.uri, host: hostOf(httpUrl(media.external.uri)), + title: typeof media.external.title === 'string' ? clip(media.external.title.trim(), 120) : '', + description: typeof media.external.description === 'string' ? clip(media.external.description.trim(), 180) : '', + thumb: thumbnailUrl(did, media.external.thumb), + } : undefined; + const video = media?.$type === 'app.bsky.embed.video' ? { alt: typeof media.alt === 'string' ? media.alt : '' } : undefined; + const quote = typeof quoted?.uri === 'string' && quoted.uri.startsWith('at://') ? { uri: quoted.uri } : undefined; + return { images, external, video, quote }; +} + +// Per-app fields that do not follow the common title/text/createdAt shape. +function readSpecial(collection, value, rkey) { + const target = value.target && typeof value.target === 'object' ? value.target : undefined; + const source = httpUrl(target?.source); + if (collection === 'fm.teal.alpha.feed.play') { + const artists = (Array.isArray(value.artists) ? value.artists : []).map(artist => artist?.artistName).filter(name => typeof name === 'string'); + return { + title: firstString(value, ['trackName']), + text: [artists.length ? 'by ' + artists.join(', ') : '', firstString(value, ['releaseName']) || ''].filter(Boolean).join(' · '), + url: httpUrl(value.originUrl)?.href, + }; + } + if (collection === 'place.wisp.fs') return { title: rkey, text: Number.isFinite(value.fileCount) ? `${value.fileCount} files` : '' }; + return { + text: typeof value.body === 'object' ? firstString(value.body, ['value', 'text']) : undefined, + passage: typeof target?.selector?.exact === 'string' ? clip(target.selector.exact.trim(), 320) : undefined, + source: source ? { url: source.href, host: hostOf(source), title: typeof target.title === 'string' ? clip(target.title.trim(), 120) : '' } : undefined, + url: source?.href || httpUrl(value.canonicalUrl)?.href, + }; +} + export function summarizeRecord(did, collection, record, now = Date.now()) { const kind = LEXICONS[collection]; const prefix = `at://${did}/${collection}/`; const value = record?.value; if (!kind || typeof record?.uri !== 'string' || !record.uri.startsWith(prefix) || !value || typeof value !== 'object') return null; const rkey = record.uri.slice(prefix.length); - const date = Date.parse(firstString(value, ['createdAt', 'publishedAt']) || ''); + const date = Date.parse(firstString(value, ['createdAt', 'publishedAt', 'playedTime']) || ''); if (!/^[a-zA-Z0-9._~:-]+$/.test(rkey) || !Number.isFinite(date) || date > now + 300000) return null; let verb = kind.verb; if (collection === 'community.lexicon.calendar.rsvp') verb = RSVP_VERBS[String(value.status || '').split('#').pop()] || verb; if (collection === 'buzz.bookhive.book') verb = BOOK_VERBS[String(value.status || '').split('#').pop()] || verb; - const title = collection === 'app.bsky.feed.post' ? undefined : firstString(value, ['title', 'name']); - const body = firstString(value, ['text', 'description', 'body', 'content', 'review', 'caption', 'shout']); - if (collection === 'app.bsky.feed.post' && typeof value.text !== 'string') return null; - const embed = value.embed?.$type === 'app.bsky.embed.recordWithMedia' ? value.embed.media : value.embed; - const images = (Array.isArray(embed?.images) ? embed.images : []).slice(0, 4) - .map(image => ({ url: imageBlobUrl(did, image.image), alt: typeof image.alt === 'string' ? image.alt : '' })) - .filter(image => image.url); - let link; - try { if (typeof value.url === 'string' && /^https?:/.test(value.url)) link = new URL(value.url).hostname.replace(/^www\./, ''); } catch { /* Not a usable link. */ } - const rating = [value.rating, value.stars].find(entry => typeof entry === 'number' && entry > 0 && entry <= 10); + const isPost = collection === 'app.bsky.feed.post'; + if (isPost && typeof value.text !== 'string') return null; + const special = readSpecial(collection, value, rkey); + const title = special.title || (isPost ? undefined : firstString(value, ['title', 'name'])); + const body = firstString(value, ['text', 'description', 'body', 'content', 'review', 'caption', 'shout']) || special.text; + const { images, external, video, quote } = readEmbed(did, value.embed); + const url = httpUrl(value.url)?.href || special.url; + const ratingMax = kind.ratingMax || 10; + const rating = [value.rating, value.stars].find(entry => typeof entry === 'number' && entry > 0 && entry <= ratingMax); return { did, rkey, uri: record.uri, collection, date, verb, title: title && clip(title.trim(), 140), - text: body ? (collection === 'app.bsky.feed.post' ? body : clip(plain(body), 280)) : '', - images, link, rating, app: kind.app, category: kind.category, - reply: !!value.reply, hasEmbed: !!value.embed && !images.length, + text: body ? (isPost ? body : clip(plain(body), 280)) : '', + images, external, video, quote, passage: special.passage, source: special.source, + url, link: url && !special.source ? hostOf(new URL(url)) : undefined, + rating, ratingMax, app: kind.app, category: kind.category, reply: !!value.reply, + }; +} + +// A Grain gallery holds only its title; photos are separate records joined by +// `social.grain.gallery.item` (gallery → photo, with a position). +async function attachGalleryPhotos(items, fetcher) { + const byDid = new Map(); + for (const item of items) if (item.collection === 'social.grain.gallery') byDid.set(item.did, [...(byDid.get(item.did) || []), item]); + await Promise.all([...byDid].map(async ([did, galleries]) => { + try { + const [links, photos] = await Promise.all(['social.grain.gallery.item', 'social.grain.photo'].map(collection => + readPublic('com.atproto.repo.listRecords', { repo: did, collection, limit: '100' }, fetcher).then(data => Array.isArray(data.records) ? data.records : []))); + const photoByUri = new Map(photos.map(record => [record.uri, record.value])); + for (const gallery of galleries) { + const members = links.filter(record => record.value?.gallery === gallery.uri) + .sort((a, b) => (a.value.position ?? 0) - (b.value.position ?? 0)) + .map(record => photoByUri.get(record.value.item)).filter(Boolean); + gallery.images = members.slice(0, 4).map(photo => image(did, photo.photo, photo.alt, ratio(photo))).filter(entry => entry.url); + gallery.photoCount = members.length; + if (members.length) gallery.verb = members.length === 1 ? 'Shared a photo' : `Shared ${members.length} photos`; + } + } catch { /* The gallery still shows its title. */ } + })); +} + +const APPVIEW = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts'; + +function quoteFromView(post) { + const author = post?.author; + if (typeof author?.did !== 'string' || typeof post.record?.text !== 'string') return null; + const media = post.embed?.$type === 'app.bsky.embed.recordWithMedia#view' ? post.embed.media : post.embed; + return { + did: author.did, handle: author.handle, displayName: author.displayName?.trim() || undefined, + avatarUrl: httpUrl(author.avatar)?.href, text: clip(post.record.text, 300), date: Date.parse(post.record.createdAt || '') || undefined, + images: (Array.isArray(media?.images) ? media.images : []).slice(0, 4).map(entry => ({ url: httpUrl(entry.thumb)?.href, alt: entry.alt || '', aspect: ratio(entry) })).filter(entry => entry.url), }; } +// Quoted posts live in whichever repo wrote them. The public AppView resolves any of +// them in one batch; local ones can still be read from this PDS if it is unreachable. +async function attachQuotes(items, localDids, fetcher) { + const quoting = items.filter(item => item.quote?.uri.includes('/app.bsky.feed.post/')); + const uris = [...new Set(quoting.map(item => item.quote.uri))]; + const resolved = new Map(); + for (let start = 0; start < uris.length; start += 25) { + try { + const query = new URLSearchParams(uris.slice(start, start + 25).map(uri => ['uris', uri])); + const response = await fetcher(`${APPVIEW}?${query}`, { credentials: 'omit', signal: AbortSignal.timeout(8000) }); + const data = response.ok ? await response.json() : {}; + for (const post of Array.isArray(data.posts) ? data.posts : []) { + const quote = quoteFromView(post); + if (quote) resolved.set(post.uri, quote); + } + } catch { /* Fall back to local reads below. */ } + } + await Promise.all(uris.filter(uri => !resolved.has(uri)).map(async uri => { + const [, , did, , rkey] = uri.split('/'); + if (!localDids.has(did)) return; + try { + const [data, profile] = await Promise.all([ + readPublic('com.atproto.repo.getRecord', { repo: did, collection: 'app.bsky.feed.post', rkey }, fetcher), + fetcher === fetch ? loadActorProfile(did) : readActorProfile(did, fetcher), + ]); + if (typeof data.value?.text !== 'string') return; + resolved.set(uri, { + did, ...profile, text: clip(data.value.text, 300), date: Date.parse(data.value.createdAt || '') || undefined, + images: readEmbed(did, data.value.embed).images, + }); + } catch { /* Shown as a link to the quoted post. */ } + })); + for (const item of quoting) { + const [, , did, , rkey] = item.quote.uri.split('/'); + item.quote = { uri: item.quote.uri, did, rkey, ...resolved.get(item.quote.uri) }; + } +} + async function eachLimited(list, concurrency, task) { let next = 0; await Promise.all(Array.from({ length: Math.min(concurrency, list.length) }, async () => { @@ -98,44 +226,79 @@ async function eachLimited(list, concurrency, task) { })); } -export async function recentLocalActivity(repos, fetcher = fetch, now = Date.now()) { - const active = repos.filter(repo => repo.active !== false); - const candidates = [...active].sort((a, b) => String(b.rev || '').localeCompare(String(a.rev || ''))).slice(0, 24); +// Per-account results keyed by repo revision. Any commit to a repo changes its `rev` in +// listRepos, so an entry is reused only while that account has had no new activity. +const CACHE_KEY = 'tranquil_pds_activity_v1'; + +export function activityCache(storage) { + let entries = {}; + try { entries = JSON.parse(storage?.getItem(CACHE_KEY) || '{}') || {}; } catch { /* Start empty. */ } + return { + get: (did, rev) => rev && entries[did]?.rev === rev ? entries[did] : undefined, + save(next) { + entries = next; + try { storage?.setItem(CACHE_KEY, JSON.stringify(next)); } catch { /* Quota or private mode: stay uncached. */ } + }, + }; +} + +async function readAccount(repo, fetcher, now) { + let failed = false; + const description = await readPublic('com.atproto.repo.describeRepo', { repo: repo.did }, fetcher); + const handle = typeof description.handle === 'string' && description.handle !== 'handle.invalid' ? description.handle : undefined; + const known = (Array.isArray(description.collections) ? description.collections : []).filter(nsid => LEXICONS[nsid]); + const ranked = known.sort((a, b) => CATEGORY_ORDER.indexOf(LEXICONS[a].category) - CATEGORY_ORDER.indexOf(LEXICONS[b].category)).slice(0, 8); const items = []; - const handles = new Map(); + await Promise.all(ranked.map(async collection => { + try { + const data = await readPublic('com.atproto.repo.listRecords', { repo: repo.did, collection, limit: collection === 'app.bsky.feed.post' ? '10' : '4' }, fetcher); + if (!Array.isArray(data.records)) throw new Error('Invalid records'); + for (const record of data.records) { + const item = summarizeRecord(repo.did, collection, record, now); + if (item) items.push(item); + } + } catch { failed = true; } + })); + // Galleries and the profile live in the same repo, so they share its revision. + await attachGalleryPhotos(items, fetcher); + const profile = items.length ? await (fetcher === fetch ? loadActorProfile(repo.did) : readActorProfile(repo.did, fetcher)) : {}; + return { rev: repo.rev, handle, profile, items, failed }; +} + +// Every active account is read; the per-person cap keeps one account from filling the timeline. +// With a cache, only accounts whose revision changed since the last visit are fetched again. +export async function recentLocalActivity(repos, fetcher = fetch, now = Date.now(), cache = activityCache()) { + const active = repos.filter(isPublicRepo); + const candidates = [...active].sort((a, b) => String(b.rev || '').localeCompare(String(a.rev || ''))); + const accounts = new Map(); let failures = 0; - await eachLimited(candidates, 6, async repo => { + await eachLimited(candidates, 8, async repo => { + const cached = cache.get(repo.did, repo.rev); + if (cached) return accounts.set(repo.did, cached); try { - const description = await readPublic('com.atproto.repo.describeRepo', { repo: repo.did }, fetcher); - if (typeof description.handle === 'string' && description.handle !== 'handle.invalid') handles.set(repo.did, description.handle); - const known = (Array.isArray(description.collections) ? description.collections : []).filter(nsid => LEXICONS[nsid]); - const ranked = known.sort((a, b) => CATEGORY_ORDER.indexOf(LEXICONS[a].category) - CATEGORY_ORDER.indexOf(LEXICONS[b].category)).slice(0, 5); - await Promise.all(ranked.map(async collection => { - try { - const data = await readPublic('com.atproto.repo.listRecords', { repo: repo.did, collection, limit: collection === 'app.bsky.feed.post' ? '6' : '3' }, fetcher); - if (!Array.isArray(data.records)) throw new Error('Invalid records'); - for (const record of data.records) { - const item = summarizeRecord(repo.did, collection, record, now); - if (item) items.push(item); - } - } catch { failures++; } - })); + const account = await readAccount(repo, fetcher, now); + if (account.failed) failures++; + accounts.set(repo.did, account); } catch { failures++; } }); + // Accounts that failed to load are retried next visit; inactive ones drop out. + cache.save(Object.fromEntries([...accounts].filter(([, account]) => account.rev && !account.failed) + .map(([did, { rev, handle, profile, items }]) => [did, { rev, handle, profile, items }]))); + + const items = [...accounts.values()].flatMap(account => account.items.map(item => ({ ...item }))); const categories = CATEGORY_ORDER .map(id => ({ id, count: items.filter(item => item.category === id && !item.reply).length })) .filter(category => category.count > 0); - const views = { all: selectActivity(items) }; - for (const { id } of categories) views[id] = selectActivity(items.filter(item => item.category === id)); + const views = { all: selectActivity(items, { limit: Infinity, perAuthor: 3 }) }; + for (const { id } of categories) views[id] = selectActivity(items.filter(item => item.category === id), { limit: Infinity, perAuthor: 6 }); - const profiles = new Map(); - const dids = [...new Set(Object.values(views).flat().map(item => item.did))]; - await Promise.all(dids.map(async did => { - profiles.set(did, await (fetcher === fetch ? loadActorProfile(did) : readActorProfile(did, fetcher))); - })); - const withAuthor = item => ({ ...item, ...profiles.get(item.did), handle: handles.get(item.did) }); + // Quotes point into other repos, so they are resolved fresh on every visit. + const shown = [...new Set(Object.values(views).flat())]; + await attachQuotes(shown, new Set(active.map(repo => repo.did)), fetcher); + + const withAuthor = item => ({ ...item, ...accounts.get(item.did)?.profile, handle: accounts.get(item.did)?.handle }); for (const key of Object.keys(views)) views[key] = views[key].map(withAuthor); - return { views, categories, checked: candidates.length, sampled: candidates.length < active.length, failures }; + return { views, categories, checked: candidates.length, failures }; } diff --git a/frontend/public/homepage.css b/frontend/public/homepage.css index 8cd7f62..944e684 100644 --- a/frontend/public/homepage.css +++ b/frontend/public/homepage.css @@ -90,6 +90,30 @@ a.activity-title:hover { text-decoration: underline; } .post-images[data-count="1"] .post-image { height: auto; max-height: 22rem; } .post-images[data-count="2"] { aspect-ratio: 2 / 1; } .post-images[data-count="3"] .post-image:first-child { grid-row: span 2; } +.post-image-more { position: relative; display: block; min-height: 0; } +.post-image-more::after { content: attr(data-more); position: absolute; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 0.45); color: #fff; font-size: 1.125rem; font-weight: 650; } + +/* Embedded things: a quoted post, a link card, a highlighted passage. One outline, one radius. */ +.post-quote, .post-card { display: flex; margin-block-start: 0.75rem; max-width: 32rem; border: 1px solid var(--border-color); border-radius: var(--radius-md); color: inherit; text-decoration: none; overflow: hidden; transition: border-color 140ms ease-out, background-color 140ms ease-out; } +.post-quote:hover, .post-card:hover { border-color: var(--border-dark); background: var(--bg-secondary); } +.post-quote { flex-direction: column; gap: 0.3rem; padding: 0.75rem 0.875rem; } +.post-quote-heading { display: flex; align-items: center; gap: 0.4rem; min-width: 0; } +.post-quote-heading .post-name { font-size: 0.875rem; } +.post-quote-heading .post-handle { font-size: 0.8125rem; } +.post-quote-text { font-size: 0.9375rem; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden; } +.post-quote .post-images { margin-block-start: 0.35rem; } +.post-quote-missing { color: var(--text-secondary); font-size: 0.875rem; } +.post-card { align-items: stretch; } +.post-card-thumb { flex: 0 0 6.5rem; width: 6.5rem; min-height: 5rem; object-fit: cover; background: var(--bg-secondary); border-inline-end: 1px solid var(--border-color); } +.post-card-text { display: grid; align-content: center; gap: 0.15rem; padding: 0.7rem 0.875rem; min-width: 0; } +.post-card-title { font-size: 0.9375rem; font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; } +.post-card-description { color: var(--text-secondary); font-size: 0.8125rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.post-card-host { color: var(--text-secondary); font-size: 0.8125rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-passage { margin: 0.5rem 0 0; max-width: 62ch; padding: 0.1rem 0 0.1rem 0.875rem; border-inline-start: 3px solid var(--accent-muted-strong, var(--border-dark)); font-size: 0.9375rem; line-height: 1.6; overflow-wrap: anywhere; text-wrap: pretty; } +.activity-passage footer { margin-block-start: 0.3rem; } +.activity-source { display: inline-block; max-width: 100%; color: var(--text-secondary); font-size: 0.8125rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; } +.activity-source:hover { color: var(--text-primary); } +a.post-context { display: inline-block; } .skeleton .post-avatar { background: var(--bg-secondary); } .skeleton .post-body { display: grid; gap: 0.6rem; padding-block-start: 0.2rem; } diff --git a/frontend/public/homepage.js b/frontend/public/homepage.js index c7cac8c..93bbf3b 100644 --- a/frontend/public/homepage.js +++ b/frontend/public/homepage.js @@ -1,4 +1,4 @@ -import { listLocalRepos, recentLocalActivity } from './homepage-data.js'; +import { activityCache, isPublicRepo, listLocalRepos, recentLocalActivity } from './homepage-data.js'; import { APPS, CATEGORY_ICONS, appLink } from './activity-taxonomy.js'; import { applyBrandColors } from './brand-colors.js'; @@ -74,7 +74,8 @@ function element(tag, className, text) { return node; } -const profileUrl = did => `https://bsky.app/profile/${encodeURIComponent(did)}`; +// DIDs are URL-safe; encoding them turns `did:plc:…` into a broken `did%3Aplc%3A…`. +const profileUrl = did => appLink('bluesky', { did }) || 'https://bsky.app'; const dateFormat = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }); const yearFormat = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); @@ -125,25 +126,88 @@ function categoryIcon(category) { const CATEGORY_LABELS = { 'Q&A': 'Questions' }; const categoryLabel = id => CATEGORY_LABELS[id] || id; -function renderImages(images) { +function externalLink(node, href) { + node.href = href; + node.target = '_blank'; + node.rel = 'noopener noreferrer'; + return node; +} + +function renderImages(images, total = images.length) { const gallery = element('div', 'post-images'); gallery.dataset.count = String(images.length); - for (const attachment of images) { + for (const [index, attachment] of images.entries()) { const image = element('img', 'post-image'); image.src = attachment.url; image.alt = attachment.alt; - image.width = 560; - image.height = 350; + image.width = attachment.aspect?.width || 560; + image.height = attachment.aspect?.height || 350; image.loading = 'lazy'; image.decoding = 'async'; image.addEventListener('error', () => { - image.replaceWith(element('p', 'post-context', attachment.alt || 'Image unavailable. Open it in the app to view it.')); + if (attachment.fallback && image.src !== new URL(attachment.fallback, location.href).href) image.src = attachment.fallback; + else image.replaceWith(element('p', 'post-context', attachment.alt || 'Image unavailable. Open it in the app to view it.')); }); - gallery.append(image); + if (index === images.length - 1 && total > images.length) { + const frame = element('span', 'post-image-more'); + frame.dataset.more = `+${total - images.length}`; + frame.append(image); + gallery.append(frame); + } else gallery.append(image); } return gallery; } +function renderExternal(card) { + const node = externalLink(element('a', 'post-card'), card.url); + if (card.thumb) { + const thumb = element('img', 'post-card-thumb'); + thumb.src = card.thumb; + thumb.alt = ''; + thumb.loading = 'lazy'; + thumb.decoding = 'async'; + thumb.addEventListener('error', () => thumb.remove()); + node.append(thumb); + } + const text = element('span', 'post-card-text'); + if (card.title) text.append(element('strong', 'post-card-title', card.title)); + if (card.description) text.append(element('span', 'post-card-description', card.description)); + text.append(element('span', 'post-card-host', card.host)); + node.append(text); + return node; +} + +// A quoted post, as a nested card linking to the original. +function renderQuote(quote) { + const node = externalLink(element('a', 'post-quote'), appLink('bluesky', quote) || profileUrl(quote.did)); + if (!quote.text && !quote.images?.length) { + node.classList.add('post-quote-missing'); + node.append(element('span', '', 'Quoted a post')); + return node; + } + const heading = element('span', 'post-quote-heading'); + heading.append(avatar(quote, 20)); + heading.append(element('strong', 'post-name', personName(quote))); + if (quote.displayName && quote.handle) heading.append(element('span', 'post-handle', '@' + quote.handle)); + if (quote.date) heading.append(element('span', 'post-handle', '· ' + shortTime(quote.date))); + node.append(heading); + if (quote.text) node.append(element('span', 'post-quote-text', quote.text)); + if (quote.images?.length) node.append(renderImages(quote.images)); + return node; +} + +function renderPassage(item) { + const passage = element('blockquote', 'activity-passage', item.passage); + if (item.source) { + const cite = externalLink(element('a', 'activity-source'), item.source.url); + cite.textContent = item.source.title || item.source.host; + const footer = element('footer'); + footer.append(cite); + passage.append(footer); + } + return passage; +} + function renderItem(item) { const li = element('li', 'post'); const face = avatar(item, 40, 'a'); @@ -157,7 +221,8 @@ function renderItem(item) { author.append(element('strong', 'post-name', personName(item))); if (item.displayName) author.append(element('span', 'post-handle', personHandle(item))); const appName = APPS[item.app]?.name || item.app; - const href = appLink(item.app, item); + // The record's own page first, then what it points at, then the app's profile page. + const href = (APPS[item.app]?.item && appLink(item.app, item)) || item.url || appLink(item.app, item); const time = element('time', '', shortTime(item.date)); time.dateTime = new Date(item.date).toISOString(); time.title = new Date(item.date).toLocaleString(); @@ -175,7 +240,7 @@ function renderItem(item) { kind.dataset.category = item.category; kind.append(categoryIcon(item.category), element('span', '', item.verb)); kind.append(element('span', 'activity-app', `on ${appName}`)); - if (item.rating) kind.append(element('span', 'activity-app', `· ${item.rating}/10`)); + if (item.rating) kind.append(element('span', 'activity-app', `· ${item.rating}/${item.ratingMax || 10}`)); body.append(header, kind); if (item.title) { @@ -188,10 +253,22 @@ function renderItem(item) { } body.append(title); } + if (item.passage) body.append(renderPassage(item)); if (item.text) body.append(element('p', item.title ? 'activity-excerpt' : 'post-text', item.text)); - if (item.link) body.append(element('p', 'post-context', item.link)); - if (item.images?.length) body.append(renderImages(item.images)); - else if (item.hasEmbed && !item.title) body.append(element('p', 'post-context', item.text ? 'Includes an attachment' : 'With an attachment')); + if (item.source && !item.passage) { + const source = externalLink(element('a', 'post-context activity-source'), item.source.url); + source.textContent = item.source.title || item.source.host; + body.append(source); + } + if (item.link && !item.title) body.append(element('p', 'post-context', item.link)); + if (item.images?.length) body.append(renderImages(item.images, item.photoCount)); + if (item.video) { + const video = href ? externalLink(element('a', 'post-context'), href) : element('p', 'post-context'); + video.textContent = item.video.alt ? `Video: ${item.video.alt}` : 'Includes a video'; + body.append(video); + } + if (item.external) body.append(renderExternal(item.external)); + if (item.quote) body.append(renderQuote(item.quote)); li.append(face, body); return li; } @@ -256,9 +333,11 @@ async function loadActivity() { const { repos, complete } = await listLocalRepos(); const format = count => count.toLocaleString() + (complete ? '' : '+'); document.getElementById('repoCount').textContent = format(repos.length); - document.getElementById('activeCount').textContent = format(repos.filter(repo => repo.active !== false).length); + document.getElementById('activeCount').textContent = format(repos.filter(isPublicRepo).length); status.textContent = complete ? '' : 'Counts cover the first 10,000 public repositories.'; - const result = await recentLocalActivity(repos); + let storage; + try { storage = localStorage; } catch { /* Storage blocked: read everything each visit. */ } + const result = await recentLocalActivity(repos, fetch, Date.now(), activityCache(storage)); const notes = []; const show = view => { const items = result.views[view] || []; @@ -266,7 +345,7 @@ async function loadActivity() { stream.scrollTop = 0; notes.length = 0; if (!result.views.all.length) notes.push(result.failures ? 'Could not load public activity.' : 'Nothing public yet. Activity from accounts here will appear once people start using their account.'); - else notes.push(`Up to two recent things per person${result.sampled ? `, from the ${result.checked} most recently active accounts` : ''}.`); + else notes.push(`A few recent things from each of the ${result.checked} accounts here.`); if (result.failures && result.views.all.length) notes.push('Some accounts could not be loaded.'); if (!complete) notes.push('Some server repositories have not been checked.'); streamStatus.textContent = notes.join(' '); diff --git a/frontend/src/components/ui/ClientBadge.svelte b/frontend/src/components/ui/ClientBadge.svelte new file mode 100644 index 0000000..1c54896 --- /dev/null +++ b/frontend/src/components/ui/ClientBadge.svelte @@ -0,0 +1,104 @@ + + +
+ + + {#if heading} +

{label}

+ {:else} + {label} + {/if} + {#if host && uri} + {host} + {/if} +
+
+ + diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 69e87c6..0d819b6 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -221,7 +221,7 @@ "loadFailed": "Failed to load DID document", "invalidMultibase": "Public key must be a valid multibase string starting with 'z'", "helpTitle": "What is this?", - "helpText": "When you migrate to another PDS, that PDS generates new signing keys. Update your DID document here so it points to your new keys and location. This enables multi-hop migrations (PDS 1 → PDS 2 → PDS 3)." + "helpText": "When you migrate to another PDS, that PDS generates new signing keys. Update your DID document here so it points to your new keys and location. This enables multi-hop migrations, so you can move again later." }, "settings": { "language": "Language", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 9f9ec76..afc3365 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -221,7 +221,7 @@ "loadFailed": "DID-dokumentin lataus epäonnistui", "invalidMultibase": "Julkisen avaimen on oltava kelvollinen multibase-merkkijono, joka alkaa 'z':llä", "helpTitle": "Mikä tämä on?", - "helpText": "Kun siirryt toiseen PDS:ään, se luo uudet allekirjoitusavaimet. Päivitä DID-dokumenttisi tässä osoittamaan uusiin avaimiin ja sijaintiin. Tämä mahdollistaa monivaiheiset siirrot (PDS 1 → PDS 2 → PDS 3)." + "helpText": "Kun siirryt toiseen PDS:ään, se luo uudet allekirjoitusavaimet. Päivitä DID-dokumenttisi tässä osoittamaan uusiin avaimiin ja sijaintiin. Tämä mahdollistaa monivaiheiset siirrot, joten voit siirtyä myöhemmin uudelleen." }, "settings": { "language": "Kieli", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 602416a..45cb4a7 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -221,7 +221,7 @@ "loadFailed": "Échec du chargement du document DID", "invalidMultibase": "La clé publique doit être une chaîne multibase valide commençant par 'z'", "helpTitle": "Qu'est-ce que c'est ?", - "helpText": "Lorsque vous migrez vers un autre PDS, ce PDS génère de nouvelles clés de signature. Mettez à jour votre document DID ici pour qu'il pointe vers vos nouvelles clés et votre nouvel emplacement. Cela permet les migrations multi-sauts (PDS 1 → PDS 2 → PDS 3)." + "helpText": "Lorsque vous migrez vers un autre PDS, ce PDS génère de nouvelles clés de signature. Mettez à jour votre document DID ici pour qu'il pointe vers vos nouvelles clés et votre nouvel emplacement. Cela permet les migrations multi-sauts, pour que vous puissiez migrer à nouveau plus tard." }, "settings": { "language": "Langue", diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte index d634271..97d725b 100644 --- a/frontend/src/routes/OAuthConsent.svelte +++ b/frontend/src/routes/OAuthConsent.svelte @@ -1,5 +1,6 @@