stream-chat-react-native-core 9.7.6 → 9.8.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.
Files changed (46) hide show
  1. package/lib/commonjs/components/Chat/Chat.js +11 -24
  2. package/lib/commonjs/components/Chat/Chat.js.map +1 -1
  3. package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js +65 -0
  4. package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
  5. package/lib/commonjs/index.js +7 -0
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/mock-builders/DB/mock.js +8 -0
  8. package/lib/commonjs/mock-builders/DB/mock.js.map +1 -1
  9. package/lib/commonjs/store/OfflineDB.js +14 -1
  10. package/lib/commonjs/store/OfflineDB.js.map +1 -1
  11. package/lib/commonjs/store/SqliteClient.js +92 -8
  12. package/lib/commonjs/store/SqliteClient.js.map +1 -1
  13. package/lib/commonjs/version.json +1 -1
  14. package/lib/module/components/Chat/Chat.js +11 -24
  15. package/lib/module/components/Chat/Chat.js.map +1 -1
  16. package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js +65 -0
  17. package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
  18. package/lib/module/index.js +7 -0
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/module/mock-builders/DB/mock.js +8 -0
  21. package/lib/module/mock-builders/DB/mock.js.map +1 -1
  22. package/lib/module/store/OfflineDB.js +14 -1
  23. package/lib/module/store/OfflineDB.js.map +1 -1
  24. package/lib/module/store/SqliteClient.js +92 -8
  25. package/lib/module/store/SqliteClient.js.map +1 -1
  26. package/lib/module/version.json +1 -1
  27. package/lib/typescript/components/Chat/Chat.d.ts +44 -0
  28. package/lib/typescript/components/Chat/Chat.d.ts.map +1 -1
  29. package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts +33 -0
  30. package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts.map +1 -0
  31. package/lib/typescript/index.d.ts +1 -1
  32. package/lib/typescript/index.d.ts.map +1 -1
  33. package/lib/typescript/store/OfflineDB.d.ts +16 -1
  34. package/lib/typescript/store/OfflineDB.d.ts.map +1 -1
  35. package/lib/typescript/store/SqliteClient.d.ts +61 -0
  36. package/lib/typescript/store/SqliteClient.d.ts.map +1 -1
  37. package/package.json +1 -1
  38. package/src/components/Chat/Chat.tsx +52 -18
  39. package/src/components/Chat/__tests__/Chat.test.tsx +242 -1
  40. package/src/components/Chat/hooks/useInitializeOfflineDb.ts +117 -0
  41. package/src/index.ts +1 -1
  42. package/src/mock-builders/DB/mock.ts +13 -0
  43. package/src/store/OfflineDB.ts +33 -2
  44. package/src/store/SqliteClient.ts +185 -1
  45. package/src/store/__tests__/SqliteClient.test.ts +258 -0
  46. package/src/version.json +1 -1
@@ -15,6 +15,50 @@ export type ChatProps = Pick<ChatContextValue, 'client'> & Partial<Pick<ChatCont
15
15
  * Enables offline storage and loading for chat data.
16
16
  */
17
17
  enableOfflineSupport?: boolean;
