diff --git a/ui/atproto/feed.js b/ui/atproto/feed.js index e40e4ad..faedf6c 100644 --- a/ui/atproto/feed.js +++ b/ui/atproto/feed.js @@ -1,23 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// Local-first "AppView": a reverse-chronological timeline of the posts indexed -// in the local ATProto store, across every tracked repo. No appview server, -// it's one read-only SQL query (`queryStore`) rendered with the same -// viewer that the at:// document viewer uses. Works -// offline against whatever the background indexer has synced. +// Local-first "AppView": a reverse-chronological timeline of every timestamped +// record indexed in the local ATProto store, across every tracked repo and all +// collections. import { queryStore } from "beaver://shared/atproto/query.js"; -// Registers the custom element used to render each post. +// Record viewers: the rich Bluesky-post view plus the generic fallback used for +// every other lexicon. Importing registers the custom elements created below. import "./viewers/app_bsky_feed_post.js"; +import "./viewers/atproto_record_card.js"; -// Posts across all tracked repos, newest first. json1's json_extract reads the -// createdAt out of the stored record value; records without one are skipped so -// they don't sort to an arbitrary edge. +// Collections we render with a dedicated viewer (NSID -> element tag); every +// other collection falls back to the generic . +const RICH_VIEWERS = { + "app.bsky.feed.post": "app-bsky-feed-post", +}; + +// 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. const FEED_SQL = ` - SELECT did, rkey, cid, json + SELECT did, collection, rkey, cid, json FROM records - WHERE collection = ? - AND json_extract(json, '$.createdAt') IS NOT NULL + WHERE json_extract(json, '$.createdAt') IS NOT NULL ORDER BY json_extract(json, '$.createdAt') DESC LIMIT 100`; @@ -42,14 +47,14 @@ async function loadFeed() { showStatus("Loading your atmosphere…"); let rows; try { - rows = await queryStore(FEED_SQL, ["app.bsky.feed.post"]); + rows = await queryStore(FEED_SQL); } catch (e) { showStatus(`Couldn't load the feed: ${e?.message || e}`); return; } if (!rows.length) { - showStatus("No posts indexed yet — sync some repos first.", true); + showStatus("Nothing indexed yet — sync some repos first.", true); return; } @@ -63,18 +68,19 @@ async function loadFeed() { } const item = document.createElement("div"); item.className = "feed-item"; - const post = document.createElement("app-bsky-feed-post"); - // Dense feed presentation (slim byline, no card chrome) rather than the - // full document-viewer layout. - post.compact = true; - // The viewer expects the getRecord envelope; build it from the row. The raw - // DID is fine — the viewer percent-encodes it when constructing at:// URLs. - post.data = { - uri: `at://${row.did}/app.bsky.feed.post/${row.rkey}`, + // Dispatch by collection: a dedicated viewer when we have one, else the + // generic record card. Both take the same getRecord envelope and render + // compact (slim byline, no card chrome). The raw DID is fine since the viewers + // percent-encode it when constructing at:// URLs. + const tag = RICH_VIEWERS[row.collection] || "atproto-record-card"; + const el = document.createElement(tag); + el.compact = true; + el.data = { + uri: `at://${row.did}/${row.collection}/${row.rkey}`, cid: row.cid, value, }; - item.appendChild(post); + item.appendChild(el); frag.appendChild(item); } feed.replaceChildren(frag); diff --git a/ui/atproto/viewers/app_bsky_feed_post.js b/ui/atproto/viewers/app_bsky_feed_post.js index 3f1c0f6..963a07f 100644 --- a/ui/atproto/viewers/app_bsky_feed_post.js +++ b/ui/atproto/viewers/app_bsky_feed_post.js @@ -8,12 +8,8 @@ import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; import { AtRecordElement } from "./at_record_element.js"; -import { - cachedProfile, - ensureProfile, - shortenDid, -} from "beaver://shared/search/avatar.js"; import "./app_bsky_actor_profile.js"; +import "./atproto_byline.js"; // Split post text into runs using the record's facets. Facet indices are UTF-8 // byte offsets, so we slice the encoded bytes (not the UTF-16 JS string) and @@ -68,24 +64,6 @@ function formatDate(iso) { }); } -// Short relative age ("3h", "2d", "Mar 4") for the compact byline timestamp. -function relativeTime(iso) { - if (!iso) return ""; - const d = new Date(iso); - if (isNaN(d)) return ""; - const secs = Math.max(0, (Date.now() - d.getTime()) / 1000); - if (secs < 60) return "now"; - const mins = Math.floor(secs / 60); - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h`; - const days = Math.floor(hours / 24); - if (days < 7) return `${days}d`; - const weeks = Math.floor(days / 7); - if (weeks < 5) return `${weeks}w`; - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); -} - // A record's stored at:// URI carries a raw DID authority // (at://did:plc:abc/coll/rkey); the colons break URL parsing in the authority // position, so percent-encode just the authority before using it as a link @@ -300,44 +278,11 @@ class AppBskyFeedPost extends AtRecordElement { :host([compact]) video { max-height: 360px; } - .byline { - display: flex; - align-items: center; - gap: 0.5em; + /* The compact byline is the shared element; only its + spacing belongs to the post. */ + atproto-byline { margin-bottom: 6px; } - .byline-id { - display: inline-flex; - align-items: center; - gap: 0.45em; - min-width: 0; - flex: 1; - text-decoration: none; - color: inherit; - } - .byline-avatar { - width: 22px; - height: 22px; - border-radius: 50%; - object-fit: cover; - background: #e8e2d8; - flex-shrink: 0; - } - .byline-name { - font-weight: 600; - color: #2a2520; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .byline-id:hover .byline-name { - text-decoration: underline; - } - .byline-time { - flex-shrink: 0; - color: #8a817a; - font-size: 0.85em; - } `; constructor() { @@ -345,23 +290,6 @@ class AppBskyFeedPost extends AtRecordElement { this.compact = false; } - // In compact mode the byline shows the author's display name + avatar, which - // this viewer resolves itself (the full profile card isn't rendered). Prefetch - // once per DID and re-render when it lands; the cache is shared with search. - willUpdate(changed) { - super.willUpdate(changed); - if ( - this.compact && - this.data && - (changed.has("data") || changed.has("compact")) - ) { - const did = this._authorDid(); - if (did && cachedProfile(did) === undefined) { - ensureProfile(did).then(() => this.requestUpdate()); - } - } - } - // Author DID: prefer the envelope uri, fall back to the document host. _authorDid() { const uri = this.data?.uri || ""; @@ -473,7 +401,7 @@ class AppBskyFeedPost extends AtRecordElement { try { host = new URL(ext.uri).host; } catch { - // keep raw uri + // keep raw uriui/atproto/viewers/app_bsky_feed_post.js } return html` ${thumb @@ -504,33 +432,6 @@ class AppBskyFeedPost extends AtRecordElement { `; } - // Slim one-line byline used in compact mode: avatar + display name (resolved - // via the shared profile cache, falling back to a shortened DID) + a relative - // timestamp. Replaces the full header card. - _renderByline(did, profileSrc, createdAt) { - const profile = cachedProfile(did); - const name = profile?.displayName || shortenDid(did); - const rel = relativeTime(createdAt); - return html``; - } - render() { if (!this.data) return html``; const post = this.data.value || {}; @@ -546,7 +447,10 @@ class AppBskyFeedPost extends AtRecordElement { : null; const header = this.compact - ? this._renderByline(did, profileSrc, post.createdAt) + ? html`` : profileSrc ? html`
diff --git a/ui/atproto/viewers/atproto_byline.js b/ui/atproto/viewers/atproto_byline.js new file mode 100644 index 0000000..65aef00 --- /dev/null +++ b/ui/atproto/viewers/atproto_byline.js @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Slim one-line author byline shared by the compact record views in the feed: +// avatar + display name (linked to the profile) + a relative timestamp. Resolves +// the profile via the shared cache (`ensureProfile`), so a DID already seen +// elsewhere in the UI is a cache hit. + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import { + cachedProfile, + ensureProfile, + shortenDid, +} from "beaver://shared/search/avatar.js"; + +// Short relative age ("3h", "2d", "Mar 4") for the byline timestamp. +function relativeTime(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d)) return ""; + const secs = Math.max(0, (Date.now() - d.getTime()) / 1000); + if (secs < 60) return "now"; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks}w`; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +// Full timestamp for the byline's hover title. +function fullDate(iso) { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d)) return ""; + return d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export class AtprotoByline extends LitElement { + static properties = { + // Raw DID (the element percent-encodes it when building at:// URLs). + did: { type: String }, + // ISO timestamp (the record's createdAt). + time: { type: String }, + }; + + static styles = css` + :host { + display: flex; + align-items: center; + gap: 0.5em; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + sans-serif; + } + .byline-id { + display: inline-flex; + align-items: center; + gap: 0.45em; + min-width: 0; + flex: 1; + text-decoration: none; + color: inherit; + } + .byline-avatar { + width: 22px; + height: 22px; + border-radius: 50%; + object-fit: cover; + background: #e8e2d8; + flex-shrink: 0; + } + .byline-name { + font-weight: 600; + color: #2a2520; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .byline-id:hover .byline-name { + text-decoration: underline; + } + .byline-time { + flex-shrink: 0; + color: #8a817a; + font-size: 0.85em; + } + `; + + constructor() { + super(); + this.did = ""; + this.time = ""; + } + + // Resolve the profile once per DID and re-render when it lands; the cache is + // shared with search and the other record views. + willUpdate(changed) { + if ( + changed.has("did") && + this.did && + cachedProfile(this.did) === undefined + ) { + ensureProfile(this.did).then(() => this.requestUpdate()); + } + } + + render() { + 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`; + const rel = relativeTime(this.time); + return html` + + ${rel + ? html`` + : ""} + `; + } +} + +customElements.define("atproto-byline", AtprotoByline); diff --git a/ui/atproto/viewers/atproto_record_card.js b/ui/atproto/viewers/atproto_record_card.js new file mode 100644 index 0000000..9ee0e3f --- /dev/null +++ b/ui/atproto/viewers/atproto_record_card.js @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Generic fallback view for record types the feed doesn't render richly yet: +// the author byline (avatar + name + time) over the human-readable lexicon name +// (e.g. "Bluesky Like"), falling back to the raw NSID. To upgrade a type from +// this placeholder to a real rendering, add a dedicated viewer in +// this directory and dispatch to it from feed.js's RICH_VIEWERS map. + +import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; +import { AtRecordElement } from "./at_record_element.js"; +import { lexiconLabel } from "beaver://shared/search/lexicons.js"; +import "./atproto_byline.js"; + +class AtprotoRecordCard extends AtRecordElement { + static properties = { + compact: { type: Boolean, reflect: true }, + }; + + static styles = css` + :host { + display: block; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + sans-serif; + color: #2a2520; + max-width: 600px; + margin: 0 auto; + line-height: 1.5; + } + :host([compact]) { + max-width: none; + font-size: 0.875rem; + line-height: 1.45; + } + .record { + border: 1px solid #e7e2da; + border-radius: 14px 16px 14px 15px; + background: #fffdf9; + padding: 16px 18px 14px; + } + :host([compact]) .record { + border: none; + border-radius: 0; + background: transparent; + padding: 10px 0; + } + atproto-byline { + margin-bottom: 4px; + } + .lexicon { + color: #8a817a; + font-size: 0.85em; + } + `; + + constructor() { + super(); + this.compact = false; + } + + // at://// → { did, collection }. The authority is + // a raw DID (no slashes), so a non-greedy two-segment match is enough. + _parts() { + const m = /^at:\/\/([^/]+)\/([^/]+)/.exec(this.data?.uri || ""); + if (!m) return { did: "", collection: "" }; + return { did: decodeURIComponent(m[1]), collection: m[2] }; + } + + render() { + if (!this.data) return html``; + const { did, collection } = this._parts(); + const value = this.data.value || {}; + return html` +
+ +
${lexiconLabel(collection)}
+
+ `; + } +} + +customElements.define("atproto-record-card", AtprotoRecordCard); diff --git a/ui/system/mobile/pebble.js b/ui/system/mobile/pebble.js index 05fe5e4..e40b2c6 100644 --- a/ui/system/mobile/pebble.js +++ b/ui/system/mobile/pebble.js @@ -38,6 +38,7 @@ const RADIAL_ZONES_HOME = [ { id: 0, action: "settings", icon: "settings" }, { id: 1, action: "tabs", icon: "layout-grid" }, { id: 2, action: "new-tab", icon: "plus" }, + { id: 3, action: "feed", icon: "rss" }, { id: 4, action: "search", icon: "search" }, ]; @@ -733,6 +734,9 @@ export class MobilePebble extends LitElement { ); this._setState(STATE.AMBIENT); break; + case "feed": + this._navigateTo("beaver://atproto/feed.html"); + break; case "search": { this._setState(STATE.GLANCE); // The glance content has visibility:hidden with a 0.18s transition