stream-chat-react-native-core 8.13.18 → 8.14.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 (60) hide show
  1. package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js +4 -1
  2. package/lib/commonjs/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
  3. package/lib/commonjs/components/Chat/Chat.js +10 -23
  4. package/lib/commonjs/components/Chat/Chat.js.map +1 -1
  5. package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js +64 -0
  6. package/lib/commonjs/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
  7. package/lib/commonjs/index.js +7 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/mock-builders/DB/mock.js +13 -1
  10. package/lib/commonjs/mock-builders/DB/mock.js.map +1 -1
  11. package/lib/commonjs/store/OfflineDB.js +15 -2
  12. package/lib/commonjs/store/OfflineDB.js.map +1 -1
  13. package/lib/commonjs/store/SqliteClient.js +94 -10
  14. package/lib/commonjs/store/SqliteClient.js.map +1 -1
  15. package/lib/commonjs/test-utils/BetterSqlite.js +3 -2
  16. package/lib/commonjs/test-utils/BetterSqlite.js.map +1 -1
  17. package/lib/commonjs/version.json +1 -1
  18. package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js +4 -1
  19. package/lib/module/components/ChannelList/hooks/usePaginatedChannels.js.map +1 -1
  20. package/lib/module/components/Chat/Chat.js +10 -23
  21. package/lib/module/components/Chat/Chat.js.map +1 -1
  22. package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js +64 -0
  23. package/lib/module/components/Chat/hooks/useInitializeOfflineDb.js.map +1 -0
  24. package/lib/module/index.js +7 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/mock-builders/DB/mock.js +13 -1
  27. package/lib/module/mock-builders/DB/mock.js.map +1 -1
  28. package/lib/module/store/OfflineDB.js +15 -2
  29. package/lib/module/store/OfflineDB.js.map +1 -1
  30. package/lib/module/store/SqliteClient.js +94 -10
  31. package/lib/module/store/SqliteClient.js.map +1 -1
  32. package/lib/module/test-utils/BetterSqlite.js +3 -2
  33. package/lib/module/test-utils/BetterSqlite.js.map +1 -1
  34. package/lib/module/version.json +1 -1
  35. package/lib/typescript/components/ChannelList/hooks/usePaginatedChannels.d.ts.map +1 -1
  36. package/lib/typescript/components/Chat/Chat.d.ts +44 -0
  37. package/lib/typescript/components/Chat/Chat.d.ts.map +1 -1
  38. package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts +27 -0
  39. package/lib/typescript/components/Chat/hooks/useInitializeOfflineDb.d.ts.map +1 -0
  40. package/lib/typescript/hooks/useTranslatedMessage.d.ts.map +1 -1
  41. package/lib/typescript/index.d.ts +1 -1
  42. package/lib/typescript/index.d.ts.map +1 -1
  43. package/lib/typescript/store/OfflineDB.d.ts +16 -1
  44. package/lib/typescript/store/OfflineDB.d.ts.map +1 -1
  45. package/lib/typescript/store/SqliteClient.d.ts +61 -0
  46. package/lib/typescript/store/SqliteClient.d.ts.map +1 -1
  47. package/lib/typescript/test-utils/BetterSqlite.d.ts.map +1 -1
  48. package/package.json +2 -2
  49. package/src/components/ChannelList/__tests__/ChannelList.test.js +87 -20
  50. package/src/components/ChannelList/hooks/usePaginatedChannels.ts +11 -6
  51. package/src/components/Chat/Chat.tsx +52 -18
  52. package/src/components/Chat/__tests__/ChatOfflineDbEncryption.test.tsx +248 -0
  53. package/src/components/Chat/hooks/useInitializeOfflineDb.ts +109 -0
  54. package/src/index.ts +1 -1
  55. package/src/mock-builders/DB/mock.ts +16 -1
  56. package/src/store/OfflineDB.ts +38 -3
  57. package/src/store/SqliteClient.ts +185 -1
  58. package/src/store/__tests__/SqliteClient.test.ts +258 -0
  59. package/src/test-utils/BetterSqlite.js +4 -1
  60. package/src/version.json +1 -1
