From c7f914b20b1b3a309dacaa88ca4c884e7ef5032a Mon Sep 17 00:00:00 2001 From: scanash00 Date: Tue, 28 Jul 2026 07:43:36 +0000 Subject: [PATCH] Bluesky embeds, semble card improvements, and some other improvements --- backend/internal/api/handler.go | 41 ++++++++++++++++++++++++++++++++++++++++- backend/internal/api/handler_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/lib/socialPost.ts | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/common/Card.tsx | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------- web/src/components/common/SocialPostEmbed.tsx | 299 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 file(s) changed, 889 insertion(s)(+), 100 deletion(s)(-) diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -1335,6 +1335,33 @@ return "" } + extractAllMeta := func(name string) []string { + var values []string + seen := make(map[string]bool) + for _, attr := range []string{ + fmt.Sprintf("name=\"%s\"", name), + fmt.Sprintf("name='%s'", name), + } { + rest := content + for { + idx := strings.Index(rest, attr) + if idx == -1 { + break + } + rest = rest[idx+len(attr):] + tag := rest + if end := strings.IndexByte(tag, '>'); end != -1 { + tag = tag[:end] + } + if v := extractContent(tag); v != "" && !seen[v] { + seen[v] = true + values = append(values, v) + } + } + } + return values + } + title := extract("title") if title == "" { if idx := strings.Index(content, ""); idx != -1 { @@ -1408,12 +1435,24 @@ } } - return map[string]string{ + result := map[string]string{ "title": title, "description": description, "image": image, "icon": favicon, } + + var atCanonical []string + for _, v := range extractAllMeta("at:canonical") { + if strings.HasPrefix(v, "at://") { + atCanonical = append(atCanonical, v) + } + } + if len(atCanonical) > 0 { + result["at:canonical"] = strings.Join(atCanonical, " ") + } + + return result } func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/handler_test.go b/backend/internal/api/handler_test.go --- a/backend/internal/api/handler_test.go +++ b/backend/internal/api/handler_test.go @@ -1,6 +1,9 @@ package api import ( + "context" + "net/http" + "net/http/httptest" "reflect" "testing" ) @@ -9,6 +12,56 @@ want := []string{"commenting", "tagging"} if got := parseMotivations("commenting"); !reflect.DeepEqual(got, want) { t.Fatalf("parseMotivations(commenting) = %v, want %v", got, want) + } +} + +func TestFetchURLMetadataExtractsAtCanonical(t *testing.T) { + page := `<!DOCTYPE html> +<html> +<head> +<title>Test Page + + + + + + + +` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(page)) + })) + defer server.Close() + + h := &Handler{} + data := h.fetchURLMetadata(context.Background(), server.URL) + + if data["title"] != "OG Title" { + t.Fatalf("title = %q, want %q", data["title"], "OG Title") + } + want := "at://did:plc:abc123/app.bsky.feed.post/xyz at://did:plc:def456/at.margin.note/rkey1" + if data["at:canonical"] != want { + t.Fatalf("at:canonical = %q, want %q", data["at:canonical"], want) + } +} + +func TestFetchURLMetadataOmitsAtCanonicalWhenAbsent(t *testing.T) { + page := `Plain` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(page)) + })) + defer server.Close() + + h := &Handler{} + data := h.fetchURLMetadata(context.Background(), server.URL) + + if data["title"] != "Plain" { + t.Fatalf("title = %q, want %q", data["title"], "Plain") + } + if v, ok := data["at:canonical"]; ok { + t.Fatalf("at:canonical should be absent, got %q", v) } } diff --git a/web/src/lib/socialPost.ts b/web/src/lib/socialPost.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/socialPost.ts @@ -0,0 +1,285 @@ +const PUBLIC_API = "https://public.api.bsky.app/xrpc"; + +export interface SocialPostRef { + host: string; + actor: string; + rkey: string; +} + +export interface SocialPostAuthor { + did: string; + handle: string; + displayName?: string; + avatar?: string; +} + +export interface SocialPostImage { + thumb: string; + fullsize: string; + alt?: string; +} + +export interface SocialPostFacet { + byteStart: number; + byteEnd: number; + link?: string; + mention?: string; + tag?: string; +} + +export interface FacetSegment { + text: string; + facet?: SocialPostFacet; +} + +export interface SocialPost { + uri: string; + author: SocialPostAuthor; + text: string; + facets?: SocialPostFacet[]; + createdAt?: string; + images: SocialPostImage[]; + video?: { thumbnail?: string }; + external?: { + uri: string; + title?: string; + description?: string; + thumb?: string; + }; + quote?: { + author: SocialPostAuthor; + text: string; + facets?: SocialPostFacet[]; + createdAt?: string; + }; + replyCount?: number; + repostCount?: number; + likeCount?: number; +} + +export function parseSocialPostUrl( + url: string | null | undefined, +): SocialPostRef | null { + if (!url) return null; + try { + const u = new URL(url); + if (u.protocol !== "https:" && u.protocol !== "http:") return null; + const match = u.pathname.match(/^\/profile\/([^/]+)\/post\/([^/]+)\/?$/); + if (!match) return null; + return { + host: u.hostname.replace(/^www\./, ""), + actor: decodeURIComponent(match[1]), + rkey: decodeURIComponent(match[2]), + }; + } catch { + return null; + } +} + +export function parseSocialPostAtUri( + uri: string, + host: string, +): SocialPostRef | null { + const match = uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/); + if (!match) return null; + return { host, actor: match[1], rkey: match[2] }; +} + +export function postRefFromAtTags( + canonical: string | null | undefined, + host: string, +): SocialPostRef | null { + if (!canonical) return null; + for (const uri of canonical.trim().split(/\s+/)) { + const ref = parseSocialPostAtUri(uri, host); + if (ref) return ref; + } + return null; +} + +function pickAuthor(author: Record | undefined) { + return { + did: (author?.did as string) || "", + handle: (author?.handle as string) || "", + displayName: author?.displayName as string | undefined, + avatar: author?.avatar as string | undefined, + }; +} + +export function segmentByFacets( + text: string, + facets: SocialPostFacet[] | undefined, +): FacetSegment[] { + if (!facets?.length) return [{ text }]; + const bytes = new TextEncoder().encode(text); + const decoder = new TextDecoder(); + const segments: FacetSegment[] = []; + let cursor = 0; + for (const facet of facets) { + if (facet.byteStart < cursor || facet.byteEnd > bytes.length) continue; + if (facet.byteStart > cursor) { + segments.push({ + text: decoder.decode(bytes.subarray(cursor, facet.byteStart)), + }); + } + segments.push({ + text: decoder.decode(bytes.subarray(facet.byteStart, facet.byteEnd)), + facet, + }); + cursor = facet.byteEnd; + } + if (cursor < bytes.length) { + segments.push({ text: decoder.decode(bytes.subarray(cursor)) }); + } + return segments; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function extractFacets(record: any): SocialPostFacet[] | undefined { + if (!Array.isArray(record?.facets)) return undefined; + const out: SocialPostFacet[] = []; + for (const facet of record.facets) { + const index = facet?.index; + if ( + typeof index?.byteStart !== "number" || + typeof index?.byteEnd !== "number" || + index.byteEnd <= index.byteStart + ) { + continue; + } + for (const feature of facet.features || []) { + const base = { byteStart: index.byteStart, byteEnd: index.byteEnd }; + if (feature?.$type === "app.bsky.richtext.facet#link" && feature.uri) { + out.push({ ...base, link: feature.uri }); + break; + } + if (feature?.$type === "app.bsky.richtext.facet#mention" && feature.did) { + out.push({ ...base, mention: feature.did }); + break; + } + if (feature?.$type === "app.bsky.richtext.facet#tag" && feature.tag) { + out.push({ ...base, tag: feature.tag }); + break; + } + } + } + if (out.length === 0) return undefined; + return out.sort((a, b) => a.byteStart - b.byteStart); +} + +function extractQuote(record: any): SocialPost["quote"] { + if (!record || record.$type !== "app.bsky.embed.record#viewRecord") { + return undefined; + } + return { + author: pickAuthor(record.author), + text: record.value?.text || "", + facets: extractFacets(record.value), + createdAt: record.value?.createdAt, + }; +} + +function extractEmbed(embed: any, out: SocialPost) { + if (!embed || typeof embed.$type !== "string") return; + if (embed.$type === "app.bsky.embed.images#view") { + out.images = (embed.images || []) + .filter((img: any) => img?.thumb) + .map((img: any) => ({ + thumb: img.thumb, + fullsize: img.fullsize || img.thumb, + alt: img.alt, + })); + } else if (embed.$type === "app.bsky.embed.video#view") { + out.video = { thumbnail: embed.thumbnail }; + } else if (embed.$type === "app.bsky.embed.external#view") { + if (embed.external?.uri) { + out.external = { + uri: embed.external.uri, + title: embed.external.title, + description: embed.external.description, + thumb: embed.external.thumb, + }; + } + } else if (embed.$type === "app.bsky.embed.record#view") { + out.quote = extractQuote(embed.record); + } else if (embed.$type === "app.bsky.embed.recordWithMedia#view") { + extractEmbed(embed.media, out); + out.quote = extractQuote(embed.record?.record); + } +} + +function simplify(post: any): SocialPost | null { + if (!post?.uri || !post.author?.did) return null; + const simplified: SocialPost = { + uri: post.uri, + author: pickAuthor(post.author), + text: post.record?.text || "", + facets: extractFacets(post.record), + createdAt: post.record?.createdAt || post.indexedAt, + images: [], + replyCount: post.replyCount, + repostCount: post.repostCount, + likeCount: post.likeCount, + }; + extractEmbed(post.embed, simplified); + return simplified; +} + +async function doFetch(ref: SocialPostRef): Promise { + let did = ref.actor; + if (!did.startsWith("did:")) { + const res = await fetch( + `${PUBLIC_API}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(ref.actor)}`, + ); + if (!res.ok) return null; + did = (await res.json()).did; + if (!did) return null; + } + const uri = `at://${did}/app.bsky.feed.post/${ref.rkey}`; + const res = await fetch( + `${PUBLIC_API}/app.bsky.feed.getPosts?uris=${encodeURIComponent(uri)}`, + ); + if (!res.ok) return null; + const data = await res.json(); + return simplify(data.posts?.[0]); +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +const inflight = new Map>(); + +export function socialPostCacheKey(url: string): string { + return `socialpost:v2:${url}`; +} + +export function fetchSocialPost( + url: string, + ref: SocialPostRef, +): Promise { + const cacheKey = socialPostCacheKey(url); + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + return Promise.resolve(cached === "null" ? null : JSON.parse(cached)); + } + } catch { + /* ignore */ + } + + const existing = inflight.get(cacheKey); + if (existing) return existing; + + const promise = doFetch(ref) + .catch(() => null) + .then((post) => { + inflight.delete(cacheKey); + try { + sessionStorage.setItem(cacheKey, post ? JSON.stringify(post) : "null"); + } catch { + /* ignore */ + } + return post; + }); + + inflight.set(cacheKey, promise); + return promise; +} diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -52,6 +52,14 @@ import { Avatar } from "../ui"; import CollectionIcon from "./CollectionIcon"; +import SocialPostEmbed from "./SocialPostEmbed"; +import { + parseSocialPostUrl, + postRefFromAtTags, + fetchSocialPost, + socialPostCacheKey, +} from "../../lib/socialPost"; +import type { SocialPost } from "../../lib/socialPost"; import ProfileHoverCard from "./ProfileHoverCard"; import { analytics } from "../../lib/analytics"; @@ -199,8 +207,14 @@ description?: string; image?: string; icon?: string; + "at:canonical"?: string; } | null>(() => { - if (initialItem.motivation !== "bookmarking") return null; + const sembleNote = + (initialItem.uri?.includes("network.cosmik") || + initialItem.uri?.includes("semble")) && + initialItem.motivation === "commenting" && + initialItem.body?.value; + if (initialItem.motivation !== "bookmarking" && !sembleNote) return null; const url = initialItem.target?.source || initialItem.source; if (!url) return null; try { @@ -242,9 +256,64 @@ const pageUrl = item.target?.source || item.source; const isBookmark = type === "bookmark" && !item.body?.value; + const isSembleNote = + isSemble && type === "annotation" && !!item.body?.value && !!pageUrl; + const showLinkPreview = isBookmark || isSembleNote; + + const socialPostRef = React.useMemo( + () => parseSocialPostUrl(pageUrl), + [pageUrl], + ); + const atTagPostRef = React.useMemo(() => { + if (socialPostRef) return null; + return postRefFromAtTags( + ogData?.["at:canonical"], + safeUrlHostname(pageUrl) || "", + ); + }, [socialPostRef, ogData, pageUrl]); + const postRef = socialPostRef ?? atTagPostRef; + const showSocialPost = + !!postRef && (isBookmark || (type === "annotation" && !!item.body?.value)); + + const [socialPost, setSocialPost] = useState(() => { + if (!pageUrl) return null; + try { + const cached = sessionStorage.getItem(socialPostCacheKey(pageUrl)); + return cached && cached !== "null" ? JSON.parse(cached) : null; + } catch { + return null; + } + }); + const [socialPostFailed, setSocialPostFailed] = useState(false); React.useEffect(() => { - if (isBookmark && item.uri && !ogData && pageUrl) { + if ( + showSocialPost && + postRef && + pageUrl && + !socialPost && + !socialPostFailed + ) { + let cancelled = false; + fetchSocialPost(pageUrl, postRef).then((data) => { + if (cancelled) return; + if (data) setSocialPost(data); + else setSocialPostFailed(true); + }); + return () => { + cancelled = true; + }; + } + }, [showSocialPost, postRef, pageUrl, socialPost, socialPostFailed]); + + React.useEffect(() => { + if ( + showLinkPreview && + (!showSocialPost || socialPostFailed) && + item.uri && + !ogData && + pageUrl + ) { let cancelled = false; import("../../lib/metadataQueue").then(({ fetchMetadata }) => { fetchMetadata(pageUrl).then((data) => { @@ -255,7 +324,14 @@ cancelled = true; }; } - }, [isBookmark, item.uri, pageUrl, ogData]); + }, [ + showLinkPreview, + showSocialPost, + socialPostFailed, + item.uri, + pageUrl, + ogData, + ]); if (contentWarning?.visibility === "hide") return null; @@ -423,6 +499,93 @@ : undefined; const displayImage = ogData?.image; + const linkPreview = ( +
{ + e.preventDefault(); + if (pageUrl) handleExternalClick(e, pageUrl); + }} + role="button" + tabIndex={0} + className={clsx( + "flex bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden cursor-pointer", + layout === "mosaic" + ? "flex-col items-stretch" + : "flex-row items-stretch", + isSembleNote && "mt-2.5", + )} + > + {displayImage && !imgError && ( +
+
+ {displayTitle setImgError(true)} + /> +
+
+ )} +
+

+ {displayTitle} +

+ + {displayDescription && ( +

+ {displayDescription} +

+ )} + +
+
+ {ogData?.icon && !iconError ? ( + setIconError(true)} + className="w-3 h-3 object-contain" + /> + ) : ( + + )} +
+ + {displayUrl || pageUrl} + +
+
+
+ ); + + const socialEmbed = + socialPost && postRef && pageUrl ? ( + + ) : null; + + const resolvedPreview = + socialEmbed ?? + (socialPostRef && showSocialPost && !socialPostFailed ? null : linkPreview); + return (
{!hideCollection && @@ -506,7 +669,12 @@ -
+
@@ -672,76 +845,7 @@ {t("card.hideContent")} )} - {!(contentWarning && !contentRevealed) && isBookmark && ( -
{ - e.preventDefault(); - if (pageUrl) handleExternalClick(e, pageUrl); - }} - role="button" - tabIndex={0} - className={clsx( - "flex bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-600 hover:bg-surface-100 dark:hover:bg-surface-700 transition-all group overflow-hidden cursor-pointer", - layout === "mosaic" - ? "flex-col items-stretch" - : "flex-row items-stretch", - )} - > - {displayImage && !imgError && ( -
-
- {displayTitle setImgError(true)} - /> -
-
- )} -
-

- {displayTitle} -

- - {displayDescription && ( -

- {displayDescription} -

- )} - -
-
- {ogData?.icon && !iconError ? ( - setIconError(true)} - className="w-3 h-3 object-contain" - /> - ) : ( - - )} -
- - {displayUrl || pageUrl} - -
-
-
- )} + {!(contentWarning && !contentRevealed) && isBookmark && resolvedPreview} {!(contentWarning && !contentRevealed) && asTextQuote(item.target?.selector)?.exact && ( @@ -791,6 +895,15 @@

)} + + {!(contentWarning && !contentRevealed) && + isSembleNote && + resolvedPreview} + + {!(contentWarning && !contentRevealed) && + !isBookmark && + !isSembleNote && + socialEmbed} {!(contentWarning && !contentRevealed) && item.tags && diff --git a/web/src/components/common/SocialPostEmbed.tsx b/web/src/components/common/SocialPostEmbed.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/common/SocialPostEmbed.tsx @@ -0,0 +1,299 @@ +import React, { useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { clsx } from "clsx"; +import { + MessageSquare, + Heart, + Repeat2, + ExternalLink, + Play, +} from "lucide-react"; +import RichText from "./RichText"; +import { Avatar } from "../ui"; +import { segmentByFacets } from "../../lib/socialPost"; +import type { SocialPost, SocialPostFacet } from "../../lib/socialPost"; +import { displayHandle } from "../../lib/handle"; + +interface SocialPostEmbedProps { + post: SocialPost; + postUrl: string; + host: string; + className?: string; + onOpen: (e: React.MouseEvent, url: string) => void; +} + +function shortTimestamp(createdAt?: string): string { + if (!createdAt) return ""; + try { + return formatDistanceToNow(new Date(createdAt), { addSuffix: false }) + .replace("less than a minute", "now") + .replace("about ", "") + .replace(/ hours?/, "h") + .replace(/ minutes?/, "m") + .replace(/ months?/, "mo") + .replace(/ days?/, "d") + .replace(/ years?/, "y"); + } catch { + return ""; + } +} + +function formatCount(count?: number): string | null { + if (!count) return null; + if (count >= 10000) return `${Math.round(count / 1000)}K`; + if (count >= 1000) return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}K`; + return String(count); +} + +const FACET_LINK_CLASS = + "text-primary-600 dark:text-primary-400 hover:underline"; + +function PostText({ + text, + facets, + host, + onOpen, +}: { + text: string; + facets?: SocialPostFacet[]; + host: string; + onOpen: (e: React.MouseEvent, url: string) => void; +}) { + if (!facets?.length) return ; + return ( + <> + {segmentByFacets(text, facets).map((segment, i) => { + const facet = segment.facet; + if (facet?.link) { + const link = facet.link; + return ( + onOpen(e, link)} + > + {segment.text} + + ); + } + if (facet?.mention) { + return ( + e.stopPropagation()} + > + {segment.text} + + ); + } + if (facet?.tag) { + const tagUrl = `https://${host}/hashtag/${encodeURIComponent(facet.tag)}`; + return ( + onOpen(e, tagUrl)} + > + {segment.text} + + ); + } + return {segment.text}; + })} + + ); +} + +export default function SocialPostEmbed({ + post, + postUrl, + host, + className, + onOpen, +}: SocialPostEmbedProps) { + const [videoThumbError, setVideoThumbError] = useState(false); + const timestamp = shortTimestamp(post.createdAt); + const counts = [ + { icon: MessageSquare, value: formatCount(post.replyCount) }, + { icon: Repeat2, value: formatCount(post.repostCount) }, + { icon: Heart, value: formatCount(post.likeCount) }, + ].filter((c) => c.value); + + return ( +
{ + e.preventDefault(); + onOpen(e, postUrl); + }} + role="button" + tabIndex={0} + className={clsx( + "block bg-surface-50 dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600 transition-colors group overflow-hidden cursor-pointer p-3 font-sans", + className, + )} + > +
+ +
+ + {post.author.displayName || + displayHandle(post.author.handle, post.author.did)} + + + @{displayHandle(post.author.handle, post.author.did)} + + {timestamp && ( + + ยท {timestamp} + + )} +
+ + + {host} + +
+ + {post.text && ( +

+ +

+ )} + + {post.images.length > 0 && ( +
+ {post.images.slice(0, 4).map((img, i) => ( + {img.alt + ))} +
+ )} + + {post.video && ( +
+ {post.video.thumbnail && !videoThumbError ? ( + setVideoThumbError(true)} + /> + ) : ( +
+ )} +
+
+ +
+
+
+ )} + + {post.external && ( +
+ {post.external.thumb && ( + + )} +
+

+ {post.external.title || post.external.uri} +

+

+ {(() => { + try { + return new URL(post.external.uri).hostname.replace( + /^www\./, + "", + ); + } catch { + return post.external.uri; + } + })()} +

+
+
+ )} + + {post.quote && ( +
+
+ + + {post.quote.author.displayName || + displayHandle(post.quote.author.handle, post.quote.author.did)} + + + @{displayHandle(post.quote.author.handle, post.quote.author.did)} + +
+ {post.quote.text && ( +

+ +

+ )} +
+ )} + + {counts.length > 0 && ( +
+ {counts.map(({ icon: Icon, value }, i) => ( + + + {value} + + ))} +
+ )} +
+ ); +} -- tangled.sh