18
+ /**
19
+ * Encrypts the offline database at rest with SQLCipher, using the key this
20
+ * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it
21
+ * unset keeps the offline database unencrypted, which is the default.
22
+ *
23
+ * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher.
24
+ * Add the following to your application's `package.json` and rebuild the native
25
+ * app - without the flag the key is accepted and then silently ignored:
26
+ *
27
+ * ```json
28
+ * { "op-sqlite": { "sqlcipher": true } }
29
+ * ```
30
+ *
31
+ * **Wrap `<Chat>` in an error boundary.** If the database cannot be opened with
32
+ * the encryption you asked for, `<Chat>` throws a {@link SqliteClientError}
33
+ * from render instead of continuing without it. The SDK deliberately takes no
34
+ * recovery action of its own - it never deletes data, and never silently falls
35
+ * back to an unencrypted or absent cache. Discriminate on `code`:
36
+ *
37
+ * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the
38
+ * key changed, or the database predates encryption). **Recommended recovery:
39
+ * `SqliteClient.deleteDatabase()`, then re-mount `<Chat>`.** The contents are a
40
+ * cache and are refetched from the server; the exception is actions queued while
41
+ * offline, which are lost - prompt the user first if that matters to you.
42
+ * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a
43
+ * launch before first unlock). The database is untouched. **Recommended
44
+ * recovery: re-mount to retry** once the key is readable - for example when the
45
+ * app next returns to the foreground.
46
+ * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key
47
+ * would be ignored and the database written in plaintext. Not recoverable at
48
+ * runtime; it needs the build flag above and a new binary. **Recommended
49
+ * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is
50
+ * persisted unencrypted.
51
+ *
52
+ * The key must be **stable for the lifetime of the database file**. There is no
53
+ * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a
54
+ * rebuild. To rotate without paying that, rotate a key-encryption key and keep the
55
+ * database key it protects unchanged (envelope encryption).
56
+ *
57
+ * Switching encryption on, or back off, leaves a database from the other mode on
58
+ * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it
59
+ * from your boundary is all that is needed.
60
+ */
61
+ getOfflineDbEncryptionKey?: () => Promise<string | undefined>;
18
62
  /**
19
63
  * Optional positive cap on the number of events a single `/sync` response may
20
64
  * contain before the offline sync manager skips replaying those events into
@@ -1 +1 @@
1
- {"version":3,"file":"Chat.d.ts","sourceRoot":"","sources":["../../../../src/components/Chat/Chat.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,iBAAiB,EAAgC,MAAM,OAAO,CAAC;AAW/E,OAAO,EAAE,gBAAgB,EAAgB,MAAM,wCAAwC,CAAC;AAGxF,OAAO,EAAE,WAAW,EAA2B,MAAM,0CAA0C,CAAC;AAChG,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yCAAyC,CAAC;AAarE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,MAAM,MAAM,SAAS,GAAG,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,GACtD,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,sBAAsB,CAAC,CAAC,GAAG;IACxD;;;;;OAKG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC;;OAEG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACpC;;;;;;;;OAQG;IACH,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkDG;IACH,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,KAAK,CAAC,EAAE,WAAW,CAAC,KAAK,CAAC,CAAC;CAC5B,CAAC;AA+JJ;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,IAAI,GAAI,OAAO,iBAAiB,CAAC,SAAS,CAAC,sBAIvD,CAAC"}
1
+ {"version":3,"file":"Chat.d.ts","sourceRoot":"","sources":["../../../../src/components/Chat/Chat.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,iBAAiB,EAAgC,MAAM,OAAO,CAAC;AAY/E,OAAO,EAAE,gBAAgB,EAAgB,MAAM,wCAAwC,CAAC;AAGxF,OAAO,EAAE,WAAW,EAA2B,MAAM,0CAA0C,CAAC;AAChG,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yCAAyC,CAAC;AAYrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAM9D,MAAM,MAAM,SAAS,GAAG,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,GACtD,OAAO,CAAC,IAAI,CAAC,gBAAgB,EAAE,sBAAsB,CAAC,CAAC,GAAG;IACxD;;;;;OAKG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;IACtC;;OAEG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA0CG;IACH,yBAAyB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC9D;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IACpC;;;;;;;;OAQG;IACH,wBAAwB,CAAC,EAAE,OAAO,CAAC;IACnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkDG;IACH,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,KAAK,CAAC,EAAE,WAAW,CAAC,KAAK,CAAC,CAAC;CAC5B,CAAC;AAqJJ;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,IAAI,GAAI,OAAO,iBAAiB,CAAC,SAAS,CAAC,sBAIvD,CAAC"}
@@ -0,0 +1,33 @@
1
+ import type { StreamChat } from 'stream-chat';
2
+ export type InitializeOfflineDbOptions = {
3
+ /**
4
+ * Encrypts the offline database at rest with SQLCipher, using the key this resolves
5
+ * to. Leaving it unset opens the database unencrypted, which is the default. See
6
+ * `ChatProps.getOfflineDbEncryptionKey` for the build flag it requires, the stability
7
+ * requirement, and how failures are surfaced.
8
+ */
9
+ getEncryptionKey?: () => Promise<string | undefined>;
10
+ /**
11
+ * Optional positive cap on the number of events a single `/sync` response may
12
+ * contain before the offline sync manager skips replaying those events into local
13
+ * storage. `false` opts out entirely.
14
+ */
15
+ maxSyncEventsLimit?: number | false;
16
+ };
17
+ export type UseInitializeOfflineDbParams = {
18
+ client: StreamChat;
19
+ /** Whether offline support is enabled at all. */
20
+ enabled: boolean;
21
+ options?: InitializeOfflineDbOptions;
22
+ userID?: string;
23
+ };
24
+ /**
25
+ * Attaches an offline database to the client and initializes it for a user.
26
+ *
27
+ * **Raises** whatever prevented the database from opening, from render, so an error
28
+ * boundary above the caller can decide what to do. The offline database is never
29
+ * silently downgraded, because an integration that asked for encryption must not end
30
+ * up with an unencrypted cache.
31
+ */
32
+ export declare const useInitializeOfflineDb: ({ client, enabled, options, userID, }: UseInitializeOfflineDbParams) => void;
33
+ //# sourceMappingURL=useInitializeOfflineDb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useInitializeOfflineDb.d.ts","sourceRoot":"","sources":["../../../../../src/components/Chat/hooks/useInitializeOfflineDb.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAM9C,MAAM,MAAM,0BAA0B,GAAG;IACvC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACrD;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACrC,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,MAAM,EAAE,UAAU,CAAC;IACnB,iDAAiD;IACjD,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,0BAA0B,CAAC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,uCAKpC,4BAA4B,SAuE9B,CAAC"}
@@ -27,7 +27,7 @@ export { default as ptBRTranslations } from './i18n/pt-br.json';
27
27
  export { default as ruTranslations } from './i18n/ru.json';
28
28
  export { default as trTranslations } from './i18n/tr.json';
29
29
  export * from './state-store';
30
- export { SqliteClient } from './store/SqliteClient';
30
+ export { SqliteClient, SqliteClientError, type SqliteClientErrorCode } from './store/SqliteClient';
31
31
  export { OfflineDB } from './store/OfflineDB';
32
32
  export { version } from './version.json';
