From 67e1c3c10ee734788f0b97fa3c94d71e70d09434 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Fri, 22 Aug 2025 13:27:04 -0400 Subject: [PATCH] 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 --- app/discover/PubListing.tsx | 2 +- app/globals.css | 2 +- .../Interactions/Comments/CommentBox.tsx | 350 ++++++++++++++++++ .../Interactions/Comments/commentAction.ts | 70 ++++ .../[rkey]/Interactions/Comments/index.tsx | 319 ++++++++++++++++ .../[rkey]/Interactions/InteractionDrawer.tsx | 16 +- .../[rkey]/Interactions/Interactions.tsx | 70 ++-- .../[rkey]/Interactions/Quotes.tsx | 2 +- .../[did]/[publication]/[rkey]/PageLayout.tsx | 2 +- .../[rkey]/PostHeader/CollapsedPostHeader.tsx | 102 ++--- .../[rkey]/PostHeader/PostHeader.tsx | 57 +-- .../[did]/[publication]/[rkey]/PostPage.tsx | 5 +- .../[publication]/[rkey]/getPostPageData.ts | 2 + app/lish/[did]/[publication]/[rkey]/page.tsx | 20 +- components/Icons/EditTiny.tsx | 1 + components/Toolbar/TextToolbar.tsx | 6 +- drizzle/relations.ts | 50 ++- drizzle/schema.ts | 10 +- lexicons/api/index.ts | 65 ++++ lexicons/api/lexicons.ts | 52 +++ lexicons/api/types/pub/leaflet/comment.ts | 47 +++ lexicons/build.ts | 2 + lexicons/pub/leaflet/comment.json | 57 +++ lexicons/src/comment.ts | 37 ++ supabase/database.types.ts | 49 ++- 25 files changed, 1242 insertions(+), 153 deletions(-) create mode 100644 app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx create mode 100644 app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts create mode 100644 app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx create mode 100644 lexicons/api/types/pub/leaflet/comment.ts create mode 100644 lexicons/pub/leaflet/comment.json create mode 100644 lexicons/src/comment.ts diff --git a/app/discover/PubListing.tsx b/app/discover/PubListing.tsx index 619c922d..4269cf3a 100644 --- a/app/discover/PubListing.tsx +++ b/app/discover/PubListing.tsx @@ -71,7 +71,7 @@ export const PubListing = (props: { ); }; -function timeAgo(timestamp: string): string { +export function timeAgo(timestamp: string): string { const now = new Date(); const date = new Date(timestamp); const diffMs = now.getTime() - date.getTime(); diff --git a/app/globals.css b/app/globals.css index fb80c249..2abd00a7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -262,7 +262,7 @@ pre.shiki { @apply rounded-lg; @apply outline; @apply outline-offset-1; - @apply outline-1; + @apply outline-2; @apply outline-transparent; @apply hover:border-border; } diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx new file mode 100644 index 00000000..d68a7dd4 --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -0,0 +1,350 @@ +import { UnicodeString } from "@atproto/api"; +import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; +import { multiBlockSchema } from "components/Blocks/TextBlock/schema"; +import { PubLeafletRichtextFacet } from "lexicons/api"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { keymap } from "prosemirror-keymap"; +import { Mark, MarkType, Node } from "prosemirror-model"; +import { EditorState, TextSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { + MutableRefObject, + RefObject, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { publishComment } from "./commentAction"; +import { ButtonPrimary } from "components/Buttons"; +import { ShareSmall } from "components/Icons/ShareSmall"; +import { useInteractionState } from "../Interactions"; +import { DotLoader } from "components/utils/DotLoader"; +import { rangeHasMark } from "src/utils/prosemirror/rangeHasMark"; +import { setMark } from "src/utils/prosemirror/setMark"; +import { multi } from "linkifyjs"; +import { Json } from "supabase/database.types"; + +export function CommentBox(props: { + doc_uri: string; + replyTo?: string; + onSubmit?: () => void; +}) { + let mountRef = useRef(null); + let [editorState, setEditorState] = useState(() => + EditorState.create({ + schema: multiBlockSchema, + plugins: [ + keymap({ + "Meta-b": toggleMark(multiBlockSchema.marks.strong), + "Ctrl-b": toggleMark(multiBlockSchema.marks.strong), + "Meta-u": toggleMark(multiBlockSchema.marks.underline), + "Ctrl-u": toggleMark(multiBlockSchema.marks.underline), + "Meta-i": toggleMark(multiBlockSchema.marks.em), + "Ctrl-i": toggleMark(multiBlockSchema.marks.em), + "Ctrl-Meta-x": toggleMark(multiBlockSchema.marks.strikethrough), + }), + keymap(baseKeymap), + autolink({ + type: multiBlockSchema.marks.link, + shouldAutoLink: () => true, + defaultProtocol: "https", + }), + ], + }), + ); + let view = useRef(null); + useLayoutEffect(() => { + if (!mountRef.current) return; + view.current = new EditorView( + { mount: mountRef.current }, + { + state: editorState, + handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { + if (!direct) return; + if (node.nodeSize - 2 <= _pos) return; + let mark = + node + .nodeAt(_pos - 1) + ?.marks.find((f) => f.type === multiBlockSchema.marks.link) || + node + .nodeAt(Math.max(_pos - 2, 0)) + ?.marks.find((f) => f.type === multiBlockSchema.marks.link); + if (mark) { + window.open(mark.attrs.href, "_blank"); + } + }, + dispatchTransaction(tr) { + console.log("dispatching?"); + let newState = this.state.apply(tr); + setEditorState(newState); + view.current?.updateState(newState); + }, + }, + ); + return () => { + view.current?.destroy(); + view.current = null; + }; + }, []); + let [loading, setLoading] = useState(false); + return ( +
+
+      
+
+ } + editor={editorState} + view={view} + /> + } + editor={editorState} + view={view} + /> + } + editor={editorState} + view={view} + /> +
+ { + setLoading(true); + let [plaintext, facets] = docToFacetedText(editorState.doc); + let comment = await publishComment({ + document: props.doc_uri, + comment: { plaintext, facets, replyTo: props.replyTo }, + }); + + setLoading(false); + props.onSubmit?.(); + useInteractionState.setState((s) => ({ + localComments: [ + ...s.localComments, + { + record: comment.record, + uri: comment.uri, + bsky_profiles: { record: comment.profile as Json }, + }, + ], + })); + }} + > + {loading ? : } + +
+
+ ); +} + +export function docToFacetedText( + doc: Node, +): [string, PubLeafletRichtextFacet.Main[]] { + let fullText = ""; + let facets: PubLeafletRichtextFacet.Main[] = []; + let byteOffset = 0; + + // Iterate through each paragraph in the document + doc.forEach((paragraph) => { + if (paragraph.type.name !== "paragraph") return; + + // Process each inline node in the paragraph + paragraph.forEach((node) => { + if (node.isText) { + const text = node.text || ""; + const unicodeString = new UnicodeString(text); + + // If this text node has marks, create a facet + if (node.marks.length > 0) { + const facet: PubLeafletRichtextFacet.Main = { + index: { + byteStart: byteOffset, + byteEnd: byteOffset + unicodeString.length, + }, + features: marksToFeatures(node.marks), + }; + + if (facet.features.length > 0) { + facets.push(facet); + } + } + + fullText += text; + byteOffset += unicodeString.length; + } + }); + + // Add newline between paragraphs (except after the last one) + if (paragraph !== doc.lastChild) { + const newline = "\n"; + const unicodeNewline = new UnicodeString(newline); + fullText += newline; + byteOffset += unicodeNewline.length; + } + }); + + return [fullText, facets]; +} + +function marksToFeatures(marks: readonly Mark[]) { + const features: PubLeafletRichtextFacet.Main["features"] = []; + + for (const mark of marks) { + switch (mark.type.name) { + case "strong": + features.push({ + $type: "pub.leaflet.richtext.facet#bold", + }); + break; + case "em": + features.push({ + $type: "pub.leaflet.richtext.facet#italic", + }); + break; + case "underline": + features.push({ + $type: "pub.leaflet.richtext.facet#underline", + }); + break; + case "strikethrough": + features.push({ + $type: "pub.leaflet.richtext.facet#strikethrough", + }); + break; + case "code": + features.push({ + $type: "pub.leaflet.richtext.facet#code", + }); + break; + case "highlight": + features.push({ + $type: "pub.leaflet.richtext.facet#highlight", + }); + break; + case "link": + features.push({ + $type: "pub.leaflet.richtext.facet#link", + uri: mark.attrs.href as string, + }); + break; + } + } + + return features; +} + +const BoldTiny = () => { + return ( + + + + ); +}; + +const ItalicTiny = () => { + return ( + + + + ); +}; + +const StrikethroughTiny = () => { + return ( + + + + ); +}; + +function TextDecorationButton(props: { + editor: EditorState; + mark: MarkType; + icon: React.ReactNode; + view: RefObject; +}) { + let hasMark: boolean = false; + let mark: Mark | null = null; + if (props.editor) { + let { to, from, $cursor, $to, $from } = props.editor + .selection as TextSelection; + + mark = rangeHasMark(props.editor, props.mark, from, to); + if ($cursor) + hasMark = !!props.mark.isInSet( + props.editor.storedMarks || $cursor.marks(), + ); + else { + hasMark = !!mark; + } + } + + return ( + + ); +} + +function toggleMarkInCommentBox( + view: EditorView, + markT: MarkType, + attrs?: any, +) { + let { to, from, $cursor } = view.state.selection as TextSelection; + let mark = rangeHasMark(view.state, markT, from, to); + if ( + to === from && + markT?.isInSet(view.state.storedMarks || $cursor?.marks() || []) + ) { + return toggleMark(markT, attrs)(view.state, view.dispatch); + } + if ( + mark && + (!attrs || JSON.stringify(attrs) === JSON.stringify(mark.attrs)) + ) { + toggleMark(markT, attrs)(view.state, view.dispatch); + } else setMark(markT, attrs)(view.state, view.dispatch); +} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts new file mode 100644 index 00000000..862e4ddd --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -0,0 +1,70 @@ +"use server"; + +import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { PubLeafletRichtextFacet } from "lexicons/api"; +import { createOauthClient } from "src/atproto-oauth"; +import { TID } from "@atproto/common"; +import { AtUri, Un$Typed } from "@atproto/api"; +import { supabaseServerClient } from "supabase/serverClient"; +import { Json } from "supabase/database.types"; + +export async function publishComment(args: { + document: string; + comment: { + plaintext: string; + facets: PubLeafletRichtextFacet.Main[]; + replyTo?: string; + }; +}) { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) throw new Error("No Identity"); + + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let record: Un$Typed = { + subject: args.document, + createdAt: new Date().toISOString(), + plaintext: args.comment.plaintext, + facets: args.comment.facets, + reply: args.comment.replyTo ? { parent: args.comment.replyTo } : undefined, + }; + let rkey = TID.nextStr(); + let uri = AtUri.make(credentialSession.did!, "pub.leaflet.comment", rkey); + let [profile, result] = await Promise.all([ + agent.app.bsky.actor.profile.get({ + repo: credentialSession.did!, + rkey: "self", + }), + agent.pub.leaflet.comment.create( + { rkey, repo: credentialSession.did! }, + record, + ), + ]); + + await supabaseServerClient.from("bsky_profiles").upsert({ + did: credentialSession.did!, + record: profile.value as Json, + }); + let { data, error } = await supabaseServerClient + .from("comments_on_documents") + .insert({ + uri: uri.toString(), + document: args.document, + profile: credentialSession.did!, + record: { + $type: "pub.leaflet.comment", + ...record, + } as unknown as Json, + }) + .select(); + + return { + record: data?.[0].record as Json, + profile: profile.value, + uri: uri.toString(), + }; +} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx new file mode 100644 index 00000000..b19dff8b --- /dev/null +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -0,0 +1,319 @@ +"use client"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { useInteractionState } from "../Interactions"; +import { useIdentityData } from "components/IdentityProvider"; +import { CommentBox } from "./CommentBox"; +import { Json } from "supabase/database.types"; +import { PubLeafletComment } from "lexicons/api"; +import { BaseTextBlock } from "../../BaseTextBlock"; +import { useMemo, useState } from "react"; +import { CommentTiny } from "components/Icons/CommentTiny"; +import { Separator } from "components/Layout"; +import { ButtonPrimary } from "components/Buttons"; +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { Popover } from "components/Popover"; +import { AppBskyActorProfile, AtUri } from "@atproto/api"; +import { timeAgo } from "app/discover/PubListing"; + +export type Comment = { + record: Json; + uri: string; + bsky_profiles: { record: Json } | null; +}; +export function Comments(props: { document_uri: string; comments: Comment[] }) { + let { identity } = useIdentityData(); + let localComments = useInteractionState((l) => l.localComments); + let comments = useMemo(() => { + return [...localComments, ...props.comments]; + }, [props.comments, localComments]); + + return ( +
+
+ Comments + +
+ {identity?.atp_did ? ( + + ) : ( +
+ Connect a Bluesky account to comment + + Connect to Bluesky + +
+ )} +
+
+ {comments + .sort((a, b) => { + let aRecord = a.record as PubLeafletComment.Record; + let bRecord = b.record as PubLeafletComment.Record; + return ( + new Date(aRecord.createdAt).getTime() - + new Date(bRecord.createdAt).getTime() + ); + }) + .filter( + (comment) => !(comment.record as PubLeafletComment.Record).reply, + ) + .map((comment) => { + let record = comment.record as PubLeafletComment.Record; + let profile = comment.bsky_profiles + ?.record as AppBskyActorProfile.Record; + return ( + + ); + })} +
+
+ ); +} + +const Comment = (props: { + document: string; + comment: Comment; + comments: Comment[]; + profile?: AppBskyActorProfile.Record; + record: PubLeafletComment.Record; +}) => { + return ( +
+
+ {props.profile && ( + + )} + +
+
+        
+      
+ +
+ ); +}; + +const Replies = (props: { + comment_uri: string; + comments: Comment[]; + document: string; +}) => { + let { identity } = useIdentityData(); + + let [replyBoxOpen, setReplyBoxOpen] = useState(false); + let [repliesOpen, setRepliesOpen] = useState(true); + let replies = props.comments + .filter( + (comment) => + (comment.record as PubLeafletComment.Record).reply?.parent === + props.comment_uri, + ) + .sort((a, b) => { + let aRecord = a.record as PubLeafletComment.Record; + let bRecord = b.record as PubLeafletComment.Record; + return ( + new Date(aRecord.createdAt).getTime() - + new Date(bRecord.createdAt).getTime() + ); + }); + return ( + <> +
+ + {identity?.atp_did && ( + <> + + + + )} +
+
+ {replyBoxOpen && ( + { + setReplyBoxOpen(false); + }} + /> + )} + {repliesOpen && replies.length > 0 && ( +
+ +
+ {replies.map((reply) => { + return ( + + ); + })} +
+
+ )} +
+ + ); +}; + +const DatePopover = (props: { date: string }) => { + let t = useMemo(() => { + return timeAgo(props.date); + }, [props.date]); + return ( + {t} + } + > +
8/18/2025 4:32PM
+
+ ); +}; + +const ProfilePopover = (props: { + profile: AppBskyActorProfile.Record; + comment: string; +}) => { + let commenterId = new AtUri(props.comment).host; + + return ( + <> + + {props.profile.displayName} + + {/* + { + setHovering(true); + hoverTimeout.current = window.setTimeout(() => { + setLoadProfile(true); + }, 500); + }} + onMouseOut={() => { + setHovering(false); + clearTimeout(hoverTimeout.current); + }} + className="font-bold text-tertiary text-sm hover:underline" + > + {props.profile.displayName} + + } + className="max-w-sm" + > + {profile && ( + <> +
+
+
+
+ +
+ +
+ {profile.description} +
+
+ {!profile.viewer?.following ? ( +
+ Following +
+ ) : ( + + Follow + + )} + {profile.viewer?.followedBy && ( +
Follows You
+ )} +
+
+
+ +
+
+
+ {profile.viewer?.knownFollowers?.followers.map((follower) => { + return ( +
+ ); + })} +
+
+
+
+ + )} + + */} + + ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index 11c88995..1f05bd2e 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -3,22 +3,32 @@ import { Media } from "components/Media"; import { Quotes } from "./Quotes"; import { useInteractionState } from "./Interactions"; import { Json } from "supabase/database.types"; +import { Comment, Comments } from "./Comments"; export const InteractionDrawer = (props: { + document_uri: string; quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; + comments: Comment[]; did: string; }) => { - let { drawerOpen: open } = useInteractionState(); + let { drawerOpen: open, drawer } = useInteractionState(); if (!open) return null; return ( <>
-
+
- + {drawer === "quotes" ? ( + + ) : ( + + )}
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index e40b367f..ec943fd5 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -1,38 +1,41 @@ "use client"; +import { CommentTiny } from "components/Icons/CommentTiny"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { flushSync } from "react-dom"; -import { Json } from "supabase/database.types"; +import type { Json } from "supabase/database.types"; import { create } from "zustand"; +import type { Comment } from "./Comments"; -export let useInteractionState = create(() => ({ drawerOpen: false })); -export function openInteractionDrawer() { +export let useInteractionState = create(() => ({ + drawerOpen: false, + drawer: undefined as undefined | "comments" | "quotes", + localComments: [] as Comment[], +})); +export function openInteractionDrawer(drawer: "comments" | "quotes") { flushSync(() => { - useInteractionState.setState({ drawerOpen: true }); + useInteractionState.setState({ drawerOpen: true, drawer }); }); let el = document.getElementById("interaction-drawer"); - let isIntersecting = false; + let isOffscreen = false; if (el) { const rect = el.getBoundingClientRect(); const windowHeight = window.innerHeight || document.documentElement.clientHeight; const windowWidth = window.innerWidth || document.documentElement.clientWidth; - isIntersecting = - rect.top < windowHeight && - rect.bottom > 0 && - rect.left < windowWidth && - rect.right > 0; + isOffscreen = rect.right > windowWidth; } - if (el && !isIntersecting) el.scrollIntoView({ behavior: "smooth" }); + if (el && isOffscreen) el.scrollIntoView({ behavior: "smooth" }); } export const Interactions = (props: { - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; + quotesCount: number; + commentsCount: number; compact?: boolean; className?: string; }) => { - let { drawerOpen } = useInteractionState(); + let { drawerOpen, drawer } = useInteractionState(); return (
{ - if (!drawerOpen) openInteractionDrawer(); + if (!drawerOpen || drawer !== "quotes") + openInteractionDrawer("quotes"); else useInteractionState.setState({ drawerOpen: false }); }} > - {props.quotes.length}{" "} - {!props.compact && `Quote${props.quotes.length === 1 ? "" : "s"}`} + {props.quotesCount}{" "} + {!props.compact && `Quote${props.quotesCount === 1 ? "" : "s"}`} + +
); }; - -function getFirstScrollableAncestor(element: HTMLElement): HTMLElement | null { - let parent = element.parentElement; - - while (parent) { - const computedStyle = window.getComputedStyle(parent); - const overflowY = computedStyle.overflowY; - const overflowX = computedStyle.overflowX; - - if ( - overflowY === "scroll" || - overflowY === "auto" || - overflowX === "scroll" || - overflowX === "auto" - ) { - return parent; - } - - parent = parent.parentElement; - } - - return null; -} diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 2a530a82..7f1f064e 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -47,7 +47,7 @@ export const Quotes = (props: {
highlight any part of this post to quote it
) : ( -
+
{props.quotes.map((q, index) => { let pv = q.bsky_posts?.post_view as unknown as PostView; let record = data?.data as PubLeafletDocument.Record; diff --git a/app/lish/[did]/[publication]/[rkey]/PageLayout.tsx b/app/lish/[did]/[publication]/[rkey]/PageLayout.tsx index 60286532..9b418a25 100644 --- a/app/lish/[did]/[publication]/[rkey]/PageLayout.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PageLayout.tsx @@ -14,7 +14,7 @@ export function PageLayout(props: { children: React.ReactNode }) { in [rkey]/page/PostPage when card borders are hidden */}
{props.children}
diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx index a176dd8a..327db410 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/CollapsedPostHeader.tsx @@ -8,56 +8,56 @@ import { import { useState, useEffect } from "react"; import { Json } from "supabase/database.types"; -export const CollapsedPostHeader = (props: { - title: string; - pubIcon?: string; - quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; -}) => { - let [headerVisible, setHeaderVisible] = useState(false); - let { drawerOpen: open } = useInteractionState(); +// export const CollapsedPostHeader = (props: { +// title: string; +// pubIcon?: string; +// quotes: { link: string; bsky_posts: { post_view: Json } | null }[]; +// }) => { +// let [headerVisible, setHeaderVisible] = useState(false); +// let { drawerOpen: open } = useInteractionState(); - useEffect(() => { - let post = window.document.getElementById("post-page"); +// useEffect(() => { +// let post = window.document.getElementById("post-page"); - function handleScroll() { - let postHeader = window.document - .getElementById("post-header") - ?.getBoundingClientRect(); - if (postHeader && postHeader.bottom <= 0) { - setHeaderVisible(true); - } else { - setHeaderVisible(false); - } - } - post?.addEventListener("scroll", handleScroll); - return () => { - post?.removeEventListener("scroll", handleScroll); - }; - }, []); - if (!headerVisible) return; - if (open) return; - return ( - -
-
- {props.title} -
-
- -
-
-
- - ); -}; +// function handleScroll() { +// let postHeader = window.document +// .getElementById("post-header") +// ?.getBoundingClientRect(); +// if (postHeader && postHeader.bottom <= 0) { +// setHeaderVisible(true); +// } else { +// setHeaderVisible(false); +// } +// } +// post?.addEventListener("scroll", handleScroll); +// return () => { +// post?.removeEventListener("scroll", handleScroll); +// }; +// }, []); +// if (!headerVisible) return; +// if (open) return; +// return ( +// +//
+//
+// {props.title} +//
+//
+// +//
+//
+//
+// +// ); +// }; diff --git a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 4638aa30..251446f2 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -2,13 +2,13 @@ import Link from "next/link"; import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; -import { CollapsedPostHeader } from "./CollapsedPostHeader"; import { Interactions } from "../Interactions/Interactions"; import { PostPageData } from "../getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { useIdentityData } from "components/IdentityProvider"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { AtUri } from "@atproto/syntax"; +import { EditTiny } from "components/Icons/EditTiny"; export function PostHeader(props: { data: PostPageData; @@ -38,17 +38,30 @@ export function PostHeader(props: { /> */}
- - {props.name} - +
+ + {props.name} + + {identity && + identity.atp_did === + document.documents_in_publications[0]?.publications + .identity_did && ( + + + + )} +

{record.title}

{record.description ? (

{record.description}

@@ -78,21 +91,11 @@ export function PostHeader(props: { ) : null} |{" "} - - {identity && - identity.atp_did === - document.documents_in_publications[0]?.publications - .identity_did && ( - <> - {" "} - | - - Edit Post - - - )} +
diff --git a/app/lish/[did]/[publication]/[rkey]/PostPage.tsx b/app/lish/[did]/[publication]/[rkey]/PostPage.tsx index d6e47909..c2792464 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostPage.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostPage.tsx @@ -69,7 +69,10 @@ export function PostPage({ did={did} prerenderedCodeBlocks={prerenderedCodeBlocks} /> - +
{identity && identity.atp_did === diff --git a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 6f3f41ae..22a5bedf 100644 --- a/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -7,6 +7,8 @@ export async function getPostPageData(uri: string) { .select( ` data, + uri, + comments_on_documents(*, bsky_profiles(*)), documents_in_publications(publications(*, publication_subscriptions(*))), document_mentions_in_bsky(*, bsky_posts(*)), leaflets_in_publications(*) diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index 1233b30b..9dba7398 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -85,12 +85,16 @@ export default async function Post(props: { ]); if (!document?.data || !document.documents_in_publications[0].publications) return ( -
-

Sorry, post not found!

-

- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -

+
+
+
+

Sorry, post not found!

+

+ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +

+
+
); let record = document.data as PubLeafletDocument.Record; @@ -157,7 +161,7 @@ export default async function Post(props: { diff --git a/components/Icons/EditTiny.tsx b/components/Icons/EditTiny.tsx index 1730d0b1..0692142a 100644 --- a/components/Icons/EditTiny.tsx +++ b/components/Icons/EditTiny.tsx @@ -8,6 +8,7 @@ export const EditTiny = (props: Props) => { viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" + {...props} > { +export const ItalicSmall = (props: Props) => { return ( { ); }; -const StrikethroughSmall = (props: Props) => { +export const StrikethroughSmall = (props: Props) => { return ( { ); }; -const BoldSmall = (props: Props) => { +export const BoldSmall = (props: Props) => { return ( ({ +export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ identity: one(identities, { fields: [bsky_profiles.did], references: [identities.atp_did] }), + comments_on_documents: many(comments_on_documents), })); export const identitiesRelations = relations(identities, ({one, many}) => ({ bsky_profiles: many(bsky_profiles), + publications: many(publications), permission_token: one(permission_tokens, { fields: [identities.home_page], references: [permission_tokens.id] @@ -27,6 +29,36 @@ export const identitiesRelations = relations(identities, ({one, many}) => ({ publication_subscriptions: many(publication_subscriptions), })); +export const publicationsRelations = relations(publications, ({one, many}) => ({ + identity: one(identities, { + fields: [publications.identity_did], + references: [identities.atp_did] + }), + subscribers_to_publications: many(subscribers_to_publications), + documents_in_publications: many(documents_in_publications), + publication_domains: many(publication_domains), + publication_subscriptions: many(publication_subscriptions), + leaflets_in_publications: many(leaflets_in_publications), +})); + +export const comments_on_documentsRelations = relations(comments_on_documents, ({one}) => ({ + document: one(documents, { + fields: [comments_on_documents.document], + references: [documents.uri] + }), + bsky_profile: one(bsky_profiles, { + fields: [comments_on_documents.profile], + references: [bsky_profiles.did] + }), +})); + +export const documentsRelations = relations(documents, ({many}) => ({ + comments_on_documents: many(comments_on_documents), + document_mentions_in_bskies: many(document_mentions_in_bsky), + documents_in_publications: many(documents_in_publications), + leaflets_in_publications: many(leaflets_in_publications), +})); + export const factsRelations = relations(facts, ({one}) => ({ entity: one(entities, { fields: [facts.entity], @@ -155,14 +187,6 @@ export const subscribers_to_publicationsRelations = relations(subscribers_to_pub }), })); -export const publicationsRelations = relations(publications, ({many}) => ({ - subscribers_to_publications: many(subscribers_to_publications), - documents_in_publications: many(documents_in_publications), - publication_domains: many(publication_domains), - publication_subscriptions: many(publication_subscriptions), - leaflets_in_publications: many(leaflets_in_publications), -})); - export const document_mentions_in_bskyRelations = relations(document_mentions_in_bsky, ({one}) => ({ document: one(documents, { fields: [document_mentions_in_bsky.document], @@ -174,12 +198,6 @@ export const document_mentions_in_bskyRelations = relations(document_mentions_in }), })); -export const documentsRelations = relations(documents, ({many}) => ({ - document_mentions_in_bskies: many(document_mentions_in_bsky), - documents_in_publications: many(documents_in_publications), - leaflets_in_publications: many(leaflets_in_publications), -})); - export const bsky_postsRelations = relations(bsky_posts, ({many}) => ({ document_mentions_in_bskies: many(document_mentions_in_bsky), })); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index f401aa8d..87e8863c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -35,7 +35,7 @@ export const publications = pgTable("publications", { uri: text("uri").primaryKey().notNull(), indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), name: text("name").notNull(), - identity_did: text("identity_did").notNull(), + identity_did: text("identity_did").notNull().references(() => identities.atp_did, { onDelete: "cascade" } ), record: jsonb("record"), }); @@ -46,6 +46,14 @@ export const bsky_posts = pgTable("bsky_posts", { cid: text("cid").notNull(), }); +export const comments_on_documents = pgTable("comments_on_documents", { + uri: text("uri").primaryKey().notNull(), + record: jsonb("record").notNull(), + document: text("document").references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), + indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), +}); + export const facts = pgTable("facts", { id: uuid("id").primaryKey().notNull(), entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index d1d0ba6a..4dc62d67 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -5,6 +5,7 @@ import { XrpcClient, FetchHandler, FetchHandlerOptions } from '@atproto/xrpc' import { schemas } from './lexicons' import { CID } from 'multiformats/cid' import { OmitKey, Un$Typed } from './util' +import * as PubLeafletComment from './types/pub/leaflet/comment' import * as PubLeafletDocument from './types/pub/leaflet/document' import * as PubLeafletPublication from './types/pub/leaflet/publication' import * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' @@ -37,6 +38,7 @@ import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' import * as AppBskyActorProfile from './types/app/bsky/actor/profile' +export * as PubLeafletComment from './types/pub/leaflet/comment' export * as PubLeafletDocument from './types/pub/leaflet/document' export * as PubLeafletPublication from './types/pub/leaflet/publication' export * as PubLeafletBlocksBlockquote from './types/pub/leaflet/blocks/blockquote' @@ -107,6 +109,7 @@ export class PubNS { export class PubLeafletNS { _client: XrpcClient + comment: CommentRecord document: DocumentRecord publication: PublicationRecord blocks: PubLeafletBlocksNS @@ -122,6 +125,7 @@ export class PubLeafletNS { this.pages = new PubLeafletPagesNS(client) this.richtext = new PubLeafletRichtextNS(client) this.theme = new PubLeafletThemeNS(client) + this.comment = new CommentRecord(client) this.document = new DocumentRecord(client) this.publication = new PublicationRecord(client) } @@ -234,6 +238,67 @@ export class PubLeafletThemeNS { } } +export class CommentRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletComment.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.comment', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ uri: string; cid: string; value: PubLeafletComment.Record }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.comment', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.comment' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'pub.leaflet.comment', ...params }, + { headers }, + ) + } +} + export class DocumentRecord { _client: XrpcClient diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index f2ff9e24..e77f2a5c 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -10,6 +10,57 @@ import { import { $Typed, is$typed, maybe$typed } from './util' export const schemaDict = { + PubLeafletComment: { + lexicon: 1, + id: 'pub.leaflet.comment', + revision: 1, + description: 'A lexicon for comments on documents', + defs: { + main: { + type: 'record', + key: 'tid', + description: 'Record containing a comment', + record: { + type: 'object', + required: ['subject', 'plaintext', 'createdAt'], + properties: { + subject: { + type: 'string', + format: 'at-uri', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + reply: { + type: 'ref', + ref: 'lex:pub.leaflet.comment#replyRef', + }, + plaintext: { + type: 'string', + }, + facets: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:pub.leaflet.richtext.facet', + }, + }, + }, + }, + }, + replyRef: { + type: 'object', + required: ['parent'], + properties: { + parent: { + type: 'string', + format: 'at-uri', + }, + }, + }, + }, + }, PubLeafletDocument: { lexicon: 1, id: 'pub.leaflet.document', @@ -1699,6 +1750,7 @@ export function validate( } export const ids = { + PubLeafletComment: 'pub.leaflet.comment', PubLeafletDocument: 'pub.leaflet.document', PubLeafletPublication: 'pub.leaflet.publication', PubLeafletBlocksBlockquote: 'pub.leaflet.blocks.blockquote', diff --git a/lexicons/api/types/pub/leaflet/comment.ts b/lexicons/api/types/pub/leaflet/comment.ts new file mode 100644 index 00000000..da67e5fc --- /dev/null +++ b/lexicons/api/types/pub/leaflet/comment.ts @@ -0,0 +1,47 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../lexicons' +import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' +import type * as PubLeafletRichtextFacet from './richtext/facet' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.comment' + +export interface Record { + $type: 'pub.leaflet.comment' + subject: string + createdAt: string + reply?: ReplyRef + plaintext: string + facets?: PubLeafletRichtextFacet.Main[] + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} + +export interface ReplyRef { + $type?: 'pub.leaflet.comment#replyRef' + parent: string +} + +const hashReplyRef = 'replyRef' + +export function isReplyRef(v: V) { + return is$typed(v, id, hashReplyRef) +} + +export function validateReplyRef(v: V) { + return validate(v, id, hashReplyRef) +} diff --git a/lexicons/build.ts b/lexicons/build.ts index a2c2b294..5d3062ac 100644 --- a/lexicons/build.ts +++ b/lexicons/build.ts @@ -7,6 +7,7 @@ import { ThemeLexicons } from "./src/theme"; import * as fs from "fs"; import * as path from "path"; import { PubLeafletRichTextFacet } from "./src/facet"; +import { PubLeafletComment } from "./src/comment"; const outdir = path.join("lexicons", "pub", "leaflet"); @@ -17,6 +18,7 @@ fs.mkdirSync(outdir, { recursive: true }); const lexicons = [ PubLeafletDocument, + PubLeafletComment, PubLeafletRichTextFacet, PageLexicons.PubLeafletPagesLinearDocument, ...ThemeLexicons, diff --git a/lexicons/pub/leaflet/comment.json b/lexicons/pub/leaflet/comment.json new file mode 100644 index 00000000..a9a27a13 --- /dev/null +++ b/lexicons/pub/leaflet/comment.json @@ -0,0 +1,57 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.comment", + "revision": 1, + "description": "A lexicon for comments on documents", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "Record containing a comment", + "record": { + "type": "object", + "required": [ + "subject", + "plaintext", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "reply": { + "type": "ref", + "ref": "#replyRef" + }, + "plaintext": { + "type": "string" + }, + "facets": { + "type": "array", + "items": { + "type": "ref", + "ref": "pub.leaflet.richtext.facet" + } + } + } + } + }, + "replyRef": { + "type": "object", + "required": [ + "parent" + ], + "properties": { + "parent": { + "type": "string", + "format": "at-uri" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/src/comment.ts b/lexicons/src/comment.ts new file mode 100644 index 00000000..0cca557d --- /dev/null +++ b/lexicons/src/comment.ts @@ -0,0 +1,37 @@ +import { LexiconDoc } from "@atproto/lexicon"; +import { PubLeafletRichTextFacet } from "./facet"; + +export const PubLeafletComment: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.comment", + revision: 1, + description: "A lexicon for comments on documents", + defs: { + main: { + type: "record", + key: "tid", + description: "Record containing a comment", + record: { + type: "object", + required: ["subject", "plaintext", "createdAt"], + properties: { + subject: { type: "string", format: "at-uri" }, + createdAt: { type: "string", format: "datetime" }, + reply: { type: "ref", ref: "#replyRef" }, + plaintext: { type: "string" }, + facets: { + type: "array", + items: { type: "ref", ref: PubLeafletRichTextFacet.id }, + }, + }, + }, + }, + replyRef: { + type: "object", + required: ["parent"], + properties: { + parent: { type: "string", format: "at-uri" }, + }, + }, + }, +}; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index ef29f3ff..c6d22807 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -84,6 +84,45 @@ export type Database = { }, ] } + comments_on_documents: { + Row: { + document: string | null + indexed_at: string + profile: string | null + record: Json + uri: string + } + Insert: { + document?: string | null + indexed_at?: string + profile?: string | null + record: Json + uri: string + } + Update: { + document?: string | null + indexed_at?: string + profile?: string | null + record?: Json + uri?: string + } + Relationships: [ + { + foreignKeyName: "comments_on_documents_document_fkey" + columns: ["document"] + isOneToOne: false + referencedRelation: "documents" + referencedColumns: ["uri"] + }, + { + foreignKeyName: "comments_on_documents_profile_fkey" + columns: ["profile"] + isOneToOne: false + referencedRelation: "bsky_profiles" + referencedColumns: ["did"] + }, + ] + } custom_domain_routes: { Row: { created_at: string @@ -838,7 +877,15 @@ export type Database = { record?: Json | null uri?: string } - Relationships: [] + Relationships: [ + { + foreignKeyName: "publications_identity_did_fkey" + columns: ["identity_did"] + isOneToOne: false + referencedRelation: "identities" + referencedColumns: ["atp_did"] + }, + ] } replicache_clients: { Row: { -- 2.51.2