@@ -1,6 +1,18 @@
1
1
  import type { DB } 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: DB | 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,EAAE,EAAiB,MAAM,2BAA2B,CAAC;AAkBnE,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,EAAE,GAAG,SAAS,CAAC;IAE1B,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,EAAE,EAAiB,MAAM,2BAA2B,CAAC;AAkBnE,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,EAAE,GAAG,SAAS,CAAC;IAE1B;;;;;;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"}
@@ -1 +1 @@
1
- {"version":3,"file":"BetterSqlite.d.ts","sourceRoot":"","sources":["../../../src/test-utils/BetterSqlite.js"],"names":[],"mappings":"AAIA;IAGE,0BAEE;IAEF,2BAEE;IAEF,yCAGE;IAEF,iCAOE;IAEF,yBAAgC,UAAK,wBAKnC;IA7BF,SAAU;CA8BX"}
1
+ {"version":3,"file":"BetterSqlite.d.ts","sourceRoot":"","sources":["../../../src/test-utils/BetterSqlite.js"],"names":[],"mappings":"AAOA;IAGE,0BAEE;IAEF,2BAEE;IAEF,yCAGE;IAEF,iCAOE;IAEF,yBAAgC,UAAK,wBAKnC;IA7BF,SAAU;CA8BX"}
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": "8.13.18",
4
+ "version": "8.14.0",
5
5
  "author": {
6
6
  "company": "Stream.io Inc",
7
7
  "name": "Stream.io Inc"
@@ -82,7 +82,7 @@
82
82
  "path": "0.12.7",
83
83
  "react-native-markdown-package": "1.8.2",
84
84
  "react-native-url-polyfill": "^2.0.0",
85
- "stream-chat": "^9.50.1",
85
+ "stream-chat": "^9.50.3",
86
86
  "use-sync-external-store": "^1.5.0"
87
87
  },
