diff --git a/ui/atproto/feed.css b/ui/atproto/feed.css index c5b8703..d2b6ff1 100644 --- a/ui/atproto/feed.css +++ b/ui/atproto/feed.css @@ -35,7 +35,8 @@ h1 { gap: var(--spacing-sm, 8px); } -.actions button { +.actions button, +.actions a { display: inline-flex; align-items: center; gap: 0.35em; @@ -46,17 +47,26 @@ h1 { border: 1px solid var(--color-border, #e7e2da); background: transparent; color: var(--color-text-secondary, #6b6259); + text-decoration: none; cursor: pointer; transition: background var(--transition-fast, 0.12s ease), color var(--transition-fast, 0.12s ease); } -.actions button:hover { +.actions button:hover, +.actions a:hover { background: var(--bg-hover, oklch(35% 0.03 105 / 0.08)); color: var(--color-text, #2a2520); } +/* The `display: inline-flex` above would otherwise override the [hidden] + attribute's UA `display: none`, leaving the "All" link visible on the + unfiltered feed. */ +.actions [hidden] { + display: none; +} + /* A centered, divided column of compact posts. The elements drop their own card chrome, so the list rhythm comes from hairline dividers between items rather than gaps + per-card borders. */ @@ -89,3 +99,32 @@ h1 { .status .link:hover { text-decoration: underline; } + +.status .sync-btn { + margin-top: 0.85rem; + font: inherit; + font-weight: var(--font-weight-bold, 600); + font-size: 0.9rem; + padding: 8px 18px; + border-radius: 9px 10px 9px 10px; + border: none; + background: var(--color-primary, #a8500f); + color: var(--bg-menu, #fffdf9); + cursor: pointer; + transition: background var(--transition-fast, 0.12s ease); +} + +.status .sync-btn:hover:not(:disabled) { + background: var(--color-primary-hover, #8f440d); +} + +.status .sync-btn:disabled { + opacity: 0.6; + cursor: default; +} + +.status .sync-error { + margin-top: 0.6rem; + color: var(--color-danger, #b3261e); + font-size: 0.85rem; +} diff --git a/ui/atproto/feed.html b/ui/atproto/feed.html index 4815317..5c7684d 100644 --- a/ui/atproto/feed.html +++ b/ui/atproto/feed.html @@ -16,8 +16,13 @@
-

Atmosphere Feed

+

+ Atmosphere Feed +

+ diff --git a/ui/atproto/feed.js b/ui/atproto/feed.js index faeb0b0..6a533be 100644 --- a/ui/atproto/feed.js +++ b/ui/atproto/feed.js @@ -5,6 +5,7 @@ // collections. import { queryStore } from "beaver://shared/atproto/query.js"; +import { ensureProfile, shortenDid } from "beaver://shared/search/avatar.js"; // Record viewers: the rich Bluesky-post view, the like/follow/collection-link // reference view, the Cosmik card view, and the generic fallback for every // other lexicon. Importing registers the custom elements created below. @@ -12,6 +13,7 @@ import "./viewers/app_bsky_feed_post.js"; import "./viewers/atproto_reference_record.js"; import "./viewers/network_cosmik_card.js"; import "./viewers/network_cosmik_collection.js"; +import "./viewers/sh_tangled_repo.js"; import "./viewers/atproto_record_card.js"; // Collections we render with a dedicated viewer (NSID -> element tag); every @@ -25,17 +27,30 @@ const RICH_VIEWERS = { "network.cosmik.collection": "network-cosmik-collection", "network.cosmik.collectionLink": "atproto-reference-record", "network.cosmik.connection": "atproto-reference-record", + "network.cosmik.follow": "atproto-reference-record", + "sh.tangled.repo": "sh-tangled-repo", }; -// Every timestamped record across all tracked repos and collections, newest -// first. json1's json_extract reads createdAt out of the stored value; records -// without one are skipped since they can't be placed in a time-ordered feed. +// Optional `?did=` scopes the feed to a single repo — the target of a byline +// tap (that author's own timeline). Absent, the feed spans every tracked repo. +const filterDid = new URLSearchParams(location.search).get("did"); + +// Every timestamped record, newest first. json1's json_extract reads createdAt +// out of the stored value; records without one are skipped (they can't be placed +// in a time-ordered feed). Both forms ride a createdAt expression index. const FEED_SQL = ` SELECT did, collection, rkey, cid, json FROM records WHERE json_extract(json, '$.createdAt') IS NOT NULL ORDER BY json_extract(json, '$.createdAt') DESC LIMIT 100`; +const FEED_SQL_DID = ` + SELECT did, collection, rkey, cid, json + FROM records + WHERE did = ? + AND json_extract(json, '$.createdAt') IS NOT NULL + ORDER BY json_extract(json, '$.createdAt') DESC + LIMIT 100`; const feed = document.getElementById("feed"); @@ -54,18 +69,76 @@ 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. +async function showFilteredEmpty(did) { + let synced = false; + try { + const status = await navigator.atproto.repoStatus(did); + synced = !!status?.rev; // a stored rev means it's in our synced set + } catch { + // Treat a status error as "not synced" and offer to sync. + } + if (synced) { + showStatus("No records 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(); + 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, + ); + feed.appendChild(box); +} + async function loadFeed() { showStatus("Loading your atmosphere…"); let rows; try { - rows = await queryStore(FEED_SQL); + rows = filterDid + ? await queryStore(FEED_SQL_DID, [filterDid]) + : await queryStore(FEED_SQL); } catch (e) { showStatus(`Couldn't load the feed: ${e?.message || e}`); return; } if (!rows.length) { - showStatus("Nothing indexed yet — sync some repos first.", true); + if (filterDid) { + await showFilteredEmpty(filterDid); + } else { + showStatus("Nothing indexed yet — sync some repos first.", true); + } return; } @@ -97,5 +170,27 @@ async function loadFeed() { feed.replaceChildren(frag); } +// When scoped to one repo, title the header with that author and reveal the +// "All" link back to the full feed. +function applyFilterChrome() { + if (!filterDid) { + return; + } + const allLink = document.getElementById("btn-all"); + if (allLink) { + allLink.hidden = false; + } + const title = document.getElementById("feed-title"); + if (title) { + title.textContent = shortenDid(filterDid); + ensureProfile(filterDid).then((p) => { + if (p?.displayName) { + title.textContent = p.displayName; + } + }); + } +} + document.getElementById("btn-refresh").addEventListener("click", loadFeed); +applyFilterChrome(); loadFeed(); diff --git a/ui/atproto/viewers/app_bsky_feed_post.js b/ui/atproto/viewers/app_bsky_feed_post.js index 6a4b684..66d575e 100644 --- a/ui/atproto/viewers/app_bsky_feed_post.js +++ b/ui/atproto/viewers/app_bsky_feed_post.js @@ -283,8 +283,9 @@ class AppBskyFeedPost extends AtRecordElement { return html`${run.text}`; } if (type === "app.bsky.richtext.facet#mention" && f.did) { - return html`${run.text}`; } diff --git a/ui/atproto/viewers/atproto_byline.js b/ui/atproto/viewers/atproto_byline.js index 65aef00..f0d253f 100644 --- a/ui/atproto/viewers/atproto_byline.js +++ b/ui/atproto/viewers/atproto_byline.js @@ -124,10 +124,12 @@ export class AtprotoByline extends LitElement { if (!this.did) return html``; const profile = cachedProfile(this.did); const name = profile?.displayName || shortenDid(this.did); - const profileSrc = `at://${encodeURIComponent(this.did)}/app.bsky.actor.profile/self`; + // Tapping the author opens the feed scoped to just this DID (their own + // timeline), not the raw profile record. + const feedHref = `feed.html?did=${encodeURIComponent(this.did)}`; const rel = relativeTime(this.time); return html` - ` + : ""} +
+ `; + } +} + +customElements.define("sh-tangled-repo", ShTangledRepo); diff --git a/ui/shared/search/lexicons.js b/ui/shared/search/lexicons.js index 7f344ef..bfcd0b3 100644 --- a/ui/shared/search/lexicons.js +++ b/ui/shared/search/lexicons.js @@ -31,6 +31,7 @@ export const LEXICON_LABELS = { "network.cosmik.collection": "Cosmik Collection", "network.cosmik.collectionLink": "Cosmik Collection Link", "network.cosmik.connection": "Cosmik Connection", + "network.cosmik.follow": "Cosmik Follow", }; /** diff --git a/ui/system/services/atproto_indexer.js b/ui/system/services/atproto_indexer.js index 8ef5939..846b4df 100644 --- a/ui/system/services/atproto_indexer.js +++ b/ui/system/services/atproto_indexer.js @@ -15,6 +15,7 @@ import * as notifications from "./notifications.js"; import { ensureProfile, shortenDid } from "beaver://shared/search/avatar.js"; +import { queryStore } from "beaver://shared/atproto/query.js"; const STORAGE_KEY = "beaver:atproto:indexer"; const STORAGE_VERSION = 1; @@ -27,13 +28,10 @@ const INITIAL_DELAY_MS = 8 * 1000; // concurrently (its in-flight guard only blocks duplicate syncs of the *same* // DID), so a bounded pool keeps cycles fast while staying gentle on the network. const SYNC_CONCURRENCY = 4; -// Cap on follows pulled during the one-time bootstrap (first listRecords page). -const MAX_BOOTSTRAP_FOLLOWS = 50; // Persisted state, loaded once at init. Shape: -// { dids: { [did]: { addedAt, lastSyncedAt, lastRev, lastError, lastResult } }, -// bootstrapped: boolean } -let state = { dids: {}, bootstrapped: false }; +// { dids: { [did]: { addedAt, lastSyncedAt, lastRev, lastError, lastResult } } } +let state = { dids: {} }; let started = false; let running = false; @@ -157,22 +155,33 @@ async function runCycle() { } running = true; try { - // Resolve the logged-in user once; drives both bootstrap and own-repo + // Resolve the logged-in user once; drives follow discovery and own-repo // tracking. Logged out -> session is null and we sync whatever we already // track (possibly nothing). const session = await currentSession(atproto); + const ownDid = session?.did || null; - // Seed the DID list from follows the first time we can reach the user. - if (session && !state.bootstrapped) { - await bootstrapFollows(session.did); - } // Always keep the user's own repo tracked. - if (session?.did && !state.dids[session.did]) { - state.dids[session.did] = makeEntry(); + if (ownDid && !state.dids[ownDid]) { + state.dids[ownDid] = makeEntry(); saveState(); emitChange(); } + // Track every account the user follows. Their follow records live in their + // own repo, so ensure it's synced (backfill it on the very first run), then + // read the full follow set from the local store — not one capped page. + if (ownDid) { + if (!state.dids[ownDid].lastRev) { + await syncOne(atproto, ownDid); + } + await refreshFollows(ownDid); + } + + // Also track repos already in the store (e.g. added via the feed's "Sync + // this account" button — a page that can't reach this service directly). + await refreshSyncedRepos(); + const dids = Object.keys(state.dids); if (!dids.length) { console.log("[atproto-indexer] no repos to sync yet"); @@ -272,7 +281,7 @@ async function syncOne(atproto, did) { } } -// ---- bootstrap ----------------------------------------------------------- +// ---- session + follows --------------------------------------------------- async function currentSession(atproto) { try { @@ -283,33 +292,74 @@ async function currentSession(atproto) { } } -async function bootstrapFollows(ownDid) { - // The user's follow records: at:///app.bsky.graph.follow resolves to a - // listRecords call; each record's value.subject is a followed DID. - try { - const resp = await fetch( - `at://${encodeURIComponent(ownDid)}/app.bsky.graph.follow`, - ); - if (!resp.ok) { - return; // Transient: retry the bootstrap on a later cycle. +async function refreshFollows(ownDid) { + // The user's follows are app.bsky.graph.follow records in their own repo, + // which the indexer syncs into the local store. Read every one straight from + // the store (no network, no page cap) and track any not seen yet. queryStore + // caps each call at 1000 rows, so page past that with OFFSET for big follows. + const PAGE = 1000; + let offset = 0; + let added = 0; + for (;;) { + let rows; + try { + rows = await queryStore( + `SELECT json_extract(json, '$.subject') AS subject + FROM records + WHERE did = ? AND collection = 'app.bsky.graph.follow' + ORDER BY rkey + LIMIT CAST(? AS INTEGER) OFFSET CAST(? AS INTEGER)`, + [ownDid, String(PAGE), String(offset)], + ); + } catch (e) { + console.warn("[atproto-indexer] refreshFollows failed:", e); + return; } - const data = await resp.json(); - const records = Array.isArray(data?.records) ? data.records : []; - for (const rec of records.slice(0, MAX_BOOTSTRAP_FOLLOWS)) { - const subject = rec?.value?.subject; + for (const row of rows) { + const subject = row.subject; if (isDid(subject) && !state.dids[subject]) { state.dids[subject] = makeEntry(); + added += 1; } } - } catch (e) { - console.warn("[atproto-indexer] follow bootstrap failed:", e); - return; // Don't mark bootstrapped; let a later cycle try again. + if (rows.length < PAGE) { + break; + } + offset += PAGE; } + if (added) { + saveState(); + emitChange(); + console.log(`[atproto-indexer] tracking ${added} newly-followed repo(s)`); + } +} - // Reached the user and read their follows, so consider bootstrap done even if - // they follow no one; this avoids re-fetching follows every cycle. - state.bootstrapped = true; - saveState(); +// Track every repo already in the local store's `repos` table — own + follows +// land there after syncing, plus any account added from the feed's "Sync this +// account" button (a separate page that can't write this service's tracked set). +// Seeds lastRev so the next sync is incremental, not a fresh backfill. +async function refreshSyncedRepos() { + let rows; + try { + rows = await queryStore("SELECT did, rev FROM repos"); + } catch (e) { + console.warn("[atproto-indexer] refreshSyncedRepos failed:", e); + return; + } + let added = 0; + for (const row of rows) { + if (isDid(row.did) && !state.dids[row.did]) { + const entry = makeEntry(); + entry.lastRev = row.rev || null; + state.dids[row.did] = entry; + added += 1; + } + } + if (added) { + saveState(); + emitChange(); + console.log(`[atproto-indexer] tracking ${added} already-synced repo(s)`); + } } // ---- notifications ------------------------------------------------------- @@ -334,13 +384,13 @@ function loadState() { if (raw) { const data = JSON.parse(raw); if (data?.version === STORAGE_VERSION && data.dids) { - return { dids: data.dids, bootstrapped: !!data.bootstrapped }; + return { dids: data.dids }; } } } catch { // Corrupt / unavailable storage: start fresh. } - return { dids: {}, bootstrapped: false }; + return { dids: {} }; } function saveState() { @@ -350,7 +400,6 @@ function saveState() { JSON.stringify({ version: STORAGE_VERSION, dids: state.dids, - bootstrapped: state.bootstrapped, }), ); } catch {