From 0c50150b9abbe17dad174505d1a0798f467b1ecd Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Thu, 4 Jan 2024 11:51:57 -0500 Subject: [PATCH 01/12] initial draft of schema utils --- package.json | 3 + .../schemaToType.ts | 18 +++++ .../useTypedDocument.ts | 79 +++++++++++++++++++ yarn.lock | 22 ++++++ 4 files changed, 122 insertions(+) create mode 100644 src/automerge-repo-schema-utils/schemaToType.ts create mode 100644 src/automerge-repo-schema-utils/useTypedDocument.ts diff --git a/package.json b/package.json index 1aacc08b..b9b60f58 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@codemirror/language": "^6.9.1", "@codemirror/language-data": "^6.3.1", "@codemirror/view": "^6.21.3", + "@effect/schema": "^0.57.0", "@lezer/highlight": "^1.1.6", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", @@ -46,7 +47,9 @@ "clsx": "^2.0.0", "cmdk": "^0.2.0", "codemirror": "^6.0.1", + "effect": "^2.0.0", "eventemitter3": "^5.0.1", + "fast-check": "^3.15.0", "lodash": "^4.17.21", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.284.0", diff --git a/src/automerge-repo-schema-utils/schemaToType.ts b/src/automerge-repo-schema-utils/schemaToType.ts new file mode 100644 index 00000000..a81d8eb0 --- /dev/null +++ b/src/automerge-repo-schema-utils/schemaToType.ts @@ -0,0 +1,18 @@ +// A utility type to recursively make an object mutable. +// This helps us take the immutable objects returned by +// the schema library and make them mutable for editing +// within an automerge document. + +import { Schema as S } from "@effect/schema"; + +type DeepMutable = { + -readonly [K in keyof T]: T[K] extends (infer R)[] + ? DeepMutable[] + : T[K] extends ReadonlyArray + ? DeepMutable[] + : T[K] extends object + ? DeepMutable + : T[K]; +}; + +export type SchemaToType> = DeepMutable>; diff --git a/src/automerge-repo-schema-utils/useTypedDocument.ts b/src/automerge-repo-schema-utils/useTypedDocument.ts new file mode 100644 index 00000000..e6ebf9ac --- /dev/null +++ b/src/automerge-repo-schema-utils/useTypedDocument.ts @@ -0,0 +1,79 @@ +import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge/next"; +import { + AutomergeUrl, + DocHandleChangePayload, +} from "@automerge/automerge-repo"; +import { useCallback, useEffect, useState } from "react"; +import { useRepo } from "@automerge/automerge-repo-react-hooks"; +import { Schema as S } from "@effect/schema"; +import { isLeft } from "effect/Either"; +import { SchemaToType } from "./schemaToType"; + +// An experimental version of the automerge-repo useDocument hook +// which has stronger schema validation powered by @effect/schema + +export function useTypedDocument>( + documentUrl: AutomergeUrl | null, + schema: T +): [ + Doc> | undefined, + (changeFn: ChangeFn>) => void +] { + const [doc, setDoc] = useState>>(); + const repo = useRepo(); + + const handle = documentUrl ? repo.find>(documentUrl) : null; + + const validateDoc = useCallback( + (doc: unknown): void => { + const parseResult = S.parseEither(schema)(doc); + // GL 12/6/23: + // TODO: Need to think a lot more about what to do with errors here. + // Should we crash the app and prevent it from loading? + // Could use effect schema transforms to do a basic cambria thing. + if (isLeft(parseResult)) { + // alert(`⚠️ WARNING: document loaded from repo does not match schema. + + // Proceed at your own risk. + + // ${String(parseResult.left)}`); + console.error( + "WARNING: document loaded from repo does not match schema" + ); + console.error(doc); + console.error(String(parseResult.left)); + } + }, + [schema] + ); + + useEffect(() => { + if (!handle) return; + + handle.doc().then((v) => { + validateDoc(v); + setDoc(v); + }); + + const onChange = (h: DocHandleChangePayload>) => { + validateDoc(h.doc); + setDoc(h.doc); + }; + handle.on("change", onChange); + const cleanup = () => { + handle.removeListener("change", onChange); + }; + + return cleanup; + }, [handle, validateDoc]); + + const changeDoc = ( + changeFn: ChangeFn>, + options?: ChangeOptions> | undefined + ) => { + if (!handle) return; + handle.change(changeFn, options); + }; + + return [doc, changeDoc]; +} diff --git a/yarn.lock b/yarn.lock index 6a6c61e0..9a4f2394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -634,6 +634,11 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@effect/schema@^0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@effect/schema/-/schema-0.57.0.tgz#e6bca1460225beb95c574dbdee82647896563f94" + integrity sha512-o4dKS1qhtdIcj4wIS6uejRwXJYcouLO5klAe62vV4QfzQGGItgvD4f0HD6tdlh2E2jlo+iRSPibZadv2J3yEVQ== + "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" @@ -2619,6 +2624,11 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" +effect@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/effect/-/effect-2.0.0.tgz#e4d0c06e024b81504f2aa7e29b07128bddab4694" + integrity sha512-yYYNf91dlk7dEl+U1QzeSiMHIM9zz1/NqSJ5hNRZ6GrFwctaCQcdxmAhll6mQRCgXFIAMhMEawmATlGLWtU5qQ== + electron-to-chromium@^1.4.535: version "1.4.557" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.557.tgz#f3941b569c82b7bb909411855c6ff9bfe1507829" @@ -2809,6 +2819,13 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== +fast-check@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-3.15.0.tgz#3ee501aa82c836efb96d7bc8c68aa7bbc1d79f8e" + integrity sha512-iBz6c+EXL6+nI931x/sbZs1JYTZtLG6Cko0ouS8LRTikhDR7+wZk4TYzdRavlnByBs2G6+nuuJ7NYL9QplNt8Q== + dependencies: + pure-rand "^6.0.0" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3690,6 +3707,11 @@ punycode@^2.1.1, punycode@^2.3.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +pure-rand@^6.0.0: + version "6.0.4" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.4.tgz#50b737f6a925468679bff00ad20eade53f37d5c7" + integrity sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA== + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" From 5ddfd905ac5dc3377b8b856aa9749370f46efd29 Mon Sep 17 00:00:00 2001 From: Geoffrey Litt Date: Thu, 4 Jan 2024 12:27:24 -0500 Subject: [PATCH 02/12] basic essay schema --- src/DocExplorer/components/DocExplorer.tsx | 31 ++- src/DocExplorer/components/Topbar.tsx | 8 +- src/tee/codemirrorPlugins/commentThreads.ts | 3 +- src/tee/components/CommentsSidebar.1.tsx | 255 ++++++++++++++++++++ src/tee/components/CommentsSidebar.tsx | 16 +- src/tee/components/MarkdownEditor.tsx | 6 +- src/tee/components/TinyEssayEditor.tsx | 11 +- src/tee/datatype.ts | 8 +- src/tee/main.tsx | 5 +- src/tee/schema.ts | 40 --- src/tee/schemas/Essay.ts | 44 ++++ src/tee/types.ts | 8 + src/tee/utils.ts | 13 +- 13 files changed, 366 insertions(+), 82 deletions(-) create mode 100644 src/tee/components/CommentsSidebar.1.tsx delete mode 100644 src/tee/schema.ts create mode 100644 src/tee/schemas/Essay.ts create mode 100644 src/tee/types.ts diff --git a/src/DocExplorer/components/DocExplorer.tsx b/src/DocExplorer/components/DocExplorer.tsx index 2fae4f3a..26464b82 100644 --- a/src/DocExplorer/components/DocExplorer.tsx +++ b/src/DocExplorer/components/DocExplorer.tsx @@ -1,13 +1,17 @@ -import { AutomergeUrl, isValidAutomergeUrl } from "@automerge/automerge-repo"; +import { + AutomergeUrl, + Repo, + isValidAutomergeUrl, +} from "@automerge/automerge-repo"; import React, { useCallback, useEffect, useState } from "react"; import { TinyEssayEditor } from "../../tee/components/TinyEssayEditor"; import { useDocument, useRepo } from "@automerge/automerge-repo-react-hooks"; import { init } from "../../tee/datatype"; import { Button } from "@/components/ui/button"; -import { MarkdownDoc } from "@/tee/schema"; import { getTitle } from "@/tee/datatype"; import { DocType, + FolderDoc, useCurrentAccount, useCurrentAccountDoc, useCurrentRootFolderDoc, @@ -16,6 +20,8 @@ import { import { Sidebar } from "./Sidebar"; import { Topbar } from "./Topbar"; import { LoadingScreen } from "./LoadingScreen"; +import { Essay } from "@/tee/schemas/Essay"; +import { ChangeFn } from "@automerge/automerge"; export const DocExplorer: React.FC = () => { const repo = useRepo(); @@ -26,7 +32,7 @@ export const DocExplorer: React.FC = () => { const [showSidebar, setShowSidebar] = useState(true); const { selectedDoc, selectDoc, selectedDocUrl, openDocFromUrl } = - useSelectedDoc({ rootFolderDoc, changeRootFolderDoc }); + useSelectedDoc({ rootFolderDoc, changeRootFolderDoc, repo }); const selectedDocName = rootFolderDoc?.docs.find( (doc) => doc.url === selectedDocUrl @@ -38,7 +44,7 @@ export const DocExplorer: React.FC = () => { throw new Error("Only essays are supported right now"); } - const newDocHandle = repo.create(); + const newDocHandle = repo.create(); newDocHandle.change(init); if (!rootFolderDoc) { @@ -195,9 +201,17 @@ export const DocExplorer: React.FC = () => { // Drive the currently selected doc using the URL hash // (We encapsulate the selection state in a hook so that the only // API for changing the selection is properly thru the URL) -const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { +const useSelectedDoc = ({ + rootFolderDoc, + changeRootFolderDoc, + repo, +}: { + rootFolderDoc: FolderDoc; + changeRootFolderDoc: ChangeFn<(doc: FolderDoc) => void>; + repo: Repo; +}) => { const [selectedDocUrl, setSelectedDocUrl] = useState(null); - const [selectedDoc] = useDocument(selectedDocUrl); + const [selectedDoc] = useDocument(selectedDocUrl); const selectDoc = (docUrl: AutomergeUrl | null) => { if (docUrl) { @@ -226,7 +240,7 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { setSelectedDocUrl(docUrl); }, - [rootFolderDoc, changeRootFolderDoc, selectDoc] + [rootFolderDoc, changeRootFolderDoc] ); // observe the URL hash to change the selected document @@ -240,6 +254,9 @@ const useSelectedDoc = ({ rootFolderDoc, changeRootFolderDoc }) => { return; } openDocFromUrl(docUrl); + + // @ts-expect-error - adding property to window + window.handle = repo.find(docUrl); } }; diff --git a/src/DocExplorer/components/Topbar.tsx b/src/DocExplorer/components/Topbar.tsx index 6eb25785..568f4d3f 100644 --- a/src/DocExplorer/components/Topbar.tsx +++ b/src/DocExplorer/components/Topbar.tsx @@ -17,7 +17,6 @@ import { import { asMarkdownFile, markCopy } from "../../tee/datatype"; import { SyncIndicatorWrapper } from "./SyncIndicator"; import { AccountPicker } from "./AccountPicker"; -import { MarkdownDoc } from "@/tee/schema"; import { getTitle } from "@/tee/datatype"; import { saveFile } from "../utils"; import { DocLink, useCurrentRootFolderDoc } from "../account"; @@ -31,6 +30,7 @@ import { } from "@/components/ui/dropdown-menu"; import { save } from "@automerge/automerge"; +import { Essay } from "@/tee/schemas/Essay"; type TopbarProps = { showSidebar: boolean; @@ -52,10 +52,10 @@ export const Topbar: React.FC = ({ const selectedDocName = rootFolderDoc?.docs.find( (doc) => doc.url === selectedDocUrl )?.name; - const selectedDocHandle = useHandle(selectedDocUrl); + const selectedDocHandle = useHandle(selectedDocUrl); // GL 12/13: here we assume this is a TEE Markdown doc, but in future should be more generic. - const [selectedDoc] = useDocument(selectedDocUrl); + const [selectedDoc] = useDocument(selectedDocUrl); const exportAsMarkdown = useCallback(() => { const file = asMarkdownFile(selectedDoc); @@ -124,7 +124,7 @@ export const Topbar: React.FC = ({ { - const newHandle = repo.clone(selectedDocHandle); + const newHandle = repo.clone(selectedDocHandle); newHandle.change((doc) => { markCopy(doc); }); diff --git a/src/tee/codemirrorPlugins/commentThreads.ts b/src/tee/codemirrorPlugins/commentThreads.ts index 2665d3f1..18052dbb 100644 --- a/src/tee/codemirrorPlugins/commentThreads.ts +++ b/src/tee/codemirrorPlugins/commentThreads.ts @@ -1,8 +1,9 @@ import { EditorView, Decoration } from "@codemirror/view"; import { StateEffect, StateField } from "@codemirror/state"; -import { CommentThreadForUI } from "../schema"; + import { amRangeToCMRange } from "../utils"; import { sortBy } from "lodash"; +import { CommentThreadForUI } from "../types"; export const setThreadsEffect = StateEffect.define(); export const threadsField = StateField.define({ diff --git a/src/tee/components/CommentsSidebar.1.tsx b/src/tee/components/CommentsSidebar.1.tsx new file mode 100644 index 00000000..60934994 --- /dev/null +++ b/src/tee/components/CommentsSidebar.1.tsx @@ -0,0 +1,255 @@ +import { Button } from "@/components/ui/button"; +import { Check, MessageSquarePlus, Reply } from "lucide-react"; +import { Textarea } from "@/components/ui/textarea"; +import { next as A, ChangeFn, uuid } from "@automerge/automerge"; +import { + Popover, + PopoverContent, + PopoverTrigger, + PopoverClose, +} from "@/components/ui/popover"; +import { TextSelection } from "./MarkdownEditor"; +import { useEffect, useState } from "react"; +import { getRelativeTimeString, cmRangeToAMRange } from "../utils"; +import { useCurrentAccount } from "@/DocExplorer/account"; +import { ContactAvatar } from "@/DocExplorer/components/ContactAvatar"; +import { CommentThread, Essay, Comment } from "../schemas/Essay"; +import { CommentThreadWithPosition } from "../types"; +import { AutomergeUrl } from "@automerge/automerge-repo"; + +export const CommentsSidebar = ({ + doc, + changeDoc, + selection, + threadsWithPositions, + activeThreadId, + setActiveThreadId, +}: { + doc: Essay; + changeDoc: (changeFn: ChangeFn) => void; + selection: TextSelection; + threadsWithPositions: CommentThreadWithPosition[]; + activeThreadId: string | null; + setActiveThreadId: (threadId: string | null) => void; +}) => { + const account = useCurrentAccount(); + const [pendingCommentText, setPendingCommentText] = useState(""); + const [commentBoxOpen, setCommentBoxOpen] = useState(false); + const [activeReplyThreadId, setActiveReplyThreadId] = useState< + string | null + >(); + + // suppress showing the button immediately after adding a thread + const [suppressButton, setSuppressButton] = useState(false); + const showCommentButton = + selection && selection.from !== selection.to && !suppressButton; + + // un-suppress the button once the selection changes + useEffect(() => { + setSuppressButton(false); + }, [selection?.from, selection?.to]); + + const startCommentThreadAtSelection = (commentText: string) => { + if (!selection) return; + + const amRange = cmRangeToAMRange(selection); + + const fromCursor = A.getCursor(doc, ["content"], amRange.from); + const toCursor = A.getCursor(doc, ["content"], amRange.to); + + const comment: Comment = { + id: uuid(), + content: commentText, + userId: null, + contactUrl: account?.contactHandle.url, + timestamp: Date.now(), + }; + + const thread: CommentThread = { + id: uuid(), + comments: [comment], + resolved: false, + fromCursor, + toCursor, + }; + + changeDoc((doc) => { + doc.commentThreads[thread.id] = thread; + }); + + setPendingCommentText(""); + }; + + const addReplyToThread = (threadId: string) => { + const comment: Comment = { + id: uuid(), + content: pendingCommentText, + contactUrl: account?.contactHandle.url, + timestamp: Date.now(), + }; + + changeDoc((doc) => { + doc.commentThreads[threadId].comments.push(comment); + }); + + setPendingCommentText(""); + }; + + return ( +
+ {threadsWithPositions.map((thread) => ( +
{ + setActiveThreadId(thread.id); + e.stopPropagation(); + }} + > +
+ {thread.comments.map((comment) => { + const legacyUserName = + doc.users?.find((user) => user.id === comment.userId)?.name ?? + "Anonymous"; + + return ( +
+
+ {comment.contactUrl ? ( + + ) : ( + legacyUserName + )} + + {getRelativeTimeString(comment.timestamp)} + +
+
+ {comment.content} +
+
+ ); + })} +
+
+ + open + ? setActiveReplyThreadId(thread.id) + : setActiveReplyThreadId(null) + } + > + + + + +