88
88
  "peerDependencies": {
@@ -67,6 +67,17 @@ const RefreshingProbe = () => {
67
67
  return <Text testID='refreshing'>{`${refreshing}`}</Text>;
68
68
  };
69
69
 
70
+ /**
71
+ * Probe that captures the context `refreshList` (the public, non-forced pull-to-refresh handler) so a
72
+ * test can invoke it directly.
73
+ */
74
+ let capturedRefreshList;
75
+ const RefreshListProbe = () => {
76
+ const { refreshing, refreshList } = useChannelsContext();
77
+ capturedRefreshList = refreshList;
78
+ return <Text testID='refreshing'>{`${refreshing}`}</Text>;
79
+ };
80
+
70
81
  class DeferredPromise {
71
82
  constructor() {
72
83
  this.promise = new Promise((resolve, reject) => {
@@ -677,12 +688,16 @@ describe('ChannelList', () => {
677
688
  });
678
689
 
679
690
  describe('connection.changed', () => {
680
- it('should keep background reconnection refreshes debounced and out of pull-to-refresh UI', async () => {
691
+ it('should force reconnection refreshes past the pull-to-refresh debounce while keeping them out of the refreshing UI', async () => {
692
+ // Regression guard: a reconnect is the sole trigger that re-watches channels on the fresh
693
+ // socket, so it must bypass the 5s pull-to-refresh throttle (`force`). Without the bypass a
694
+ // second reconnect landing inside the debounce window is dropped and its channels stay
695
+ // un-watched (frozen last message / unread) until the next reconnect > 5s later.
681
696
  useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
682
- const deferredPromise = new DeferredPromise();
683
- const dateNowSpy = jest.spyOn(Date, 'now');
684
- dateNowSpy.mockReturnValueOnce(0);
685
- dateNowSpy.mockReturnValue(6000);
697
+ const createChannelManagerSpy = jest.spyOn(chatClient, 'createChannelManager');
698
+ // Freeze the clock at t=0 for the whole mount so `lastRefresh` is seeded to 0 regardless of
699
+ // how many `Date.now()` calls the render makes.
700
+ const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0);
686
701
 
687
702
  render(
688
703
  <Chat client={chatClient}>
@@ -690,36 +705,88 @@ describe('ChannelList', () => {
690
705
  </Chat>,
691
706
  );
692
707
 
708
+ // The probe only renders once the mount query populates the list.
693
709
  await waitFor(() => {
694
710
  expect(screen.getByTestId('refreshing').children[0]).toBe('false');
695
711
  });
696
712
 
697
- const queryChannelsSpy = jest
698
- .spyOn(chatClient, 'queryChannels')
699
- .mockImplementation(() => deferredPromise.promise);
713
+ // Advance the clock 6s past mount so both reconnects observe t=6000.
714
+ dateNowSpy.mockReturnValue(6000);
700
715
 
701
- await act(async () => {
702
- dispatchConnectionChangedEvent(chatClient, false);
703
- dispatchConnectionChangedEvent(chatClient, true);
704
- await Promise.resolve();
705
- });
716
+ const channelManager = createChannelManagerSpy.mock.results[0]?.value;
717
+ // Spy (not replace) so reconnect queries still hydrate through the mocked axios response and
718
+ // keep the list — and therefore the refreshing probe — mounted.
719
+ const querySpy = jest.spyOn(chatClient, 'queryChannels');
706
720
 
721
+ // Reconnect #1 at t=6000, i.e. 6s after mount → outside the debounce window.
722
+ act(() => dispatchConnectionChangedEvent(chatClient, false));
723
+ act(() => dispatchConnectionChangedEvent(chatClient, true));
724
+ await waitFor(() => {
725
+ expect(querySpy).toHaveBeenCalledTimes(1);
726
+ });
727
+ // Let query #1 settle so the ChannelManager's in-flight guard (isLoading) clears; otherwise it,
728
+ // not the debounce, would be what drops the second query.
707
729
  await waitFor(() => {
708
- expect(queryChannelsSpy).toHaveBeenCalledTimes(1);
730
+ expect(channelManager.state.getLatestValue().pagination.isLoading).toBe(false);
709
731
  });
710
732
 
711
- await act(async () => {
712
- dispatchConnectionChangedEvent(chatClient, true);
713
- await Promise.resolve();
733
+ // Reconnect #2 at t=6000, i.e. 0ms after reconnect #1 → inside the debounce window. It fires a
734
+ // fresh query only because reconnection refreshes are forced past the throttle.
735
+ act(() => dispatchConnectionChangedEvent(chatClient, false));
736
+ act(() => dispatchConnectionChangedEvent(chatClient, true));
737
+ await waitFor(() => {
738
+ expect(querySpy).toHaveBeenCalledTimes(2);
714
739
  });
715
740
 
716
- expect(queryChannelsSpy).toHaveBeenCalledTimes(1);
741
+ // Background reconnection refreshes never surface in the pull-to-refresh UI.
717
742
  expect(screen.getByTestId('refreshing').children[0]).toBe('false');
718
743
 
744
+ await waitFor(() => {
745
+ expect(channelManager.state.getLatestValue().pagination.isLoading).toBe(false);
746
+ });
747
+ dateNowSpy.mockRestore();
748
+ });
749
+ });
750
+
751
+ describe('refreshList (pull-to-refresh)', () => {
752
+ it('should throttle a non-forced refresh that lands within the retry interval', async () => {
753
+ // Counterpart to the forced reconnect above: the public `refreshList` is NOT forced, so its
754
+ // 5s debounce must still hold — a second pull within the window of the last successful refresh
755
+ // is a no-op and fires no query.
756
+ useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
757
+ const createChannelManagerSpy = jest.spyOn(chatClient, 'createChannelManager');
758
+ const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0); // mount seeds `lastRefresh` to 0
759
+
760
+ render(
761
+ <Chat client={chatClient}>
762
+ <ChannelList {...props} List={RefreshListProbe} />
763
+ </Chat>,
764
+ );
765
+
766
+ await waitFor(() => {
767
+ expect(screen.getByTestId('refreshing').children[0]).toBe('false');
768
+ });
769
+
770
+ const channelManager = createChannelManagerSpy.mock.results[0]?.value;
771
+ const querySpy = jest.spyOn(chatClient, 'queryChannels');
772
+
773
+ // First pull at t=6000 (6s after mount → outside the window) fires a query.
774
+ dateNowSpy.mockReturnValue(6000);
775
+ await act(async () => {
776
+ await capturedRefreshList?.();
777
+ });
778
+ await waitFor(() => {
779
+ expect(querySpy).toHaveBeenCalledTimes(1);
780
+ });
781
+ await waitFor(() => {
782
+ expect(channelManager.state.getLatestValue().pagination.isLoading).toBe(false);
783
+ });
784
+
785
+ // Second pull at t=6000 (0ms later → inside the window) is throttled: no additional query.
719
786
  await act(async () => {
720
- deferredPromise.resolve([generateChannel({ id: testChannel1.channel.id })]);
721
- await deferredPromise.promise;
787
+ await capturedRefreshList?.();
722
788
  });
789
+ expect(querySpy).toHaveBeenCalledTimes(1);
723
790
 
724
791
  dateNowSpy.mockRestore();
725
792
  });
@@ -130,10 +130,14 @@ export const usePaginatedChannels = ({
130
130
  setActiveQueryType(null);
131
131
  };
132
132
 
133
- const refreshList = async ({ isBackground = false }: { isBackground?: boolean } = {}) => {
133
+ const refreshList = async ({
134
+ force = false,
135
+ isBackground = false,
136
+ }: { force?: boolean; isBackground?: boolean } = {}) => {
134
137
  const now = Date.now();
135
- // Only allow pull-to-refresh 5 seconds after last successful refresh.
136
- if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) {
138
+ // Only allow pull-to-refresh 5 seconds after last successful refresh, unless the request
139
+ // is invoked with force: true.
140
+ if (!force && now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) {
137
141
  return;
138
142
  }
139
143
 
@@ -168,9 +172,10 @@ export const usePaginatedChannels = ({
168
172
  'connection.changed',
169
173
  async (event) => {
170
174
  if (event.online) {
171
- // Reconnection refreshes should stay silent, but still share the same debounce
172
- // path as pull-to-refresh.
173
- await refreshList({ isBackground: true });
175
+ // Reconnection refreshes stay silent but must NOT be throttled by the
176
+ // pull-to-refresh debounce. This is the query that rewatches the
177
+ // channels on the fresh socket.
178
+ await refreshList({ force: true, isBackground: true });
174
179
  }
175
180
  },
176
181
  );
@@ -5,6 +5,7 @@ import { Channel, OfflineDBState } from 'stream-chat';
5
5
 
6
6
  import { useAppSettings } from './hooks/useAppSettings';
7
7
  import { useCreateChatContext } from './hooks/useCreateChatContext';
8
+ import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
8
9
  import { useIsOnline } from './hooks/useIsOnline';
9
10
  import { useMutedUsers } from './hooks/useMutedUsers';
10
11
 
@@ -22,7 +23,6 @@ import { useStreami18n } from '../../hooks/useStreami18n';
22
23
  import init from '../../init';
23
24
 
24
25
  import { NativeHandlers } from '../../native';
25
- import { OfflineDB } from '../../store/OfflineDB';
26
26
 
27
27
  import type { Streami18n } from '../../utils/i18n/Streami18n';
28
28
  import { version } from '../../version.json';
@@ -42,6 +42,50 @@ export type ChatProps = Pick<ChatContextValue, 'client'> &
42
42
  * Enables offline storage and loading for chat data.
43
43
  */
44
44
  enableOfflineSupport?: boolean;
45
+ /**
46
+ * Encrypts the offline database at rest with SQLCipher, using the key this
47
+ * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it
48
+ * unset keeps the offline database unencrypted, which is the default.
49
+ *
50
+ * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher.
51
+ * Add the following to your application's `package.json` and rebuild the native
52
+ * app - without the flag the key is accepted and then silently ignored:
53
+ *
54
+ * ```json
55
+ * { "op-sqlite": { "sqlcipher": true } }
56
+ * ```
57
+ *
58
+ * **Wrap `<Chat>` in an error boundary.** If the database cannot be opened with
59
+ * the encryption you asked for, `<Chat>` throws a {@link SqliteClientError}
60
+ * from render instead of continuing without it. The SDK deliberately takes no
61
+ * recovery action of its own - it never deletes data, and never silently falls
62
+ * back to an unencrypted or absent cache. Discriminate on `code`:
63
+ *
64
+ * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the
65
+ * key changed, or the database predates encryption). **Recommended recovery:
66
+ * `SqliteClient.deleteDatabase()`, then re-mount `<Chat>`.** The contents are a
67
+ * cache and are refetched from the server; the exception is actions queued while
68
+ * offline, which are lost - prompt the user first if that matters to you.
69
+ * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a
70
+ * launch before first unlock). The database is untouched. **Recommended
71
+ * recovery: re-mount to retry** once the key is readable - for example when the
72
+ * app next returns to the foreground.
73
+ * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key
74
+ * would be ignored and the database written in plaintext. Not recoverable at
75
+ * runtime; it needs the build flag above and a new binary. **Recommended
76
+ * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is
77
+ * persisted unencrypted.
78
+ *
79
+ * The key must be **stable for the lifetime of the database file**. There is no
80
+ * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a
81
+ * rebuild. To rotate without paying that, rotate a key-encryption key and keep the
82
+ * database key it protects unchanged (envelope encryption).
83
+ *
84
+ * Switching encryption on, or back off, leaves a database from the other mode on
85
+ * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it
86
+ * from your boundary is all that is needed.
87
+ */
88
+ getOfflineDbEncryptionKey?: () => Promise<string | undefined>;
45
89
  /**
46
90
  * Instance of Streami18n class should be provided to Chat component to enable internationalization.
47
91
  *
@@ -143,6 +187,7 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
143
187
  client,
144
188
  closeConnectionOnBackground = true,
145
189
  enableOfflineSupport = false,
190
+ getOfflineDbEncryptionKey,
146
191
  i18nInstance,
147
192
  ImageComponent = Image,
148
193
  isMessageAIGenerated,
@@ -211,23 +256,12 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
211
256
 
212
257
  const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel);
213
258
 
214
- useEffect(() => {
215
- if (!(userID && enableOfflineSupport)) {
216
- return;
217
- }
218
-
219
- const initializeDatabase = async () => {
220
- if (!client.offlineDb) {
221
- client.setOfflineDBApi(new OfflineDB({ client }));
222
- }
223
-
224
- if (client.offlineDb) {
225
- await client.offlineDb.init(userID);
226
- }
227
- };
228
-
229
- initializeDatabase();
230
- }, [userID, enableOfflineSupport, client]);
259
+ useInitializeOfflineDb({
260
+ client,
261
+ enabled: enableOfflineSupport,
262
+ options: { getEncryptionKey: getOfflineDbEncryptionKey },
263
+ userID,
264
+ });
231
265
 
232
266
  useEffect(() => {
233
267
  if (!client) {
@@ -0,0 +1,248 @@
1
+ import React, { PropsWithChildren } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { cleanup, render, waitFor } from '@testing-library/react-native';
5
+
6
+ import { sqliteMock } from '../../../mock-builders/DB/mock';
7
+ import { getTestClientWithUser } from '../../../mock-builders/mock';
8
+ import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient';
9
+ import { Chat } from '../Chat';
10
+
11
+ describe('Chat offline DB encryption', () => {
12
+ const installedSpies: jest.SpyInstance[] = [];
13
+
14
+ /**
15
+ * Registers a spy for teardown. Deliberately not jest.restoreAllMocks(): that also
16
+ * restores the connection privates mockClient() stubs out on every client created
17
+ * by earlier tests in this file, after which those clients reconnect for real and
18
+ * the failed websocket handshake resurfaces as an unhandled error somewhere else.
19
+ */
20
+ const track = <T extends jest.SpyInstance>(spy: T): T => {
21
+ installedSpies.push(spy);
22
+ return spy;
23
+ };
24
+
25
+ /**
26
+ * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the
27
+ * foreground. Left real, that connection attempt outlives the test and rejects
28
+ * asynchronously. Nothing in this block needs a connection.
29
+ */
30
+ const createClient = async () => {
31
+ const client = await getTestClientWithUser({ id: 'testID' });
32
+ track(jest.spyOn(client, 'openConnection').mockResolvedValue(undefined));
33
+ track(jest.spyOn(client, 'closeConnection').mockResolvedValue(undefined));
34
+ return client;
35
+ };
36
+
37
+ /** Minimal error boundary, since `<Chat>` reports encryption failures by throwing. */
38
+ class Boundary extends React.Component<
39
+ PropsWithChildren<{ onCatch: (error: Error) => void }>,
40
+ { caught: boolean }
41
+ > {
42
+ state = { caught: false };
43
+
44
+ static getDerivedStateFromError() {
45
+ return { caught: true };
46
+ }
47
+
48
+ componentDidCatch(error: Error) {
49
+ this.props.onCatch(error);
50
+ }
51
+
52
+ render() {
53
+ return this.state.caught ? <View testID='boundary' /> : this.props.children;
54
+ }
55
+ }
56
+
57
+ afterEach(() => {
58
+ cleanup();
59
+ installedSpies.splice(0).forEach((spy) => spy.mockRestore());
60
+ SqliteClient.getEncryptionKey = undefined;
61
+ });
62
+
63
+ it('does not configure an encryption key when the prop is omitted', async () => {
64
+ const chatClientWithUser = await createClient();
65
+
66
+ render(<Chat client={chatClientWithUser} enableOfflineSupport />);
67
+
68
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
69
+ expect(SqliteClient.getEncryptionKey).toBeUndefined();
70
+ });
71
+
72
+ it('forwards getOfflineDbEncryptionKey to the sqlite client', async () => {
73
+ const chatClientWithUser = await createClient();
74
+ const getOfflineDbEncryptionKey = jest.fn().mockResolvedValue('a-stable-key');
75
+
76
+ render(
77
+ <Chat
78
+ client={chatClientWithUser}
79
+ enableOfflineSupport
80
+ getOfflineDbEncryptionKey={getOfflineDbEncryptionKey}
81
+ />,
82
+ );
83
+
84
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
85
+ await waitFor(() => expect(getOfflineDbEncryptionKey).toHaveBeenCalled());
86
+ });
87
+
88
+ it('does not re-initialize when getOfflineDbEncryptionKey is a new function every render', async () => {
89
+ const chatClientWithUser = await createClient();
90
+ const resolveKey = jest.fn().mockResolvedValue('a-stable-key');
91
+
92
+ // An inline arrow is the shape integrators reach for first, so a changing
93
+ // identity must not restart initialization on every render.
94
+ const { rerender } = render(
95
+ <Chat
96
+ client={chatClientWithUser}
97
+ enableOfflineSupport
98
+ getOfflineDbEncryptionKey={() => resolveKey()}
99
+ />,
100
+ );
101
+
102
+ await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined());
103
+ const initSpy = track(jest.spyOn(chatClientWithUser.offlineDb!, 'init'));
104
+
105
+ rerender(
106
+ <Chat
107
+ client={chatClientWithUser}
108
+ enableOfflineSupport
109
+ getOfflineDbEncryptionKey={() => resolveKey()}
110
+ />,
111
+ );
112
+ rerender(
113
+ <Chat
114
+ client={chatClientWithUser}
115
+ enableOfflineSupport
116
+ getOfflineDbEncryptionKey={() => resolveKey()}
117
+ />,
118
+ );
119
+
120
+ await waitFor(() => expect(initSpy).not.toHaveBeenCalled());
121
+ });
122
+
123
+ it.each<[string, () => Promise<string | undefined>, string]>([
124
+ ['the key cannot be read', () => Promise.resolve(undefined), 'ENCRYPTION_KEY_UNAVAILABLE'],
125
+ [
126
+ 'the key getter throws',
127
+ () => Promise.reject(new Error('keychain is locked')),
128
+ 'ENCRYPTION_KEY_UNAVAILABLE',
129
+ ],
130
+ ])('throws %s so an error boundary can decide', async (_label, getKey, code) => {
131
+ const chatClientWithUser = await createClient();
132
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
133
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
134
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
135
+ const onCatch = jest.fn();
136
+
137
+ const { getByTestId } = render(
138
+ <Boundary onCatch={onCatch}>
139
+ <Chat client={chatClientWithUser} enableOfflineSupport getOfflineDbEncryptionKey={getKey}>
140
+ <View testID='children' />
141
+ </Chat>
142
+ </Boundary>,
143
+ );
144
+
145
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
146
+ expect(onCatch).toHaveBeenCalledWith(expect.any(SqliteClientError));
147
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe(code);
148
+ // Never silently downgraded to online-only.
149
+ expect(() => getByTestId('children')).toThrow();
150
+ });
151
+
152
+ it('throws when the native build has no SQLCipher', async () => {
153
+ const chatClientWithUser = await createClient();
154
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
155
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
156
+ track(jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false));
157
+ const onCatch = jest.fn();
158
+
159
+ const { getByTestId } = render(
160
+ <Boundary onCatch={onCatch}>
161
+ <Chat
162
+ client={chatClientWithUser}
163
+ enableOfflineSupport
164
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
165
+ />
166
+ </Boundary>,
167
+ );
168
+
169
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
170
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('SQLCIPHER_BUILD_MISSING');
171
+ });
172
+
173
+ it('throws OFFLINE_DB_UNREADABLE without deleting the database', async () => {
174
+ const chatClientWithUser = await createClient();
175
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
176
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
177
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
178
+ // Preflight passes, then the first read of the file fails to decrypt.
179
+ track(
180
+ jest
181
+ .spyOn(SqliteClient, 'getUserPragmaVersion')
182
+ .mockRejectedValue(new Error('Querying for user_version failed: file is not a database')),
183
+ );
184
+ const deleteSpy = track(jest.spyOn(SqliteClient, 'deleteDatabase'));
185
+ const onCatch = jest.fn();
186
+
187
+ const { getByTestId } = render(
188
+ <Boundary onCatch={onCatch}>
189
+ <Chat
190
+ client={chatClientWithUser}
191
+ enableOfflineSupport
192
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
193
+ />
194
+ </Boundary>,
195
+ );
196
+
197
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
198
+ expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('OFFLINE_DB_UNREADABLE');
199
+ // Wiping is the integrator's decision, made from the boundary.
200
+ expect(deleteSpy).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it('never attaches an offline DB it cannot open', async () => {
204
+ const chatClientWithUser = await createClient();
205
+ track(jest.spyOn(console, 'warn').mockImplementation(() => undefined));
206
+ track(jest.spyOn(console, 'error').mockImplementation(() => undefined));
207
+ track(jest.spyOn(console, 'log').mockImplementation(() => undefined));
208
+ const setOfflineDBApiSpy = track(jest.spyOn(chatClientWithUser, 'setOfflineDBApi'));
209
+
210
+ const { getByTestId } = render(
211
+ <Boundary onCatch={() => undefined}>
212
+ <Chat
213
+ client={chatClientWithUser}
214
+ enableOfflineSupport
215
+ getOfflineDbEncryptionKey={() => Promise.resolve(undefined)}
216
+ />
217
+ </Boundary>,
218
+ );
219
+
220
+ await waitFor(() => expect(getByTestId('boundary')).toBeTruthy());
221
+
222
+ // Parts of the client write through `client.offlineDb` without checking that it
223
+ // initialized - queryChannels upserts into it - so an instance we cannot open
224
+ // would turn those writes into rejections.
225
+ expect(setOfflineDBApiSpy).not.toHaveBeenCalled();
226
+ expect(chatClientWithUser.offlineDb).toBeUndefined();
227
+ });
228
+
229
+ it('renders normally when nothing is wrong with encryption', async () => {
230
+ const chatClientWithUser = await createClient();
231
+ const onCatch = jest.fn();
232
+
233
+ const { getByTestId } = render(
234
+ <Boundary onCatch={onCatch}>
235
+ <Chat
236
+ client={chatClientWithUser}
237
+ enableOfflineSupport
238
+ getOfflineDbEncryptionKey={() => Promise.resolve('a-stable-key')}
239
+ >
240
+ <View testID='children' />
241
+ </Chat>
242
+ </Boundary>,
243
+ );
244
+
245
+ await waitFor(() => expect(getByTestId('children')).toBeTruthy());
246
+ expect(onCatch).not.toHaveBeenCalled();
247
+ });
248
+ });