From 5d3b17545fa6f59c817004360524426a39e3e7cc Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 9 Jun 2024 09:44:51 +0200 Subject: [PATCH] store locker and list names locally and fix loading ug --- README.md | 4 +- .../src/app/(app)/list-invitation/[token].tsx | 5 - apps/app/src/app/(app)/list/[listId].tsx | 227 ++---------------- apps/app/src/components/document.tsx | 217 +++++++++++++++++ apps/app/src/components/drawerContent.tsx | 8 +- apps/app/src/hooks/useLocker.ts | 21 +- 6 files changed, 264 insertions(+), 218 deletions(-) create mode 100644 apps/app/src/components/document.tsx diff --git a/README.md b/README.md index 26bef81..e43da1a 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,6 @@ TODO better version where the token is also never exposed to the network so not ## Todos -- handle case where locker is not available yet when directly opening a list -- update locker entries locally when new locker data comes in - - create a new invitation needs a change in react-native-libsodium (use noble?) - show pending changes in the sidebar and the list @@ -130,6 +127,7 @@ TODO better version where the token is also never exposed to the network so not - locker bug during registration 🤷 - sort lists in the drawer +- store me and members data also locally - figure out how author keys are managed (tabs in serenity and possible change in secsync) - add retry for locker in case write fails (invalid clock) - allow to delete list (needs a tombstone and properly cleanup local stores) diff --git a/apps/app/src/app/(app)/list-invitation/[token].tsx b/apps/app/src/app/(app)/list-invitation/[token].tsx index 452af1f..cadb133 100644 --- a/apps/app/src/app/(app)/list-invitation/[token].tsx +++ b/apps/app/src/app/(app)/list-invitation/[token].tsx @@ -38,11 +38,6 @@ const Invitation: React.FC = () => { return; } - console.log( - "documentInvitationByTokenQuery", - documentInvitationByTokenQuery - ); - const { listKey } = acceptInvitation({ ciphertext: documentInvitationByTokenQuery.data.ciphertext, nonce: documentInvitationByTokenQuery.data.nonce, diff --git a/apps/app/src/app/(app)/list/[listId].tsx b/apps/app/src/app/(app)/list/[listId].tsx index 7f2d751..e3e955f 100644 --- a/apps/app/src/app/(app)/list/[listId].tsx +++ b/apps/app/src/app/(app)/list/[listId].tsx @@ -1,223 +1,38 @@ import { useLocalSearchParams } from "expo-router"; -import React, { useEffect, useRef, useState } from "react"; +import React from "react"; import { View } from "react-native"; -import sodium, { KeyPair } from "react-native-libsodium"; -import { generateId } from "secsync"; -import { useYjsSync } from "secsync-react-yjs"; -import * as Yjs from "yjs"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; +import sodium from "react-native-libsodium"; import { Text } from "~/components/ui/text"; -import { X } from "~/lib/icons/X"; -import { Checkbox } from "../../../components/checkbox"; -import { DocumentMembers } from "../../../components/documentMember"; -import { SubtleInput } from "../../../components/subtleInput"; -import { UpdateDocumentNameForm } from "../../../components/updateDocumentNameForm"; +import Document from "../../../components/document"; import { useLocker } from "../../../hooks/useLocker"; -import { useYData } from "../../../hooks/useYData"; -import { convertChecklistToArrayAndSort } from "../../../utils/convertChecklistToArrayAndSort"; -import { deserialize } from "../../../utils/deserialize"; -import { getDocumentStorage } from "../../../utils/documentStorage"; -import { position } from "../../../utils/position"; -import { serialize } from "../../../utils/serialize"; -import { trpc } from "../../../utils/trpc"; -const websocketEndpoint = - process.env.NODE_ENV === "development" - ? "ws://localhost:3030" - : "wss://secsync.fly.dev"; - -type Props = { - documentId: string; -}; - -const List: React.FC = () => { +const List: React.FC = () => { const { listId } = useLocalSearchParams(); - const documentId = typeof listId === "string" ? listId : ""; - const getDocumentQuery = trpc.getDocument.useQuery(documentId, { - refetchInterval: 5000, - }); - - const [authorKeyPair] = useState(() => { - return sodium.crypto_sign_keypair(); - }); - - // load initial data - const [initialData] = useState(() => { - const yDoc = new Yjs.Doc(); - // load full document - const serializedDoc = - getDocumentStorage().documentStorage.getString(documentId); - if (serializedDoc) { - Yjs.applyUpdateV2(yDoc, deserialize(serializedDoc)); - } - - // loads the pendingChanges - const pendingChanges = - getDocumentStorage().documentPendingChangesStorage.getString(documentId); - - return { - yDoc, - pendingChanges: pendingChanges ? deserialize(pendingChanges) : [], - }; - }); - - const yDocRef = useRef(initialData.yDoc); - - // update the document after every change (could be debounced) - useEffect(() => { - const onUpdate = (update: any) => { - const fullYDoc = Yjs.encodeStateAsUpdateV2(yDocRef.current); - getDocumentStorage().documentStorage.set(documentId, serialize(fullYDoc)); - }; - yDocRef.current.on("updateV2", onUpdate); - - return () => { - yDocRef.current.off("updateV2", onUpdate); - }; - }, []); - - const yDocument: Yjs.Map> = yDocRef.current.getMap("document"); - const document = useYData<{ [k: string]: ChecklistItem }>(yDocument); - const checklist = document ? convertChecklistToArrayAndSort(document) : []; - const [newTodoText, setNewTodoText] = useState(""); + const documentId = typeof listId === "string" ? listId : null; const { content } = useLocker(); - const documentKey = sodium.from_base64(content[`document:${documentId}`]); - - const [state, send] = useYjsSync({ - yDoc: yDocRef.current, - pendingChanges: initialData.pendingChanges, - // callback to store the pending changes in - onPendingChangesUpdated: (allChanges) => { - getDocumentStorage().documentPendingChangesStorage.set( - documentId, - serialize(allChanges) - ); - }, - documentId, - signatureKeyPair: authorKeyPair, - websocketEndpoint, - websocketSessionKey: "your-secret-session-key", - getNewSnapshotData: async ({ id }) => { - return { - data: Yjs.encodeStateAsUpdateV2(yDocRef.current), - key: documentKey, - publicData: {}, - }; - }, - getSnapshotKey: async () => { - return documentKey; - }, - shouldSendSnapshot: ({ snapshotUpdatesCount }) => { - // create a new snapshot if the active snapshot has more than 100 updates - return snapshotUpdatesCount > 100; - }, - isValidClient: async (signingPublicKey: string) => { - return true; - }, - sodium, - logging: "debug", - }); - const addChecklistItem = (event: any) => { - event.preventDefault(); - // @ts-expect-error sodium is not typed - const id = generateId(sodium); - const text = new Yjs.Text(newTodoText); - const todo = new Yjs.Map(); - const newPosition = - checklist.length > 0 - ? position.createBetween(undefined, checklist[0].position) - : position.createBetween(); - todo.set("type", "checklist-item"); - todo.set("text", text); - todo.set("checked", false); - todo.set("position", newPosition); - - yDocument.set(id, todo); - setNewTodoText(""); - }; - - return ( - - - - + if (!documentId) { + return ( + + Document not found + ); + } - + const documentKeyBase64 = content[`document:${documentId}`]; - - - setNewTodoText(value)} - value={newTodoText} - autoCapitalize="none" - autoCorrect={false} - autoComplete="off" - numberOfLines={1} - onSubmitEditing={addChecklistItem} - /> - - + if (!documentKeyBase64) { + return ( + + Loading document key … + ); + } + + const documentKey = sodium.from_base64(documentKeyBase64); - {checklist.map((entry, index) => { - return ( - - { - const yEntry = yDocument.get(entry.id); - if (yEntry) { - yEntry.set("checked", !entry.checked); - } - }} - /> - - { - const yEntry = yDocument.get(entry.id); - if (yEntry) { - yEntry.set("text", newText); - } - }} - /> - - - - ); - })} - - ); + return ; }; export default List; diff --git a/apps/app/src/components/document.tsx b/apps/app/src/components/document.tsx new file mode 100644 index 0000000..ba651fd --- /dev/null +++ b/apps/app/src/components/document.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useRef, useState } from "react"; +import { View } from "react-native"; +import sodium, { KeyPair } from "react-native-libsodium"; +import { generateId } from "secsync"; +import { useYjsSync } from "secsync-react-yjs"; +import * as Yjs from "yjs"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Text } from "~/components/ui/text"; +import { X } from "~/lib/icons/X"; +import { Checkbox } from "../components/checkbox"; +import { DocumentMembers } from "../components/documentMember"; +import { SubtleInput } from "../components/subtleInput"; +import { UpdateDocumentNameForm } from "../components/updateDocumentNameForm"; +import { useYData } from "../hooks/useYData"; +import { convertChecklistToArrayAndSort } from "../utils/convertChecklistToArrayAndSort"; +import { deserialize } from "../utils/deserialize"; +import { getDocumentStorage } from "../utils/documentStorage"; +import { position } from "../utils/position"; +import { serialize } from "../utils/serialize"; +import { trpc } from "../utils/trpc"; + +const websocketEndpoint = + process.env.NODE_ENV === "development" + ? "ws://localhost:3030" + : "wss://secsync.fly.dev"; + +type Props = { + documentId: string; + documentKey: Uint8Array; +}; + +const Document: React.FC = ({ documentKey, documentId }) => { + const getDocumentQuery = trpc.getDocument.useQuery(documentId, { + refetchInterval: 5000, + }); + + const [authorKeyPair] = useState(() => { + return sodium.crypto_sign_keypair(); + }); + + // load initial data + const [initialData] = useState(() => { + const yDoc = new Yjs.Doc(); + // load full document + const serializedDoc = + getDocumentStorage().documentStorage.getString(documentId); + if (serializedDoc) { + Yjs.applyUpdateV2(yDoc, deserialize(serializedDoc)); + } + + // loads the pendingChanges + const pendingChanges = + getDocumentStorage().documentPendingChangesStorage.getString(documentId); + + return { + yDoc, + pendingChanges: pendingChanges ? deserialize(pendingChanges) : [], + }; + }); + + const yDocRef = useRef(initialData.yDoc); + + // update the document after every change (could be debounced) + useEffect(() => { + const onUpdate = (update: any) => { + const fullYDoc = Yjs.encodeStateAsUpdateV2(yDocRef.current); + getDocumentStorage().documentStorage.set(documentId, serialize(fullYDoc)); + }; + yDocRef.current.on("updateV2", onUpdate); + + return () => { + yDocRef.current.off("updateV2", onUpdate); + }; + }, []); + + const yDocument: Yjs.Map> = yDocRef.current.getMap("document"); + const document = useYData<{ [k: string]: ChecklistItem }>(yDocument); + const checklist = document ? convertChecklistToArrayAndSort(document) : []; + const [newTodoText, setNewTodoText] = useState(""); + + const [state, send] = useYjsSync({ + yDoc: yDocRef.current, + pendingChanges: initialData.pendingChanges, + // callback to store the pending changes in + onPendingChangesUpdated: (allChanges) => { + getDocumentStorage().documentPendingChangesStorage.set( + documentId, + serialize(allChanges) + ); + }, + documentId, + signatureKeyPair: authorKeyPair, + websocketEndpoint, + websocketSessionKey: "your-secret-session-key", + getNewSnapshotData: async ({ id }) => { + return { + data: Yjs.encodeStateAsUpdateV2(yDocRef.current), + key: documentKey, + publicData: {}, + }; + }, + getSnapshotKey: async () => { + return documentKey; + }, + shouldSendSnapshot: ({ snapshotUpdatesCount }) => { + // create a new snapshot if the active snapshot has more than 100 updates + return snapshotUpdatesCount > 100; + }, + isValidClient: async (signingPublicKey: string) => { + return true; + }, + sodium, + logging: "debug", + }); + + const addChecklistItem = (event: any) => { + event.preventDefault(); + // @ts-expect-error sodium is not typed + const id = generateId(sodium); + const text = new Yjs.Text(newTodoText); + const todo = new Yjs.Map(); + const newPosition = + checklist.length > 0 + ? position.createBetween(undefined, checklist[0].position) + : position.createBetween(); + todo.set("type", "checklist-item"); + todo.set("text", text); + todo.set("checked", false); + todo.set("position", newPosition); + + yDocument.set(id, todo); + setNewTodoText(""); + }; + + return ( + + + + + + + + + + + setNewTodoText(value)} + value={newTodoText} + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + numberOfLines={1} + onSubmitEditing={addChecklistItem} + /> + + + + + {checklist.map((entry, index) => { + return ( + + { + const yEntry = yDocument.get(entry.id); + if (yEntry) { + yEntry.set("checked", !entry.checked); + } + }} + /> + + { + const yEntry = yDocument.get(entry.id); + if (yEntry) { + yEntry.set("text", newText); + } + }} + /> + + + + ); + })} + + ); +}; + +export default Document; diff --git a/apps/app/src/components/drawerContent.tsx b/apps/app/src/components/drawerContent.tsx index 7a6fa0c..32bee9b 100644 --- a/apps/app/src/components/drawerContent.tsx +++ b/apps/app/src/components/drawerContent.tsx @@ -30,7 +30,8 @@ export const DrawerContent: React.FC = () => { const documentsQuery = trpc.documents.useQuery(undefined, { refetchInterval: 5000, }); - let keys = getDocumentStorage().documentNameStorage.getAllKeys(); + const documentNameStorage = getDocumentStorage().documentNameStorage; + let keys = documentNameStorage.getAllKeys(); if (documentsQuery.data) { const remoteDocumentIds = documentsQuery.data.map((doc) => doc.id); // merge remote and local keys and deduplicate them @@ -77,6 +78,11 @@ export const DrawerContent: React.FC = () => { }) : getDocumentStorage().documentNameStorage.getString(docId); + // store the name if it's not already stored + if (!documentNameStorage.getString(docId)) { + documentNameStorage.set(docId, name || "Untitled"); + } + return ( diff --git a/apps/app/src/hooks/useLocker.ts b/apps/app/src/hooks/useLocker.ts index 3749703..94bbe9b 100644 --- a/apps/app/src/hooks/useLocker.ts +++ b/apps/app/src/hooks/useLocker.ts @@ -27,16 +27,31 @@ export const useLocker = () => { commitment: string; clock: number; }) => { - const localLocker = getCompleteLocalLocker(); + const localLocker = getLockerStorage(); + const localLockerValues = getCompleteLocalLocker(); + const lockerKeyString = getLockerKey(); if (!lockerKeyString) { throw new Error("Locker key not found."); } const lockerKey = sodium.from_base64(lockerKeyString); const contentString = decryptLocker(data, lockerKey); + // TODO validate schema + const newContent = JSON.parse(contentString); + + // update the local locker with the remote locker values + Object.keys(newContent).forEach((key) => { + const value = newContent[key]; + if (!value) { + return; + } + if (!localLockerValues[key]) { + localLocker.set(key, value); + } + }); + return { - // TODO validate schema - ...JSON.parse(contentString), + ...newContent, ...localLocker, }; };