diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index 2fb3099e..6892fd10 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -125,41 +125,44 @@ export function HypergraphAppProvider({ const sessionToken = useSelectorStore(store, (state) => state.context.sessionToken); const keys = useSelectorStore(store, (state) => state.context.keys); - async function login(signer: Identity.Signer) { - if (!signer) { - return; - } - const address = await signer.getAddress(); - if (!address) { - return; - } - const accountId = getAddress(address); - const keys = Identity.loadKeys(storage, accountId); - let authData: { - accountId: Address; - sessionToken: string; - keys: Identity.IdentityKeys; - }; - const location = { - host: window.location.host, - origin: window.location.origin, - }; - if (!keys && !(await Identity.identityExists(accountId, syncServerUri))) { - authData = await Identity.signup(signer, accountId, syncServerUri, chainId, storage, location); - } else if (keys) { - authData = await Identity.loginWithKeys(keys, accountId, syncServerUri, chainId, storage, location); - } else { - authData = await Identity.loginWithWallet(signer, accountId, syncServerUri, chainId, storage, location); - } - console.log('Identity initialized'); - store.send({ - ...authData, - type: 'setAuth', - }); - store.send({ type: 'reset' }); - } + const login = useCallback( + async (signer: Identity.Signer) => { + if (!signer) { + return; + } + const address = await signer.getAddress(); + if (!address) { + return; + } + const accountId = getAddress(address); + const keys = Identity.loadKeys(storage, accountId); + let authData: { + accountId: Address; + sessionToken: string; + keys: Identity.IdentityKeys; + }; + const location = { + host: window.location.host, + origin: window.location.origin, + }; + if (!keys && !(await Identity.identityExists(accountId, syncServerUri))) { + authData = await Identity.signup(signer, accountId, syncServerUri, chainId, storage, location); + } else if (keys) { + authData = await Identity.loginWithKeys(keys, accountId, syncServerUri, chainId, storage, location); + } else { + authData = await Identity.loginWithWallet(signer, accountId, syncServerUri, chainId, storage, location); + } + console.log('Identity initialized'); + store.send({ + ...authData, + type: 'setAuth', + }); + store.send({ type: 'reset' }); + }, + [storage, syncServerUri, chainId], + ); - function logout() { + const logout = useCallback(() => { websocketConnection?.close(); setWebsocketConnection(undefined); @@ -171,7 +174,7 @@ export function HypergraphAppProvider({ Identity.wipeKeys(storage, accountIdToLogout); Identity.wipeSyncServerSessionToken(storage, accountIdToLogout); store.send({ type: 'resetAuth' }); - } + }, [accountId, storage, websocketConnection]); const setIdentityAndSessionToken = useCallback( (account: Identity.Identity & { sessionToken: string }) => { @@ -508,7 +511,7 @@ export function HypergraphAppProvider({ }; }, [websocketConnection, spaces, accountId, keys?.encryptionPrivateKey, keys?.signaturePrivateKey, syncServerUri]); - const createSpaceForContext = async () => { + const createSpaceForContext = useCallback(async () => { if (!accountId) { throw new Error('No account id found'); } @@ -547,7 +550,14 @@ export function HypergraphAppProvider({ }, } as const satisfies Messages.RequestCreateSpaceEvent; websocketConnection?.send(Messages.serialize(message)); - }; + }, [ + accountId, + keys?.encryptionPrivateKey, + keys?.encryptionPublicKey, + keys?.signaturePrivateKey, + keys?.signaturePublicKey, + websocketConnection, + ]); const listSpaces = useCallback(() => { const message: Messages.RequestListSpaces = { type: 'list-spaces' }; @@ -559,49 +569,59 @@ export function HypergraphAppProvider({ websocketConnection?.send(Messages.serialize(message)); }, [websocketConnection]); - const acceptInvitationForContext = async ({ - invitation, - }: Readonly<{ - invitation: Messages.Invitation; - }>) => { - if (!accountId) { - throw new Error('No account id found'); - } - const encryptionPrivateKey = keys?.encryptionPrivateKey; - const encryptionPublicKey = keys?.encryptionPublicKey; - const signaturePrivateKey = keys?.signaturePrivateKey; - const signaturePublicKey = keys?.signaturePublicKey; - if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { - throw new Error('Missing keys'); - } - const spaceEvent = await Effect.runPromiseExit( - SpaceEvents.acceptInvitation({ - author: { - accountId, - signaturePublicKey, - encryptionPublicKey, - signaturePrivateKey, - }, - previousEventHash: invitation.previousEventHash, - }), - ); - if (Exit.isFailure(spaceEvent)) { - console.error('Failed to accept invitation', spaceEvent); - return; - } - const message: Messages.RequestAcceptInvitationEvent = { - type: 'accept-invitation-event', - event: spaceEvent.value, - spaceId: invitation.spaceId, - }; - websocketConnection?.send(Messages.serialize(message)); + const acceptInvitationForContext = useCallback( + async ({ + invitation, + }: Readonly<{ + invitation: Messages.Invitation; + }>) => { + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = keys?.encryptionPrivateKey; + const encryptionPublicKey = keys?.encryptionPublicKey; + const signaturePrivateKey = keys?.signaturePrivateKey; + const signaturePublicKey = keys?.signaturePublicKey; + if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { + throw new Error('Missing keys'); + } + const spaceEvent = await Effect.runPromiseExit( + SpaceEvents.acceptInvitation({ + author: { + accountId, + signaturePublicKey, + encryptionPublicKey, + signaturePrivateKey, + }, + previousEventHash: invitation.previousEventHash, + }), + ); + if (Exit.isFailure(spaceEvent)) { + console.error('Failed to accept invitation', spaceEvent); + return; + } + const message: Messages.RequestAcceptInvitationEvent = { + type: 'accept-invitation-event', + event: spaceEvent.value, + spaceId: invitation.spaceId, + }; + websocketConnection?.send(Messages.serialize(message)); - // temporary until we have define a strategy for accepting invitations response - setTimeout(() => { - const message2: Messages.RequestListInvitations = { type: 'list-invitations' }; - websocketConnection?.send(Messages.serialize(message2)); - }, 1000); - }; + // temporary until we have define a strategy for accepting invitations response + setTimeout(() => { + const message2: Messages.RequestListInvitations = { type: 'list-invitations' }; + websocketConnection?.send(Messages.serialize(message2)); + }, 1000); + }, + [ + accountId, + keys?.encryptionPrivateKey, + keys?.encryptionPublicKey, + keys?.signaturePrivateKey, + keys?.signaturePublicKey, + websocketConnection, + ], + ); const subscribeToSpace = useCallback( (params: { spaceId: string }) => { @@ -611,73 +631,84 @@ export function HypergraphAppProvider({ [websocketConnection], ); - const inviteToSpace = async ({ - space, - invitee, - }: Readonly<{ - space: SpaceStorageEntry; - invitee: { - accountId: string; - }; - }>) => { - if (!accountId) { - throw new Error('No account id found'); - } - const encryptionPrivateKey = keys?.encryptionPrivateKey; - const encryptionPublicKey = keys?.encryptionPublicKey; - const signaturePrivateKey = keys?.signaturePrivateKey; - const signaturePublicKey = keys?.signaturePublicKey; - if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { - throw new Error('Missing keys'); - } - if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { - throw new Error('Missing keys'); - } - if (!space.state) { - console.error('No state found for space'); - return; - } - const inviteeWithKeys = await Identity.getVerifiedIdentity(invitee.accountId, syncServerUri); - const spaceEvent = await Effect.runPromiseExit( - SpaceEvents.createInvitation({ - author: { - accountId, - signaturePublicKey, - encryptionPublicKey, - signaturePrivateKey, - }, - previousEventHash: space.state.lastEventHash, - invitee: inviteeWithKeys, - }), - ); - if (Exit.isFailure(spaceEvent)) { - console.error('Failed to create invitation', spaceEvent); - return; - } + const inviteToSpace = useCallback( + async ({ + space, + invitee, + }: Readonly<{ + space: SpaceStorageEntry; + invitee: { + accountId: string; + }; + }>) => { + if (!accountId) { + throw new Error('No account id found'); + } + const encryptionPrivateKey = keys?.encryptionPrivateKey; + const encryptionPublicKey = keys?.encryptionPublicKey; + const signaturePrivateKey = keys?.signaturePrivateKey; + const signaturePublicKey = keys?.signaturePublicKey; + if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { + throw new Error('Missing keys'); + } + if (!encryptionPrivateKey || !encryptionPublicKey || !signaturePrivateKey || !signaturePublicKey) { + throw new Error('Missing keys'); + } + if (!space.state) { + console.error('No state found for space'); + return; + } + const inviteeWithKeys = await Identity.getVerifiedIdentity(invitee.accountId, syncServerUri); + const spaceEvent = await Effect.runPromiseExit( + SpaceEvents.createInvitation({ + author: { + accountId, + signaturePublicKey, + encryptionPublicKey, + signaturePrivateKey, + }, + previousEventHash: space.state.lastEventHash, + invitee: inviteeWithKeys, + }), + ); + if (Exit.isFailure(spaceEvent)) { + console.error('Failed to create invitation', spaceEvent); + return; + } - const keyBoxes = space.keys.map((key) => { - const keyBox = Key.encryptKey({ - key: Utils.hexToBytes(key.key), - publicKey: Utils.hexToBytes(inviteeWithKeys.encryptionPublicKey), - privateKey: Utils.hexToBytes(encryptionPrivateKey), + const keyBoxes = space.keys.map((key) => { + const keyBox = Key.encryptKey({ + key: Utils.hexToBytes(key.key), + publicKey: Utils.hexToBytes(inviteeWithKeys.encryptionPublicKey), + privateKey: Utils.hexToBytes(encryptionPrivateKey), + }); + return { + id: key.id, + ciphertext: Utils.bytesToHex(keyBox.keyBoxCiphertext), + nonce: Utils.bytesToHex(keyBox.keyBoxNonce), + authorPublicKey: encryptionPublicKey, + accountId: invitee.accountId, + }; }); - return { - id: key.id, - ciphertext: Utils.bytesToHex(keyBox.keyBoxCiphertext), - nonce: Utils.bytesToHex(keyBox.keyBoxNonce), - authorPublicKey: encryptionPublicKey, - accountId: invitee.accountId, - }; - }); - const message: Messages.RequestCreateInvitationEvent = { - type: 'create-invitation-event', - event: spaceEvent.value, - spaceId: space.id, - keyBoxes, - }; - websocketConnection?.send(Messages.serialize(message)); - }; + const message: Messages.RequestCreateInvitationEvent = { + type: 'create-invitation-event', + event: spaceEvent.value, + spaceId: space.id, + keyBoxes, + }; + websocketConnection?.send(Messages.serialize(message)); + }, + [ + accountId, + keys?.encryptionPrivateKey, + keys?.encryptionPublicKey, + keys?.signaturePrivateKey, + keys?.signaturePublicKey, + websocketConnection, + syncServerUri, + ], + ); const getVerifiedIdentity = useCallback( (accountId: string) => { diff --git a/packages/hypergraph/src/store.ts b/packages/hypergraph/src/store.ts index 639dc82a..45fddbf0 100644 --- a/packages/hypergraph/src/store.ts +++ b/packages/hypergraph/src/store.ts @@ -12,7 +12,6 @@ export type SpaceStorageEntry = { events: SpaceEvent[]; state: SpaceState | undefined; keys: { id: string; key: string }[]; - lastUpdateClock: number; automergeDocHandle: DocHandle | undefined; }; @@ -33,6 +32,7 @@ interface StoreContext { accountId: Address | null; sessionToken: string | null; keys: Identity.IdentityKeys | null; + lastUpdateClock: { [spaceId: string]: number }; } const initialStoreContext: StoreContext = { @@ -45,6 +45,7 @@ const initialStoreContext: StoreContext = { accountId: null, sessionToken: null, keys: null, + lastUpdateClock: {}, }; type StoreEvent = @@ -113,6 +114,7 @@ export const store: Store = create }, setSpaceFromList: (context, event: { spaceId: string }) => { const existingSpace = context.spaces.find((s) => s.id === event.spaceId); + const lastUpdateClock = context.lastUpdateClock[event.spaceId] ?? -1; const automergeDocHandle = context.repo.find(idToAutomergeId(event.spaceId) as AnyDocumentId); // set it to ready to interact with the document @@ -128,13 +130,16 @@ export const store: Store = create events: existingSpace.events ?? [], state: existingSpace.state, keys: existingSpace.keys ?? [], - lastUpdateClock: existingSpace.lastUpdateClock ?? -1, automergeDocHandle, }; return newSpace; } return existingSpace; }), + lastUpdateClock: { + ...context.lastUpdateClock, + [event.spaceId]: lastUpdateClock, + }, }; } return { @@ -167,31 +172,26 @@ export const store: Store = create updateConfirmed: (context, event: { spaceId: string; clock: number }) => { return { ...context, - spaces: context.spaces.map((space) => { - if (space.id === event.spaceId && space.lastUpdateClock + 1 === event.clock) { - return { ...space, lastUpdateClock: event.clock }; - } - return space; - }), + lastUpdateClock: { + ...context.lastUpdateClock, + [event.spaceId]: event.clock, + }, }; }, applyUpdate: (context, event: { spaceId: string; firstUpdateClock: number; lastUpdateClock: number }) => { - return { - ...context, - spaces: context.spaces.map((space) => { - if (space.id === event.spaceId) { - let lastUpdateClock = space.lastUpdateClock; - if (event.firstUpdateClock === space.lastUpdateClock + 1) { - lastUpdateClock = event.lastUpdateClock; - } else { - // TODO request missing updates from server - } + const lastUpdateClock = context.lastUpdateClock[event.spaceId] ?? -1; + if (event.firstUpdateClock === lastUpdateClock + 1) { + return { + ...context, + lastUpdateClock: { + ...context.lastUpdateClock, + [event.spaceId]: event.lastUpdateClock, + }, + }; + } - return { ...space, lastUpdateClock }; - } - return space; - }), - }; + // TODO else case: request missing updates from server + return context; }, addVerifiedIdentity: ( context, @@ -239,38 +239,43 @@ export const store: Store = create id: event.spaceId, events: event.events, state: event.spaceState, - lastUpdateClock: -1, keys: event.keys, automergeDocHandle, }; return { ...context, spaces: [...context.spaces, newSpace], + lastUpdateClock: { + ...context.lastUpdateClock, + [event.spaceId]: -1, + }, }; } + let lastUpdateClock = context.lastUpdateClock[event.spaceId] ?? -1; + if (event.updates?.firstUpdateClock === lastUpdateClock + 1) { + lastUpdateClock = event.updates.lastUpdateClock; + } else { + // TODO request missing updates from server + } + return { ...context, spaces: context.spaces.map((space) => { if (space.id === event.spaceId) { - let lastUpdateClock = space.lastUpdateClock; - - if (event.updates?.firstUpdateClock === lastUpdateClock + 1) { - lastUpdateClock = event.updates.lastUpdateClock; - } else { - // TODO request missing updates from server - } - return { ...space, events: event.events, state: event.spaceState, - lastUpdateClock, keys: event.keys, }; } return space; }), + lastUpdateClock: { + ...context.lastUpdateClock, + [event.spaceId]: lastUpdateClock, + }, }; }, setAuth: (context, event: { accountId: Address; sessionToken: string; keys: Identity.IdentityKeys }) => {