diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index 52eff70b..0d3c7010 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -25,6 +25,15 @@ import { setMark } from "src/utils/prosemirror/setMark"; import { multi } from "linkifyjs"; import { Json } from "supabase/database.types"; import { isIOS } from "src/utils/isDevice"; +import { + decodeQuotePosition, + QUOTE_PARAM, + QuotePosition, +} from "../../quotePosition"; +import { QuoteContent } from "../Quotes"; +import { create } from "zustand"; +import { CloseTiny } from "components/Icons/CloseTiny"; +import { CloseFillTiny } from "components/Icons/CloseFillTiny"; export function CommentBox(props: { doc_uri: string; @@ -32,6 +41,7 @@ export function CommentBox(props: { onSubmit?: () => void; }) { let mountRef = useRef(null); + let quote = useInteractionState((s) => s.commentBox.quote); let [editorState, setEditorState] = useState(() => EditorState.create({ schema: multiBlockSchema, @@ -61,6 +71,32 @@ export function CommentBox(props: { { mount: mountRef.current }, { state: editorState, + handlePaste: (view, e) => { + let text = + e.clipboardData?.getData("text") || + e.clipboardData?.getData("text/html"); + let html = e.clipboardData?.getData("text/html"); + if (!text && html) { + let xml = new DOMParser().parseFromString(html, "text/html"); + text = xml.textContent || ""; + } + console.log("URL: " + window.location.toString()); + console.log("TEXT: " + text, text?.includes(QUOTE_PARAM)); + if ( + text?.includes(QUOTE_PARAM) && + text.includes(window.location.toString()) + ) { + const url = new URL(text); + const quoteParam = url.pathname.split("/l-quote/")[1]; + if (!quoteParam) return; + const quotePosition = decodeQuotePosition(quoteParam); + if (!quotePosition) return; + useInteractionState.setState({ + commentBox: { quote: quotePosition }, + }); + return true; + } + }, handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { if (!direct) return; if (node.nodeSize - 2 <= _pos) return; @@ -90,15 +126,28 @@ export function CommentBox(props: { }, []); let [loading, setLoading] = useState(false); return ( -
+
+ {quote && ( +
+ + +
+ )}
         
       
-
+
({ + commentBox: { + quote: null, + }, localComments: [ ...s.localComments, { diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index 862e4ddd..f0b28d7c 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -15,6 +15,7 @@ export async function publishComment(args: { plaintext: string; facets: PubLeafletRichtextFacet.Main[]; replyTo?: string; + attachment: PubLeafletComment.Record["attachment"]; }; }) { const oauthClient = await createOauthClient(); @@ -31,6 +32,7 @@ export async function publishComment(args: { plaintext: args.comment.plaintext, facets: args.comment.facets, reply: args.comment.replyTo ? { parent: args.comment.replyTo } : undefined, + attachment: args.comment.attachment, }; let rkey = TID.nextStr(); let uri = AtUri.make(credentialSession.did!, "pub.leaflet.comment", rkey); diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx index fcc631ce..91e8a3e6 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx @@ -16,6 +16,7 @@ import { AppBskyActorProfile, AtUri } from "@atproto/api"; import { timeAgo } from "app/discover/PubListing"; import { BlueskyLogin } from "app/login/LoginForm"; import { usePathname } from "next/navigation"; +import { QuoteContent } from "../Quotes"; export type Comment = { record: Json; @@ -57,7 +58,7 @@ export function Comments(props: { document_uri: string; comments: Comment[] }) {
)}
-
+
{comments .sort((a, b) => { let aRecord = a.record as PubLeafletComment.Record; @@ -99,16 +100,26 @@ const Comment = (props: { }) => { return (
-
+
{props.profile && ( )}
+ {props.record.attachment && + PubLeafletComment.isLinearDocumentQuote(props.record.attachment) && ( +
+ +
+ )}
          ({
   drawerOpen: undefined as boolean | undefined,
   drawer: undefined as undefined | "comments" | "quotes",
   localComments: [] as Comment[],
+  commentBox: { quote: null as QuotePosition | null },
 }));
 export function openInteractionDrawer(drawer: "comments" | "quotes") {
   flushSync(() => {
diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
index 7f1f064e..77f8686b 100644
--- a/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
+++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx
@@ -15,11 +15,8 @@ import {
   PubLeafletPagesLinearDocument,
   PubLeafletBlocksCode,
 } from "lexicons/api";
-import {
-  decodeQuotePosition,
-  QuotePosition,
-  useActiveHighlightState,
-} from "../useHighlight";
+import { decodeQuotePosition, QuotePosition } from "../quotePosition";
+import { useActiveHighlightState } from "../useHighlight";
 import { PostContent } from "../PostContent";
 import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
 
@@ -27,7 +24,6 @@ export const Quotes = (props: {
   quotes: { link: string; bsky_posts: { post_view: Json } | null }[];
   did: string;
 }) => {
-  let isMobile = useIsMobile();
   let data = useContext(PostPageContext);
 
   return (
@@ -50,73 +46,27 @@ export const Quotes = (props: {
         
{props.quotes.map((q, index) => { let pv = q.bsky_posts?.post_view as unknown as PostView; - let record = data?.data as PubLeafletDocument.Record; const url = new URL(q.link); const quoteParam = url.pathname.split("/l-quote/")[1]; if (!quoteParam) return null; const quotePosition = decodeQuotePosition(quoteParam); if (!quotePosition) return null; - - let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; - // Extract blocks within the quote range - const content = extractQuotedBlocks( - page.blocks || [], - quotePosition, - [], - ); return ( -
{ - useActiveHighlightState.setState({ activeHighlight: null }); - }} - onMouseEnter={() => { - useActiveHighlightState.setState({ activeHighlight: index }); - }} - > -
{ - let scrollMargin = isMobile - ? 16 - : e.currentTarget.getBoundingClientRect().top; - let scrollContainer = - window.document.getElementById("post-page"); - let el = window.document.getElementById( - quotePosition.start.block.join("."), - ); - if (!el || !scrollContainer) return; - let blockRect = el.getBoundingClientRect(); - let quoteScrollTop = - (scrollContainer && - blockRect.top + scrollContainer.scrollTop) || - 0; - - if (blockRect.left < 0) - scrollContainer.scrollIntoView({ behavior: "smooth" }); - scrollContainer?.scrollTo({ - top: quoteScrollTop - scrollMargin, - behavior: "smooth", - }); - }} - > -
- -
- -
+
+ + +
+
); })} @@ -126,6 +76,65 @@ export const Quotes = (props: { ); }; +export const QuoteContent = (props: { + position: QuotePosition; + index: number; + did: string; +}) => { + let isMobile = useIsMobile(); + const data = useContext(PostPageContext); + + let record = data?.data as PubLeafletDocument.Record; + let page = record.pages[0] as PubLeafletPagesLinearDocument.Main; + // Extract blocks within the quote range + const content = extractQuotedBlocks(page.blocks || [], props.position, []); + return ( +
{ + useActiveHighlightState.setState({ activeHighlight: null }); + }} + onMouseEnter={() => { + useActiveHighlightState.setState({ activeHighlight: props.index }); + }} + > +
{ + let scrollMargin = isMobile + ? 16 + : e.currentTarget.getBoundingClientRect().top; + let scrollContainer = window.document.getElementById("post-page"); + let el = window.document.getElementById( + props.position.start.block.join("."), + ); + if (!el || !scrollContainer) return; + let blockRect = el.getBoundingClientRect(); + let quoteScrollTop = + (scrollContainer && blockRect.top + scrollContainer.scrollTop) || 0; + + if (blockRect.left < 0) + scrollContainer.scrollIntoView({ behavior: "smooth" }); + scrollContainer?.scrollTo({ + top: quoteScrollTop - scrollMargin, + behavior: "smooth", + }); + }} + > +
+ +
+
+
+ ); +}; + const BskyPost = (props: { rkey: string; content: string; @@ -137,7 +146,7 @@ const BskyPost = (props: { {props.profile.avatar && ( {props.user}
@{props.handle}
-
{props.content}
+
{props.content}
); diff --git a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx index 3afd7634..0437110e 100644 --- a/app/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -29,17 +29,22 @@ export function PostContent({ blocks, did, preview, + className, prerenderedCodeBlocks, bskyPostData, }: { blocks: PubLeafletPagesLinearDocument.Block[]; did: string; preview?: boolean; + className?: string; prerenderedCodeBlocks?: Map; bskyPostData: AppBskyFeedDefs.PostView[]; }) { return ( -
+
{blocks.map((b, index) => { return ( { let smoker = useSmoker(); - let url = useMemo(() => { + let { identity } = useIdentityData(); + let [url, position] = useMemo(() => { let currentUrl = new URL(window.location.href); let pos = decodeQuotePosition(props.position); if (currentUrl.pathname.includes("/l-quote/")) { @@ -132,15 +137,15 @@ export const QuoteOptionButtons = (props: { position: string }) => { currentUrl.pathname = currentUrl.pathname + `/l-quote/${props.position}`; currentUrl.hash = `#${pos?.start.block.join(".")}_${pos?.start.offset}`; - return currentUrl.toString(); + return [currentUrl.toString(), pos]; }, [props.position]); return ( <> -
Share Quote via
+
Share via
{ Bluesky - + + + + {identity?.atp_did && ( + + )} ); }; diff --git a/app/lish/[did]/[publication]/[rkey]/quotePosition.ts b/app/lish/[did]/[publication]/[rkey]/quotePosition.ts index f0fdeea6..4f2d1154 100644 --- a/app/lish/[did]/[publication]/[rkey]/quotePosition.ts +++ b/app/lish/[did]/[publication]/[rkey]/quotePosition.ts @@ -9,7 +9,7 @@ export interface QuotePosition { }; } -export const QUOTE_PARAM = "l_quote"; +export const QUOTE_PARAM = "/l-quote/"; /** * Encodes quote position into a URL-friendly string diff --git a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx index 2f26dfe2..efe62578 100644 --- a/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx +++ b/app/lish/[did]/[publication]/[rkey]/useHighlight.tsx @@ -5,17 +5,7 @@ import { useParams, useSearchParams } from "next/navigation"; import { useContext } from "react"; import { PostPageContext } from "./PostPageContext"; import { create } from "zustand"; - -export interface QuotePosition { - start: { - block: number[]; - offset: number; - }; - end: { - block: number[]; - offset: number; - }; -} +import { decodeQuotePosition } from "./quotePosition"; export const useActiveHighlightState = create(() => ({ activeHighlight: null as null | number, @@ -77,47 +67,3 @@ export const useHighlight = (pos: number[]) => { }) .filter((highlight) => highlight !== null); }; - -/** - * Encodes quote position into a URL-friendly string - * Format: startBlock_startOffset-endBlock_endOffset - * Block paths are joined with dots: 1.2.0_45-1.2.3_67 - * Simple blocks: 0:12-2:45 - */ -export function encodeQuotePosition(position: QuotePosition): string { - const { start, end } = position; - return `${start.block.join(".")}_${start.offset}-${end.block.join(".")}_${end.offset}`; -} - -/** - * Decodes quote position from URL parameter - * Returns null if the format is invalid - */ -export function decodeQuotePosition(encoded: string): QuotePosition | null { - try { - // Match format: blockPath:number-blockPath:number - // Block paths can be: 5, 1.2, 0.1.3, etc. - const match = encoded.match(/^([\d.]+)_(\d+)-([\d.]+)_(\d+)$/); - - if (!match) { - return null; - } - - const [, startBlockPath, startOffset, endBlockPath, endOffset] = match; - - const position: QuotePosition = { - start: { - block: startBlockPath.split(".").map((i) => parseInt(i)), - offset: parseInt(startOffset, 10), - }, - end: { - block: endBlockPath.split(".").map((i) => parseInt(i)), - offset: parseInt(endOffset, 10), - }, - }; - - return position; - } catch (error) { - return null; - } -} diff --git a/components/Icons/CloseFillTiny.tsx b/components/Icons/CloseFillTiny.tsx new file mode 100644 index 00000000..b479de54 --- /dev/null +++ b/components/Icons/CloseFillTiny.tsx @@ -0,0 +1,21 @@ +import { Props } from "./Props"; + +export const CloseFillTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index b9d56607..166488dd 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -46,6 +46,24 @@ export const schemaDict = { ref: 'lex:pub.leaflet.richtext.facet', }, }, + attachment: { + type: 'union', + refs: ['lex:pub.leaflet.comment#linearDocumentQuote'], + }, + }, + }, + }, + linearDocumentQuote: { + type: 'object', + required: ['document', 'quote'], + properties: { + document: { + type: 'string', + format: 'at-uri', + }, + quote: { + type: 'ref', + ref: 'lex:pub.leaflet.pages.linearDocument#quote', }, }, }, @@ -552,6 +570,35 @@ export const schemaDict = { textAlignRight: { type: 'token', }, + quote: { + type: 'object', + required: ['start', 'end'], + properties: { + start: { + type: 'ref', + ref: 'lex:pub.leaflet.pages.linearDocument#position', + }, + end: { + type: 'ref', + ref: 'lex:pub.leaflet.pages.linearDocument#position', + }, + }, + }, + position: { + type: 'object', + required: ['block', 'offset'], + properties: { + block: { + type: 'array', + items: { + type: 'integer', + }, + }, + offset: { + type: 'integer', + }, + }, + }, }, }, PubLeafletRichtextFacet: { diff --git a/lexicons/api/types/pub/leaflet/comment.ts b/lexicons/api/types/pub/leaflet/comment.ts index da67e5fc..6592f2e9 100644 --- a/lexicons/api/types/pub/leaflet/comment.ts +++ b/lexicons/api/types/pub/leaflet/comment.ts @@ -6,6 +6,7 @@ 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' +import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' const is$typed = _is$typed, validate = _validate @@ -18,6 +19,7 @@ export interface Record { reply?: ReplyRef plaintext: string facets?: PubLeafletRichtextFacet.Main[] + attachment?: $Typed | { $type: string } [k: string]: unknown } @@ -31,6 +33,22 @@ export function validateRecord(v: V) { return validate(v, id, hashRecord, true) } +export interface LinearDocumentQuote { + $type?: 'pub.leaflet.comment#linearDocumentQuote' + document: string + quote: PubLeafletPagesLinearDocument.Quote +} + +const hashLinearDocumentQuote = 'linearDocumentQuote' + +export function isLinearDocumentQuote(v: V) { + return is$typed(v, id, hashLinearDocumentQuote) +} + +export function validateLinearDocumentQuote(v: V) { + return validate(v, id, hashLinearDocumentQuote) +} + export interface ReplyRef { $type?: 'pub.leaflet.comment#replyRef' parent: string diff --git a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts index e5669878..55cea45b 100644 --- a/lexicons/api/types/pub/leaflet/pages/linearDocument.ts +++ b/lexicons/api/types/pub/leaflet/pages/linearDocument.ts @@ -71,3 +71,35 @@ export function validateBlock(v: V) { export const TEXTALIGNLEFT = `${id}#textAlignLeft` export const TEXTALIGNCENTER = `${id}#textAlignCenter` export const TEXTALIGNRIGHT = `${id}#textAlignRight` + +export interface Quote { + $type?: 'pub.leaflet.pages.linearDocument#quote' + start: Position + end: Position +} + +const hashQuote = 'quote' + +export function isQuote(v: V) { + return is$typed(v, id, hashQuote) +} + +export function validateQuote(v: V) { + return validate(v, id, hashQuote) +} + +export interface Position { + $type?: 'pub.leaflet.pages.linearDocument#position' + block: number[] + offset: number +} + +const hashPosition = 'position' + +export function isPosition(v: V) { + return is$typed(v, id, hashPosition) +} + +export function validatePosition(v: V) { + return validate(v, id, hashPosition) +} diff --git a/lexicons/pub/leaflet/comment.json b/lexicons/pub/leaflet/comment.json index a9a27a13..afca5a69 100644 --- a/lexicons/pub/leaflet/comment.json +++ b/lexicons/pub/leaflet/comment.json @@ -37,10 +37,33 @@ "type": "ref", "ref": "pub.leaflet.richtext.facet" } + }, + "attachment": { + "type": "union", + "refs": [ + "#linearDocumentQuote" + ] } } } }, + "linearDocumentQuote": { + "type": "object", + "required": [ + "document", + "quote" + ], + "properties": { + "document": { + "type": "string", + "format": "at-uri" + }, + "quote": { + "type": "ref", + "ref": "pub.leaflet.pages.linearDocument#quote" + } + } + }, "replyRef": { "type": "object", "required": [ diff --git a/lexicons/pub/leaflet/pages/linearDocument.json b/lexicons/pub/leaflet/pages/linearDocument.json index 14708dcf..41905c30 100644 --- a/lexicons/pub/leaflet/pages/linearDocument.json +++ b/lexicons/pub/leaflet/pages/linearDocument.json @@ -54,6 +54,41 @@ }, "textAlignRight": { "type": "token" + }, + "quote": { + "type": "object", + "required": [ + "start", + "end" + ], + "properties": { + "start": { + "type": "ref", + "ref": "#position" + }, + "end": { + "type": "ref", + "ref": "#position" + } + } + }, + "position": { + "type": "object", + "required": [ + "block", + "offset" + ], + "properties": { + "block": { + "type": "array", + "items": { + "type": "integer" + } + }, + "offset": { + "type": "integer" + } + } } } } \ No newline at end of file diff --git a/lexicons/src/comment.ts b/lexicons/src/comment.ts index 0cca557d..18e5a73d 100644 --- a/lexicons/src/comment.ts +++ b/lexicons/src/comment.ts @@ -23,9 +23,18 @@ export const PubLeafletComment: LexiconDoc = { type: "array", items: { type: "ref", ref: PubLeafletRichTextFacet.id }, }, + attachment: { type: "union", refs: ["#linearDocumentQuote"] }, }, }, }, + linearDocumentQuote: { + type: "object", + required: ["document", "quote"], + properties: { + document: { type: "string", format: "at-uri" }, + quote: { type: "ref", ref: "pub.leaflet.pages.linearDocument#quote" }, + }, + }, replyRef: { type: "object", required: ["parent"], diff --git a/lexicons/src/pages/LinearDocument.ts b/lexicons/src/pages/LinearDocument.ts index 873e1afb..8bad6139 100644 --- a/lexicons/src/pages/LinearDocument.ts +++ b/lexicons/src/pages/LinearDocument.ts @@ -29,5 +29,21 @@ export const PubLeafletPagesLinearDocument: LexiconDoc = { textAlignLeft: { type: "token" }, textAlignCenter: { type: "token" }, textAlignRight: { type: "token" }, + quote: { + type: "object", + required: ["start", "end"], + properties: { + start: { type: "ref", ref: "#position" }, + end: { type: "ref", ref: "#position" }, + }, + }, + position: { + type: "object", + required: ["block", "offset"], + properties: { + block: { type: "array", items: { type: "integer" } }, + offset: { type: "integer" }, + }, + }, }, }; diff --git a/next-env.d.ts b/next-env.d.ts index 1b3be084..830fb594 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.