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