@agora-sdk/secure-chat-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/context/secure-chat-context.d.ts +24 -18
- package/dist/cjs/context/secure-chat-context.js +42 -23
- package/dist/cjs/context/secure-chat-context.js.map +1 -1
- package/dist/cjs/contract/index.js +2 -2
- package/dist/cjs/contract/index.js.map +1 -1
- package/dist/cjs/hooks/useSecureConversations.d.ts +1 -1
- package/dist/cjs/hooks/useSecureConversations.js +9 -7
- package/dist/cjs/hooks/useSecureConversations.js.map +1 -1
- package/dist/cjs/hooks/useSecureDevice.d.ts +14 -13
- package/dist/cjs/hooks/useSecureDevice.js +58 -25
- package/dist/cjs/hooks/useSecureDevice.js.map +1 -1
- package/dist/cjs/hooks/useSecureMessages.d.ts +10 -11
- package/dist/cjs/hooks/useSecureMessages.js +89 -21
- package/dist/cjs/hooks/useSecureMessages.js.map +1 -1
- package/dist/cjs/index.d.ts +18 -14
- package/dist/cjs/index.js +23 -19
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/persistence/memory-store.d.ts +9 -0
- package/dist/cjs/persistence/memory-store.js +27 -0
- package/dist/cjs/persistence/memory-store.js.map +1 -0
- package/dist/cjs/persistence/repository.d.ts +36 -0
- package/dist/cjs/persistence/repository.js +72 -0
- package/dist/cjs/persistence/repository.js.map +1 -0
- package/dist/cjs/persistence/store.d.ts +11 -0
- package/dist/cjs/persistence/store.js +8 -0
- package/dist/cjs/persistence/store.js.map +1 -0
- package/dist/cjs/transport/rest.d.ts +1 -1
- package/dist/cjs/transport/socket.d.ts +1 -1
- package/dist/esm/context/secure-chat-context.d.ts +24 -18
- package/dist/esm/context/secure-chat-context.js +41 -22
- package/dist/esm/context/secure-chat-context.js.map +1 -1
- package/dist/esm/contract/index.js +2 -2
- package/dist/esm/contract/index.js.map +1 -1
- package/dist/esm/hooks/useSecureConversations.d.ts +1 -1
- package/dist/esm/hooks/useSecureConversations.js +6 -4
- package/dist/esm/hooks/useSecureConversations.js.map +1 -1
- package/dist/esm/hooks/useSecureDevice.d.ts +14 -13
- package/dist/esm/hooks/useSecureDevice.js +55 -22
- package/dist/esm/hooks/useSecureDevice.js.map +1 -1
- package/dist/esm/hooks/useSecureMessages.d.ts +10 -11
- package/dist/esm/hooks/useSecureMessages.js +86 -18
- package/dist/esm/hooks/useSecureMessages.js.map +1 -1
- package/dist/esm/index.d.ts +18 -14
- package/dist/esm/index.js +9 -7
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/persistence/memory-store.d.ts +9 -0
- package/dist/esm/persistence/memory-store.js +23 -0
- package/dist/esm/persistence/memory-store.js.map +1 -0
- package/dist/esm/persistence/repository.d.ts +36 -0
- package/dist/esm/persistence/repository.js +68 -0
- package/dist/esm/persistence/repository.js.map +1 -0
- package/dist/esm/persistence/store.d.ts +11 -0
- package/dist/esm/persistence/store.js +7 -0
- package/dist/esm/persistence/store.js.map +1 -0
- package/dist/esm/transport/rest.d.ts +1 -1
- package/dist/esm/transport/socket.d.ts +1 -1
- package/package.json +3 -3
|
@@ -1,36 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
// SecureChatProvider — wires
|
|
2
|
+
// SecureChatProvider — wires transport + crypto + persistence for the secure-chat hooks.
|
|
3
3
|
//
|
|
4
4
|
// Sits INSIDE a ReplykeProvider: by default it resolves the API base URL and socket origin from
|
|
5
|
-
// @agora-sdk/core's runtime singletons (getApiBaseUrl / getSocketUrl)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
// @agora-sdk/core's runtime singletons (getApiBaseUrl / getSocketUrl). Crypto AND the persistence
|
|
6
|
+
// store are injected, keeping core platform- and library-agnostic. The provider builds a typed
|
|
7
|
+
// SecureChatRepository over the store plus a cached resolveGroup/rememberGroup so the hooks become
|
|
8
|
+
// self-sufficient (no need to thread a GroupHandle in by hand).
|
|
9
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
|
9
10
|
import { getApiBaseUrl, getSocketUrl } from "@agora-sdk/core";
|
|
10
|
-
import { SecureChatRestClient } from "../transport/rest";
|
|
11
|
-
import { SecureChatSocketClient } from "../transport/socket";
|
|
11
|
+
import { SecureChatRestClient } from "../transport/rest.js";
|
|
12
|
+
import { SecureChatSocketClient } from "../transport/socket.js";
|
|
13
|
+
import { MemoryStore } from "../persistence/memory-store.js";
|
|
14
|
+
import { SecureChatRepository } from "../persistence/repository.js";
|
|
12
15
|
const SecureChatContext = createContext(null);
|
|
13
16
|
/**
|
|
14
|
-
* Provides
|
|
15
|
-
* `ReplykeProvider
|
|
16
|
-
* `@agora-sdk/core`'s runtime, and disconnects the socket on unmount.
|
|
17
|
+
* Provides secure-chat transport, crypto, and persistence to the `useSecure*` hooks. Render inside a
|
|
18
|
+
* `ReplykeProvider`; disconnects the socket on unmount.
|
|
17
19
|
*
|
|
18
|
-
* @param props - {@link SecureChatProviderProps}
|
|
19
|
-
* optional base/socket URL overrides.
|
|
20
|
+
* @param props - {@link SecureChatProviderProps}.
|
|
20
21
|
* @returns A context provider wrapping `children`.
|
|
21
22
|
*
|
|
22
23
|
* @example
|
|
23
24
|
* ```tsx
|
|
24
|
-
* <
|
|
25
|
-
* <
|
|
26
|
-
*
|
|
27
|
-
* </SecureChatProvider>
|
|
28
|
-
* </ReplykeProvider>
|
|
25
|
+
* <SecureChatProvider crypto={crypto} projectId={projectId} store={createIndexedDBStore()} accessToken={token}>
|
|
26
|
+
* <Chat />
|
|
27
|
+
* </SecureChatProvider>
|
|
29
28
|
* ```
|
|
30
29
|
*/
|
|
31
|
-
export function SecureChatProvider({ crypto, projectId, accessToken, getAccessToken, baseUrl, socketUrl, children, }) {
|
|
32
|
-
// Keep the latest token in a ref so the per-request resolver always returns the current value
|
|
33
|
-
// without rebuilding the transport clients on every token change.
|
|
30
|
+
export function SecureChatProvider({ crypto, projectId, store, accessToken, getAccessToken, baseUrl, socketUrl, children, }) {
|
|
34
31
|
const tokenRef = useRef(accessToken);
|
|
35
32
|
tokenRef.current = accessToken;
|
|
36
33
|
const resolveToken = useMemo(() => getAccessToken ?? (() => tokenRef.current), [getAccessToken]);
|
|
@@ -44,16 +41,38 @@ export function SecureChatProvider({ crypto, projectId, accessToken, getAccessTo
|
|
|
44
41
|
getAccessToken: resolveToken,
|
|
45
42
|
getSocketUrl: () => socketUrl ?? getSocketUrl(),
|
|
46
43
|
}), [projectId, resolveToken, socketUrl]);
|
|
44
|
+
const resolvedStore = useMemo(() => store ?? new MemoryStore(), [store]);
|
|
45
|
+
const repo = useMemo(() => new SecureChatRepository(resolvedStore), [resolvedStore]);
|
|
46
|
+
// In-memory GroupHandle cache, keyed by conversationId. Survives re-renders via the ref.
|
|
47
|
+
// NOTE: not cleared on a `store` prop swap — a store change in practice means a new provider
|
|
48
|
+
// instance (fresh cache), not a live prop change on the same mounted provider.
|
|
49
|
+
const groupCache = useRef(new Map());
|
|
50
|
+
const resolveGroup = useCallback(async (conversationId) => {
|
|
51
|
+
const cached = groupCache.current.get(conversationId);
|
|
52
|
+
if (cached)
|
|
53
|
+
return cached;
|
|
54
|
+
const bytes = await repo.loadGroupState(conversationId);
|
|
55
|
+
if (!bytes)
|
|
56
|
+
return null;
|
|
57
|
+
const handle = await crypto.importGroupState(bytes);
|
|
58
|
+
groupCache.current.set(conversationId, handle);
|
|
59
|
+
return handle;
|
|
60
|
+
}, [repo, crypto]);
|
|
61
|
+
const rememberGroup = useCallback(async (conversationId, handle) => {
|
|
62
|
+
groupCache.current.set(conversationId, handle);
|
|
63
|
+
const bytes = await crypto.exportGroupState(handle);
|
|
64
|
+
await repo.saveGroupState(conversationId, bytes);
|
|
65
|
+
}, [repo, crypto]);
|
|
47
66
|
useEffect(() => {
|
|
48
67
|
return () => socket.disconnect();
|
|
49
68
|
}, [socket]);
|
|
50
|
-
const value = useMemo(() => ({ rest, socket, crypto, projectId }), [rest, socket, crypto, projectId]);
|
|
69
|
+
const value = useMemo(() => ({ rest, socket, crypto, repo, resolveGroup, rememberGroup, projectId }), [rest, socket, crypto, repo, resolveGroup, rememberGroup, projectId]);
|
|
51
70
|
return _jsx(SecureChatContext.Provider, { value: value, children: children });
|
|
52
71
|
}
|
|
53
72
|
/**
|
|
54
73
|
* Access the nearest {@link SecureChatContextValue}.
|
|
55
74
|
*
|
|
56
|
-
* @returns The shared rest/socket/crypto/projectId for this provider subtree.
|
|
75
|
+
* @returns The shared rest/socket/crypto/repo/resolveGroup/projectId for this provider subtree.
|
|
57
76
|
* @throws {Error} When called outside a `<SecureChatProvider>`.
|
|
58
77
|
*/
|
|
59
78
|
export function useSecureChat() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure-chat-context.js","sourceRoot":"","sources":["../../../src/context/secure-chat-context.tsx"],"names":[],"mappings":";AAAA
|
|
1
|
+
{"version":3,"file":"secure-chat-context.js","sourceRoot":"","sources":["../../../src/context/secure-chat-context.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Secure-chat wire contract — stand-in for @agora/contract (the Apache-2.0 server contract).
|
|
1
|
+
// Secure-chat wire contract — stand-in for @agora-server/contract (the Apache-2.0 server contract).
|
|
2
2
|
//
|
|
3
3
|
// Mirrors the response models + request bodies of agora-server's
|
|
4
4
|
// `packages/contract/src/secure-chat.ts`. That package is the source of truth (zod + TS); the
|
|
5
5
|
// client only needs the TypeScript shapes, so this copy is types-only.
|
|
6
6
|
//
|
|
7
7
|
// ⚠️ Keep this byte-faithful to the server contract. The arrow is SDK → contract: once
|
|
8
|
-
// `@agora/contract` is published, DELETE this file and `import type { ... } from "@agora/contract"`.
|
|
8
|
+
// `@agora-server/contract` is published, DELETE this file and `import type { ... } from "@agora-server/contract"`.
|
|
9
9
|
// Do NOT publish a separate `@agora-sdk/secure-chat-contract` (that would invert the dependency —
|
|
10
10
|
// see STATUS.md). Do not let this copy drift in the meantime.
|
|
11
11
|
//
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/contract/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/contract/index.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,EAAE;AACF,iEAAiE;AACjE,8FAA8F;AAC9F,uEAAuE;AACvE,EAAE;AACF,uFAAuF;AACvF,mHAAmH;AACnH,kGAAkG;AAClG,8DAA8D;AAC9D,EAAE;AACF,gGAAgG;AAChG,0FAA0F"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SecureConversationModel } from "../contract";
|
|
1
|
+
import { SecureConversationModel } from "../contract/index.js";
|
|
2
2
|
/** The state and actions returned by {@link useSecureConversations}. */
|
|
3
3
|
export interface UseSecureConversationsValues {
|
|
4
4
|
/** The loaded conversations, newest activity first. */
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// POST /conversations with the Welcomes targeted to each peer device. The MLS group secrets stay on
|
|
5
5
|
// the client; the server only stores ciphertext metadata.
|
|
6
6
|
import { useCallback, useEffect, useState } from "react";
|
|
7
|
-
import { toBase64, fromBase64 } from "../util/base64";
|
|
8
|
-
import { useSecureChat } from "../context/secure-chat-context";
|
|
7
|
+
import { toBase64, fromBase64 } from "../util/base64.js";
|
|
8
|
+
import { useSecureChat } from "../context/secure-chat-context.js";
|
|
9
9
|
/**
|
|
10
10
|
* List the caller's secure conversations and start new direct messages.
|
|
11
11
|
*
|
|
@@ -23,7 +23,7 @@ import { useSecureChat } from "../context/secure-chat-context";
|
|
|
23
23
|
* ```
|
|
24
24
|
*/
|
|
25
25
|
export function useSecureConversations() {
|
|
26
|
-
const { rest, crypto, socket } = useSecureChat();
|
|
26
|
+
const { rest, crypto, socket, rememberGroup } = useSecureChat();
|
|
27
27
|
const [conversations, setConversations] = useState([]);
|
|
28
28
|
const [cursor, setCursor] = useState(undefined);
|
|
29
29
|
const [hasMore, setHasMore] = useState(true);
|
|
@@ -81,9 +81,11 @@ export function useSecureConversations() {
|
|
|
81
81
|
epoch: group.epoch.toString(),
|
|
82
82
|
})),
|
|
83
83
|
});
|
|
84
|
+
// Persist + cache the creator's MLS group handle so messages resolve after a reload.
|
|
85
|
+
await rememberGroup(conversation.id, group);
|
|
84
86
|
setConversations((prev) => [conversation, ...prev.filter((c) => c.id !== conversation.id)]);
|
|
85
87
|
return conversation;
|
|
86
|
-
}, [rest, crypto]);
|
|
88
|
+
}, [rest, crypto, rememberGroup]);
|
|
87
89
|
useEffect(() => {
|
|
88
90
|
refresh();
|
|
89
91
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSecureConversations.js","sourceRoot":"","sources":["../../../src/hooks/useSecureConversations.tsx"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,EAAE;AACF,iGAAiG;AACjG,oGAAoG;AACpG,0DAA0D;AAE1D,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEzD,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"useSecureConversations.js","sourceRoot":"","sources":["../../../src/hooks/useSecureConversations.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { SecureDeviceModel } from "../contract";
|
|
1
|
+
import { SecureDeviceModel } from "../contract/index.js";
|
|
2
2
|
/** Options for {@link useSecureDevice}. */
|
|
3
3
|
export interface UseSecureDeviceOptions {
|
|
4
|
-
/** Stable, persisted client device id. Generated
|
|
4
|
+
/** Stable, persisted client device id. Generated (and persisted) if omitted. A previously
|
|
5
|
+
* persisted device's id takes precedence over this value when one exists on mount. */
|
|
5
6
|
deviceId?: string;
|
|
6
7
|
/** MLS ciphersuite to register under. Defaults to the crypto implementation's preferred suite. */
|
|
7
8
|
ciphersuite?: number;
|
|
@@ -14,13 +15,15 @@ export interface UseSecureDeviceOptions {
|
|
|
14
15
|
export interface UseSecureDeviceValues {
|
|
15
16
|
/** The registered device row (its `.id` is the uuid used as targetDeviceId everywhere). */
|
|
16
17
|
device: SecureDeviceModel | null;
|
|
18
|
+
/** True until the initial persisted-device load settles. */
|
|
19
|
+
loading: boolean;
|
|
17
20
|
/** True while {@link UseSecureDeviceValues.register} is in flight. */
|
|
18
21
|
registering: boolean;
|
|
19
|
-
/** The last error thrown by registration or replenishment, or `null`. */
|
|
22
|
+
/** The last error thrown by load, registration, or replenishment, or `null`. */
|
|
20
23
|
error: unknown;
|
|
21
24
|
/** Last known count of unconsumed KeyPackages, or `null` until refreshed. */
|
|
22
25
|
keyPackagesAvailable: number | null;
|
|
23
|
-
/** Generate identity + register (idempotent server-side on (userId, deviceId)). */
|
|
26
|
+
/** Generate identity + register (idempotent server-side on (userId, deviceId)) and persist it. */
|
|
24
27
|
register: () => Promise<SecureDeviceModel>;
|
|
25
28
|
/** Generate + publish `count` fresh KeyPackages (default = keyPackageTarget). */
|
|
26
29
|
publishKeyPackages: (count?: number) => Promise<number>;
|
|
@@ -28,20 +31,18 @@ export interface UseSecureDeviceValues {
|
|
|
28
31
|
refreshKeyPackageCount: () => Promise<number>;
|
|
29
32
|
}
|
|
30
33
|
/**
|
|
31
|
-
* Register this client as an MLS device
|
|
34
|
+
* Register this client as an MLS device, persist its identity, and keep KeyPackages stocked.
|
|
32
35
|
*
|
|
33
|
-
* On
|
|
34
|
-
*
|
|
35
|
-
* `secure:key-packages-low` for this device. The device's private key material must be persisted by
|
|
36
|
-
* the platform layer — this hook only handles registration and relay.
|
|
36
|
+
* On mount it loads any persisted device and re-hydrates the crypto identity (stable id, no
|
|
37
|
+
* re-register). When none exists, await {@link UseSecureDeviceValues.register}.
|
|
37
38
|
*
|
|
38
|
-
* @param options - {@link UseSecureDeviceOptions}
|
|
39
|
-
* @returns {@link UseSecureDeviceValues}
|
|
39
|
+
* @param options - {@link UseSecureDeviceOptions}.
|
|
40
|
+
* @returns {@link UseSecureDeviceValues}.
|
|
40
41
|
*
|
|
41
42
|
* @example
|
|
42
43
|
* ```tsx
|
|
43
|
-
* const { device, register } = useSecureDevice(
|
|
44
|
-
* useEffect(() => { register(); }, []);
|
|
44
|
+
* const { device, loading, register } = useSecureDevice();
|
|
45
|
+
* useEffect(() => { if (!loading && !device) register(); }, [loading, device, register]);
|
|
45
46
|
* ```
|
|
46
47
|
*/
|
|
47
48
|
export declare function useSecureDevice(options?: UseSecureDeviceOptions): UseSecureDeviceValues;
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
// useSecureDevice — register this client as an MLS device (leaf) and keep its
|
|
1
|
+
// useSecureDevice — register this client as an MLS device (leaf), persist its identity, and keep its
|
|
2
|
+
// KeyPackages topped up.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// NOTE: the device's PRIVATE state (`privateState` from generateDeviceIdentity, and MLS group
|
|
7
|
-
// state) must be persisted by the platform layer (IndexedDB on web, keystore on native — Phase 2/3).
|
|
8
|
-
// Core only performs registration + relay; it does not persist secrets.
|
|
4
|
+
// On mount it re-hydrates a persisted device (stable deviceId + private state via
|
|
5
|
+
// crypto.importDeviceState) so a reload does NOT mint a new identity. register() generates, registers
|
|
6
|
+
// on the server, and persists. Replenishes on the `secure:key-packages-low` realtime signal.
|
|
9
7
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
|
-
import { toBase64 } from "../util/base64";
|
|
11
|
-
import { useSecureChat } from "../context/secure-chat-context";
|
|
8
|
+
import { toBase64 } from "../util/base64.js";
|
|
9
|
+
import { useSecureChat } from "../context/secure-chat-context.js";
|
|
12
10
|
/**
|
|
13
11
|
* Mint a device id when the caller doesn't supply one — `crypto.randomUUID()` when available, else a
|
|
14
|
-
* non-cryptographic timestamp+random fallback.
|
|
12
|
+
* non-cryptographic timestamp+random fallback.
|
|
15
13
|
*
|
|
16
14
|
* @returns A fresh device id string.
|
|
17
15
|
*/
|
|
@@ -19,34 +17,64 @@ function newDeviceId() {
|
|
|
19
17
|
const g = globalThis.crypto;
|
|
20
18
|
if (g?.randomUUID)
|
|
21
19
|
return g.randomUUID();
|
|
22
|
-
// Platform layer should supply a persisted, stable device id; this is a non-crypto fallback.
|
|
23
20
|
return `dev-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
24
21
|
}
|
|
25
22
|
/**
|
|
26
|
-
* Register this client as an MLS device
|
|
23
|
+
* Register this client as an MLS device, persist its identity, and keep KeyPackages stocked.
|
|
27
24
|
*
|
|
28
|
-
* On
|
|
29
|
-
*
|
|
30
|
-
* `secure:key-packages-low` for this device. The device's private key material must be persisted by
|
|
31
|
-
* the platform layer — this hook only handles registration and relay.
|
|
25
|
+
* On mount it loads any persisted device and re-hydrates the crypto identity (stable id, no
|
|
26
|
+
* re-register). When none exists, await {@link UseSecureDeviceValues.register}.
|
|
32
27
|
*
|
|
33
|
-
* @param options - {@link UseSecureDeviceOptions}
|
|
34
|
-
* @returns {@link UseSecureDeviceValues}
|
|
28
|
+
* @param options - {@link UseSecureDeviceOptions}.
|
|
29
|
+
* @returns {@link UseSecureDeviceValues}.
|
|
35
30
|
*
|
|
36
31
|
* @example
|
|
37
32
|
* ```tsx
|
|
38
|
-
* const { device, register } = useSecureDevice(
|
|
39
|
-
* useEffect(() => { register(); }, []);
|
|
33
|
+
* const { device, loading, register } = useSecureDevice();
|
|
34
|
+
* useEffect(() => { if (!loading && !device) register(); }, [loading, device, register]);
|
|
40
35
|
* ```
|
|
41
36
|
*/
|
|
42
37
|
export function useSecureDevice(options = {}) {
|
|
43
|
-
const { crypto, rest, socket } = useSecureChat();
|
|
38
|
+
const { crypto, rest, socket, repo } = useSecureChat();
|
|
44
39
|
const { ciphersuite, keyPackageTarget = 20, autoReplenish = true } = options;
|
|
45
40
|
const [device, setDevice] = useState(null);
|
|
41
|
+
const [loading, setLoading] = useState(true);
|
|
46
42
|
const [registering, setRegistering] = useState(false);
|
|
47
43
|
const [error, setError] = useState(null);
|
|
48
44
|
const [keyPackagesAvailable, setKeyPackagesAvailable] = useState(null);
|
|
49
45
|
const deviceIdRef = useRef(options.deviceId ?? newDeviceId());
|
|
46
|
+
const registerStartedRef = useRef(false);
|
|
47
|
+
// On mount: re-hydrate a persisted device (stable id + private state). No persisted device ⇒
|
|
48
|
+
// first-run; the app calls register().
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let alive = true;
|
|
51
|
+
(async () => {
|
|
52
|
+
const persisted = await repo.loadDevice();
|
|
53
|
+
if (!alive || registerStartedRef.current) {
|
|
54
|
+
setLoading(false);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (persisted) {
|
|
58
|
+
await crypto.importDeviceState(persisted.deviceState);
|
|
59
|
+
// register() may have started during the await — don't overwrite its identity.
|
|
60
|
+
if (!alive || registerStartedRef.current) {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
deviceIdRef.current = persisted.deviceId;
|
|
65
|
+
setDevice(persisted.device);
|
|
66
|
+
}
|
|
67
|
+
setLoading(false);
|
|
68
|
+
})().catch((err) => {
|
|
69
|
+
if (!alive)
|
|
70
|
+
return;
|
|
71
|
+
setError(err);
|
|
72
|
+
setLoading(false);
|
|
73
|
+
});
|
|
74
|
+
return () => {
|
|
75
|
+
alive = false;
|
|
76
|
+
};
|
|
77
|
+
}, [repo, crypto]);
|
|
50
78
|
const publishKeyPackages = useCallback(async (count = keyPackageTarget) => {
|
|
51
79
|
if (!device)
|
|
52
80
|
throw new Error("Register the device before publishing KeyPackages.");
|
|
@@ -69,6 +97,7 @@ export function useSecureDevice(options = {}) {
|
|
|
69
97
|
return available;
|
|
70
98
|
}, [rest, device]);
|
|
71
99
|
const register = useCallback(async () => {
|
|
100
|
+
registerStartedRef.current = true;
|
|
72
101
|
setRegistering(true);
|
|
73
102
|
setError(null);
|
|
74
103
|
try {
|
|
@@ -82,6 +111,9 @@ export function useSecureDevice(options = {}) {
|
|
|
82
111
|
credential: toBase64(identity.credential),
|
|
83
112
|
ciphersuite: identity.ciphersuite,
|
|
84
113
|
});
|
|
114
|
+
const deviceState = await crypto.exportDeviceState();
|
|
115
|
+
await repo.saveDevice({ deviceId: identity.deviceId, deviceState, device: registered });
|
|
116
|
+
deviceIdRef.current = identity.deviceId;
|
|
85
117
|
setDevice(registered);
|
|
86
118
|
return registered;
|
|
87
119
|
}
|
|
@@ -92,7 +124,7 @@ export function useSecureDevice(options = {}) {
|
|
|
92
124
|
finally {
|
|
93
125
|
setRegistering(false);
|
|
94
126
|
}
|
|
95
|
-
}, [crypto, rest, ciphersuite]);
|
|
127
|
+
}, [crypto, rest, repo, ciphersuite]);
|
|
96
128
|
// Auto-replenish on the server's low-water signal for this device.
|
|
97
129
|
useEffect(() => {
|
|
98
130
|
if (!autoReplenish || !device)
|
|
@@ -106,6 +138,7 @@ export function useSecureDevice(options = {}) {
|
|
|
106
138
|
}, [autoReplenish, device, socket, publishKeyPackages]);
|
|
107
139
|
return {
|
|
108
140
|
device,
|
|
141
|
+
loading,
|
|
109
142
|
registering,
|
|
110
143
|
error,
|
|
111
144
|
keyPackagesAvailable,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useSecureDevice.js","sourceRoot":"","sources":["../../../src/hooks/useSecureDevice.tsx"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,
|
|
1
|
+
{"version":3,"file":"useSecureDevice.js","sourceRoot":"","sources":["../../../src/hooks/useSecureDevice.tsx"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SecureMessageModel } from "../contract";
|
|
1
|
+
import { SecureMessageModel } from "../contract/index.js";
|
|
2
2
|
import { GroupHandle } from "@agora-sdk/secure-chat-crypto";
|
|
3
3
|
/** A stored message paired with its decrypted text (when a group handle is available). */
|
|
4
4
|
export interface DecryptedSecureMessage {
|
|
@@ -9,9 +9,9 @@ export interface DecryptedSecureMessage {
|
|
|
9
9
|
}
|
|
10
10
|
/** Options for {@link useSecureMessages}. */
|
|
11
11
|
export interface UseSecureMessagesOptions {
|
|
12
|
-
/**
|
|
12
|
+
/** Override the MLS group handle. Defaults to the persisted handle via `resolveGroup`. */
|
|
13
13
|
group?: GroupHandle;
|
|
14
|
-
/**
|
|
14
|
+
/** Override the sender device row id. Defaults to the persisted device's `.id`. */
|
|
15
15
|
senderDeviceId?: string;
|
|
16
16
|
}
|
|
17
17
|
/** The state and actions returned by {@link useSecureMessages}. */
|
|
@@ -28,24 +28,23 @@ export interface UseSecureMessagesValues {
|
|
|
28
28
|
loadMore: () => Promise<void>;
|
|
29
29
|
/** Reload from the newest message, replacing the current list. */
|
|
30
30
|
refresh: () => Promise<void>;
|
|
31
|
-
/** Encrypt + send a text message. Requires
|
|
31
|
+
/** Encrypt + send a text message. Requires a resolvable group + sender device. */
|
|
32
32
|
sendMessage: (text: string) => Promise<void>;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
35
|
* Load, decrypt, send, and live-receive messages in one secure conversation.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
40
|
-
* wiring. Joins the conversation's socket room to receive `secure:message` events live.
|
|
37
|
+
* Auto-resolves the MLS group handle (via `resolveGroup`) and the sender device id (from the
|
|
38
|
+
* persisted device) unless overridden in `options`. Joins the conversation socket room for live
|
|
39
|
+
* `secure:message` events.
|
|
41
40
|
*
|
|
42
41
|
* @param conversationId - The conversation to read and send within.
|
|
43
|
-
* @param options - {@link UseSecureMessagesOptions}
|
|
44
|
-
* @returns {@link UseSecureMessagesValues}
|
|
42
|
+
* @param options - {@link UseSecureMessagesOptions}.
|
|
43
|
+
* @returns {@link UseSecureMessagesValues}.
|
|
45
44
|
*
|
|
46
45
|
* @example
|
|
47
46
|
* ```tsx
|
|
48
|
-
* const { messages, sendMessage } = useSecureMessages(conversationId
|
|
47
|
+
* const { messages, sendMessage } = useSecureMessages(conversationId);
|
|
49
48
|
* await sendMessage("hello 💜");
|
|
50
49
|
* ```
|
|
51
50
|
*/
|
|
@@ -1,39 +1,86 @@
|
|
|
1
1
|
// useSecureMessages — load, decrypt, send, and live-receive messages in a secure conversation.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { useSecureChat } from "../context/secure-chat-context";
|
|
3
|
+
// Self-sufficient once a store is wired: the MLS GroupHandle is auto-resolved from persistence via
|
|
4
|
+
// resolveGroup, and senderDeviceId is read from the persisted device. Both stay overridable through
|
|
5
|
+
// options for advanced use. Without a resolvable handle, ciphertext is still listed/received
|
|
6
|
+
// (plaintext: null) and sending is disabled.
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
|
+
import { toBase64, fromBase64, utf8ToBytes, bytesToUtf8 } from "../util/base64.js";
|
|
9
|
+
import { useSecureChat } from "../context/secure-chat-context.js";
|
|
11
10
|
/**
|
|
12
11
|
* Load, decrypt, send, and live-receive messages in one secure conversation.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
* wiring. Joins the conversation's socket room to receive `secure:message` events live.
|
|
13
|
+
* Auto-resolves the MLS group handle (via `resolveGroup`) and the sender device id (from the
|
|
14
|
+
* persisted device) unless overridden in `options`. Joins the conversation socket room for live
|
|
15
|
+
* `secure:message` events.
|
|
18
16
|
*
|
|
19
17
|
* @param conversationId - The conversation to read and send within.
|
|
20
|
-
* @param options - {@link UseSecureMessagesOptions}
|
|
21
|
-
* @returns {@link UseSecureMessagesValues}
|
|
18
|
+
* @param options - {@link UseSecureMessagesOptions}.
|
|
19
|
+
* @returns {@link UseSecureMessagesValues}.
|
|
22
20
|
*
|
|
23
21
|
* @example
|
|
24
22
|
* ```tsx
|
|
25
|
-
* const { messages, sendMessage } = useSecureMessages(conversationId
|
|
23
|
+
* const { messages, sendMessage } = useSecureMessages(conversationId);
|
|
26
24
|
* await sendMessage("hello 💜");
|
|
27
25
|
* ```
|
|
28
26
|
*/
|
|
29
27
|
export function useSecureMessages(conversationId, options = {}) {
|
|
30
|
-
const { rest, crypto, socket } = useSecureChat();
|
|
31
|
-
const { group, senderDeviceId } = options;
|
|
28
|
+
const { rest, crypto, socket, repo, resolveGroup } = useSecureChat();
|
|
32
29
|
const [messages, setMessages] = useState([]);
|
|
33
30
|
const [before, setBefore] = useState(undefined);
|
|
34
31
|
const [hasMore, setHasMore] = useState(true);
|
|
35
32
|
const [loading, setLoading] = useState(false);
|
|
36
33
|
const [error, setError] = useState(null);
|
|
34
|
+
const [group, setGroup] = useState(options.group ?? null);
|
|
35
|
+
const [senderDeviceId, setSenderDeviceId] = useState(options.senderDeviceId);
|
|
36
|
+
// Latest messages, read by the "decrypt history once the group resolves" effect below without
|
|
37
|
+
// making `messages` one of its deps (which would loop).
|
|
38
|
+
const messagesRef = useRef(messages);
|
|
39
|
+
messagesRef.current = messages;
|
|
40
|
+
// Resolve the group handle: explicit override, else persisted state.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (options.group) {
|
|
43
|
+
setGroup(options.group);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Clear any stale handle from the previous conversation before re-resolving, so live messages
|
|
47
|
+
// for the new conversation never decrypt against the old group during the async window.
|
|
48
|
+
setGroup(null);
|
|
49
|
+
let alive = true;
|
|
50
|
+
resolveGroup(conversationId)
|
|
51
|
+
.then((g) => {
|
|
52
|
+
if (alive)
|
|
53
|
+
setGroup(g);
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
if (alive)
|
|
57
|
+
setGroup(null);
|
|
58
|
+
});
|
|
59
|
+
return () => {
|
|
60
|
+
alive = false;
|
|
61
|
+
};
|
|
62
|
+
}, [options.group, conversationId, resolveGroup]);
|
|
63
|
+
// Resolve the sender device id: explicit override, else persisted device row.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (options.senderDeviceId) {
|
|
66
|
+
setSenderDeviceId(options.senderDeviceId);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
let alive = true;
|
|
70
|
+
repo
|
|
71
|
+
.loadDevice()
|
|
72
|
+
.then((d) => {
|
|
73
|
+
if (alive)
|
|
74
|
+
setSenderDeviceId(d?.device?.id ?? undefined);
|
|
75
|
+
})
|
|
76
|
+
.catch(() => {
|
|
77
|
+
if (alive)
|
|
78
|
+
setSenderDeviceId(undefined);
|
|
79
|
+
});
|
|
80
|
+
return () => {
|
|
81
|
+
alive = false;
|
|
82
|
+
};
|
|
83
|
+
}, [options.senderDeviceId, repo]);
|
|
37
84
|
const decrypt = useCallback(async (model) => {
|
|
38
85
|
if (!group)
|
|
39
86
|
return { model, plaintext: null };
|
|
@@ -78,6 +125,9 @@ export function useSecureMessages(conversationId, options = {}) {
|
|
|
78
125
|
await load(false);
|
|
79
126
|
}, [hasMore, loading, load]);
|
|
80
127
|
const sendMessage = useCallback(async (text) => {
|
|
128
|
+
// Assumes the crypto identity is already hydrated (mount `useSecureDevice` under the same
|
|
129
|
+
// provider): the crypto layer tags the sender from the restored device identity, so after a
|
|
130
|
+
// reload the first send must wait for useSecureDevice's importDeviceState to complete.
|
|
81
131
|
if (!group)
|
|
82
132
|
throw new Error("Cannot send: no MLS group handle for this conversation.");
|
|
83
133
|
if (!senderDeviceId)
|
|
@@ -95,13 +145,31 @@ export function useSecureMessages(conversationId, options = {}) {
|
|
|
95
145
|
refresh();
|
|
96
146
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
97
147
|
}, [conversationId]);
|
|
148
|
+
// Decrypt history that was listed before the group handle resolved. On reload, the first page loads
|
|
149
|
+
// while resolveGroup is still in flight, so those rows come back `plaintext: null`; once the handle
|
|
150
|
+
// arrives (decrypt is recreated with it), re-decrypt the still-undecrypted rows in place — no
|
|
151
|
+
// re-fetch, scroll/pagination preserved.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!group)
|
|
154
|
+
return;
|
|
155
|
+
if (!messagesRef.current.some((m) => m.plaintext === null))
|
|
156
|
+
return;
|
|
157
|
+
let alive = true;
|
|
158
|
+
Promise.all(messagesRef.current.map((m) => (m.plaintext === null ? decrypt(m.model) : Promise.resolve(m)))).then((next) => {
|
|
159
|
+
if (alive)
|
|
160
|
+
setMessages(next);
|
|
161
|
+
});
|
|
162
|
+
return () => {
|
|
163
|
+
alive = false;
|
|
164
|
+
};
|
|
165
|
+
}, [group, decrypt]);
|
|
98
166
|
// Live receive: join the conversation room and decrypt inbound ciphertext.
|
|
99
167
|
useEffect(() => {
|
|
100
168
|
socket.joinConversation(conversationId);
|
|
101
169
|
const off = socket.on("secure:message", (model) => {
|
|
102
170
|
if (model.conversationId !== conversationId)
|
|
103
171
|
return;
|
|
104
|
-
decrypt(model).then((m) => setMessages((prev) => prev.some((p) => p.model.id === m.model.id) ? prev : [m, ...prev]));
|
|
172
|
+
decrypt(model).then((m) => setMessages((prev) => (prev.some((p) => p.model.id === m.model.id) ? prev : [m, ...prev])));
|
|
105
173
|
});
|
|
106
174
|
return off;
|
|
107
175
|
}, [socket, conversationId, decrypt]);
|