LeafletComments.tsx
edited
1/*
2Copyright (c) 2025 SharpMars
3
4Permission to use, copy, modify, and/or distribute this software for
5any purpose with or without fee is hereby granted.
6
7THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
8WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
9OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
10FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
11DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
14*/
15import { Client, simpleFetchHandler } from "@atcute/client";
16import type { RecordKey } from "@atcute/lexicons";
17import { createResource, For, Show, Suspense } from "solid-js";
18import type { PubLeafletComment, PubLeafletRichtextFacet } from "@atcute/leaflet";
19import { formatDistance } from "date-fns";
20
21export function LeafletComments(props: { rkey: RecordKey }) {
22 const leafletUrl = "https://sharpmars.leaflet.pub/";
23 const atUriPrefix = "at://did:plc:irx36xprktslecsbopbwnh5w/pub.leaflet.document/";
24
25 const splitTextByFacet = (text: string, facets?: PubLeafletRichtextFacet.Main[]) => {
26 if (!facets || facets.length == 0) return text;
27
28 const sortedFacets = facets.toSorted((a, b) => a.index.byteStart - b.index.byteStart);
29 const encoder = new TextEncoder();
30 const decoder = new TextDecoder();
31 const encodedText = encoder.encode(text);
32
33 const indexSet = new Set<number>();
34 indexSet.add(0);
35 indexSet.add(encodedText.length);
36
37 for (const facet of sortedFacets) {
38 indexSet.add(facet.index.byteStart);
39 indexSet.add(facet.index.byteEnd);
40 }
41
42 const indexes = [...indexSet].sort((a, b) => a - b);
43
44 const ranges: { start: number; end: number }[] = [];
45
46 for (let i = 0; i < indexSet.size - 1; i++) {
47 ranges.push({ start: indexes[i], end: indexes[i + 1] });
48 }
49
50 const segments = ranges.map((range) => {
51 const segmentFacets = sortedFacets
52 .filter((val) => val.index.byteStart >= range.start && val.index.byteEnd <= range.end)
53 .flatMap((val) => val.features)
54 .map((val) => val.$type);
55
56 return (
57 <span
58 classList={{
59 "line-through decoration-[hsl(29,100%,65%)] decoration-1.5": segmentFacets.includes(
60 "pub.leaflet.richtext.facet#strikethrough"
61 ),
62 italic: segmentFacets.includes("pub.leaflet.richtext.facet#italic"),
63 "font-bold": segmentFacets.includes("pub.leaflet.richtext.facet#bold"),
64 }}
65 >
66 {decoder.decode(encodedText.subarray(range.start, range.end))}
67 </span>
68 );
69 });
70
71 return segments;
72 };
73
74 const [comments] = createResource(async () => {
75 const atUri = atUriPrefix + props.rkey;
76
77 let cursor = null;
78 const records = [];
79
80 do {
81 const res = await (
82 await fetch(
83 `https://constellation.microcosm.blue/xrpc/blue.microcosm.links.getBacklinks?subject=${atUri}&source=pub.leaflet.comment:subject&limit=100`
84 )
85 ).json();
86
87 records.push(...res.records);
88 cursor = res.cursor;
89 } while (cursor);
90
91 const slingshot = new Client({ handler: simpleFetchHandler({ service: "https://slingshot.microcosm.blue" }) });
92
93 const comments: {
94 handle: string;
95 createdAt: string;
96 text: ReturnType<typeof splitTextByFacet>;
97 attachment: boolean;
98 }[] = [];
99
100 for (const record of records) {
101 const commentRes = await slingshot.get("com.atproto.repo.getRecord", {
102 params: { rkey: record.rkey, collection: record.collection, repo: record.did },
103 headers: [["Accept", "application/json"]],
104 });
105
106 const handleRes = await slingshot.get("com.bad-example.identity.resolveMiniDoc", {
107 params: { identifier: record.did },
108 });
109
110 const handle = handleRes.ok ? handleRes.data.handle : record.did;
111
112 if (commentRes.ok) {
113 const comment = commentRes.data.value as PubLeafletComment.Main;
114 comments.push({
115 handle: handle ? handle : record.did,
116 createdAt: comment.createdAt,
117 text: splitTextByFacet(comment.plaintext, comment.facets),
118 attachment: !!comment.attachment,
119 });
120 }
121 }
122
123 return comments;
124 });
125
126 return (
127 <div class="flex gap-3 flex-col max-w-768px w-full">
128 <hr class="text-[hsl(29,100%,65%)]" />
129 <p class="text-6 font-700">Comments</p>
130 <p>
131 To comment or see them in their original form check out{" "}
132 <a
133 class="underline text-[hsl(29,100%,65%)] after:content-['↗'] hover:brightness-120 transition-all"
134 href={leafletUrl + props.rkey}
135 >
136 leaflet.pub
137 </a>
138 </p>
139 <Suspense fallback={"Loading..."}>
140 <For each={comments()}>
141 {(comment, i) => (
142 <div class="flex flex-col gap-1">
143 <hr class="m-t-1 m-b-1 text-neutral-700" />
144 <div class="flex justify-between items-center">
145 <p>{comment.handle}</p>
146 <p>{formatDistance(new Date(comment.createdAt), new Date(), { addSuffix: true })}</p>
147 </div>
148 <Show when={comment.attachment}>
149 <div class="flex justify-center w-full">
150 <div class="b-1 b-neutral-700 p-1 flex justify-center items-center max-w-98% w-full">
151 <p>
152 Check quote on{" "}
153 <a
154 class="underline text-[hsl(29,100%,65%)] after:content-['↗'] hover:brightness-120 transition-all"
155 href={leafletUrl + props.rkey}
156 >
157 leaflet.pub
158 </a>
159 </p>
160 </div>
161 </div>
162 </Show>
163 <p class="m-x-1%">{comment.text}</p>
164 </div>
165 )}
166 </For>
167 </Suspense>
168 </div>
169 );
170}