a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Feature/comments (#178)

* wip

* added some styling to the pub not found message

* more styling for comments

* tweaks here and there

* added a popover for date and profile

* tweaks to the profile popover

* wire up replies and other things

* fix comment count

* remove collapsed post header

* remove spacer

* remove unused imports

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by

Jared Pereira
celine
and committed by
GitHub
67e1c3c1 7092885f

+1242 -153
+1 -1
app/discover/PubListing.tsx
··· 71 71 ); 72 72 }; 73 73 74 - function timeAgo(timestamp: string): string { 74 + export function timeAgo(timestamp: string): string { 75 75 const now = new Date(); 76 76 const date = new Date(timestamp); 77 77 const diffMs = now.getTime() - date.getTime();
+1 -1
app/globals.css
··· 262 262 @apply rounded-lg; 263 263 @apply outline; 264 264 @apply outline-offset-1; 265 - @apply outline-1; 265 + @apply outline-2; 266 266 @apply outline-transparent; 267 267 @apply hover:border-border; 268 268 }
+350
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 1 + import { UnicodeString } from "@atproto/api"; 2 + import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 3 + import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; 4 + import { PubLeafletRichtextFacet } from "lexicons/api"; 5 + import { baseKeymap, toggleMark } from "prosemirror-commands"; 6 + import { keymap } from "prosemirror-keymap"; 7 + import { Mark, MarkType, Node } from "prosemirror-model"; 8 + import { EditorState, TextSelection } from "prosemirror-state"; 9 + import { EditorView } from "prosemirror-view"; 10 + import { 11 + MutableRefObject, 12 + RefObject, 13 + useLayoutEffect, 14 + useRef, 15 + useState, 16 + } from "react"; 17 + import { publishComment } from "./commentAction"; 18 + import { ButtonPrimary } from "components/Buttons"; 19 + import { ShareSmall } from "components/Icons/ShareSmall"; 20 + import { useInteractionState } from "../Interactions"; 21 + import { DotLoader } from "components/utils/DotLoader"; 22 + import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; 23 + import { setMark } from "src/utils/prosemirror/setMark"; 24 + import { multi } from "linkifyjs"; 25 + import { Json } from "supabase/database.types"; 26 + 27 + export function CommentBox(props: { 28 + doc_uri: string; 29 + replyTo?: string; 30 + onSubmit?: () => void; 31 + }) { 32 + let mountRef = useRef<HTMLPreElement | null>(null); 33 + let [editorState, setEditorState] = useState(() => 34 + EditorState.create({ 35 + schema: multiBlockSchema, 36 + plugins: [ 37 + keymap({ 38 + "Meta-b": toggleMark(multiBlockSchema.marks.strong), 39 + "Ctrl-b": toggleMark(multiBlockSchema.marks.strong), 40 + "Meta-u": toggleMark(multiBlockSchema.marks.underline), 41 + "Ctrl-u": toggleMark(multiBlockSchema.marks.underline), 42 + "Meta-i": toggleMark(multiBlockSchema.marks.em), 43 + "Ctrl-i": toggleMark(multiBlockSchema.marks.em), 44 + "Ctrl-Meta-x": toggleMark(multiBlockSchema.marks.strikethrough), 45 + }), 46 + keymap(baseKeymap), 47 + autolink({ 48 + type: multiBlockSchema.marks.link, 49 + shouldAutoLink: () => true, 50 + defaultProtocol: "https", 51 + }), 52 + ], 53 + }), 54 + ); 55 + let view = useRef<null | EditorView>(null); 56 + useLayoutEffect(() => { 57 + if (!mountRef.current) return; 58 + view.current = new EditorView( 59 + { mount: mountRef.current }, 60 + { 61 + state: editorState, 62 + handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 63 + if (!direct) return; 64 + if (node.nodeSize - 2 <= _pos) return; 65 + let mark = 66 + node 67 + .nodeAt(_pos - 1) 68 + ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || 69 + node 70 + .nodeAt(Math.max(_pos - 2, 0)) 71 + ?.marks.find((f) => f.type === multiBlockSchema.marks.link); 72 + if (mark) { 73 + window.open(mark.attrs.href, "_blank"); 74 + } 75 + }, 76 + dispatchTransaction(tr) { 77 + console.log("dispatching?"); 78 + let newState = this.state.apply(tr); 79 + setEditorState(newState); 80 + view.current?.updateState(newState); 81 + }, 82 + }, 83 + ); 84 + return () => { 85 + view.current?.destroy(); 86 + view.current = null; 87 + }; 88 + }, []); 89 + let [loading, setLoading] = useState(false); 90 + return ( 91 + <div className=" flex flex-col gap-1"> 92 + <pre 93 + ref={mountRef} 94 + className={`border whitespace-pre-wrap input-with-border min-h-32 h-fit`} 95 + /> 96 + <div className="flex justify-between"> 97 + <div className="flex gap-1"> 98 + <TextDecorationButton 99 + mark={multiBlockSchema.marks.strong} 100 + icon={<BoldTiny />} 101 + editor={editorState} 102 + view={view} 103 + /> 104 + <TextDecorationButton 105 + mark={multiBlockSchema.marks.em} 106 + icon={<ItalicTiny />} 107 + editor={editorState} 108 + view={view} 109 + /> 110 + <TextDecorationButton 111 + mark={multiBlockSchema.marks.strikethrough} 112 + icon={<StrikethroughTiny />} 113 + editor={editorState} 114 + view={view} 115 + /> 116 + </div> 117 + <ButtonPrimary 118 + compact 119 + onClick={async () => { 120 + setLoading(true); 121 + let [plaintext, facets] = docToFacetedText(editorState.doc); 122 + let comment = await publishComment({ 123 + document: props.doc_uri, 124 + comment: { plaintext, facets, replyTo: props.replyTo }, 125 + }); 126 + 127 + setLoading(false); 128 + props.onSubmit?.(); 129 + useInteractionState.setState((s) => ({ 130 + localComments: [ 131 + ...s.localComments, 132 + { 133 + record: comment.record, 134 + uri: comment.uri, 135 + bsky_profiles: { record: comment.profile as Json }, 136 + }, 137 + ], 138 + })); 139 + }} 140 + > 141 + {loading ? <DotLoader /> : <ShareSmall />} 142 + </ButtonPrimary> 143 + </div> 144 + </div> 145 + ); 146 + } 147 + 148 + export function docToFacetedText( 149 + doc: Node, 150 + ): [string, PubLeafletRichtextFacet.Main[]] { 151 + let fullText = ""; 152 + let facets: PubLeafletRichtextFacet.Main[] = []; 153 + let byteOffset = 0; 154 + 155 + // Iterate through each paragraph in the document 156 + doc.forEach((paragraph) => { 157 + if (paragraph.type.name !== "paragraph") return; 158 + 159 + // Process each inline node in the paragraph 160 + paragraph.forEach((node) => { 161 + if (node.isText) { 162 + const text = node.text || ""; 163 + const unicodeString = new UnicodeString(text); 164 + 165 + // If this text node has marks, create a facet 166 + if (node.marks.length > 0) { 167 + const facet: PubLeafletRichtextFacet.Main = { 168 + index: { 169 + byteStart: byteOffset, 170 + byteEnd: byteOffset + unicodeString.length, 171 + }, 172 + features: marksToFeatures(node.marks), 173 + }; 174 + 175 + if (facet.features.length > 0) { 176 + facets.push(facet); 177 + } 178 + } 179 + 180 + fullText += text; 181 + byteOffset += unicodeString.length; 182 + } 183 + }); 184 + 185 + // Add newline between paragraphs (except after the last one) 186 + if (paragraph !== doc.lastChild) { 187 + const newline = "\n"; 188 + const unicodeNewline = new UnicodeString(newline); 189 + fullText += newline; 190 + byteOffset += unicodeNewline.length; 191 + } 192 + }); 193 + 194 + return [fullText, facets]; 195 + } 196 + 197 + function marksToFeatures(marks: readonly Mark[]) { 198 + const features: PubLeafletRichtextFacet.Main["features"] = []; 199 + 200 + for (const mark of marks) { 201 + switch (mark.type.name) { 202 + case "strong": 203 + features.push({ 204 + $type: "pub.leaflet.richtext.facet#bold", 205 + }); 206 + break; 207 + case "em": 208 + features.push({ 209 + $type: "pub.leaflet.richtext.facet#italic", 210 + }); 211 + break; 212 + case "underline": 213 + features.push({ 214 + $type: "pub.leaflet.richtext.facet#underline", 215 + }); 216 + break; 217 + case "strikethrough": 218 + features.push({ 219 + $type: "pub.leaflet.richtext.facet#strikethrough", 220 + }); 221 + break; 222 + case "code": 223 + features.push({ 224 + $type: "pub.leaflet.richtext.facet#code", 225 + }); 226 + break; 227 + case "highlight": 228 + features.push({ 229 + $type: "pub.leaflet.richtext.facet#highlight", 230 + }); 231 + break; 232 + case "link": 233 + features.push({ 234 + $type: "pub.leaflet.richtext.facet#link", 235 + uri: mark.attrs.href as string, 236 + }); 237 + break; 238 + } 239 + } 240 + 241 + return features; 242 + } 243 + 244 + const BoldTiny = () => { 245 + return ( 246 + <svg 247 + width="16" 248 + height="16" 249 + viewBox="0 0 16 16" 250 + fill="none" 251 + xmlns="http://www.w3.org/2000/svg" 252 + > 253 + <path 254 + d="M8.51904 1.80078C9.39881 1.80078 10.4849 1.94674 11.062 2.2373C11.6391 2.52384 12.0714 2.91292 12.3579 3.40527C12.6444 3.8935 12.7875 4.36916 12.7876 4.98242C12.7876 5.49891 12.6921 5.82754 12.5024 6.18262C12.3128 6.53367 12.143 6.75088 11.8501 6.98242C11.5707 7.20327 11.2323 7.37691 10.8726 7.47656C10.8417 7.4851 10.8199 7.51295 10.8198 7.54492C10.8198 7.58218 10.8491 7.61315 10.8862 7.61621C11.2746 7.64684 11.6546 7.77785 12.0249 8.01074C12.4203 8.25283 12.7471 8.59809 13.0054 9.0459C13.2636 9.49384 13.3931 10.2216 13.3931 10.8633C13.3931 11.4969 13.2546 12.0133 12.9243 12.5557C12.6308 13.0377 12.1941 13.4681 11.5767 13.7627C10.9592 14.0533 9.69145 14.1992 8.73096 14.1992H2.80713C2.69673 14.1992 2.60703 14.1094 2.60693 13.999V12.4775C2.60693 12.3671 2.69667 12.2773 2.80713 12.2773H3.779C3.88946 12.2773 3.979 12.1878 3.979 12.0773V3.92266C3.979 3.8122 3.88946 3.72266 3.779 3.72266H2.80713C2.69667 3.72265 2.60693 3.63292 2.60693 3.52246V2.00098C2.60709 1.89066 2.69677 1.80078 2.80713 1.80078H8.51904ZM6.43799 8.53027C6.32753 8.53027 6.23779 8.62001 6.23779 8.73047V12.0918C6.238 12.2021 6.32766 12.291 6.43799 12.291H8.54932C9.44524 12.291 10.2343 12.0981 10.5679 11.751C10.9012 11.404 11.0583 11.1411 11.1196 10.6719C11.175 10.2478 11.0842 9.86677 10.9253 9.59375C10.7664 9.3208 10.4981 9.04179 10.1226 8.85254C9.65963 8.61937 9.11701 8.53032 8.6167 8.53027H6.43799ZM6.43799 3.72754C6.32765 3.72754 6.23799 3.81647 6.23779 3.92676V6.7207C6.23801 6.83097 6.32767 6.91992 6.43799 6.91992H8.35596C8.77567 6.91992 9.05325 6.89438 9.4126 6.79883C9.79755 6.69625 10.1309 6.37531 10.3286 6.08496C10.5304 5.79036 10.5086 5.41232 10.4976 5.07129C10.4865 4.7303 10.2791 4.42356 10.061 4.21289C9.70192 3.86602 9.14702 3.72759 8.40479 3.72754H6.43799Z" 255 + fill="currentColor" 256 + /> 257 + </svg> 258 + ); 259 + }; 260 + 261 + const ItalicTiny = () => { 262 + return ( 263 + <svg 264 + width="16" 265 + height="16" 266 + viewBox="0 0 16 16" 267 + fill="none" 268 + xmlns="http://www.w3.org/2000/svg" 269 + > 270 + <path 271 + d="M11.9718 3.47455C11.945 3.6162 11.8212 3.71875 11.677 3.71875H10.1524C10.0079 3.71875 9.88393 3.82177 9.85748 3.96384L8.37739 11.9136C8.34303 12.0982 8.48463 12.2686 8.67232 12.2686H10.2232C10.4117 12.2686 10.5535 12.4404 10.5178 12.6254L10.2606 13.9571C10.2333 14.0982 10.1098 14.2002 9.96605 14.2002H4.07323C3.88472 14.2002 3.74293 14.0284 3.77867 13.8433L4.03584 12.5117C4.0631 12.3705 4.18665 12.2686 4.3304 12.2686H5.85886C6.00338 12.2686 6.12736 12.1655 6.15379 12.0234L7.63297 4.07363C7.6673 3.88912 7.52571 3.71875 7.33804 3.71875H5.79236C5.60502 3.71875 5.46351 3.54896 5.49727 3.36469L5.73891 2.04574C5.76501 1.90328 5.88916 1.7998 6.034 1.7998H11.9267C12.1148 1.7998 12.2565 1.97083 12.2215 2.15561L11.9718 3.47455Z" 272 + fill="currentColor" 273 + /> 274 + </svg> 275 + ); 276 + }; 277 + 278 + const StrikethroughTiny = () => { 279 + return ( 280 + <svg 281 + width="16" 282 + height="16" 283 + viewBox="0 0 16 16" 284 + fill="none" 285 + xmlns="http://www.w3.org/2000/svg" 286 + > 287 + <path 288 + d="M3.47824 10.2282C3.85747 9.95649 4.41094 9.92137 4.79752 10.3202C5.10101 10.6333 5.10273 10.8728 5.40723 11.2778C5.57421 11.4999 5.76937 11.7077 6.05762 11.8315C6.64383 12.0834 7.18409 12.1938 7.83301 12.1938C8.50278 12.1938 9.04034 12.0559 9.44531 11.7808C9.85526 11.5057 10.0605 11.166 10.0605 10.7612C10.0605 10.6778 10.0521 10.5892 10.0356 10.4999C10.0101 10.362 10.1076 10.2202 10.2479 10.2202H12.4776C12.5542 10.2202 12.6253 10.2635 12.6508 10.3357C12.8475 10.8919 12.7128 11.7814 12.2803 12.353C11.8494 12.9241 11.2497 13.371 10.4814 13.6929C9.71303 14.0148 8.82987 14.1763 7.83301 14.1763C6.36899 14.1762 5.19083 13.8689 4.29785 13.2563C3.83342 12.935 3.35679 12.332 3.11095 11.6321C2.94332 11.1549 3.04086 10.5415 3.47824 10.2282ZM7.87207 1.82373C9.27366 1.82373 10.3769 2.12237 11.1816 2.71924C11.5933 3.02271 12.1658 3.57481 12.3008 4.13037C12.4675 4.81678 12.1099 5.40264 11.459 5.45361C10.7289 5.51065 10.5556 5.13107 10.0415 4.62217C9.76697 4.35042 9.359 4.14659 8.98535 4.00928C8.57282 3.85768 8.31587 3.79834 7.87988 3.79834C7.29331 3.79835 6.80013 3.92586 6.40039 4.18018C6.00588 4.43454 5.82897 4.74493 5.77441 5.21338C5.72814 5.61106 5.80186 5.87447 6.02246 6.2085C6.36736 6.73062 7.05638 6.77775 7.67773 6.85693H14.0264C14.5784 6.85712 15.0262 7.30493 15.0264 7.85693C15.0264 8.4091 14.5785 8.85674 14.0264 8.85693H1.97363C1.42151 8.85674 0.973633 8.4091 0.973633 7.85693C0.973831 7.30493 1.42163 6.85712 1.97363 6.85693H3.7688C3.36714 6.37962 3.28552 5.7781 3.28857 5.25617C3.28338 4.51892 3.45712 3.94992 3.86208 3.40995C4.2722 2.86485 4.61406 2.57182 5.34082 2.27588C6.07288 1.97475 6.91677 1.82374 7.87207 1.82373Z" 289 + fill="currentColor" 290 + /> 291 + </svg> 292 + ); 293 + }; 294 + 295 + function TextDecorationButton(props: { 296 + editor: EditorState; 297 + mark: MarkType; 298 + icon: React.ReactNode; 299 + view: RefObject<EditorView | null>; 300 + }) { 301 + let hasMark: boolean = false; 302 + let mark: Mark | null = null; 303 + if (props.editor) { 304 + let { to, from, $cursor, $to, $from } = props.editor 305 + .selection as TextSelection; 306 + 307 + mark = rangeHasMark(props.editor, props.mark, from, to); 308 + if ($cursor) 309 + hasMark = !!props.mark.isInSet( 310 + props.editor.storedMarks || $cursor.marks(), 311 + ); 312 + else { 313 + hasMark = !!mark; 314 + } 315 + } 316 + 317 + return ( 318 + <button 319 + className={`rounded-md hover:bg-border-light p-1 ${hasMark ? "bg-border-light text-primary" : "text-border"}`} 320 + onMouseDown={(e) => { 321 + e.preventDefault(); 322 + if (!props.view.current) return; 323 + toggleMarkInCommentBox(props.view.current, props.mark); 324 + }} 325 + > 326 + {props.icon} 327 + </button> 328 + ); 329 + } 330 + 331 + function toggleMarkInCommentBox( 332 + view: EditorView, 333 + markT: MarkType, 334 + attrs?: any, 335 + ) { 336 + let { to, from, $cursor } = view.state.selection as TextSelection; 337 + let mark = rangeHasMark(view.state, markT, from, to); 338 + if ( 339 + to === from && 340 + markT?.isInSet(view.state.storedMarks || $cursor?.marks() || []) 341 + ) { 342 + return toggleMark(markT, attrs)(view.state, view.dispatch); 343 + } 344 + if ( 345 + mark && 346 + (!attrs || JSON.stringify(attrs) === JSON.stringify(mark.attrs)) 347 + ) { 348 + toggleMark(markT, attrs)(view.state, view.dispatch); 349 + } else setMark(markT, attrs)(view.state, view.dispatch); 350 + }
+70
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { PubLeafletRichtextFacet } from "lexicons/api"; 6 + import { createOauthClient } from "src/atproto-oauth"; 7 + import { TID } from "@atproto/common"; 8 + import { AtUri, Un$Typed } from "@atproto/api"; 9 + import { supabaseServerClient } from "supabase/serverClient"; 10 + import { Json } from "supabase/database.types"; 11 + 12 + export async function publishComment(args: { 13 + document: string; 14 + comment: { 15 + plaintext: string; 16 + facets: PubLeafletRichtextFacet.Main[]; 17 + replyTo?: string; 18 + }; 19 + }) { 20 + const oauthClient = await createOauthClient(); 21 + let identity = await getIdentityData(); 22 + if (!identity || !identity.atp_did) throw new Error("No Identity"); 23 + 24 + let credentialSession = await oauthClient.restore(identity.atp_did); 25 + let agent = new AtpBaseClient( 26 + credentialSession.fetchHandler.bind(credentialSession), 27 + ); 28 + let record: Un$Typed<PubLeafletComment.Record> = { 29 + subject: args.document, 30 + createdAt: new Date().toISOString(), 31 + plaintext: args.comment.plaintext, 32 + facets: args.comment.facets, 33 + reply: args.comment.replyTo ? { parent: args.comment.replyTo } : undefined, 34 + }; 35 + let rkey = TID.nextStr(); 36 + let uri = AtUri.make(credentialSession.did!, "pub.leaflet.comment", rkey); 37 + let [profile, result] = await Promise.all([ 38 + agent.app.bsky.actor.profile.get({ 39 + repo: credentialSession.did!, 40 + rkey: "self", 41 + }), 42 + agent.pub.leaflet.comment.create( 43 + { rkey, repo: credentialSession.did! }, 44 + record, 45 + ), 46 + ]); 47 + 48 + await supabaseServerClient.from("bsky_profiles").upsert({ 49 + did: credentialSession.did!, 50 + record: profile.value as Json, 51 + }); 52 + let { data, error } = await supabaseServerClient 53 + .from("comments_on_documents") 54 + .insert({ 55 + uri: uri.toString(), 56 + document: args.document, 57 + profile: credentialSession.did!, 58 + record: { 59 + $type: "pub.leaflet.comment", 60 + ...record, 61 + } as unknown as Json, 62 + }) 63 + .select(); 64 + 65 + return { 66 + record: data?.[0].record as Json, 67 + profile: profile.value, 68 + uri: uri.toString(), 69 + }; 70 + }
+319
app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 1 + "use client"; 2 + import { CloseTiny } from "components/Icons/CloseTiny"; 3 + import { useInteractionState } from "../Interactions"; 4 + import { useIdentityData } from "components/IdentityProvider"; 5 + import { CommentBox } from "./CommentBox"; 6 + import { Json } from "supabase/database.types"; 7 + import { PubLeafletComment } from "lexicons/api"; 8 + import { BaseTextBlock } from "../../BaseTextBlock"; 9 + import { useMemo, useState } from "react"; 10 + import { CommentTiny } from "components/Icons/CommentTiny"; 11 + import { Separator } from "components/Layout"; 12 + import { ButtonPrimary } from "components/Buttons"; 13 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 + import { Popover } from "components/Popover"; 15 + import { AppBskyActorProfile, AtUri } from "@atproto/api"; 16 + import { timeAgo } from "app/discover/PubListing"; 17 + 18 + export type Comment = { 19 + record: Json; 20 + uri: string; 21 + bsky_profiles: { record: Json } | null; 22 + }; 23 + export function Comments(props: { document_uri: string; comments: Comment[] }) { 24 + let { identity } = useIdentityData(); 25 + let localComments = useInteractionState((l) => l.localComments); 26 + let comments = useMemo(() => { 27 + return [...localComments, ...props.comments]; 28 + }, [props.comments, localComments]); 29 + 30 + return ( 31 + <div className="flex flex-col gap-2 relative"> 32 + <div className="w-full flex justify-between text-secondary font-bold"> 33 + Comments 34 + <button 35 + className="text-tertiary" 36 + onClick={() => useInteractionState.setState({ drawerOpen: false })} 37 + > 38 + <CloseTiny /> 39 + </button> 40 + </div> 41 + {identity?.atp_did ? ( 42 + <CommentBox doc_uri={props.document_uri} /> 43 + ) : ( 44 + <div className="w-full accent-container text-tertiary text-center italic p-3"> 45 + Connect a Bluesky account to comment 46 + <ButtonPrimary compact className="mx-auto mt-1"> 47 + <BlueskyTiny /> Connect to Bluesky 48 + </ButtonPrimary> 49 + </div> 50 + )} 51 + <hr className="border-border-light" /> 52 + <div className="flex flex-col gap-6"> 53 + {comments 54 + .sort((a, b) => { 55 + let aRecord = a.record as PubLeafletComment.Record; 56 + let bRecord = b.record as PubLeafletComment.Record; 57 + return ( 58 + new Date(aRecord.createdAt).getTime() - 59 + new Date(bRecord.createdAt).getTime() 60 + ); 61 + }) 62 + .filter( 63 + (comment) => !(comment.record as PubLeafletComment.Record).reply, 64 + ) 65 + .map((comment) => { 66 + let record = comment.record as PubLeafletComment.Record; 67 + let profile = comment.bsky_profiles 68 + ?.record as AppBskyActorProfile.Record; 69 + return ( 70 + <Comment 71 + profile={profile} 72 + document={props.document_uri} 73 + comment={comment} 74 + record={record} 75 + comments={comments} 76 + key={comment.uri} 77 + /> 78 + ); 79 + })} 80 + </div> 81 + </div> 82 + ); 83 + } 84 + 85 + const Comment = (props: { 86 + document: string; 87 + comment: Comment; 88 + comments: Comment[]; 89 + profile?: AppBskyActorProfile.Record; 90 + record: PubLeafletComment.Record; 91 + }) => { 92 + return ( 93 + <div className="comment"> 94 + <div className="flex gap-2 "> 95 + {props.profile && ( 96 + <ProfilePopover profile={props.profile} comment={props.comment.uri} /> 97 + )} 98 + <DatePopover date={props.record.createdAt} /> 99 + </div> 100 + <pre 101 + key={props.comment.uri} 102 + className="whitespace-pre-wrap text-secondary pb-[4px]" 103 + > 104 + <BaseTextBlock 105 + index={[]} 106 + plaintext={props.record.plaintext} 107 + facets={props.record.facets} 108 + /> 109 + </pre> 110 + <Replies 111 + comment_uri={props.comment.uri} 112 + comments={props.comments} 113 + document={props.document} 114 + /> 115 + </div> 116 + ); 117 + }; 118 + 119 + const Replies = (props: { 120 + comment_uri: string; 121 + comments: Comment[]; 122 + document: string; 123 + }) => { 124 + let { identity } = useIdentityData(); 125 + 126 + let [replyBoxOpen, setReplyBoxOpen] = useState(false); 127 + let [repliesOpen, setRepliesOpen] = useState(true); 128 + let replies = props.comments 129 + .filter( 130 + (comment) => 131 + (comment.record as PubLeafletComment.Record).reply?.parent === 132 + props.comment_uri, 133 + ) 134 + .sort((a, b) => { 135 + let aRecord = a.record as PubLeafletComment.Record; 136 + let bRecord = b.record as PubLeafletComment.Record; 137 + return ( 138 + new Date(aRecord.createdAt).getTime() - 139 + new Date(bRecord.createdAt).getTime() 140 + ); 141 + }); 142 + return ( 143 + <> 144 + <div className="flex gap-2 items-center"> 145 + <button 146 + className="flex gap-1 items-center text-sm text-tertiary" 147 + onClick={() => { 148 + setRepliesOpen(!repliesOpen); 149 + setReplyBoxOpen(false); 150 + }} 151 + > 152 + <CommentTiny className="text-border" /> 0 153 + </button> 154 + {identity?.atp_did && ( 155 + <> 156 + <Separator classname="h-[14px]" /> 157 + <button 158 + className="text-accent-contrast text-sm" 159 + onClick={() => { 160 + setRepliesOpen(true); 161 + setReplyBoxOpen(true); 162 + }} 163 + > 164 + Reply 165 + </button> 166 + </> 167 + )} 168 + </div> 169 + <div className="flex flex-col gap-2"> 170 + {replyBoxOpen && ( 171 + <CommentBox 172 + doc_uri={props.document} 173 + replyTo={props.comment_uri} 174 + onSubmit={() => { 175 + setReplyBoxOpen(false); 176 + }} 177 + /> 178 + )} 179 + {repliesOpen && replies.length > 0 && ( 180 + <div className="repliesWrapper flex"> 181 + <button 182 + className="repliesCollapse pr-[14px] ml-[7px] pt-0.5" 183 + onClick={() => { 184 + setReplyBoxOpen(false); 185 + setRepliesOpen(false); 186 + }} 187 + > 188 + <div className="bg-border-light w-[2px] h-full" /> 189 + </button> 190 + <div className="repliesContent flex flex-col gap-3 pt-2 w-full"> 191 + {replies.map((reply) => { 192 + return ( 193 + <Comment 194 + document={props.document} 195 + key={reply.uri} 196 + comment={reply} 197 + profile={ 198 + reply.bsky_profiles?.record as AppBskyActorProfile.Record 199 + } 200 + record={reply.record as PubLeafletComment.Record} 201 + comments={props.comments} 202 + /> 203 + ); 204 + })} 205 + </div> 206 + </div> 207 + )} 208 + </div> 209 + </> 210 + ); 211 + }; 212 + 213 + const DatePopover = (props: { date: string }) => { 214 + let t = useMemo(() => { 215 + return timeAgo(props.date); 216 + }, [props.date]); 217 + return ( 218 + <Popover 219 + trigger={ 220 + <div className="italic text-sm text-tertiary hover:underline">{t}</div> 221 + } 222 + > 223 + <div className="text-sm text-secondary">8/18/2025 4:32PM</div> 224 + </Popover> 225 + ); 226 + }; 227 + 228 + const ProfilePopover = (props: { 229 + profile: AppBskyActorProfile.Record; 230 + comment: string; 231 + }) => { 232 + let commenterId = new AtUri(props.comment).host; 233 + 234 + return ( 235 + <> 236 + <a 237 + className="font-bold text-tertiary text-sm hover:underline" 238 + href={`https://bsky.app/profile/${commenterId}`} 239 + > 240 + {props.profile.displayName} 241 + </a> 242 + {/*<Media mobile={false}> 243 + <Popover 244 + align="start" 245 + trigger={ 246 + <div 247 + onMouseOver={() => { 248 + setHovering(true); 249 + hoverTimeout.current = window.setTimeout(() => { 250 + setLoadProfile(true); 251 + }, 500); 252 + }} 253 + onMouseOut={() => { 254 + setHovering(false); 255 + clearTimeout(hoverTimeout.current); 256 + }} 257 + className="font-bold text-tertiary text-sm hover:underline" 258 + > 259 + {props.profile.displayName} 260 + </div> 261 + } 262 + className="max-w-sm" 263 + > 264 + {profile && ( 265 + <> 266 + <div className="profilePopover text-sm flex gap-2"> 267 + <div className="w-5 h-5 bg-test rounded-full shrink-0 mt-[2px]" /> 268 + <div className="flex flex-col"> 269 + <div className="flex justify-between"> 270 + <div className="profileHeader flex gap-2 items-center"> 271 + <div className="font-bold">celine</div> 272 + <a className="text-tertiary" href="/"> 273 + @{profile.handle} 274 + </a> 275 + </div> 276 + </div> 277 + 278 + <div className="profileBio text-secondary "> 279 + {profile.description} 280 + </div> 281 + <div className="flex flex-row gap-2 items-center pt-2 font-bold"> 282 + {!profile.viewer?.following ? ( 283 + <div className="text-tertiary bg-border-light rounded-md px-1 py-0"> 284 + Following 285 + </div> 286 + ) : ( 287 + <ButtonPrimary compact className="text-sm"> 288 + Follow <BlueskyTiny /> 289 + </ButtonPrimary> 290 + )} 291 + {profile.viewer?.followedBy && ( 292 + <div className="text-tertiary">Follows You</div> 293 + )} 294 + </div> 295 + </div> 296 + </div> 297 + 298 + <hr className="my-2 border-border-light" /> 299 + <div className="flex gap-2 leading-tight items-center text-tertiary text-sm"> 300 + <div className="flex flex-col w-6 justify-center"> 301 + {profile.viewer?.knownFollowers?.followers.map((follower) => { 302 + return ( 303 + <div 304 + className="w-[18px] h-[18px] bg-test rounded-full border-2 border-bg-page" 305 + key={follower.did} 306 + /> 307 + ); 308 + })} 309 + <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 310 + <div className="w-[18px] h-[18px] bg-test rounded-full -mt-2 border-2 border-bg-page" /> 311 + </div> 312 + </div> 313 + </> 314 + )} 315 + </Popover> 316 + </Media>*/} 317 + </> 318 + ); 319 + };
+13 -3
app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 3 3 import { Quotes } from "./Quotes"; 4 4 import { useInteractionState } from "./Interactions"; 5 5 import { Json } from "supabase/database.types"; 6 + import { Comment, Comments } from "./Comments"; 6 7 7 8 export const InteractionDrawer = (props: { 9 + document_uri: string; 8 10 quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 11 + comments: Comment[]; 9 12 did: string; 10 13 }) => { 11 - let { drawerOpen: open } = useInteractionState(); 14 + let { drawerOpen: open, drawer } = useInteractionState(); 12 15 if (!open) return null; 13 16 return ( 14 17 <> 15 18 <div className="sm:pr-4 pr-[6px] snap-center"> 16 - <div className="shrink-0 w-96 max-w-[var(--page-width-units)] h-full flex z-10"> 19 + <div className="shrink-0 w-[calc(var(--page-width-units)-12px)] sm:w-[var(--page-width-units)] h-full flex z-10"> 17 20 <div 18 21 id="interaction-drawer" 19 22 className="opaque-container !rounded-lg h-full w-full px-3 sm:px-4 pt-2 sm:pt-3 pb-6 overflow-scroll " 20 23 > 21 - <Quotes {...props} /> 24 + {drawer === "quotes" ? ( 25 + <Quotes {...props} /> 26 + ) : ( 27 + <Comments 28 + document_uri={props.document_uri} 29 + comments={props.comments} 30 + /> 31 + )} 22 32 </div> 23 33 </div> 24 34 </div>
+31 -39
app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 1 1 "use client"; 2 + import { CommentTiny } from "components/Icons/CommentTiny"; 2 3 import { QuoteTiny } from "components/Icons/QuoteTiny"; 3 4 import { flushSync } from "react-dom"; 4 - import { Json } from "supabase/database.types"; 5 + import type { Json } from "supabase/database.types"; 5 6 import { create } from "zustand"; 7 + import type { Comment } from "./Comments"; 6 8 7 - export let useInteractionState = create(() => ({ drawerOpen: false })); 8 - export function openInteractionDrawer() { 9 + export let useInteractionState = create(() => ({ 10 + drawerOpen: false, 11 + drawer: undefined as undefined | "comments" | "quotes", 12 + localComments: [] as Comment[], 13 + })); 14 + export function openInteractionDrawer(drawer: "comments" | "quotes") { 9 15 flushSync(() => { 10 - useInteractionState.setState({ drawerOpen: true }); 16 + useInteractionState.setState({ drawerOpen: true, drawer }); 11 17 }); 12 18 let el = document.getElementById("interaction-drawer"); 13 - let isIntersecting = false; 19 + let isOffscreen = false; 14 20 if (el) { 15 21 const rect = el.getBoundingClientRect(); 16 22 const windowHeight = 17 23 window.innerHeight || document.documentElement.clientHeight; 18 24 const windowWidth = 19 25 window.innerWidth || document.documentElement.clientWidth; 20 - isIntersecting = 21 - rect.top < windowHeight && 22 - rect.bottom > 0 && 23 - rect.left < windowWidth && 24 - rect.right > 0; 26 + isOffscreen = rect.right > windowWidth; 25 27 } 26 28 27 - if (el && !isIntersecting) el.scrollIntoView({ behavior: "smooth" }); 29 + if (el && isOffscreen) el.scrollIntoView({ behavior: "smooth" }); 28 30 } 29 31 30 32 export const Interactions = (props: { 31 - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 33 + quotesCount: number; 34 + commentsCount: number; 32 35 compact?: boolean; 33 36 className?: string; 34 37 }) => { 35 - let { drawerOpen } = useInteractionState(); 38 + let { drawerOpen, drawer } = useInteractionState(); 36 39 37 40 return ( 38 41 <div ··· 41 44 <button 42 45 className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 43 46 onClick={() => { 44 - if (!drawerOpen) openInteractionDrawer(); 47 + if (!drawerOpen || drawer !== "quotes") 48 + openInteractionDrawer("quotes"); 45 49 else useInteractionState.setState({ drawerOpen: false }); 46 50 }} 47 51 > 48 - <QuoteTiny /> {props.quotes.length}{" "} 49 - {!props.compact && `Quote${props.quotes.length === 1 ? "" : "s"}`} 52 + <QuoteTiny /> {props.quotesCount}{" "} 53 + {!props.compact && `Quote${props.quotesCount === 1 ? "" : "s"}`} 54 + </button> 55 + <button 56 + className={`flex gap-1 items-center ${!props.compact && "px-1 py-0.5 border border-border-light rounded-lg trasparent-outline selected-outline"}`} 57 + onClick={() => { 58 + if (!drawerOpen || drawer !== "comments") 59 + openInteractionDrawer("comments"); 60 + else useInteractionState.setState({ drawerOpen: false }); 61 + }} 62 + > 63 + <CommentTiny /> {props.commentsCount}{" "} 64 + {!props.compact && `Comment${props.commentsCount === 1 ? "" : "s"}`} 50 65 </button> 51 66 </div> 52 67 ); 53 68 }; 54 - 55 - function getFirstScrollableAncestor(element: HTMLElement): HTMLElement | null { 56 - let parent = element.parentElement; 57 - 58 - while (parent) { 59 - const computedStyle = window.getComputedStyle(parent); 60 - const overflowY = computedStyle.overflowY; 61 - const overflowX = computedStyle.overflowX; 62 - 63 - if ( 64 - overflowY === "scroll" || 65 - overflowY === "auto" || 66 - overflowX === "scroll" || 67 - overflowX === "auto" 68 - ) { 69 - return parent; 70 - } 71 - 72 - parent = parent.parentElement; 73 - } 74 - 75 - return null; 76 - }
+1 -1
app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
··· 47 47 <div>highlight any part of this post to quote it</div> 48 48 </div> 49 49 ) : ( 50 - <div className="quotes flex flex-col gap-12"> 50 + <div className="quotes flex flex-col gap-8"> 51 51 {props.quotes.map((q, index) => { 52 52 let pv = q.bsky_posts?.post_view as unknown as PostView; 53 53 let record = data?.data as PubLeafletDocument.Record;
+1 -1
app/lish/[did]/[publication]/[rkey]/PageLayout.tsx
··· 14 14 in [rkey]/page/PostPage when card borders are hidden */} 15 15 <div 16 16 id="pages" 17 - className="postWrapper flex h-full gap-3 py-2 sm:py-6 w-full" 17 + className="postWrapper flex h-full gap-0 sm:gap-3 py-2 sm:py-6 w-full" 18 18 > 19 19 {props.children} 20 20 </div>
+51 -51
app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx
··· 8 8 import { useState, useEffect } from "react"; 9 9 import { Json } from "supabase/database.types"; 10 10 11 - export const CollapsedPostHeader = (props: { 12 - title: string; 13 - pubIcon?: string; 14 - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 15 - }) => { 16 - let [headerVisible, setHeaderVisible] = useState(false); 17 - let { drawerOpen: open } = useInteractionState(); 11 + // export const CollapsedPostHeader = (props: { 12 + // title: string; 13 + // pubIcon?: string; 14 + // quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; 15 + // }) => { 16 + // let [headerVisible, setHeaderVisible] = useState(false); 17 + // let { drawerOpen: open } = useInteractionState(); 18 18 19 - useEffect(() => { 20 - let post = window.document.getElementById("post-page"); 19 + // useEffect(() => { 20 + // let post = window.document.getElementById("post-page"); 21 21 22 - function handleScroll() { 23 - let postHeader = window.document 24 - .getElementById("post-header") 25 - ?.getBoundingClientRect(); 26 - if (postHeader && postHeader.bottom <= 0) { 27 - setHeaderVisible(true); 28 - } else { 29 - setHeaderVisible(false); 30 - } 31 - } 32 - post?.addEventListener("scroll", handleScroll); 33 - return () => { 34 - post?.removeEventListener("scroll", handleScroll); 35 - }; 36 - }, []); 37 - if (!headerVisible) return; 38 - if (open) return; 39 - return ( 40 - <Media 41 - mobile 42 - className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3" 43 - > 44 - <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 "> 45 - <div className="text-tertiary font-bold text-sm truncate pr-1 grow"> 46 - {props.title} 47 - </div> 48 - <div className="flex gap-2 "> 49 - <Interactions compact quotes={props.quotes} /> 50 - <div 51 - style={{ 52 - backgroundRepeat: "no-repeat", 53 - backgroundPosition: "center", 54 - backgroundSize: "cover", 55 - backgroundImage: `url(${props.pubIcon})`, 56 - }} 57 - className="shrink-0 w-4 h-4 rounded-full mt-[2px]" 58 - /> 59 - </div> 60 - </div> 61 - </Media> 62 - ); 63 - }; 22 + // function handleScroll() { 23 + // let postHeader = window.document 24 + // .getElementById("post-header") 25 + // ?.getBoundingClientRect(); 26 + // if (postHeader && postHeader.bottom <= 0) { 27 + // setHeaderVisible(true); 28 + // } else { 29 + // setHeaderVisible(false); 30 + // } 31 + // } 32 + // post?.addEventListener("scroll", handleScroll); 33 + // return () => { 34 + // post?.removeEventListener("scroll", handleScroll); 35 + // }; 36 + // }, []); 37 + // if (!headerVisible) return; 38 + // if (open) return; 39 + // return ( 40 + // <Media 41 + // mobile 42 + // className="sticky top-0 left-0 right-0 w-full bg-bg-page border-b border-border-light -mx-3" 43 + // > 44 + // <div className="flex gap-2 items-center justify-between px-3 pt-2 pb-0.5 "> 45 + // <div className="text-tertiary font-bold text-sm truncate pr-1 grow"> 46 + // {props.title} 47 + // </div> 48 + // <div className="flex gap-2 "> 49 + // <Interactions compact quotes={props.quotes.length} /> 50 + // <div 51 + // style={{ 52 + // backgroundRepeat: "no-repeat", 53 + // backgroundPosition: "center", 54 + // backgroundSize: "cover", 55 + // backgroundImage: `url(${props.pubIcon})`, 56 + // }} 57 + // className="shrink-0 w-4 h-4 rounded-full mt-[2px]" 58 + // /> 59 + // </div> 60 + // </div> 61 + // </Media> 62 + // ); 63 + // };
+30 -27
app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 2 2 import Link from "next/link"; 3 3 import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 4 4 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 5 - import { CollapsedPostHeader } from "./CollapsedPostHeader"; 6 5 import { Interactions } from "../Interactions/Interactions"; 7 6 import { PostPageData } from "../getPostPageData"; 8 7 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 9 8 import { useIdentityData } from "components/IdentityProvider"; 10 9 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 11 10 import { AtUri } from "@atproto/syntax"; 11 + import { EditTiny } from "components/Icons/EditTiny"; 12 12 13 13 export function PostHeader(props: { 14 14 data: PostPageData; ··· 38 38 /> */} 39 39 <div className="max-w-prose w-full mx-auto" id="post-header"> 40 40 <div className="pubHeader flex flex-col pb-5"> 41 - <Link 42 - className="font-bold hover:no-underline text-accent-contrast" 43 - href={ 44 - document && 45 - getPublicationURL( 46 - document.documents_in_publications[0].publications, 47 - ) 48 - } 49 - > 50 - {props.name} 51 - </Link> 41 + <div className="flex justify-between w-full"> 42 + <Link 43 + className="font-bold hover:no-underline text-accent-contrast" 44 + href={ 45 + document && 46 + getPublicationURL( 47 + document.documents_in_publications[0].publications, 48 + ) 49 + } 50 + > 51 + {props.name} 52 + </Link> 53 + {identity && 54 + identity.atp_did === 55 + document.documents_in_publications[0]?.publications 56 + .identity_did && ( 57 + <a 58 + className=" rounded-full flex place-items-center" 59 + href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 60 + > 61 + <EditTiny className="shrink-0" /> 62 + </a> 63 + )} 64 + </div> 52 65 <h2 className="">{record.title}</h2> 53 66 {record.description ? ( 54 67 <p className="italic text-secondary">{record.description}</p> ··· 78 91 </> 79 92 ) : null} 80 93 |{" "} 81 - <Interactions compact quotes={document.document_mentions_in_bsky} /> 82 - {identity && 83 - identity.atp_did === 84 - document.documents_in_publications[0]?.publications 85 - .identity_did && ( 86 - <> 87 - {" "} 88 - | 89 - <a 90 - href={`https://leaflet.pub/${document.leaflets_in_publications[0].leaflet}`} 91 - > 92 - Edit Post 93 - </a> 94 - </> 95 - )} 94 + <Interactions 95 + compact 96 + quotesCount={document.document_mentions_in_bsky.length} 97 + commentsCount={document.comments_on_documents.length} 98 + /> 96 99 </div> 97 100 </div> 98 101 </div>
+4 -1
app/lish/[did]/[publication]/[rkey]/PostPage.tsx
··· 69 69 did={did} 70 70 prerenderedCodeBlocks={prerenderedCodeBlocks} 71 71 /> 72 - <Interactions quotes={document.document_mentions_in_bsky} /> 72 + <Interactions 73 + quotesCount={document.document_mentions_in_bsky.length} 74 + commentsCount={document.comments_on_documents.length} 75 + /> 73 76 <hr className="border-border-light mb-4 mt-4" /> 74 77 {identity && 75 78 identity.atp_did ===
+2
app/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 7 7 .select( 8 8 ` 9 9 data, 10 + uri, 11 + comments_on_documents(*, bsky_profiles(*)), 10 12 documents_in_publications(publications(*, publication_subscriptions(*))), 11 13 document_mentions_in_bsky(*, bsky_posts(*)), 12 14 leaflets_in_publications(*)
+13 -7
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 85 85 ]); 86 86 if (!document?.data || !document.documents_in_publications[0].publications) 87 87 return ( 88 - <div className="p-4 text-lg text-center flex flex-col gap-4"> 89 - <p>Sorry, post not found!</p> 90 - <p> 91 - This may be a glitch on our end. If the issue persists please{" "} 92 - <a href="mailto:contact@leaflet.pub">send us a note</a>. 93 - </p> 88 + <div className="bg-bg-leaflet h-full p-3 text-center relative"> 89 + <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 max-w-md w-full"> 90 + <div className=" px-3 py-4 opaque-container flex flex-col gap-1 mx-2 "> 91 + <h3>Sorry, post not found!</h3> 92 + <p> 93 + This may be a glitch on our end. If the issue persists please{" "} 94 + <a href="mailto:contact@leaflet.pub">send us a note</a>. 95 + </p> 96 + </div> 97 + </div> 94 98 </div> 95 99 ); 96 100 let record = document.data as PubLeafletDocument.Record; ··· 157 161 <PageLayout> 158 162 <PostPage 159 163 pubRecord={pubRecord} 160 - profile={profile.data} 164 + profile={JSON.parse(JSON.stringify(profile.data))} 161 165 document={document} 162 166 bskyPostData={bskyPostData.data.posts} 163 167 did={did} ··· 166 170 prerenderedCodeBlocks={prerenderedCodeBlocks} 167 171 /> 168 172 <InteractionDrawer 173 + document_uri={document.uri} 174 + comments={document.comments_on_documents} 169 175 quotes={document.document_mentions_in_bsky} 170 176 did={did} 171 177 />
+1
components/Icons/EditTiny.tsx
··· 8 8 viewBox="0 0 16 16" 9 9 fill="none" 10 10 xmlns="http://www.w3.org/2000/svg" 11 + {...props} 11 12 > 12 13 <path 13 14 d="M10.1056 1.68769L12.1633 2.08905L12.2336 2.10858C12.3024 2.13294 12.3657 2.17212 12.4181 2.22382L13.8303 3.61933C13.9072 3.69532 13.9578 3.79462 13.9738 3.90155L14.2668 5.8664C14.2902 6.02306 14.2381 6.18207 14.1262 6.29413L7.04803 13.3723C6.99391 13.4263 6.92837 13.4678 6.85662 13.4924L6.78338 13.5109L2.31756 14.3137C2.1587 14.3422 1.99469 14.2925 1.87908 14.1799C1.76354 14.0671 1.70901 13.9049 1.73358 13.7453L2.43768 9.17987L2.45623 9.10272C2.48044 9.02777 2.5221 8.95878 2.5783 8.90253L9.65643 1.8244L9.7033 1.78339C9.8165 1.6953 9.96291 1.6599 10.1056 1.68769ZM3.40155 9.49335L3.10858 11.3879C3.47498 11.4495 3.97079 11.6089 4.1867 11.8312C4.58807 12.2451 4.57767 12.6117 4.5783 12.8908L6.45233 12.5539L13.2404 5.76581L13.1154 4.93085L11.8312 6.21601C11.6361 6.41112 11.3195 6.41084 11.1242 6.21601C10.9289 6.02074 10.9289 5.70424 11.1242 5.50897L12.7111 3.92011L12.05 3.26679L6.89862 8.33808C6.7018 8.53176 6.38528 8.52903 6.19158 8.33222C5.99798 8.13539 6.00066 7.81885 6.19744 7.62519L11.0148 2.88397L10.175 2.71991L3.40155 9.49335ZM10.1213 6.45722C10.3143 6.32736 10.5789 6.34638 10.7512 6.51581C10.9235 6.68535 10.9472 6.94959 10.8205 7.14472L10.757 7.22284L6.67694 11.3693C6.48327 11.5661 6.16674 11.5688 5.9699 11.3752C5.77307 11.1815 5.77037 10.865 5.96405 10.6682L10.0441 6.52167L10.1213 6.45722ZM5.06854 8.71698C5.26259 8.58889 5.5266 8.61065 5.69744 8.78144C5.86828 8.95227 5.88998 9.21627 5.7619 9.41034L5.69744 9.48847L5.35662 9.82929C5.16136 10.0245 4.84485 10.0245 4.64959 9.82929C4.45439 9.63402 4.45435 9.3175 4.64959 9.12226L4.99041 8.78144L5.06854 8.71698Z"
+3 -3
components/Toolbar/TextToolbar.tsx
··· 87 87 ); 88 88 }; 89 89 90 - const ItalicSmall = (props: Props) => { 90 + export const ItalicSmall = (props: Props) => { 91 91 return ( 92 92 <svg 93 93 width="24" ··· 105 105 ); 106 106 }; 107 107 108 - const StrikethroughSmall = (props: Props) => { 108 + export const StrikethroughSmall = (props: Props) => { 109 109 return ( 110 110 <svg 111 111 width="24" ··· 124 124 </svg> 125 125 ); 126 126 }; 127 - const BoldSmall = (props: Props) => { 127 + export const BoldSmall = (props: Props) => { 128 128 return ( 129 129 <svg 130 130 width="24"
+34 -16
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { identities, bsky_profiles, entities, facts, entity_sets, permission_tokens, email_subscriptions_to_entity, email_auth_tokens, custom_domains, phone_rsvps_to_entity, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, documents, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, documents_in_publications, publication_domains, publication_subscriptions, leaflets_in_publications, permission_token_rights } from "./schema"; 2 + import { identities, bsky_profiles, publications, documents, comments_on_documents, entities, facts, entity_sets, permission_tokens, email_subscriptions_to_entity, email_auth_tokens, custom_domains, phone_rsvps_to_entity, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, document_mentions_in_bsky, bsky_posts, permission_token_on_homepage, documents_in_publications, publication_domains, publication_subscriptions, leaflets_in_publications, permission_token_rights } from "./schema"; 3 3 4 - export const bsky_profilesRelations = relations(bsky_profiles, ({one}) => ({ 4 + export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ 5 5 identity: one(identities, { 6 6 fields: [bsky_profiles.did], 7 7 references: [identities.atp_did] 8 8 }), 9 + comments_on_documents: many(comments_on_documents), 9 10 })); 10 11 11 12 export const identitiesRelations = relations(identities, ({one, many}) => ({ 12 13 bsky_profiles: many(bsky_profiles), 14 + publications: many(publications), 13 15 permission_token: one(permission_tokens, { 14 16 fields: [identities.home_page], 15 17 references: [permission_tokens.id] ··· 25 27 permission_token_on_homepages: many(permission_token_on_homepage), 26 28 publication_domains: many(publication_domains), 27 29 publication_subscriptions: many(publication_subscriptions), 30 + })); 31 + 32 + export const publicationsRelations = relations(publications, ({one, many}) => ({ 33 + identity: one(identities, { 34 + fields: [publications.identity_did], 35 + references: [identities.atp_did] 36 + }), 37 + subscribers_to_publications: many(subscribers_to_publications), 38 + documents_in_publications: many(documents_in_publications), 39 + publication_domains: many(publication_domains), 40 + publication_subscriptions: many(publication_subscriptions), 41 + leaflets_in_publications: many(leaflets_in_publications), 42 + })); 43 + 44 + export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ 45 + document: one(documents, { 46 + fields: [comments_on_documents.document], 47 + references: [documents.uri] 48 + }), 49 + bsky_profile: one(bsky_profiles, { 50 + fields: [comments_on_documents.profile], 51 + references: [bsky_profiles.did] 52 + }), 53 + })); 54 + 55 + export const documentsRelations = relations(documents, ({many}) => ({ 56 + comments_on_documents: many(comments_on_documents), 57 + document_mentions_in_bskies: many(document_mentions_in_bsky), 58 + documents_in_publications: many(documents_in_publications), 59 + leaflets_in_publications: many(leaflets_in_publications), 28 60 })); 29 61 30 62 export const factsRelations = relations(facts, ({one}) => ({ ··· 155 187 }), 156 188 })); 157 189 158 - export const publicationsRelations = relations(publications, ({many}) => ({ 159 - subscribers_to_publications: many(subscribers_to_publications), 160 - documents_in_publications: many(documents_in_publications), 161 - publication_domains: many(publication_domains), 162 - publication_subscriptions: many(publication_subscriptions), 163 - leaflets_in_publications: many(leaflets_in_publications), 164 - })); 165 - 166 190 export const document_mentions_in_bskyRelations = relations(document_mentions_in_bsky, ({one}) => ({ 167 191 document: one(documents, { 168 192 fields: [document_mentions_in_bsky.document], ··· 172 196 fields: [document_mentions_in_bsky.uri], 173 197 references: [bsky_posts.uri] 174 198 }), 175 - })); 176 - 177 - export const documentsRelations = relations(documents, ({many}) => ({ 178 - document_mentions_in_bskies: many(document_mentions_in_bsky), 179 - documents_in_publications: many(documents_in_publications), 180 - leaflets_in_publications: many(leaflets_in_publications), 181 199 })); 182 200 183 201 export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({
+9 -1
drizzle/schema.ts
··· 35 35 uri: text("uri").primaryKey().notNull(), 36 36 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 37 37 name: text("name").notNull(), 38 - identity_did: text("identity_did").notNull(), 38 + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), 39 39 record: jsonb("record"), 40 40 }); 41 41 ··· 44 44 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 45 45 post_view: jsonb("post_view").notNull(), 46 46 cid: text("cid").notNull(), 47 + }); 48 + 49 + export const comments_on_documents = pgTable("comments_on_documents", { 50 + uri: text("uri").primaryKey().notNull(), 51 + record: jsonb("record").notNull(), 52 + document: text("document").references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 53 + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 54 + profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), 47 55 }); 48 56 49 57 export const facts = pgTable("facts", {
+65
lexicons/api/index.ts
··· 5 5 import { schemas } from './lexicons' 6 6 import { CID } from 'multiformats/cid' 7 7 import { OmitKey, Un$Typed } from './util' 8 + import * as PubLeafletComment from './types/pub/leaflet/comment' 8 9 import * as PubLeafletDocument from './types/pub/leaflet/document' 9 10 import * as PubLeafletPublication from './types/pub/leaflet/publication' 10 11 import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' ··· 37 38 import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' 38 39 import * as AppBskyActorProfile from './types/app/bsky/actor/profile' 39 40 41 + export * as PubLeafletComment from './types/pub/leaflet/comment' 40 42 export * as PubLeafletDocument from './types/pub/leaflet/document' 41 43 export * as PubLeafletPublication from './types/pub/leaflet/publication' 42 44 export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' ··· 107 109 108 110 export class PubLeafletNS { 109 111 _client: XrpcClient 112 + comment: CommentRecord 110 113 document: DocumentRecord 111 114 publication: PublicationRecord 112 115 blocks: PubLeafletBlocksNS ··· 122 125 this.pages = new PubLeafletPagesNS(client) 123 126 this.richtext = new PubLeafletRichtextNS(client) 124 127 this.theme = new PubLeafletThemeNS(client) 128 + this.comment = new CommentRecord(client) 125 129 this.document = new DocumentRecord(client) 126 130 this.publication = new PublicationRecord(client) 127 131 } ··· 231 235 232 236 constructor(client: XrpcClient) { 233 237 this._client = client 238 + } 239 + } 240 + 241 + export class CommentRecord { 242 + _client: XrpcClient 243 + 244 + constructor(client: XrpcClient) { 245 + this._client = client 246 + } 247 + 248 + async list( 249 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 250 + ): Promise<{ 251 + cursor?: string 252 + records: { uri: string; value: PubLeafletComment.Record }[] 253 + }> { 254 + const res = await this._client.call('com.atproto.repo.listRecords', { 255 + collection: 'pub.leaflet.comment', 256 + ...params, 257 + }) 258 + return res.data 259 + } 260 + 261 + async get( 262 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 263 + ): Promise<{ uri: string; cid: string; value: PubLeafletComment.Record }> { 264 + const res = await this._client.call('com.atproto.repo.getRecord', { 265 + collection: 'pub.leaflet.comment', 266 + ...params, 267 + }) 268 + return res.data 269 + } 270 + 271 + async create( 272 + params: OmitKey< 273 + ComAtprotoRepoCreateRecord.InputSchema, 274 + 'collection' | 'record' 275 + >, 276 + record: Un$Typed<PubLeafletComment.Record>, 277 + headers?: Record<string, string>, 278 + ): Promise<{ uri: string; cid: string }> { 279 + const collection = 'pub.leaflet.comment' 280 + const res = await this._client.call( 281 + 'com.atproto.repo.createRecord', 282 + undefined, 283 + { collection, ...params, record: { ...record, $type: collection } }, 284 + { encoding: 'application/json', headers }, 285 + ) 286 + return res.data 287 + } 288 + 289 + async delete( 290 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 291 + headers?: Record<string, string>, 292 + ): Promise<void> { 293 + await this._client.call( 294 + 'com.atproto.repo.deleteRecord', 295 + undefined, 296 + { collection: 'pub.leaflet.comment', ...params }, 297 + { headers }, 298 + ) 234 299 } 235 300 } 236 301
+52
lexicons/api/lexicons.ts
··· 10 10 import { $Typed, is$typed, maybe$typed } from './util' 11 11 12 12 export const schemaDict = { 13 + PubLeafletComment: { 14 + lexicon: 1, 15 + id: 'pub.leaflet.comment', 16 + revision: 1, 17 + description: 'A lexicon for comments on documents', 18 + defs: { 19 + main: { 20 + type: 'record', 21 + key: 'tid', 22 + description: 'Record containing a comment', 23 + record: { 24 + type: 'object', 25 + required: ['subject', 'plaintext', 'createdAt'], 26 + properties: { 27 + subject: { 28 + type: 'string', 29 + format: 'at-uri', 30 + }, 31 + createdAt: { 32 + type: 'string', 33 + format: 'datetime', 34 + }, 35 + reply: { 36 + type: 'ref', 37 + ref: 'lex:pub.leaflet.comment#replyRef', 38 + }, 39 + plaintext: { 40 + type: 'string', 41 + }, 42 + facets: { 43 + type: 'array', 44 + items: { 45 + type: 'ref', 46 + ref: 'lex:pub.leaflet.richtext.facet', 47 + }, 48 + }, 49 + }, 50 + }, 51 + }, 52 + replyRef: { 53 + type: 'object', 54 + required: ['parent'], 55 + properties: { 56 + parent: { 57 + type: 'string', 58 + format: 'at-uri', 59 + }, 60 + }, 61 + }, 62 + }, 63 + }, 13 64 PubLeafletDocument: { 14 65 lexicon: 1, 15 66 id: 'pub.leaflet.document', ··· 1699 1750 } 1700 1751 1701 1752 export const ids = { 1753 + PubLeafletComment: 'pub.leaflet.comment', 1702 1754 PubLeafletDocument: 'pub.leaflet.document', 1703 1755 PubLeafletPublication: 'pub.leaflet.publication', 1704 1756 PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote',
+47
lexicons/api/types/pub/leaflet/comment.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' 8 + import type * as PubLeafletRichtextFacet from './richtext/facet' 9 + 10 + const is$typed = _is$typed, 11 + validate = _validate 12 + const id = 'pub.leaflet.comment' 13 + 14 + export interface Record { 15 + $type: 'pub.leaflet.comment' 16 + subject: string 17 + createdAt: string 18 + reply?: ReplyRef 19 + plaintext: string 20 + facets?: PubLeafletRichtextFacet.Main[] 21 + [k: string]: unknown 22 + } 23 + 24 + const hashRecord = 'main' 25 + 26 + export function isRecord<V>(v: V) { 27 + return is$typed(v, id, hashRecord) 28 + } 29 + 30 + export function validateRecord<V>(v: V) { 31 + return validate<Record & V>(v, id, hashRecord, true) 32 + } 33 + 34 + export interface ReplyRef { 35 + $type?: 'pub.leaflet.comment#replyRef' 36 + parent: string 37 + } 38 + 39 + const hashReplyRef = 'replyRef' 40 + 41 + export function isReplyRef<V>(v: V) { 42 + return is$typed(v, id, hashReplyRef) 43 + } 44 + 45 + export function validateReplyRef<V>(v: V) { 46 + return validate<ReplyRef & V>(v, id, hashReplyRef) 47 + }
+2
lexicons/build.ts
··· 7 7 import * as fs from "fs"; 8 8 import * as path from "path"; 9 9 import { PubLeafletRichTextFacet } from "./src/facet"; 10 + import { PubLeafletComment } from "./src/comment"; 10 11 11 12 const outdir = path.join("lexicons", "pub", "leaflet"); 12 13 ··· 17 18 18 19 const lexicons = [ 19 20 PubLeafletDocument, 21 + PubLeafletComment, 20 22 PubLeafletRichTextFacet, 21 23 PageLexicons.PubLeafletPagesLinearDocument, 22 24 ...ThemeLexicons,
+57
lexicons/pub/leaflet/comment.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.comment", 4 + "revision": 1, 5 + "description": "A lexicon for comments on documents", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "description": "Record containing a comment", 11 + "record": { 12 + "type": "object", 13 + "required": [ 14 + "subject", 15 + "plaintext", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "subject": { 20 + "type": "string", 21 + "format": "at-uri" 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime" 26 + }, 27 + "reply": { 28 + "type": "ref", 29 + "ref": "#replyRef" 30 + }, 31 + "plaintext": { 32 + "type": "string" 33 + }, 34 + "facets": { 35 + "type": "array", 36 + "items": { 37 + "type": "ref", 38 + "ref": "pub.leaflet.richtext.facet" 39 + } 40 + } 41 + } 42 + } 43 + }, 44 + "replyRef": { 45 + "type": "object", 46 + "required": [ 47 + "parent" 48 + ], 49 + "properties": { 50 + "parent": { 51 + "type": "string", 52 + "format": "at-uri" 53 + } 54 + } 55 + } 56 + } 57 + }
+37
lexicons/src/comment.ts
··· 1 + import { LexiconDoc } from "@atproto/lexicon"; 2 + import { PubLeafletRichTextFacet } from "./facet"; 3 + 4 + export const PubLeafletComment: LexiconDoc = { 5 + lexicon: 1, 6 + id: "pub.leaflet.comment", 7 + revision: 1, 8 + description: "A lexicon for comments on documents", 9 + defs: { 10 + main: { 11 + type: "record", 12 + key: "tid", 13 + description: "Record containing a comment", 14 + record: { 15 + type: "object", 16 + required: ["subject", "plaintext", "createdAt"], 17 + properties: { 18 + subject: { type: "string", format: "at-uri" }, 19 + createdAt: { type: "string", format: "datetime" }, 20 + reply: { type: "ref", ref: "#replyRef" }, 21 + plaintext: { type: "string" }, 22 + facets: { 23 + type: "array", 24 + items: { type: "ref", ref: PubLeafletRichTextFacet.id }, 25 + }, 26 + }, 27 + }, 28 + }, 29 + replyRef: { 30 + type: "object", 31 + required: ["parent"], 32 + properties: { 33 + parent: { type: "string", format: "at-uri" }, 34 + }, 35 + }, 36 + }, 37 + };
+48 -1
supabase/database.types.ts
··· 84 84 }, 85 85 ] 86 86 } 87 + comments_on_documents: { 88 + Row: { 89 + document: string | null 90 + indexed_at: string 91 + profile: string | null 92 + record: Json 93 + uri: string 94 + } 95 + Insert: { 96 + document?: string | null 97 + indexed_at?: string 98 + profile?: string | null 99 + record: Json 100 + uri: string 101 + } 102 + Update: { 103 + document?: string | null 104 + indexed_at?: string 105 + profile?: string | null 106 + record?: Json 107 + uri?: string 108 + } 109 + Relationships: [ 110 + { 111 + foreignKeyName: "comments_on_documents_document_fkey" 112 + columns: ["document"] 113 + isOneToOne: false 114 + referencedRelation: "documents" 115 + referencedColumns: ["uri"] 116 + }, 117 + { 118 + foreignKeyName: "comments_on_documents_profile_fkey" 119 + columns: ["profile"] 120 + isOneToOne: false 121 + referencedRelation: "bsky_profiles" 122 + referencedColumns: ["did"] 123 + }, 124 + ] 125 + } 87 126 custom_domain_routes: { 88 127 Row: { 89 128 created_at: string ··· 838 877 record?: Json | null 839 878 uri?: string 840 879 } 841 - Relationships: [] 880 + Relationships: [ 881 + { 882 + foreignKeyName: "publications_identity_did_fkey" 883 + columns: ["identity_did"] 884 + isOneToOne: false 885 + referencedRelation: "identities" 886 + referencedColumns: ["atp_did"] 887 + }, 888 + ] 842 889 } 843 890 replicache_clients: { 844 891 Row: {