diff --git a/lexicons/app/bsky/richtext/facet.json b/lexicons/app/bsky/richtext/facet.json new file mode 100644 index 00000000..388a3a5e --- /dev/null +++ b/lexicons/app/bsky/richtext/facet.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "app.bsky.richtext.facet", + "defs": { + "main": { + "type": "object", + "description": "Annotation of a sub-string within rich text.", + "required": ["index", "features"], + "properties": { + "index": { "type": "ref", "ref": "#byteSlice" }, + "features": { + "type": "array", + "items": { "type": "union", "refs": ["#mention", "#link", "#tag"] } + } + } + }, + "mention": { + "type": "object", + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + }, + "link": { + "type": "object", + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", + "required": ["uri"], + "properties": { + "uri": { "type": "string", "format": "uri" } + } + }, + "tag": { + "type": "object", + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", + "required": ["tag"], + "properties": { + "tag": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } + } + }, + "byteSlice": { + "type": "object", + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", + "required": ["byteStart", "byteEnd"], + "properties": { + "byteStart": { "type": "integer", "minimum": 0 }, + "byteEnd": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/lexicons/com/atproto/repo/applyWrites.json b/lexicons/com/atproto/repo/applyWrites.json index 26dd96c5..47511709 100644 --- a/lexicons/com/atproto/repo/applyWrites.json +++ b/lexicons/com/atproto/repo/applyWrites.json @@ -70,7 +70,7 @@ "required": ["collection", "value"], "properties": { "collection": { "type": "string", "format": "nsid" }, - "rkey": { "type": "string", "maxLength": 15 }, + "rkey": { "type": "string", "maxLength": 512 }, "value": { "type": "unknown" } } }, diff --git a/lexicons/com/atproto/repo/createRecord.json b/lexicons/com/atproto/repo/createRecord.json index 72900857..6a0d6adc 100644 --- a/lexicons/com/atproto/repo/createRecord.json +++ b/lexicons/com/atproto/repo/createRecord.json @@ -24,7 +24,7 @@ "rkey": { "type": "string", "description": "The Record Key.", - "maxLength": 15 + "maxLength": 512 }, "validate": { "type": "boolean", diff --git a/lexicons/com/atproto/repo/putRecord.json b/lexicons/com/atproto/repo/putRecord.json index 9a841f6a..c39a9f2a 100644 --- a/lexicons/com/atproto/repo/putRecord.json +++ b/lexicons/com/atproto/repo/putRecord.json @@ -25,7 +25,7 @@ "rkey": { "type": "string", "description": "The Record Key.", - "maxLength": 15 + "maxLength": 512 }, "validate": { "type": "boolean", diff --git a/lexicons/fyi/unravel/frontpage/comment.json b/lexicons/fyi/unravel/frontpage/comment.json index 69259475..464591aa 100644 --- a/lexicons/fyi/unravel/frontpage/comment.json +++ b/lexicons/fyi/unravel/frontpage/comment.json @@ -16,6 +16,11 @@ "maxGraphemes": 10000, "description": "The content of the comment." }, + "facets": { + "type": "array", + "description": "Annotations of text (mentions, URLs, hashtags, etc)", + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } + }, "createdAt": { "type": "string", "format": "datetime", diff --git a/packages/frontpage-atproto-client/fetch-lexicons.mts b/packages/frontpage-atproto-client/fetch-lexicons.mts index 7eeed16d..39f4ed0e 100644 --- a/packages/frontpage-atproto-client/fetch-lexicons.mts +++ b/packages/frontpage-atproto-client/fetch-lexicons.mts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import AdmZip from "adm-zip"; -const LEXICON_PREFIXES_TO_FETCH = ["com/atproto/repo"]; +const LEXICON_PREFIXES_TO_FETCH = ["com/atproto/repo", "app/bsky/richtext"]; const LEXICON_OUTPUT_PATH = path.resolve(import.meta.dirname, "../../lexicons"); const isWorkingDirectoryClean = await new Promise((resolve, reject) => diff --git a/packages/frontpage-atproto-client/src/index.ts b/packages/frontpage-atproto-client/src/index.ts index 70e85a2f..ed1de9b8 100644 --- a/packages/frontpage-atproto-client/src/index.ts +++ b/packages/frontpage-atproto-client/src/index.ts @@ -4,6 +4,7 @@ import { XrpcClient, FetchHandler, FetchHandlerOptions } from "@atproto/xrpc"; import { schemas } from "./lexicons"; import { CID } from "multiformats/cid"; +import * as AppBskyRichtextFacet from "./types/app/bsky/richtext/facet"; import * as ComAtprotoRepoApplyWrites from "./types/com/atproto/repo/applyWrites"; import * as ComAtprotoRepoCreateRecord from "./types/com/atproto/repo/createRecord"; import * as ComAtprotoRepoDefs from "./types/com/atproto/repo/defs"; @@ -20,6 +21,7 @@ import * as FyiUnravelFrontpageComment from "./types/fyi/unravel/frontpage/comme import * as FyiUnravelFrontpagePost from "./types/fyi/unravel/frontpage/post"; import * as FyiUnravelFrontpageVote from "./types/fyi/unravel/frontpage/vote"; +export * as AppBskyRichtextFacet from "./types/app/bsky/richtext/facet"; export * as ComAtprotoRepoApplyWrites from "./types/com/atproto/repo/applyWrites"; export * as ComAtprotoRepoCreateRecord from "./types/com/atproto/repo/createRecord"; export * as ComAtprotoRepoDefs from "./types/com/atproto/repo/defs"; @@ -37,11 +39,13 @@ export * as FyiUnravelFrontpagePost from "./types/fyi/unravel/frontpage/post"; export * as FyiUnravelFrontpageVote from "./types/fyi/unravel/frontpage/vote"; export class AtpBaseClient extends XrpcClient { + app: AppNS; com: ComNS; fyi: FyiNS; constructor(options: FetchHandler | FetchHandlerOptions) { super(options, schemas); + this.app = new AppNS(this); this.com = new ComNS(this); this.fyi = new FyiNS(this); } @@ -52,6 +56,34 @@ export class AtpBaseClient extends XrpcClient { } } +export class AppNS { + _client: XrpcClient; + bsky: AppBskyNS; + + constructor(client: XrpcClient) { + this._client = client; + this.bsky = new AppBskyNS(client); + } +} + +export class AppBskyNS { + _client: XrpcClient; + richtext: AppBskyRichtextNS; + + constructor(client: XrpcClient) { + this._client = client; + this.richtext = new AppBskyRichtextNS(client); + } +} + +export class AppBskyRichtextNS { + _client: XrpcClient; + + constructor(client: XrpcClient) { + this._client = client; + } +} + export class ComNS { _client: XrpcClient; atproto: ComAtprotoNS; diff --git a/packages/frontpage-atproto-client/src/lexicons.ts b/packages/frontpage-atproto-client/src/lexicons.ts index 8facc3b4..5dd646f6 100644 --- a/packages/frontpage-atproto-client/src/lexicons.ts +++ b/packages/frontpage-atproto-client/src/lexicons.ts @@ -4,6 +4,87 @@ import { LexiconDoc, Lexicons } from "@atproto/lexicon"; export const schemaDict = { + AppBskyRichtextFacet: { + lexicon: 1, + id: "app.bsky.richtext.facet", + defs: { + main: { + type: "object", + description: "Annotation of a sub-string within rich text.", + required: ["index", "features"], + properties: { + index: { + type: "ref", + ref: "lex:app.bsky.richtext.facet#byteSlice", + }, + features: { + type: "array", + items: { + type: "union", + refs: [ + "lex:app.bsky.richtext.facet#mention", + "lex:app.bsky.richtext.facet#link", + "lex:app.bsky.richtext.facet#tag", + ], + }, + }, + }, + }, + mention: { + type: "object", + description: + "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", + required: ["did"], + properties: { + did: { + type: "string", + format: "did", + }, + }, + }, + link: { + type: "object", + description: + "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", + required: ["uri"], + properties: { + uri: { + type: "string", + format: "uri", + }, + }, + }, + tag: { + type: "object", + description: + "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", + required: ["tag"], + properties: { + tag: { + type: "string", + maxLength: 640, + maxGraphemes: 64, + }, + }, + }, + byteSlice: { + type: "object", + description: + "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.", + required: ["byteStart", "byteEnd"], + properties: { + byteStart: { + type: "integer", + minimum: 0, + }, + byteEnd: { + type: "integer", + minimum: 0, + }, + }, + }, + }, + }, ComAtprotoRepoApplyWrites: { lexicon: 1, id: "com.atproto.repo.applyWrites", @@ -94,7 +175,7 @@ export const schemaDict = { }, rkey: { type: "string", - maxLength: 15, + maxLength: 512, }, value: { type: "unknown", @@ -203,7 +284,7 @@ export const schemaDict = { rkey: { type: "string", description: "The Record Key.", - maxLength: 15, + maxLength: 512, }, validate: { type: "boolean", @@ -654,7 +735,7 @@ export const schemaDict = { rkey: { type: "string", description: "The Record Key.", - maxLength: 15, + maxLength: 512, }, validate: { type: "boolean", @@ -778,6 +859,15 @@ export const schemaDict = { maxGraphemes: 10000, description: "The content of the comment.", }, + facets: { + type: "array", + description: + "Annotations of text (mentions, URLs, hashtags, etc)", + items: { + type: "ref", + ref: "lex:app.bsky.richtext.facet", + }, + }, createdAt: { type: "string", format: "datetime", @@ -862,6 +952,7 @@ export const schemaDict = { export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[]; export const lexicons: Lexicons = new Lexicons(schemas); export const ids = { + AppBskyRichtextFacet: "app.bsky.richtext.facet", ComAtprotoRepoApplyWrites: "com.atproto.repo.applyWrites", ComAtprotoRepoCreateRecord: "com.atproto.repo.createRecord", ComAtprotoRepoDefs: "com.atproto.repo.defs", diff --git a/packages/frontpage-atproto-client/src/types/app/bsky/richtext/facet.ts b/packages/frontpage-atproto-client/src/types/app/bsky/richtext/facet.ts new file mode 100644 index 00000000..0edeedf4 --- /dev/null +++ b/packages/frontpage-atproto-client/src/types/app/bsky/richtext/facet.ts @@ -0,0 +1,98 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from "@atproto/lexicon"; +import { isObj, hasProp } from "../../../../util"; +import { lexicons } from "../../../../lexicons"; +import { CID } from "multiformats/cid"; + +/** Annotation of a sub-string within rich text. */ +export interface Main { + index: ByteSlice; + features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[]; + [k: string]: unknown; +} + +export function isMain(v: unknown): v is Main { + return ( + isObj(v) && + hasProp(v, "$type") && + (v.$type === "app.bsky.richtext.facet#main" || + v.$type === "app.bsky.richtext.facet") + ); +} + +export function validateMain(v: unknown): ValidationResult { + return lexicons.validate("app.bsky.richtext.facet#main", v); +} + +/** Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID. */ +export interface Mention { + did: string; + [k: string]: unknown; +} + +export function isMention(v: unknown): v is Mention { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.bsky.richtext.facet#mention" + ); +} + +export function validateMention(v: unknown): ValidationResult { + return lexicons.validate("app.bsky.richtext.facet#mention", v); +} + +/** Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. */ +export interface Link { + uri: string; + [k: string]: unknown; +} + +export function isLink(v: unknown): v is Link { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.bsky.richtext.facet#link" + ); +} + +export function validateLink(v: unknown): ValidationResult { + return lexicons.validate("app.bsky.richtext.facet#link", v); +} + +/** Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags'). */ +export interface Tag { + tag: string; + [k: string]: unknown; +} + +export function isTag(v: unknown): v is Tag { + return ( + isObj(v) && hasProp(v, "$type") && v.$type === "app.bsky.richtext.facet#tag" + ); +} + +export function validateTag(v: unknown): ValidationResult { + return lexicons.validate("app.bsky.richtext.facet#tag", v); +} + +/** Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. */ +export interface ByteSlice { + byteStart: number; + byteEnd: number; + [k: string]: unknown; +} + +export function isByteSlice(v: unknown): v is ByteSlice { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.bsky.richtext.facet#byteSlice" + ); +} + +export function validateByteSlice(v: unknown): ValidationResult { + return lexicons.validate("app.bsky.richtext.facet#byteSlice", v); +} diff --git a/packages/frontpage-atproto-client/src/types/fyi/unravel/frontpage/comment.ts b/packages/frontpage-atproto-client/src/types/fyi/unravel/frontpage/comment.ts index e2442f43..3637ac1d 100644 --- a/packages/frontpage-atproto-client/src/types/fyi/unravel/frontpage/comment.ts +++ b/packages/frontpage-atproto-client/src/types/fyi/unravel/frontpage/comment.ts @@ -5,11 +5,14 @@ import { ValidationResult, BlobRef } from "@atproto/lexicon"; import { isObj, hasProp } from "../../../../util"; import { lexicons } from "../../../../lexicons"; import { CID } from "multiformats/cid"; +import * as AppBskyRichtextFacet from "../../../app/bsky/richtext/facet"; import * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef"; export interface Record { /** The content of the comment. */ content: string; + /** Annotations of text (mentions, URLs, hashtags, etc) */ + facets?: AppBskyRichtextFacet.Main[]; /** Client-declared timestamp when this comment was originally created. */ createdAt: string; parent?: ComAtprotoRepoStrongRef.Main; diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx index 6e6b3137..a4d67b0e 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/actions.tsx @@ -13,14 +13,26 @@ import { parseReportForm } from "@/lib/data/db/report-shared"; import { createReport } from "@/lib/data/db/report"; import { getVoteForComment } from "@/lib/data/db/vote"; import { ensureUser } from "@/lib/data/user"; +import { createHeadlessEditor } from "@lexical/headless"; +import { + SerializedEditorState, + $parseSerializedNode, + LexicalEditor, + $getRoot, + EditorState, + LexicalNode, + $isTextNode, + $isElementNode, +} from "lexical"; import { revalidatePath } from "next/cache"; +import { deletePost } from "@/lib/data/atproto/post"; -export async function createCommentAction( - input: { parentRkey?: string; postRkey: string; postAuthorDid: DID }, - _prevState: unknown, - formData: FormData, -) { - const content = formData.get("comment") as string; +export async function createCommentAction(input: { + parentRkey?: string; + postRkey: string; + postAuthorDid: DID; + content: SerializedEditorState; +}) { const user = await ensureUser(); const [post, comment] = await Promise.all([ @@ -41,13 +53,15 @@ export async function createCommentAction( throw new Error(`[naughty] Cannot comment on deleted post. ${user.did}`); } - const { rkey } = await createComment({ - content, - post, - parent: comment, - }); - await waitForComment(rkey); - revalidatePath(`/post`); + const state = createHeadlessEditor().parseEditorState(input.content); + + // const { rkey } = await createComment({ + // content, + // post, + // parent: comment, + // }); + // await waitForComment(rkey); + // revalidatePath(`/post`); } const MAX_POLLS = 15; @@ -64,6 +78,28 @@ async function waitForComment(rkey: string) { } } +function editorStateToCommentContent(editorState: EditorState) { + return editorState.read(() => { + const root = $getRoot(); + root.getChildren().forEach((child) => {}); + + const text = root.getTextContent(); + }); +} + +function $nodeToFacets(node: LexicalNode) { + if ($isTextNode(node)) { + if (node.hasFormat("bold")) { + return node.selectStart; + } + } + if ($isElementNode(node) && node.isEmpty()) return []; +} + +export async function deletePostAction(rkey: string) { + await deletePost(rkey); +} + export async function deleteCommentAction(rkey: string) { await ensureUser(); await deleteComment(rkey); diff --git a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx index e8fd4cac..0afc8071 100644 --- a/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx +++ b/packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment-client.tsx @@ -11,7 +11,7 @@ import { AlertDialogTrigger, } from "@/lib/components/ui/alert-dialog"; import { Button } from "@/lib/components/ui/button"; -import { Textarea } from "@/lib/components/ui/textarea"; +import { EditableTextArea } from "@/lib/components/ui/textarea"; import { SimpleTooltip } from "@/lib/components/ui/tooltip"; import { useToast } from "@/lib/components/ui/use-toast"; import { @@ -22,13 +22,7 @@ import { reportCommentAction, } from "./actions"; import { ChatBubbleIcon, TrashIcon } from "@radix-ui/react-icons"; -import { - useActionState, - useRef, - useState, - useId, - startTransition, -} from "react"; +import { useRef, useState, useId, startTransition, useTransition } from "react"; import { VoteButton, VoteButtonState, @@ -44,6 +38,7 @@ import { DeleteButton } from "@/app/(app)/_components/delete-button"; import { cva, VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; import { ShareDropdownButton } from "@/app/(app)/_components/share-button"; +import { LexicalEditor } from "lexical"; const commentVariants = cva(undefined, { variants: { @@ -226,7 +221,6 @@ export function NewComment({ postRkey, postAuthorDid, extraButton, - textAreaRef, onActionDone, }: { parentRkey?: string; @@ -237,23 +231,28 @@ export function NewComment({ extraButton?: React.ReactNode; textAreaRef?: React.RefObject; }) { + const editorRef = useRef(null); const [input, setInput] = useState(""); - const [_, action, isPending] = useActionState( - createCommentAction.bind(null, { parentRkey, postRkey, postAuthorDid }), - undefined, - ); + const [isPending, startTransition] = useTransition(); + const id = useId(); const textAreaId = `${id}-comment`; return (
{ event.preventDefault(); - startTransition(() => { - action(new FormData(event.currentTarget)); + startTransition(async () => { + const state = editorRef.current?.getEditorState().toJSON(); + if (!state) throw new Error("Empty comment"); + console.log(state); + await createCommentAction({ + parentRkey, + postRkey, + postAuthorDid, + content: state, + }); onActionDone?.(); - setInput(""); }); }} aria-busy={isPending} @@ -271,7 +270,7 @@ export function NewComment({ }} className="space-y-2" > -