diff --git a/ui/atproto/feed.js b/ui/atproto/feed.js index faedf6c..faeb0b0 100644 --- a/ui/atproto/feed.js +++ b/ui/atproto/feed.js @@ -5,15 +5,26 @@ // collections. import { queryStore } from "beaver://shared/atproto/query.js"; -// Record viewers: the rich Bluesky-post view plus the generic fallback used for -// every other lexicon. Importing registers the custom elements created below. +// 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. 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/atproto_record_card.js"; // 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", + "app.bsky.feed.like": "atproto-reference-record", + "app.bsky.feed.repost": "atproto-reference-record", + "app.bsky.graph.follow": "atproto-reference-record", + "network.cosmik.card": "network-cosmik-card", + "network.cosmik.collection": "network-cosmik-collection", + "network.cosmik.collectionLink": "atproto-reference-record", + "network.cosmik.connection": "atproto-reference-record", }; // Every timestamped record across all tracked repos and collections, newest diff --git a/ui/atproto/viewers/app_bsky_feed_post.js b/ui/atproto/viewers/app_bsky_feed_post.js index 963a07f..6a4b684 100644 --- a/ui/atproto/viewers/app_bsky_feed_post.js +++ b/ui/atproto/viewers/app_bsky_feed_post.js @@ -10,6 +10,7 @@ import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; import { AtRecordElement } from "./at_record_element.js"; import "./app_bsky_actor_profile.js"; import "./atproto_byline.js"; +import "./atproto_link_card.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 @@ -175,54 +176,10 @@ class AppBskyFeedPost extends AtRecordElement { display: block; margin: 12px 0; } - .card { + atproto-link-card { display: block; - border: 1px solid #e7e2da; - border-radius: 12px; - overflow: hidden; - text-decoration: none; - color: inherit; margin: 12px 0; } - .card:hover { - border-color: #d8cfc1; - background: #faf7f1; - } - .card-thumb { - width: 100%; - max-height: 280px; - object-fit: cover; - display: block; - border-bottom: 1px solid #e7e2da; - background: #f3efe8; - } - .card-body { - padding: 10px 12px; - } - .card-host { - font-size: 0.76em; - color: #8a817a; - text-transform: lowercase; - } - .card-title { - font-weight: 600; - font-size: 0.95em; - margin: 2px 0; - } - .card-desc { - font-size: 0.86em; - color: #6b6259; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - .quote { - font-size: 0.9em; - } - .quote .card-title { - color: #a8500f; - } .footer { display: flex; align-items: center; @@ -271,7 +228,7 @@ class AppBskyFeedPost extends AtRecordElement { } :host([compact]) .images, :host([compact]) video, - :host([compact]) .card { + :host([compact]) atproto-link-card { margin: 8px 0; } :host([compact]) .images.n1 img, @@ -397,24 +354,12 @@ class AppBskyFeedPost extends AtRecordElement { return ""; } const thumb = this._blobUrl(did, ext.thumb?.ref?.["$link"]); - let host = ext.uri; - try { - host = new URL(ext.uri).host; - } catch { - // keep raw uriui/atproto/viewers/app_bsky_feed_post.js - } - return html` - ${thumb - ? html`` - : ""} -
-
${host}
- ${ext.title ? html`
${ext.title}
` : ""} - ${ext.description - ? html`
${ext.description}
` - : ""} -
-
`; + return html``; } // Quoted post: we only have the {uri, cid} ref here, so link out to it @@ -424,12 +369,12 @@ class AppBskyFeedPost extends AtRecordElement { if (!uri) { return ""; } - return html` -
-
Quoted record
-
${uri}
-
-
`; + return html``; } render() { diff --git a/ui/atproto/viewers/atproto_link_card.js b/ui/atproto/viewers/atproto_link_card.js new file mode 100644 index 0000000..f7b51dc --- /dev/null +++ b/ui/atproto/viewers/atproto_link_card.js @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// A link preview card: an optional thumbnail over a site / title / description +// body, the whole thing linking to `url`. Shared by the post viewer (external +// embeds + quoted records) and the Cosmik URL-card view. `image` is a ready-to- +// use URL (https or an at:// blob); the consumer sets the surrounding margin. +// The `quote` variant is the slimmer, accent-titled look used for quoted records. + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; + +class AtprotoLinkCard extends LitElement { + static properties = { + url: { type: String }, + title: { type: String }, + description: { type: String }, + image: { type: String }, + // Overrides the host line; otherwise derived from the url's hostname. + siteName: { type: String }, + quote: { type: Boolean, reflect: true }, + }; + + static styles = css` + :host { + display: block; + } + .card { + display: block; + border: 1px solid #e7e2da; + border-radius: 12px; + overflow: hidden; + text-decoration: none; + color: inherit; + } + .card:hover { + border-color: #d8cfc1; + background: #faf7f1; + } + .card-thumb { + width: 100%; + max-height: 280px; + object-fit: cover; + display: block; + border-bottom: 1px solid #e7e2da; + background: #f3efe8; + } + .card-body { + padding: 10px 12px; + } + .card-host { + font-size: 0.76em; + color: #8a817a; + text-transform: lowercase; + } + .card-title { + font-weight: 600; + font-size: 0.95em; + margin: 2px 0; + } + .card-desc { + font-size: 0.86em; + color: #6b6259; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + :host([quote]) { + font-size: 0.9em; + } + :host([quote]) .card-title { + color: #a8500f; + } + `; + + constructor() { + super(); + this.url = ""; + this.title = ""; + this.description = ""; + this.image = ""; + this.siteName = ""; + this.quote = false; + } + + render() { + let host = this.siteName; + if (!host && this.url) { + try { + host = new URL(this.url).host; + } catch { + host = ""; + } + } + const title = this.title || this.url; + if (!this.url && !title) return html``; + return html` + ${this.image + ? html`` + : ""} +
+ ${host ? html`
${host}
` : ""} + ${title ? html`
${title}
` : ""} + ${this.description + ? html`
${this.description}
` + : ""} +
+
`; + } +} + +customElements.define("atproto-link-card", AtprotoLinkCard); diff --git a/ui/atproto/viewers/atproto_record_card.js b/ui/atproto/viewers/atproto_record_card.js index 9ee0e3f..af92821 100644 --- a/ui/atproto/viewers/atproto_record_card.js +++ b/ui/atproto/viewers/atproto_record_card.js @@ -9,6 +9,7 @@ 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 { recordCardStyles } from "./record_card_styles.js"; import "./atproto_byline.js"; class AtprotoRecordCard extends AtRecordElement { @@ -16,45 +17,18 @@ class AtprotoRecordCard extends AtRecordElement { 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; - } - `; + static styles = [ + recordCardStyles, + css` + atproto-byline { + margin-bottom: 4px; + } + .lexicon { + color: #8a817a; + font-size: 0.85em; + } + `, + ]; constructor() { super(); diff --git a/ui/atproto/viewers/atproto_reference_record.js b/ui/atproto/viewers/atproto_reference_record.js new file mode 100644 index 0000000..6e58858 --- /dev/null +++ b/ui/atproto/viewers/atproto_reference_record.js @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Viewer for "reference" records: an actor relating a subject +// rendered as the actor byline over a reference line: an icon + one or more +// links, plus an optional note. Handles app.bsky.feed.like / .repost, +// app.bsky.graph.follow, network.cosmik.collectionLink, and +// network.cosmik.connection. + +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 { recordCardStyles } from "./record_card_styles.js"; +import { + cachedProfile, + ensureProfile, + shortenDid, +} from "beaver://shared/search/avatar.js"; +import "./atproto_byline.js"; + +// Percent-encode just the raw DID authority of an at:// URI so it survives URL +// parsing (mirrors the helper in app_bsky_feed_post.js). +function encodeAtUri(uri) { + const m = /^at:\/\/([^/]+)(\/.*)?$/.exec(uri || ""); + if (!m) return uri || ""; + return `at://${encodeURIComponent(m[1])}${m[2] || ""}`; +} + +class AtprotoReferenceRecord extends AtRecordElement { + static properties = { + compact: { type: Boolean, reflect: true }, + }; + + static styles = [ + recordCardStyles, + css` + atproto-byline { + margin-bottom: 4px; + } + .reference { + display: flex; + align-items: center; + gap: 0.4em; + font-size: 0.9em; + color: #6b6259; + } + .ref-icon { + flex-shrink: 0; + font-size: 1.1em; + font-weight: bolder; + color: #8a817a; + } + .ref-link { + color: #a8500f; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .ref-link:hover { + text-decoration: underline; + } + .ref-sep { + flex-shrink: 0; + color: #8a817a; + } + .ref-note { + margin-top: 4px; + color: #6b6259; + font-size: 0.9em; + overflow-wrap: anywhere; + } + `, + ]; + + constructor() { + super(); + this.compact = false; + } + + // Resolve the followed profile's name (the byline already handles the actor); + // shared cache, prefetch once and re-render when it lands. + willUpdate(changed) { + super.willUpdate(changed); + if (changed.has("data")) { + const did = this._subjectDid(); + if (did && cachedProfile(did) === undefined) { + ensureProfile(did).then(() => this.requestUpdate()); + } + } + } + + // at://// → { actorDid, collection, value }. + _record() { + const m = /^at:\/\/([^/]+)\/([^/]+)/.exec(this.data?.uri || ""); + return { + actorDid: m ? decodeURIComponent(m[1]) : "", + collection: m ? m[2] : "", + value: this.data?.value || {}, + }; + } + + // The followed DID for a graph.follow (a bare DID string), else "". + _subjectDid() { + const { collection, value } = this._record(); + return collection === "app.bsky.graph.follow" ? value.subject || "" : ""; + } + + // Render a connection endpoint (a URL string or an at:// URI) as a link: + // at:// → the lexicon label of the referenced record; a URL → its hostname. + _entityRef(s) { + const str = s || ""; + if (str.startsWith("at://")) { + const coll = (/^at:\/\/[^/]+\/([^/]+)/.exec(str) || [])[1] || ""; + return { + href: encodeAtUri(str), + text: coll ? lexiconLabel(coll) : "record", + }; + } + let text = str; + try { + text = new URL(str).host; + } catch { + // keep the raw string + } + return { href: str, text }; + } + + // { icon, parts: [{ href, text }], joiner? } for the reference line, by + // collection. `parts` is usually a single link; collectionLink and connection + // have two, joined by `joiner`. + _reference() { + const { collection, value } = this._record(); + if ( + collection === "app.bsky.feed.like" || + collection === "app.bsky.feed.repost" + ) { + const uri = value.subject?.uri || ""; + const subColl = (/^at:\/\/[^/]+\/([^/]+)/.exec(uri) || [])[1] || ""; + return { + icon: collection === "app.bsky.feed.repost" ? "repeat" : "heart", + parts: [ + { + href: uri ? encodeAtUri(uri) : "", + text: subColl ? lexiconLabel(subColl) : "record", + }, + ], + }; + } + if (collection === "app.bsky.graph.follow") { + const did = value.subject || ""; + const profile = did ? cachedProfile(did) : null; + return { + icon: "user-plus", + parts: [ + { + href: did + ? `at://${encodeURIComponent(did)}/app.bsky.actor.profile/self` + : "", + text: profile?.displayName || shortenDid(did), + }, + ], + }; + } + if (collection === "network.cosmik.collectionLink") { + const cardUri = value.card?.uri || value.originalCard?.uri || ""; + const collUri = value.collection?.uri || ""; + return { + icon: "folder-plus", + joiner: "→", + parts: [ + { href: cardUri ? encodeAtUri(cardUri) : "", text: "Cosmik Card" }, + { href: collUri ? encodeAtUri(collUri) : "", text: "collection" }, + ], + }; + } + if (collection === "network.cosmik.connection") { + return { + icon: "link", + joiner: value.connectionType || "→", + parts: [this._entityRef(value.source), this._entityRef(value.target)], + }; + } + return { + icon: "circle", + parts: [{ href: "", text: lexiconLabel(collection) }], + }; + } + + // One reference part: a link (or plain span when there's no target), + // preceded by a separator for every part after the first. + _renderPart(part, joiner) { + const link = part.href + ? html`${part.text}` + : html`${part.text}`; + return joiner ? html`${joiner}${link}` : link; + } + + render() { + if (!this.data) return html``; + const { actorDid, value } = this._record(); + const ref = this._reference(); + return html` +
+ +
+ + ${ref.parts.map((part, i) => + this._renderPart(part, i === 0 ? "" : ref.joiner || "·"), + )} +
+ ${value.note ? html`
${value.note}
` : ""} +
+ `; + } +} + +customElements.define("atproto-reference-record", AtprotoReferenceRecord); diff --git a/ui/atproto/viewers/network_cosmik_card.js b/ui/atproto/viewers/network_cosmik_card.js new file mode 100644 index 0000000..499e0a2 --- /dev/null +++ b/ui/atproto/viewers/network_cosmik_card.js @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Smart viewer for network.cosmik.card. A card is either +// a URL bookmark (type "URL": a link with title / description / image / site +// metadata under content.metadata) or a NOTE (type "NOTE": free text in +// content.text, optionally attached to a parentCard). Rendered as the author +// byline over a link-card (URL) or the note text (NOTE). + +import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; +import { AtRecordElement } from "./at_record_element.js"; +import { recordCardStyles } from "./record_card_styles.js"; +import "./atproto_byline.js"; +import "./atproto_link_card.js"; + +class NetworkCosmikCard extends AtRecordElement { + static properties = { + compact: { type: Boolean, reflect: true }, + }; + + static styles = [ + recordCardStyles, + css` + atproto-byline { + margin-bottom: 6px; + } + .note { + white-space: pre-wrap; + overflow-wrap: anywhere; + margin: 0; + } + `, + ]; + + constructor() { + super(); + this.compact = false; + } + + _authorDid() { + const m = /^at:\/\/([^/]+)\//.exec(this.data?.uri || ""); + return m ? decodeURIComponent(m[1]) : ""; + } + + // A URL bookmark: a link card built from whatever metadata Cosmik captured. + // imageUrl is a plain https URL (not a blob), so it goes straight to the card. + _renderUrlCard(value) { + const md = value.content?.metadata || {}; + return html``; + } + + // A note: free text. `white-space: pre-wrap`, so keep the expression flush + // against the tags. + _renderNote(value) { + return html`
${value.content?.text || ""}
`; + } + + render() { + if (!this.data) return html``; + const value = this.data.value || {}; + const did = this._authorDid(); + // Prefer the explicit discriminator; fall back to the content shape. + const isNote = + value.type === "NOTE" || (!value.url && value.content?.text != null); + return html` +
+ + ${isNote ? this._renderNote(value) : this._renderUrlCard(value)} +
+ `; + } +} + +customElements.define("network-cosmik-card", NetworkCosmikCard); diff --git a/ui/atproto/viewers/network_cosmik_collection.js b/ui/atproto/viewers/network_cosmik_collection.js new file mode 100644 index 0000000..f11ac4a --- /dev/null +++ b/ui/atproto/viewers/network_cosmik_collection.js @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Smart viewer for network.cosmik.collection +// Rendered as the owner byline over the collection name + description, +// with a meta line noting open/closed access and any collaborator count. + +import { html, css } from "beaver://shared/third_party/lit/lit-all.min.js"; +import { AtRecordElement } from "./at_record_element.js"; +import { recordCardStyles } from "./record_card_styles.js"; +import "./atproto_byline.js"; + +class NetworkCosmikCollection extends AtRecordElement { + static properties = { + compact: { type: Boolean, reflect: true }, + }; + + static styles = [ + recordCardStyles, + css` + atproto-byline { + margin-bottom: 6px; + } + .name { + font-weight: 600; + font-size: 1.05em; + margin: 0 0 2px; + } + .desc { + color: #6b6259; + font-size: 0.9em; + margin: 0 0 6px; + overflow-wrap: anywhere; + } + .meta { + display: flex; + align-items: center; + gap: 0.4em; + font-size: 0.85em; + color: #8a817a; + } + .meta-icon { + flex-shrink: 0; + } + `, + ]; + + constructor() { + super(); + this.compact = false; + } + + _authorDid() { + const m = /^at:\/\/([^/]+)\//.exec(this.data?.uri || ""); + return m ? decodeURIComponent(m[1]) : ""; + } + + render() { + if (!this.data) return html``; + const value = this.data.value || {}; + const did = this._authorDid(); + const access = value.accessType === "CLOSED" ? "Closed" : "Open"; + const collabs = Array.isArray(value.collaborators) + ? value.collaborators.length + : 0; + const meta = + collabs > 0 + ? `${access} collection · ${collabs} collaborator${collabs === 1 ? "" : "s"}` + : `${access} collection`; + return html` +
+ + ${value.name ? html`
${value.name}
` : ""} + ${value.description + ? html`
${value.description}
` + : ""} +
+ + ${meta} +
+
+ `; + } +} + +customElements.define("network-cosmik-collection", NetworkCosmikCollection); diff --git a/ui/atproto/viewers/record_card_styles.js b/ui/atproto/viewers/record_card_styles.js new file mode 100644 index 0000000..f4ef67a --- /dev/null +++ b/ui/atproto/viewers/record_card_styles.js @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Shared compact card frame for the feed's record viewers. + +import { css } from "beaver://shared/third_party/lit/lit-all.min.js"; + +export const recordCardStyles = css` + :host { + display: block; + /* Warm, crafted system stack — no web fonts load in the json-viewer + context, so lean on a refined native stack rather than monospace. */ + 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; + } +`; diff --git a/ui/shared/search/lexicons.js b/ui/shared/search/lexicons.js index 421b00e..7f344ef 100644 --- a/ui/shared/search/lexicons.js +++ b/ui/shared/search/lexicons.js @@ -28,6 +28,9 @@ export const LEXICON_LABELS = { "ing.dasl.masl": "Web Tile", "sh.tangled.repo": "Tangled Repository", "network.cosmik.card": "Cosmik Card", + "network.cosmik.collection": "Cosmik Collection", + "network.cosmik.collectionLink": "Cosmik Collection Link", + "network.cosmik.connection": "Cosmik Connection", }; /**