SolidJS component to embed comments from your Leaflet post
LeafletComments.tsx edited
170 lines 6.1 kB view raw
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}