@@ -672,76 +845,7 @@ export default function Card({
{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 && (
-
-
-

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 && (
@@ -792,6 +896,15 @@ export default function Card({
)}
+ {!(contentWarning && !contentRevealed) &&
+ isSembleNote &&
+ resolvedPreview}
+
+ {!(contentWarning && !contentRevealed) &&
+ !isBookmark &&
+ !isSembleNote &&
+ socialEmbed}
+
{!(contentWarning && !contentRevealed) &&
item.tags &&
item.tags.length > 0 && (
diff --git a/web/src/components/common/SocialPostEmbed.tsx b/web/src/components/common/SocialPostEmbed.tsx
new file mode 100644
index 0000000..8a795d8
--- /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) => (
+

+ ))}
+
+ )}
+
+ {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}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/src/lib/socialPost.ts b/web/src/lib/socialPost.ts
new file mode 100644
index 0000000..6a742b0
--- /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;
+}