tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
get bluesky quotes and mentions, fetch data on client
awarm.space
4 months ago
ef4542c7
8f05b519
+210
-47
9 changed files
expand all
collapse all
unified
split
app
lish
[did]
[publication]
[rkey]
CanvasPage.tsx
Interactions
InteractionDrawer.tsx
Interactions.tsx
Quotes.tsx
getBlueskyMentions.ts
PostHeader
PostHeader.tsx
PostPages.tsx
getPostPageData.ts
page.tsx
+2
app/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
···
53
fullPageScroll: boolean;
54
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
55
}) {
0
0
56
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
57
let isSubpage = !!pageId;
58
let drawer = useDrawerOpen(document_uri);
···
53
fullPageScroll: boolean;
54
pages: (PubLeafletPagesLinearDocument.Main | PubLeafletPagesCanvas.Main)[];
55
}) {
56
+
if (!document) return null;
57
+
58
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
59
let isSubpage = !!pageId;
60
let drawer = useDrawerOpen(document_uri);
+5
-4
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
···
10
11
export const InteractionDrawer = (props: {
12
document_uri: string;
13
-
quotes: { link: string; bsky_posts: { post_view: Json } | null }[];
14
comments: Comment[];
15
did: string;
16
pageId?: string;
···
23
(c) => (c.record as any)?.onPage === props.pageId,
24
);
25
26
-
const filteredQuotes = props.quotes.filter((q) => {
0
27
const url = new URL(q.link);
28
const quoteParam = url.pathname.split("/l-quote/")[1];
29
-
if (!quoteParam) return null;
30
const quotePosition = decodeQuotePosition(quoteParam);
31
return quotePosition?.pageId === props.pageId;
32
});
···
40
className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] "
41
>
42
{drawer.drawer === "quotes" ? (
43
-
<Quotes {...props} quotes={filteredQuotes} />
44
) : (
45
<Comments
46
document_uri={props.document_uri}
···
10
11
export const InteractionDrawer = (props: {
12
document_uri: string;
13
+
quotesAndMentions: { uri: string; link?: string }[];
14
comments: Comment[];
15
did: string;
16
pageId?: string;
···
23
(c) => (c.record as any)?.onPage === props.pageId,
24
);
25
26
+
const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => {
27
+
if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page
28
const url = new URL(q.link);
29
const quoteParam = url.pathname.split("/l-quote/")[1];
30
+
if (!quoteParam) return !props.pageId;
31
const quotePosition = decodeQuotePosition(quoteParam);
32
return quotePosition?.pageId === props.pageId;
33
});
···
41
className="opaque-container rounded-l-none! rounded-r-lg! h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll -ml-[1px] "
42
>
43
{drawer.drawer === "quotes" ? (
44
+
<Quotes {...props} quotesAndMentions={filteredQuotesAndMentions} />
45
) : (
46
<Comments
47
document_uri={props.document_uri}
+16
-7
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
···
154
155
export function getQuoteCount(document: PostPageData, pageId?: string) {
156
if (!document) return;
0
0
157
158
-
if (pageId)
159
-
return document.document_mentions_in_bsky.filter((q) =>
160
-
q.link.includes(pageId),
161
-
).length;
162
-
else
163
-
return document.document_mentions_in_bsky.filter((q) => {
0
0
0
0
0
0
164
const url = new URL(q.link);
165
const quoteParam = url.pathname.split("/l-quote/")[1];
166
-
if (!quoteParam) return null;
167
const quotePosition = decodeQuotePosition(quoteParam);
168
return !quotePosition?.pageId;
169
}).length;
0
170
}
171
172
export function getCommentCount(document: PostPageData, pageId?: string) {
···
154
155
export function getQuoteCount(document: PostPageData, pageId?: string) {
156
if (!document) return;
157
+
return getQuoteCountFromArray(document.quotesAndMentions, pageId);
158
+
}
159
160
+
export function getQuoteCountFromArray(
161
+
quotesAndMentions: { uri: string; link?: string }[],
162
+
pageId?: string,
163
+
) {
164
+
if (pageId) {
165
+
return quotesAndMentions.filter((q) => {
166
+
if (!q.link) return false;
167
+
return q.link.includes(pageId);
168
+
}).length;
169
+
} else {
170
+
return quotesAndMentions.filter((q) => {
171
+
if (!q.link) return true; // Direct mentions go to main page
172
const url = new URL(q.link);
173
const quoteParam = url.pathname.split("/l-quote/")[1];
174
+
if (!quoteParam) return true;
175
const quotePosition = decodeQuotePosition(quoteParam);
176
return !quotePosition?.pageId;
177
}).length;
178
+
}
179
}
180
181
export function getCommentCount(document: PostPageData, pageId?: string) {
+56
-7
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
···
5
import { setInteractionState } from "./Interactions";
6
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
7
import { AtUri } from "@atproto/api";
8
-
import { Json } from "supabase/database.types";
9
import { PostPageContext } from "../PostPageContext";
10
import {
11
PubLeafletBlocksText,
···
21
import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
22
import { flushSync } from "react-dom";
23
import { openPage } from "../PostPages";
0
0
0
24
25
export const Quotes = (props: {
26
-
quotes: { link: string; bsky_posts: { post_view: Json } | null }[];
27
did: string;
28
}) => {
29
let data = useContext(PostPageContext);
···
31
if (!document_uri)
32
throw new Error("document_uri not available in PostPageContext");
33
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
34
return (
35
<div className="flex flex-col gap-2">
36
<div className="w-full flex justify-between text-secondary font-bold">
···
44
<CloseTiny />
45
</button>
46
</div>
47
-
{props.quotes.length === 0 ? (
48
<div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center">
49
<div className="font-bold">no quotes yet!</div>
50
<div>highlight any part of this post to quote it</div>
51
</div>
0
0
0
0
0
52
) : (
53
<div className="quotes flex flex-col gap-8">
54
-
{props.quotes.map((q, index) => {
55
-
let pv = q.bsky_posts?.post_view as unknown as PostView;
0
0
56
const url = new URL(q.link);
57
const quoteParam = url.pathname.split("/l-quote/")[1];
58
if (!quoteParam) return null;
59
const quotePosition = decodeQuotePosition(quoteParam);
60
if (!quotePosition) return null;
61
return (
62
-
<div key={index} className="flex flex-col ">
63
<QuoteContent
64
index={index}
65
did={props.did}
···
77
</div>
78
);
79
})}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
80
</div>
81
)}
82
</div>
···
154
);
155
};
156
157
-
const BskyPost = (props: {
158
rkey: string;
159
content: string;
160
user: string;
···
5
import { setInteractionState } from "./Interactions";
6
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
7
import { AtUri } from "@atproto/api";
0
8
import { PostPageContext } from "../PostPageContext";
9
import {
10
PubLeafletBlocksText,
···
20
import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
21
import { flushSync } from "react-dom";
22
import { openPage } from "../PostPages";
23
+
import useSWR from "swr";
24
+
import { hydrateBlueskyPosts } from "./getBlueskyMentions";
25
+
import { DotLoader } from "components/utils/DotLoader";
26
27
export const Quotes = (props: {
28
+
quotesAndMentions: { uri: string; link?: string }[];
29
did: string;
30
}) => {
31
let data = useContext(PostPageContext);
···
33
if (!document_uri)
34
throw new Error("document_uri not available in PostPageContext");
35
36
+
// Fetch Bluesky post data for all URIs
37
+
const uris = props.quotesAndMentions.map((q) => q.uri);
38
+
const { data: bskyPosts, isLoading } = useSWR(
39
+
uris.length > 0 ? JSON.stringify(uris) : null,
40
+
() => hydrateBlueskyPosts(uris),
41
+
);
42
+
43
+
// Separate quotes with links (quoted content) from direct mentions
44
+
const quotesWithLinks = props.quotesAndMentions.filter((q) => q.link);
45
+
const directMentions = props.quotesAndMentions.filter((q) => !q.link);
46
+
47
+
// Create a map of URIs to post views for easy lookup
48
+
const postViewMap = new Map<string, PostView>();
49
+
bskyPosts?.forEach((pv) => {
50
+
postViewMap.set(pv.uri, pv);
51
+
});
52
+
53
return (
54
<div className="flex flex-col gap-2">
55
<div className="w-full flex justify-between text-secondary font-bold">
···
63
<CloseTiny />
64
</button>
65
</div>
66
+
{props.quotesAndMentions.length === 0 ? (
67
<div className="opaque-container flex flex-col gap-0.5 p-[6px] text-tertiary italic text-sm text-center">
68
<div className="font-bold">no quotes yet!</div>
69
<div>highlight any part of this post to quote it</div>
70
</div>
71
+
) : isLoading ? (
72
+
<div className="flex items-center justify-center gap-1 text-tertiary italic text-sm mt-8">
73
+
<span>loading</span>
74
+
<DotLoader />
75
+
</div>
76
) : (
77
<div className="quotes flex flex-col gap-8">
78
+
{/* Quotes with links (quoted content) */}
79
+
{quotesWithLinks.map((q, index) => {
80
+
const pv = postViewMap.get(q.uri);
81
+
if (!pv || !q.link) return null;
82
const url = new URL(q.link);
83
const quoteParam = url.pathname.split("/l-quote/")[1];
84
if (!quoteParam) return null;
85
const quotePosition = decodeQuotePosition(quoteParam);
86
if (!quotePosition) return null;
87
return (
88
+
<div key={`quote-${index}`} className="flex flex-col ">
89
<QuoteContent
90
index={index}
91
did={props.did}
···
103
</div>
104
);
105
})}
106
+
107
+
{/* Direct post mentions (without quoted content) */}
108
+
{directMentions.length > 0 && (
109
+
<div className="flex flex-col gap-4">
110
+
<h3>Post Mentions</h3>
111
+
<div className="flex flex-col gap-8">
112
+
{directMentions.map((q, index) => {
113
+
const pv = postViewMap.get(q.uri);
114
+
if (!pv) return null;
115
+
return (
116
+
<BskyPost
117
+
key={`mention-${index}`}
118
+
rkey={new AtUri(pv.uri).rkey}
119
+
content={pv.record.text as string}
120
+
user={pv.author.displayName || pv.author.handle}
121
+
profile={pv.author}
122
+
handle={pv.author.handle}
123
+
/>
124
+
);
125
+
})}
126
+
</div>
127
+
</div>
128
+
)}
129
</div>
130
)}
131
</div>
···
203
);
204
};
205
206
+
export const BskyPost = (props: {
207
rkey: string;
208
content: string;
209
user: string;
+80
app/lish/[did]/[publication]/[rkey]/Interactions/getBlueskyMentions.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
"use server";
2
+
3
+
import { AtUri, Agent, lexToJson } from "@atproto/api";
4
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
5
+
6
+
type ConstellationResponse = {
7
+
records: { did: string; collection: string; rkey: string }[];
8
+
};
9
+
10
+
const headers = {
11
+
"Content-type": "application/json",
12
+
"user-agent": "leaflet.pub",
13
+
};
14
+
15
+
// Fetch constellation backlinks without hydrating with Bluesky post data
16
+
export async function getConstellationBacklinks(
17
+
url: string,
18
+
): Promise<{ uri: string }[]> {
19
+
let baseURL = `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(url)}`;
20
+
let externalEmbeds = new URL(
21
+
`${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:embed.external.uri")}`,
22
+
);
23
+
let linkFacets = new URL(
24
+
`${baseURL}&source=${encodeURIComponent("app.bsky.feed.post:facets[].features[app.bsky.richtext.facet#link].uri")}`,
25
+
);
26
+
27
+
let [links, embeds] = (await Promise.all([
28
+
fetch(linkFacets, { headers, next: { revalidate: 3600 } }).then((req) =>
29
+
req.json(),
30
+
),
31
+
fetch(externalEmbeds, { headers, next: { revalidate: 3600 } }).then((req) =>
32
+
req.json(),
33
+
),
34
+
])) as ConstellationResponse[];
35
+
36
+
let uris = [...links.records, ...embeds.records].map((i) =>
37
+
AtUri.make(i.did, i.collection, i.rkey).toString(),
38
+
);
39
+
40
+
return uris.map((uri) => ({ uri }));
41
+
}
42
+
43
+
// Hydrate Bluesky URIs with post data
44
+
export async function hydrateBlueskyPosts(uris: string[]): Promise<PostView[]> {
45
+
if (uris.length === 0) return [];
46
+
47
+
let agent = new Agent({
48
+
service: "https://public.api.bsky.app",
49
+
fetch: (...args) =>
50
+
fetch(args[0], {
51
+
...args[1],
52
+
next: { revalidate: 3600 },
53
+
}),
54
+
});
55
+
56
+
// Process URIs in batches of 25
57
+
let allPostRequests = [];
58
+
for (let i = 0; i < uris.length; i += 25) {
59
+
let batch = uris.slice(i, i + 25);
60
+
let batchPosts = agent.getPosts(
61
+
{
62
+
uris: batch,
63
+
},
64
+
{ headers: {} },
65
+
);
66
+
allPostRequests.push(batchPosts);
67
+
}
68
+
let allPosts = (await Promise.all(allPostRequests)).flatMap(
69
+
(r) => r.data.posts,
70
+
);
71
+
72
+
return lexToJson(allPosts) as PostView[];
73
+
}
74
+
75
+
// Legacy function - kept for backwards compatibility if needed
76
+
export async function getMentions(url: string) {
77
+
let backlinks = await getConstellationBacklinks(url);
78
+
let uris = backlinks.map((b) => b.uri);
79
+
return hydrateBlueskyPosts(uris);
80
+
}
+7
-16
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
···
5
PubLeafletPublication,
6
} from "lexicons/api";
7
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
8
-
import { Interactions } from "../Interactions/Interactions";
0
0
0
0
9
import { PostPageData } from "../getPostPageData";
10
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
11
import { useIdentityData } from "components/IdentityProvider";
12
import { EditTiny } from "components/Icons/EditTiny";
13
import { SpeedyLink } from "components/SpeedyLink";
14
-
import { decodeQuotePosition } from "../quotePosition";
15
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
16
17
export function PostHeader(props: {
···
95
<Interactions
96
showComments={props.preferences.showComments}
97
compact
98
-
quotesCount={
99
-
document.document_mentions_in_bsky.filter((q) => {
100
-
const url = new URL(q.link);
101
-
const quoteParam = url.pathname.split("/l-quote/")[1];
102
-
if (!quoteParam) return null;
103
-
const quotePosition = decodeQuotePosition(quoteParam);
104
-
return !quotePosition?.pageId;
105
-
}).length
106
-
}
107
-
commentsCount={
108
-
document.comments_on_documents.filter(
109
-
(c) => !(c.record as PubLeafletComment.Record)?.onPage,
110
-
).length
111
-
}
112
/>
113
</div>
114
</div>
···
5
PubLeafletPublication,
6
} from "lexicons/api";
7
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
8
+
import {
9
+
Interactions,
10
+
getQuoteCount,
11
+
getCommentCount,
12
+
} from "../Interactions/Interactions";
13
import { PostPageData } from "../getPostPageData";
14
import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
15
import { useIdentityData } from "components/IdentityProvider";
16
import { EditTiny } from "components/Icons/EditTiny";
17
import { SpeedyLink } from "components/SpeedyLink";
0
18
import { useLocalizedDate } from "src/hooks/useLocalizedDate";
19
20
export function PostHeader(props: {
···
98
<Interactions
99
showComments={props.preferences.showComments}
100
compact
101
+
quotesCount={getQuoteCount(document) || 0}
102
+
commentsCount={getCommentCount(document) || 0}
0
0
0
0
0
0
0
0
0
0
0
0
103
/>
104
</div>
105
</div>
+3
-2
app/lish/[did]/[publication]/[rkey]/PostPages.tsx
···
129
130
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
131
let record = document.data as PubLeafletDocument.Record;
0
132
133
let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0;
134
return (
···
156
? []
157
: document.comments_on_documents
158
}
159
-
quotes={document.document_mentions_in_bsky}
160
did={did}
161
/>
162
)}
···
232
? []
233
: document.comments_on_documents
234
}
235
-
quotes={document.document_mentions_in_bsky}
236
did={did}
237
/>
238
)}
···
129
130
let hasPageBackground = !!pubRecord.theme?.showPageBackground;
131
let record = document.data as PubLeafletDocument.Record;
132
+
let quotesAndMentions = document.quotesAndMentions;
133
134
let fullPageScroll = !hasPageBackground && !drawer && pages.length === 0;
135
return (
···
157
? []
158
: document.comments_on_documents
159
}
160
+
quotesAndMentions={quotesAndMentions}
161
did={did}
162
/>
163
)}
···
233
? []
234
: document.comments_on_documents
235
}
236
+
quotesAndMentions={quotesAndMentions}
237
did={did}
238
/>
239
)}
+31
-3
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
···
1
import { supabaseServerClient } from "supabase/serverClient";
0
0
0
2
3
-
export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>;
4
export async function getPostPageData(uri: string) {
5
let { data: document } = await supabaseServerClient
6
.from("documents")
···
10
uri,
11
comments_on_documents(*, bsky_profiles(*)),
12
documents_in_publications(publications(*, publication_subscriptions(*))),
13
-
document_mentions_in_bsky(*, bsky_posts(*)),
14
leaflets_in_publications(*)
15
`,
16
)
17
.eq("uri", uri)
18
.single();
19
-
return document;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
20
}
0
0
···
1
import { supabaseServerClient } from "supabase/serverClient";
2
+
import { AtUri } from "@atproto/syntax";
3
+
import { getConstellationBacklinks } from "./Interactions/getBlueskyMentions";
4
+
import { PubLeafletPublication } from "lexicons/api";
5
0
6
export async function getPostPageData(uri: string) {
7
let { data: document } = await supabaseServerClient
8
.from("documents")
···
12
uri,
13
comments_on_documents(*, bsky_profiles(*)),
14
documents_in_publications(publications(*, publication_subscriptions(*))),
15
+
document_mentions_in_bsky(*),
16
leaflets_in_publications(*)
17
`,
18
)
19
.eq("uri", uri)
20
.single();
21
+
22
+
if (!document) return null;
23
+
24
+
// Fetch constellation backlinks for mentions
25
+
const pubRecord = document.documents_in_publications[0]?.publications
26
+
?.record as PubLeafletPublication.Record;
27
+
const rkey = new AtUri(uri).rkey;
28
+
const postUrl = `https://${pubRecord?.base_path}/${rkey}`;
29
+
const constellationBacklinks = await getConstellationBacklinks(postUrl);
30
+
31
+
// Combine database mentions and constellation backlinks
32
+
const quotesAndMentions: { uri: string; link?: string }[] = [
33
+
// Database mentions (quotes with link to quoted content)
34
+
...document.document_mentions_in_bsky.map((m) => ({
35
+
uri: m.uri,
36
+
link: m.link,
37
+
})),
38
+
// Constellation backlinks (direct post mentions without quote context)
39
+
...constellationBacklinks,
40
+
];
41
+
42
+
return {
43
+
...document,
44
+
quotesAndMentions,
45
+
};
46
}
47
+
48
+
export type PostPageData = Awaited<ReturnType<typeof getPostPageData>>;
+10
-8
app/lish/[did]/[publication]/[rkey]/page.tsx
···
67
fetch: (...args) =>
68
fetch(args[0], {
69
...args[1],
70
-
cache: "no-store",
71
next: { revalidate: 3600 },
72
}),
73
});
···
132
// Extract poll blocks and fetch vote data
133
let pollBlocks = record.pages.flatMap((p) => {
134
let page = p as PubLeafletPagesLinearDocument.Main;
135
-
return page.blocks?.filter(
136
-
(b) => b.block.$type === ids.PubLeafletBlocksPoll,
137
-
) || [];
0
138
});
139
-
let pollData = await fetchPollData(pollBlocks.map(b => (b.block as any).pollRef.uri));
0
0
0
0
0
140
141
let firstPage = record.pages[0];
142
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
143
if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
144
blocks = firstPage.blocks || [];
145
}
146
-
147
-
let pubRecord = document.documents_in_publications[0]?.publications
148
-
.record as PubLeafletPublication.Record;
149
150
let prerenderedCodeBlocks = await extractCodeBlocks(blocks);
151
···
67
fetch: (...args) =>
68
fetch(args[0], {
69
...args[1],
0
70
next: { revalidate: 3600 },
71
}),
72
});
···
131
// Extract poll blocks and fetch vote data
132
let pollBlocks = record.pages.flatMap((p) => {
133
let page = p as PubLeafletPagesLinearDocument.Main;
134
+
return (
135
+
page.blocks?.filter((b) => b.block.$type === ids.PubLeafletBlocksPoll) ||
136
+
[]
137
+
);
138
});
139
+
let pollData = await fetchPollData(
140
+
pollBlocks.map((b) => (b.block as any).pollRef.uri),
141
+
);
142
+
143
+
let pubRecord = document.documents_in_publications[0]?.publications
144
+
.record as PubLeafletPublication.Record;
145
146
let firstPage = record.pages[0];
147
let blocks: PubLeafletPagesLinearDocument.Block[] = [];
148
if (PubLeafletPagesLinearDocument.isMain(firstPage)) {
149
blocks = firstPage.blocks || [];
150
}
0
0
0
151
152
let prerenderedCodeBlocks = await extractCodeBlocks(blocks);
153