33
33
  import * as OfflineStoreApis from './store/apis';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,6IAA6I;AAC7I,OAAO,kBAAkB,CAAC;AAC1B,OAAO,aAAa,CAAC;AAErB,cAAc,cAAc,CAAC;AAC7B,cAAc,SAAS,CAAC;AACxB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,sBAAsB,EACtB,eAAe,GAChB,MAAM,UAAU,CAAC;AAClB,cAAc,YAAY,CAAC;AAE3B,cAAc,SAAS,CAAC;AAExB,cAAc,eAAe,CAAC;AAE9B,cAAc,eAAe,CAAC;AAE9B,cAAc,iCAAiC,CAAC;AAChD,cAAc,yBAAyB,CAAC;AACxC,cAAc,mCAAmC,CAAC;AAClD,cAAc,uCAAuC,CAAC;AACtD,cAAc,eAAe,CAAC;AAC9B,cAAc,yBAAyB,CAAC;AAExC,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE3D,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAGzC,OAAO,KAAK,gBAAgB,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,CAAC;AAE5B,eAAO,MAAM,4BAA4B,qBAA8C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,6IAA6I;AAC7I,OAAO,kBAAkB,CAAC;AAC1B,OAAO,aAAa,CAAC;AAErB,cAAc,cAAc,CAAC;AAC7B,cAAc,SAAS,CAAC;AACxB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,eAAe,EACf,sBAAsB,EACtB,eAAe,GAChB,MAAM,UAAU,CAAC;AAClB,cAAc,YAAY,CAAC;AAE3B,cAAc,SAAS,CAAC;AAExB,cAAc,eAAe,CAAC;AAE9B,cAAc,eAAe,CAAC;AAE9B,cAAc,iCAAiC,CAAC;AAChD,cAAc,yBAAyB,CAAC;AACxC,cAAc,mCAAmC,CAAC;AAClD,cAAc,uCAAuC,CAAC;AACtD,cAAc,eAAe,CAAC;AAC9B,cAAc,yBAAyB,CAAC;AAExC,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAC3D,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE3D,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,KAAK,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AACnG,OAAO,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAGzC,OAAO,KAAK,gBAAgB,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAE,CAAC;AAE5B,eAAO,MAAM,4BAA4B,qBAA8C,CAAC"}
@@ -1,8 +1,14 @@
1
1
  import { AbstractOfflineDB, StreamChat } from 'stream-chat';
2
2
  import type { DBGetAppSettingsType, DBGetChannelsForQueryType, DBGetChannelsType, DBGetLastSyncedAtType, DBUpsertAppSettingsType, DBUpsertUserSyncStatusType } from 'stream-chat';
