an app to share curated trails
sidetrail.app
atproto
nextjs
react
rsc
1"use server";
2
3import "server-only";
4import { cacheLife, cacheTag } from "next/cache";
5import { Client, type l } from "@atproto/lex";
6import * as getPostThread from "@/lib/lexicons/app/bsky/feed/getPostThread";
7import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs";
8import { loadTrailCardByUri } from "@/data/queries";
9import { TrailCard } from "./TrailCard";
10import { BlueskyPostEmbed } from "./at/(trail)/[handle]/trail/[rkey]/embeds/BlueskyPostEmbed";
11import { LinkPreview } from "./at/(trail)/[handle]/trail/[rkey]/LinkPreview";
12import { blueskyUrlToAtUri } from "./at/(trail)/[handle]/trail/[rkey]/utils/bluesky";
13
14type BlueskyPost = feedDefs.ThreadViewPost;
15
16type LinkMetadata = {
17 uri: string;
18 title: string;
19 description: string;
20 thumb?: string;
21};
22
23function isTrailUri(uri: string): boolean {
24 return uri.includes("/app.sidetrail.trail/");
25}
26
27function isBlueskyPostUri(uri: string): boolean {
28 return (
29 uri.includes("/app.bsky.feed.post/") || (uri.includes("bsky.app") && uri.includes("/post/"))
30 );
31}
32
33// Cached fetch for Bluesky posts - throws on failure
34async function fetchBlueskyPost(atUri: string): Promise<BlueskyPost> {
35 "use cache: redis";
36 cacheTag(`embed:bsky:${atUri}`);
37 cacheLife("days");
38
39 const atprotoClient = new Client("https://public.api.bsky.app");
40 const result = await atprotoClient.call(getPostThread.main, {
41 uri: atUri as l.AtUri,
42 depth: 0,
43 parentHeight: 0,
44 });
45 if (feedDefs.threadViewPost.$check(result.thread)) {
46 // Serialize to strip non-plain objects (like CID)
47 return JSON.parse(JSON.stringify(result.thread)) as BlueskyPost;
48 }
49 throw new Error(`Invalid thread response for ${atUri}`);
50}
51
52// Cached fetch for link metadata - throws on failure
53async function fetchLinkMetadata(url: string): Promise<LinkMetadata> {
54 "use cache: redis";
55 cacheTag(`embed:link:${url}`);
56 cacheLife("days");
57
58 const response = await fetch(`https://cardyb.bsky.app/v1/extract?url=${encodeURIComponent(url)}`);
59 if (!response.ok) {
60 throw new Error(`Failed to fetch link metadata: ${response.status}`);
61 }
62
63 const data = await response.json();
64 return {
65 uri: url,
66 title: data.title || url,
67 description: data.description || "",
68 thumb: data.image || undefined,
69 };
70}
71
72export async function loadEmbed(uri: string): Promise<React.ReactElement> {
73 if (isTrailUri(uri)) {
74 const trail = await loadTrailCardByUri(uri);
75 if (!trail) throw new Error(`Trail not found: ${uri}`);
76 return <TrailCard {...trail} />;
77 }
78
79 if (isBlueskyPostUri(uri)) {
80 const atUri = uri.startsWith("at://")
81 ? uri
82 : uri.startsWith("http")
83 ? blueskyUrlToAtUri(uri)
84 : null;
85 if (!atUri) throw new Error(`Invalid Bluesky URI: ${uri}`);
86
87 const post = await fetchBlueskyPost(atUri);
88 return <BlueskyPostEmbed post={post} />;
89 }
90
91 // Regular link
92 const metadata = await fetchLinkMetadata(uri);
93 return (
94 <LinkPreview
95 external={{
96 uri: metadata.uri,
97 title: metadata.title,
98 description: metadata.description,
99 thumb: metadata.thumb,
100 }}
101 fallbackToUrl
102 />
103 );
104}