Skip to content

Commit

Permalink
store locker and list names locally and fix loading ug
Browse files Browse the repository at this point in the history
  • Loading branch information
nikgraf committed Jun 9, 2024
1 parent 94cb119 commit 5d3b175
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 218 deletions.
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions apps/app/src/app/(app)/list-invitation/[token].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,6 @@ const Invitation: React.FC = () => {
return;
}

console.log(
"documentInvitationByTokenQuery",
documentInvitationByTokenQuery
);

const { listKey } = acceptInvitation({
ciphertext: documentInvitationByTokenQuery.data.ciphertext,
nonce: documentInvitationByTokenQuery.data.nonce,
Expand Down
227 changes: 21 additions & 206 deletions apps/app/src/app/(app)/list/[listId].tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = () => {
const List: React.FC = () => {
const { listId } = useLocalSearchParams();
const documentId = typeof listId === "string" ? listId : "";
const getDocumentQuery = trpc.getDocument.useQuery(documentId, {
refetchInterval: 5000,
});

const [authorKeyPair] = useState<KeyPair>(() => {
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<Yjs.Doc>(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<Yjs.Map<any>> = 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<any>();
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 (
<View className="py-8 gap-4">
<View className="flex flex-row gap-6 items-center justify-end pl-4 pr-6">
<UpdateDocumentNameForm
documentId={documentId}
documentKey={documentKey}
/>
<DocumentMembers
documentId={documentId}
documentKey={documentKey}
currentUserIsAdmin={getDocumentQuery.data?.isAdmin || false}
/>
if (!documentId) {
return (
<View>
<Text>Document not found</Text>
</View>
);
}

<View className="h-4" />
const documentKeyBase64 = content[`document:${documentId}`];

<View className="flex flex-row items-center gap-2 px-6">
<View className="flex flex-1">
<Input
placeholder="What needs to be done?"
onChangeText={(value) => setNewTodoText(value)}
value={newTodoText}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
numberOfLines={1}
onSubmitEditing={addChecklistItem}
/>
</View>
<Button className="add" onPress={addChecklistItem}>
<Text>Add</Text>
</Button>
if (!documentKeyBase64) {
return (
<View>
<Text>Loading document key …</Text>
</View>
);
}

const documentKey = sodium.from_base64(documentKeyBase64);

{checklist.map((entry, index) => {
return (
<View
key={`${index}-${entry}`}
className="flex flex-row items-center gap-2 px-6"
>
<Checkbox
checked={entry.checked}
onCheckedChange={() => {
const yEntry = yDocument.get(entry.id);
if (yEntry) {
yEntry.set("checked", !entry.checked);
}
}}
/>
<View className="flex flex-1">
<SubtleInput
placeholder="What needs to be done?"
value={entry.text}
numberOfLines={1}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
onChangeText={(newText) => {
const yEntry = yDocument.get(entry.id);
if (yEntry) {
yEntry.set("text", newText);
}
}}
/>
</View>
<Button
variant="ghost"
size="icon"
onPress={() => {
yDocument.delete(entry.id);
}}
>
<X width={16} height={16} className="text-red-700" />
</Button>
</View>
);
})}
</View>
);
return <Document documentId={documentId} documentKey={documentKey} />;
};

export default List;
Loading

0 comments on commit 5d3b175

Please sign in to comment.