3
+ import { SqliteClientError } from './SqliteClient';
3
4
  export declare class OfflineDB extends AbstractOfflineDB {
4
- constructor({ client, maxSyncEventsLimit, }: {
5
+ constructor({ client, getEncryptionKey, maxSyncEventsLimit, }: {
5
6
  client: StreamChat;
7
+ /**
8
+ * Supplies the SQLCipher key the offline database is opened with. See
9
+ * {@link SqliteClient.getEncryptionKey} for the stability requirement.
10
+ */
11
+ getEncryptionKey?: () => Promise<string | undefined>;
6
12
  maxSyncEventsLimit?: number | false;
7
13
  });
8
14
  upsertCidsForQuery: ({ cids, filters, execute, options, sort, }: {
@@ -121,6 +127,15 @@ export declare class OfflineDB extends AbstractOfflineDB {
121
127
  }) => Promise<boolean>;
122
128
  resetDB: () => Promise<void>;
123
129
  executeSqlBatch: (queries: import("./types").PreparedBatchQueries[]) => Promise<void>;
130
+ /**
131
+ * Why the most recent {@link initializeDB} failed, if it did.
132
+ *
133
+ * `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not
134
+ * re-throw it, so a caller has no way to see the reason. Recording it here on the
135
+ * way out gives the caller something to read back once `init` has settled. Kept on
136
+ * the instance rather than a static so two clients cannot overwrite each other.
137
+ */
138
+ initializationError: SqliteClientError | undefined;
124
139
  initializeDB: () => Promise<boolean>;
125
140
  }
126
141
  //# sourceMappingURL=OfflineDB.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"OfflineDB.d.ts","sourceRoot":"","sources":["../../../src/store/OfflineDB.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,KAAK,EACV,oBAAoB,EACpB,yBAAyB,EACzB,iBAAiB,EACjB,qBAAqB,EACrB,uBAAuB,EACvB,0BAA0B,EAC3B,MAAM,aAAa,CAAC;AAKrB,qBAAa,SAAU,SAAQ,iBAAiB;gBAClC,EACV,MAAM,EACN,kBAAkB,GACnB,EAAE;QACD,MAAM,EAAE,UAAU,CAAC;QACnB,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;KACrC;IAOD,kBAAkB;;eAZV,CAAC;eAET,CAAC;eACa,CAAC;YACA,CAAC;uDAQ4B;IAE5C,cAAc;;eAAK,CAAC;2BAER,CAAC;uDAFuB;IAGpC,oBAAoB,GAAI,mCAAmC,0BAA0B,kDACR;IAG7E,iBAAiB,GAAI,kCAAkC,uBAAuB,kDACL;IAEzE,UAAU;;eA1BgC,CAAC;uDA0Bf;IAE5B,WAAW;;eAlB6D,CAAC;uDAkB3C;IAE9B,QAAQ;;;iBAxBY,CAAC;8DAwBG;IAExB,WAAW;;iBAtCW,CAAC;eACX,CAAC;uDAqCiB;IAE9B,iBAAiB;;eAlCc,CAAC;uDAkCU;IAE1C,WAAW;;;eA9BL,CAAC;uDA8BuB;IAE9B,cAAc;;eApBU,CAAC;uDAoBW;IAEpC,aAAa;;;eAlCqB,CAAC;uDAkCD;IAElC,aAAa;;eA1BS,CAAC;uDA0BW;IAGlC,WAAW,GAAI,kBAAkB,iBAAiB,2EACa;IAG/D,mBAAmB,GAAI,oCAAoC,yBAAyB,kFACF;IAElF,iBAAiB,0BAAwB;IAGzC,eAAe,GAAI,YAAY,qBAAqB,iCACH;IAEjD,cAAc,GAAI,YAAY,oBAAoB,kEACF;IAEhD,YAAY;;eA3C+B,CAAC;YAAuC,CAAC;aAC5E,CAAC;mEA0CoC;IAE7C,cAAc,4EAAsB;IAEpC,iBAAiB,gHAAyB;IAE1C,iBAAiB;;uDAAyB;IAE1C,cAAc;;eAhEX,CAAC;eAC4B,CAAC;uDA+DG;IAEpC,YAAY;;;eAzEe,CAAA;uDAyEK;IAEhC,aAAa;;eA/EJ,CAAC;uDA+EwB;IAElC,wBAAwB;;oBAlFxB,CAAF;eAAmB,CAAC;mDAkFsC;IAExD,gBAAgB;;eA3DgD,CAAC;uDA2DzB;IAExC,iBAAiB;;eApFA,CAAC;uDAoFoB;IAEtC,iBAAiB,6HAAyB;IAE1C,eAAe;iBArFR,CAAC;uDAqF8B;IAEtC,cAAc;;;eAnE6B,CAAC;uDAmER;IAEpC,cAAc;;;eA7ES,CAAC;uDA6EY;IAEpC,aAAa;;2BAAqB;IAElC,OAAO,sBAAwB;IAE/B,eAAe,uEAAgC;IAE/C,YAAY,yBAAmC;CAChD"}
1
+ {"version":3,"file":"OfflineDB.d.ts","sourceRoot":"","sources":["../../../src/store/OfflineDB.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,KAAK,EACV,oBAAoB,EACpB,yBAAyB,EACzB,iBAAiB,EACjB,qBAAqB,EACrB,uBAAuB,EACvB,0BAA0B,EAC3B,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAgB,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAEjE,qBAAa,SAAU,SAAQ,iBAAiB;gBAClC,EACV,MAAM,EACN,gBAAgB,EAChB,kBAAkB,GACnB,EAAE;QACD,MAAM,EAAE,UAAU,CAAC;QACnB;;;WAGG;QACH,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;QACrD,kBAAkB,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;KACrC;IAWD,kBAAkB;;eAvBb,CAAC;eAED,CAAC;eACF,CAAC;YAEH,CAAH;uDAkB6C;IAE5C,cAAc;;eAfoB,CAAC;2BAC1B,CAAC;uDAc0B;IAGpC,oBAAoB,GAAI,mCAAmC,0BAA0B,kDACR;IAG7E,iBAAiB,GAAI,kCAAkC,uBAAuB,kDACL;IAEzE,UAAU;;eApCa,CAAC;uDAoCI;IAE5B,WAAW;;eA7BW,CAAC;uDA6BO;IAE9B,QAAQ;;;iBAlCL,CAAC;8DAkCoB;IAExB,WAAW;;iBAhDW,CAAC;eACX,CAAC;uDA+CiB;IAE9B,iBAAiB;;eA5CL,CAAC;uDA4C6B;IAE1C,WAAW;;;eA1CS,CAAC;uDA0CS;IAE9B,cAAc;;eApCO,CAAC;uDAoCc;IAEpC,aAAa;;;eA5CI,CAAC;uDA4CgB;IAElC,aAAa;;eAzCwB,CAAC;uDAyCJ;IAGlC,WAAW,GAAI,kBAAkB,iBAAiB,2EACa;IAG/D,mBAAmB,GAAI,oCAAoC,yBAAyB,kFACF;IAElF,iBAAiB,0BAAwB;IAGzC,eAAe,GAAI,YAAY,qBAAqB,iCACH;IAEjD,cAAc,GAAI,YAAY,oBAAoB,kEACF;IAEhD,YAAY;;eA1DZ,CAAA;YAGM,CAAC;aAAuB,CAAC;mEAuDc;IAE7C,cAAc,4EAAsB;IAEpC,iBAAiB,gHAAyB;IAE1C,iBAAiB;;uDAAyB;IAE1C,cAAc;;eA5EC,CAAC;eAGD,CAAC;uDAyEoB;IAEpC,YAAY;;;eAnFc,CAAC;uDAmFK;IAEhC,aAAa;;eAzFJ,CAAC;uDAyFwB;IAElC,wBAAwB;;oBA5FxB,CAAF;eAAmB,CAAC;mDA4FsC;IAExD,gBAAgB;;eAxEZ,CAAF;uDAwEsC;IAExC,iBAAiB;;eA9FA,CAAC;uDA8FoB;IAEtC,iBAAiB,6HAAyB;IAE1C,eAAe;iBA/FR,CAAC;uDA+F8B;IAEtC,cAAc;;;eAlFd,CAAA;uDAkFoC;IAEpC,cAAc;;;eAzFkC,CAAC;uDAyFb;IAEpC,aAAa;;2BAAqB;IAElC,OAAO,sBAAwB;IAE/B,eAAe,uEAAgC;IAE/C;;;;;;;OAOG;IACH,mBAAmB,EAAE,iBAAiB,GAAG,SAAS,CAAC;IAEnD,YAAY,yBAWV;CACH"}
@@ -1,6 +1,18 @@
1
1
  import type { _InternalDB } from '@op-engineering/op-sqlite';
2
2
  import { Logger } from 'stream-chat';
3
3
  import type { PreparedBatchQueries, Scalar } from './types';
4
+ /**
5
+ * Why the offline database could not be opened. The first two only arise when
6
+ * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean
7
+ * plain corruption, or a database left behind from the other encryption mode.
8
+ */
9
+ export type SqliteClientErrorCode = 'SQLCIPHER_BUILD_MISSING' | 'ENCRYPTION_KEY_UNAVAILABLE' | 'OFFLINE_DB_UNREADABLE';
10
+ export declare class SqliteClientError extends Error {
11
+ readonly code: SqliteClientErrorCode;
12
+ constructor(code: SqliteClientErrorCode, message: string, options?: {
13
+ cause?: unknown;
14
+ });
15
+ }
4
16
  /**
5
17
  * SqliteClient takes care of any direct interaction with sqlite.
6
18
  * This way usage @op-engineering/op-sqlite package is scoped to a single class/file.
@@ -11,14 +23,63 @@ export declare class SqliteClient {
11
23
  static dbLocation: string;
12
24
  static logger: Logger | undefined;
13
25
  static db: _InternalDB | undefined;
26
+ /**
27
+ * Supplies the SQLCipher key the offline database is opened with; `undefined`
28
+ * opens it unencrypted, which is the default. The key must be stable for the
29
+ * lifetime of the database file - there is no rekey path, so a database this key
30
+ * cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is
31
+ * left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount.
32
+ */
33
+ static getEncryptionKey: (() => Promise<string | undefined>) | undefined;
34
+ /** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */
35
+ private static preflightedKey;
36
+ /** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */
37
+ private static TRANSIENT_ERROR;
38
+ /**
39
+ * The bytes on disk cannot be read with the key we have: wrong/rotated key,
40
+ * plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific
41
+ * code and overloads NOTADB (26), occasionally CORRUPT (11).
42
+ */
43
+ private static UNREADABLE_ERROR;
14
44
  static getDbVersion: () => number;
15
45
  static setDbVersion: (version: number) => number;
46
+ /**
47
+ * Records and re-throws. Deliberately does not write to the console: the error is
48
+ * thrown, so logging it here would duplicate whatever the caller's error boundary
49
+ * reports - and in dev React already logs every boundary-caught error, which is what
50
+ * LogBox turns red.
51
+ */
52
+ private static recordError;
53
+ /**
54
+ * Resolves the encryption key without opening the database, so callers can decide
55
+ * whether to attach an `OfflineDB` at all. Parts of the client write through
56
+ * `client.offlineDb` without checking that it initialized (`queryChannels` upserts
57
+ * into it), so attaching one we cannot open turns those writes into rejections.
58
+ *
59
+ * Throws {@link SqliteClientError}. The key is handed to the next
60
+ * {@link openDB} rather than read from `getEncryptionKey` twice.
61
+ */
62
+ static preflightEncryption: () => Promise<void>;
63
+ /**
64
+ * The key to open with, or `undefined` when the database is meant to be
65
+ * unencrypted. Throws rather than silently falling back to an unencrypted
66
+ * database, which would hand an integration that asked for encryption a plaintext
67
+ * cache of its users' messages.
68
+ */
69
+ private static resolveEncryptionKey;
16
70
  static openDB: () => Promise<void>;
17
71
  static closeDB: () => void;
18
72
  static executeSqlBatch: (queries: PreparedBatchQueries[]) => Promise<void>;
19
73
  static executeSql: (query: string, params?: Scalar[]) => Promise<Record<string, string>[]>;
20
74
  static dropTables: () => Promise<void>;
21
75
  static deleteDatabase: () => boolean;
76
+ /**
77
+ * Whether the file cannot be read with the key we have, as opposed to being
78
+ * temporarily unavailable (busy, locked, disk). Works off message text because
79
+ * op-sqlite rejects with a plain Error and this class re-wraps those messages, so
80
+ * no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`.
81
+ */
82
+ static isUnreadableDbError: (e: unknown) => boolean;
22
83
  static initializeDatabase: () => Promise<boolean>;
23
84
  static updateUserPragmaVersion: (version: number) => Promise<void>;
24
85
  static getUserPragmaVersion: () => Promise<number>;
@@ -1 +1 @@
1
- {"version":3,"file":"SqliteClient.d.ts","sourceRoot":"","sources":["../../../src/store/SqliteClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,2BAA2B,CAAC;AAkB5E,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAKrC,OAAO,KAAK,EAAE,oBAAoB,EAAmB,MAAM,EAAS,MAAM,SAAS,CAAC;AAEpF;;;GAGG;AACH,qBAAa,YAAY;IACvB,MAAM,CAAC,SAAS,SAAM;IAEtB,MAAM,CAAC,MAAM,SAAW;IACxB,MAAM,CAAC,UAAU,SAAe;IAChC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,MAAM,CAAC,EAAE,EAAE,WAAW,GAAG,SAAS,CAAC;IAEnC,MAAM,CAAC,YAAY,eAAgC;IAEnD,MAAM,CAAC,YAAY,GAAI,SAAS,MAAM,YAAwC;IAE9E,MAAM,CAAC,MAAM,sBAmBX;IAEF,MAAM,CAAC,OAAO,aAaZ;IAEF,MAAM,CAAC,eAAe,GAAU,SAAS,oBAAoB,EAAE,mBA2B7D;IAEF,MAAM,CAAC,UAAU,GAAU,OAAO,MAAM,EAAE,SAAS,MAAM,EAAE,uCAezD;IAEF,MAAM,CAAC,UAAU,sBASf;IAEF,MAAM,CAAC,cAAc,gBAoBnB;IAEF,MAAM,CAAC,kBAAkB,yBAmCvB;IAEF,MAAM,CAAC,uBAAuB,GAAU,SAAS,MAAM,mBAMrD;IAEF,MAAM,CAAC,oBAAoB,wBAezB;IAEF,MAAM,CAAC,OAAO,sBAOZ;CACH"}
1
+ {"version":3,"file":"SqliteClient.d.ts","sourceRoot":"","sources":["../../../src/store/SqliteClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,2BAA2B,CAAC;AAkB5E,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAKrC,OAAO,KAAK,EAAE,oBAAoB,EAAmB,MAAM,EAAS,MAAM,SAAS,CAAC;AAEpF;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAC7B,yBAAyB,GACzB,4BAA4B,GAC5B,uBAAuB,CAAC;AAE5B,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,SAAgB,IAAI,EAAE,qBAAqB,CAAC;gBAEhC,IAAI,EAAE,qBAAqB,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAQxF;AAED;;;GAGG;AACH,qBAAa,YAAY;IACvB,MAAM,CAAC,SAAS,SAAM;IAEtB,MAAM,CAAC,MAAM,SAAW;IACxB,MAAM,CAAC,UAAU,SAAe;IAChC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,MAAM,CAAC,EAAE,EAAE,WAAW,GAAG,SAAS,CAAC;IAEnC;;;;;;OAMG;IACH,MAAM,CAAC,gBAAgB,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,GAAG,SAAS,CAAC;IAEzE,wFAAwF;IACxF,OAAO,CAAC,MAAM,CAAC,cAAc,CAAqB;IAElD,sFAAsF;IACtF,OAAO,CAAC,MAAM,CAAC,eAAe,CACiG;IAE/H;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,gBAAgB,CACsG;IAErI,MAAM,CAAC,YAAY,eAAgC;IAEnD,MAAM,CAAC,YAAY,GAAI,SAAS,MAAM,YAAwC;IAE9E;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,WAAW,CAIxB;IAEF;;;;;;;;OAQG;IACH,MAAM,CAAC,mBAAmB,sBASxB;IAEF;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,oBAAoB,CAoDjC;IAEF,MAAM,CAAC,MAAM,sBA8BX;IAEF,MAAM,CAAC,OAAO,aAaZ;IAEF,MAAM,CAAC,eAAe,GAAU,SAAS,oBAAoB,EAAE,mBA2B7D;IAEF,MAAM,CAAC,UAAU,GAAU,OAAO,MAAM,EAAE,SAAS,MAAM,EAAE,uCAezD;IAEF,MAAM,CAAC,UAAU,sBASf;IAEF,MAAM,CAAC,cAAc,gBAoBnB;IAEF;;;;;OAKG;IACH,MAAM,CAAC,mBAAmB,GAAI,GAAG,OAAO,aAQtC;IAEF,MAAM,CAAC,kBAAkB,QAAa,OAAO,CAAC,OAAO,CAAC,CAqDpD;IAEF,MAAM,CAAC,uBAAuB,GAAU,SAAS,MAAM,mBAMrD;IAEF,MAAM,CAAC,oBAAoB,wBAezB;IAEF,MAAM,CAAC,OAAO,sBAOZ;CACH"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stream-chat-react-native-core",
3
3
  "description": "The official React Native and Expo components for Stream Chat, a service for building chat applications",
4
- "version": "9.7.6",
4
+ "version": "9.8.0",
5
5
  "author": {
6
6
  "company": "Stream.io Inc",
7
7
  "name": "Stream.io Inc"
@@ -6,6 +6,7 @@ import { Channel, OfflineDBState } from 'stream-chat';
6
6
  import { useClientMutedUsers } from './hooks';
7
7
  import { useAppSettings } from './hooks/useAppSettings';
8
8
  import { useCreateChatContext } from './hooks/useCreateChatContext';
9
+ import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
9
10
  import { useIsOnline } from './hooks/useIsOnline';
10
11
 
11
12
  import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext';
@@ -24,7 +25,6 @@ import init from '../../init';
24
25
 
25
26
  import { NativeHandlers } from '../../native';
26
27
  import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants';
27
- import { OfflineDB } from '../../store/OfflineDB';
28
28
 
29
29
  import type { Streami18n } from '../../utils/i18n/Streami18n';
30
30
  import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter';
@@ -45,6 +45,50 @@ export type ChatProps = Pick<ChatContextValue, 'client'> &
45
45
  * Enables offline storage and loading for chat data.
46
46
  */
47
47
  enableOfflineSupport?: boolean;
48
+ /**
49
+ * Encrypts the offline database at rest with SQLCipher, using the key this
50
+ * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it
51
+ * unset keeps the offline database unencrypted, which is the default.
52
+ *
53
+ * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher.
54
+ * Add the following to your application's `package.json` and rebuild the native
55
+ * app - without the flag the key is accepted and then silently ignored:
56
+ *
57
+ * ```json
58
+ * { "op-sqlite": { "sqlcipher": true } }
59
+ * ```
60
+ *
61
+ * **Wrap `<Chat>` in an error boundary.** If the database cannot be opened with
62
+ * the encryption you asked for, `<Chat>` throws a {@link SqliteClientError}
63
+ * from render instead of continuing without it. The SDK deliberately takes no
64
+ * recovery action of its own - it never deletes data, and never silently falls
65
+ * back to an unencrypted or absent cache. Discriminate on `code`:
66
+ *
67
+ * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the
68
+ * key changed, or the database predates encryption). **Recommended recovery:
69
+ * `SqliteClient.deleteDatabase()`, then re-mount `<Chat>`.** The contents are a
70
+ * cache and are refetched from the server; the exception is actions queued while
71
+ * offline, which are lost - prompt the user first if that matters to you.
72
+ * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a
73
+ * launch before first unlock). The database is untouched. **Recommended
74
+ * recovery: re-mount to retry** once the key is readable - for example when the
75
+ * app next returns to the foreground.
76
+ * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key
77
+ * would be ignored and the database written in plaintext. Not recoverable at
78
+ * runtime; it needs the build flag above and a new binary. **Recommended
79
+ * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is
80
+ * persisted unencrypted.
81
+ *
82
+ * The key must be **stable for the lifetime of the database file**. There is no
83
+ * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a
84
+ * rebuild. To rotate without paying that, rotate a key-encryption key and keep the
85
+ * database key it protects unchanged (envelope encryption).
86
+ *
87
+ * Switching encryption on, or back off, leaves a database from the other mode on
88
+ * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it
89
+ * from your boundary is all that is needed.
90
+ */
91
+ getOfflineDbEncryptionKey?: () => Promise<string | undefined>;
48
92
  /**
49
93
  * Optional positive cap on the number of events a single `/sync` response may
50
94
  * contain before the offline sync manager skips replaying those events into
@@ -172,6 +216,7 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
172
216
  client,
173
217
  closeConnectionOnBackground = true,
174
218
  enableOfflineSupport = false,
219
+ getOfflineDbEncryptionKey,
175
220
  i18nInstance,
176
221
  isMessageAIGenerated,
177
222
  maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT,
@@ -241,23 +286,12 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
241
286
 
242
287
  const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel);
243
288
 
244
- useEffect(() => {
245
- if (!(userID && enableOfflineSupport)) {
246
- return;
247
- }
248
-
249
- const initializeDatabase = async () => {
250
- if (!client.offlineDb) {
251
- client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit }));
252
- }
253
-
254
- if (client.offlineDb) {
255
- await client.offlineDb.init(userID);
256
- }
257
- };
258
-
259
- initializeDatabase();
260
- }, [userID, enableOfflineSupport, client, maxSyncEventsLimit]);
289
+ useInitializeOfflineDb({
290
+ client,
291
+ enabled: enableOfflineSupport,
292
+ options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit },
293
+ userID,
294
+ });
261
295
 
262
296
  useEffect(() => {
263
297
  if (!client) {
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { PropsWithChildren } from 'react';
2
2
  import { View } from 'react-native';
3
3
 
4
4
  import NetInfo from '@react-native-community/netinfo';
@@ -9,10 +9,12 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext';
9
9
 
10
10
  import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext';
11
11
  import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
12
+ import { sqliteMock } from '../../../mock-builders/DB/mock';
12
13
  import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged';
13
14
  import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered';
14
15
  import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock';
15
16
  import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../../store/constants';
17
+ import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient';
16
18
  import { Streami18n } from '../../../utils/i18n/Streami18n';
17
19
  import { Chat } from '../Chat';
18
20
 
@@ -368,3 +370,242 @@ describe('TranslationContext', () => {
368
370
  expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBeUndefined();
369
371
  });
370
372
  });
373
+
374
+ describe('Chat offline DB encryption', () => {
375
+ const installedSpies: jest.SpyInstance[] = [];
376
+
377
+ /**
378
+ * Registers a spy for teardown. Deliberately not jest.restoreAllMocks(): that also
379
+ * restores the connection privates mockClient() stubs out on every client created
380
+ * by earlier tests in this file, after which those clients reconnect for real and
381
+ * the failed websocket handshake resurfaces as an unhandled error somewhere else.
382
+ */
383
+ const track = <T extends jest.SpyInstance>(spy: T): T => {
384
+ installedSpies.push(spy);
385
+ return spy;
386
+ };
387
+
388
+ /**
389
+ * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the
390
+ * foreground. Left real, that connection attempt outlives the test and rejects
391
+ * asynchronously. Nothing in this block needs a connection.
392
+ */
393
+ const createClient = async () => {
394
+ const client = await getTestClientWithUser({ id: 'testID' });
395
+ track(jest.spyOn(client, 'openConnection').mockResolvedValue(undefined));
396
+ track(jest.spyOn(client, 'closeConnection').mockResolvedValue(undefined));
397
+ return client;
398
+ };
399
+
400
+ /** Minimal error boundary, since `<Chat>` reports encryption failures by throwing. */
401
+ class Boundary extends React.Component<
402
+ PropsWithChildren<{ onCatch: (error: Error) => void }>,
403
+ { caught: boolean }
404
+ > {
405
+ state = { caught: false };
406
+
407
+ static getDerivedStateFromError() {
408
+ return { caught: true };
409
+ }
410
+
411
+ componentDidCatch(error: Error) {
412
+ this.props.onCatch(error);
413
+ }
414
+
415
+ render() {
416
+ return this.state.caught ? <View testID='boundary' /> : this.props.children;
417
+ }
418
+ }
419
+
420
+ afterEach(() => {
421
+ cleanup();
422
+ installedSpies.splice(0).forEach((spy) => spy.mockRestore());
423
+ SqliteClient.getEncryptionKey = undefined;
424
+ });
425
+
426
+ it('does not configure an encryption key when the prop is omitted', async () => {
427
+ const chatClientWithUser = await createClient();
428
+
429
+ render(<Chat client={chatClientWithUser} enableOfflineSupport />);
430
+
431
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
432
+ expect(SqliteClient.getEncryptionKey).toBeUndefined();
433
+ });
434
+
435
+ it('forwards getOfflineDbEncryptionKey to the sqlite client', async () => {
436
+ const chatClientWithUser = await createClient();
437
+ const getOfflineDbEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
438
+
439
+ render(
440
+ <Chat
441
+ client={chatClientWithUser}
442
+ enableOfflineSupport
443
+ getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
444
+ />,
445
+ );
446
+
447
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
448
+ await waitFor(() => expect(getOfflineDbEncryptionKey).toHaveBeenCalled());
449
+ });
450
+
451
+ it('does not re-initialize when getOfflineDbEncryptionKey is a new function every render', async () => {
452
+ const chatClientWithUser = await createClient();
453
+ const resolveKey = jest.fn().mockResolvedValue('a-stable-key');
454
+
455
+ // An inline arrow is the shape integrators reach for first, so a changing
456
+ // identity must not restart initialization on every render.
457
+ const { rerender } = render(
458
+ <Chat
459
+ client={chatClientWithUser}
460
+ enableOfflineSupport
461
+ getOfflineDbEncryptionKey={() => resolveKey()}
462
+ />,
463
+ );
464
+
465
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
466
+ const initSpy = track(jest.spyOn(chatClientWithUser.offlineDb!, 'init'));
467
+
468
+ rerender(
469
+ <Chat
470
+ client={chatClientWithUser}
471
+ enableOfflineSupport
472
+ getOfflineDbEncryptionKey={() => resolveKey()}
473
+ />,
474
+ );
475
+ rerender(
476
+ <Chat
477
+ client={chatClientWithUser}
478
+ enableOfflineSupport
479
+ getOfflineDbEncryptionKey={() => resolveKey()}
480
+ />,
481
+ );
482
+
483
+ await waitFor(() => expect(initSpy).not.toHaveBeenCalled());
484
+ });
485
+
486
+ it.each<[string, () => Promise<string | undefined>, string]>([
487
+ ['the key cannot be read', () => Promise.resolve(undefined), 'ENCRYPTION_KEY_UNAVAILABLE'],
488
+ [
489
+ 'the key getter throws',
490
+ () => Promise.reject(new Error('keychain is locked')),
491
+ 'ENCRYPTION_KEY_UNAVAILABLE',
492
+ ],
493
+ ])('throws %s so an error boundary can decide', async (_label, getKey, code) => {
494
+ const chatClientWithUser = await createClient();
495
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
496
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
497
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
498
+ const onCatch = jest.fn();
499
+
500
+ const { getByTestId } = render(
501
+ <Boundary onCatch={onCatch}>
502
+ <Chat client={chatClientWithUser} enableOfflineSupport getOfflineDbEncryptionKey={getKey}>
503
+ <View testID='children' />
504
+ </Chat>
505
+ </Boundary>,
506
+ );
507
+
508
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
509
+ expect(onCatch).toHaveBeenCalledWith(expect.any(SqliteClientError));
510
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe(code);
511
+ // Never silently downgraded to online-only.
512
+ expect(() => getByTestId('children')).toThrow();
513
+ });
514
+
515
+ it('throws when the native build has no SQLCipher', async () => {
516
+ const chatClientWithUser = await createClient();
517
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
518
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
519
+ track(jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false));
520
+ const onCatch = jest.fn();
521
+
522
+ const { getByTestId } = render(
523
+ <Boundary onCatch={onCatch}>
524
+ <Chat
525
+ client={chatClientWithUser}
526
+ enableOfflineSupport
527
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
528
+ />
529
+ </Boundary>,
530
+ );
531
+
532
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
533
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('SQLCIPHER_BUILD_MISSING');
534
+ });
535
+
536
+ it('throws OFFLINE_DB_UNREADABLE without deleting the database', async () => {
537
+ const chatClientWithUser = await createClient();
538
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
539
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
540
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
541
+ // Preflight passes, then the first read of the file fails to decrypt.
542
+ track(
543
+ jest
544
+ .spyOn(SqliteClient, 'getUserPragmaVersion')
545
+ .mockRejectedValue(new Error('Querying for user_version failed: file is not a database')),
546
+ );
547
+ const deleteSpy = track(jest.spyOn(SqliteClient, 'deleteDatabase'));
548
+ const onCatch = jest.fn();
549
+
550
+ const { getByTestId } = render(
551
+ <Boundary onCatch={onCatch}>
552
+ <Chat
553
+ client={chatClientWithUser}
554
+ enableOfflineSupport
555
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
556
+ />
557
+ </Boundary>,
558
+ );
559
+
560
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
561
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('OFFLINE_DB_UNREADABLE');
562
+ // Wiping is the integrator's decision, made from the boundary.
563
+ expect(deleteSpy).not.toHaveBeenCalled();
564
+ });
565
+
566
+ it('never attaches an offline DB it cannot open', async () => {
567
+ const chatClientWithUser = await createClient();
568
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
569
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
570
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
571
+ const setOfflineDBApiSpy = track(jest.spyOn(chatClientWithUser, 'setOfflineDBApi'));
572
+
573
+ const { getByTestId } = render(
574
+ <Boundary onCatch={() => undefined}>
575
+ <Chat
576
+ client={chatClientWithUser}
577
+ enableOfflineSupport
578
+ getOfflineDbEncryptionKey={() => Promise.resolve(undefined)}
579
+ />
580
+ </Boundary>,
581
+ );
582
+
583
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
584
+
585
+ // Parts of the client write through `client.offlineDb` without checking that it
586
+ // initialized - queryChannels upserts into it - so an instance we cannot open
587
+ // would turn those writes into rejections.
588
+ expect(setOfflineDBApiSpy).not.toHaveBeenCalled();
589
+ expect(chatClientWithUser.offlineDb).toBeUndefined();
590
+ });
591
+
592
+ it('renders normally when nothing is wrong with encryption', async () => {
593
+ const chatClientWithUser = await createClient();
594
+ const onCatch = jest.fn();
595
+
596
+ const { getByTestId } = render(
597
+ <Boundary onCatch={onCatch}>
598
+ <Chat
599
+ client={chatClientWithUser}
600
+ enableOfflineSupport
601
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
602
+ >
603
+ <View testID='children' />
604
+ </Chat>
605
+ </Boundary>,
606
+ );
607
+
608
+ await waitFor(() => expect(getByTestId('children')).toBeTruthy());
609
+ expect(onCatch).not.toHaveBeenCalled();
610
+ });
611
+ });