Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions examples/SampleApp/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from 'stream-chat-react-native';

import { MenuDrawer } from './src/components/MenuDrawer';
import { OfflineDbBoundary } from './src/components/OfflineDbBoundary';
import { useSampleAppComponentOverrides } from './src/components/SampleAppComponentOverrides';
import {
MessageInputFloatingConfigItem,
Expand Down Expand Up @@ -334,20 +335,35 @@ const DrawerNavigatorWrapper: React.FC<{
chatClient: StreamChat;
i18nInstance: Streami18n;
}> = ({ chatClient, i18nInstance }) => {
// `attempt` re-mounts <Chat> after the offline database has been deleted;
// `offlineSupport` is switched off once there is no usable encryption key.
const [attempt, setAttempt] = useState(0);
const [offlineSupport, setOfflineSupport] = useState(true);

// The boundary stops rendering its children once it has caught (see its render), and
// nothing else clears that. Keying it on both recovery levers re-mounts it when one is
// pulled - without that it would sit on a blank screen forever, having already deleted
// the database.
return (
<Chat
client={chatClient}
enableOfflineSupport
isMessageAIGenerated={isMessageAIGenerated}
i18nInstance={i18nInstance}
useNativeMultipartUpload
<OfflineDbBoundary
key={`${attempt}-${offlineSupport}`}
onGiveUp={() => setOfflineSupport(false)}
onRetry={() => setAttempt((value) => value + 1)}
>
<StreamChatProvider>
<UserSearchProvider>
<DrawerNavigator />
</UserSearchProvider>
</StreamChatProvider>
</Chat>
<Chat
client={chatClient}
enableOfflineSupport={offlineSupport}
i18nInstance={i18nInstance}
isMessageAIGenerated={isMessageAIGenerated}
useNativeMultipartUpload
>
<StreamChatProvider>
<UserSearchProvider>
<DrawerNavigator />
</UserSearchProvider>
</StreamChatProvider>
</Chat>
</OfflineDbBoundary>
);
};

Expand Down
10 changes: 5 additions & 5 deletions examples/SampleApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ PODS:
- React-utils
- ReactNativeDependencies
- Yoga
- React-Core-prebuilt (0.86.0):
- React-Core-prebuilt (0.86.2):
- ReactNativeDependencies
- React-Core/CoreModulesHeaders (0.86.2):
- hermes-engine
Expand Down Expand Up @@ -2857,7 +2857,7 @@ PODS:
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- stream-chat-react-native (9.7.2):
- stream-chat-react-native (9.7.6):
- hermes-engine
- RCTRequired
- RCTTypeSafety
Expand Down Expand Up @@ -3291,7 +3291,7 @@ SPEC CHECKSUMS:
GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
hermes-engine: 188393eb43a0cce2dfbf912e6d22c7bb6469957d
hermes-engine: 3730f5b467f988fa954ded67cbea8a9ba32d854c
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Expand All @@ -3309,7 +3309,7 @@ SPEC CHECKSUMS:
React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f
React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92
React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948
React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c
React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85
React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e
React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6
React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8
Expand Down Expand Up @@ -3399,7 +3399,7 @@ SPEC CHECKSUMS:
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d
stream-chat-react-native: ea3499916019c499ecb745be61e7ecf82f0fd999
Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a
Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801

Expand Down
72 changes: 72 additions & 0 deletions examples/SampleApp/src/components/OfflineDbBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';

import {
SqliteClient,
SqliteClientError,
type SqliteClientErrorCode,
} from 'stream-chat-react-native';

/**
* `<Chat>` throws a {@link SqliteClientError} from render when it cannot open the
* offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk
* cannot be read (corruption, or a database left behind from a different encryption
* mode). It never silently continues without the cache; recovery is the application's
* decision.
*
* The recommended recovery, shown here: the contents are a cache, so delete the
* database and let it rebuild from the server. The only real loss is actions that were
* queued while offline, so a real app may want to confirm with the user first.
*
* The `onGiveUp` path covers the codes that mean "no usable encryption key"
* (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when
* `<Chat>` is given a `getOfflineDbEncryptionKey` prop, which this sample does not do -
* a new database would then be written in plaintext, so running online-only is the safe
* response.
*/
type BoundaryProps = React.PropsWithChildren<{
onGiveUp: () => void;
onRetry: () => void;
}>;

type BoundaryState = { code?: SqliteClientErrorCode };

export class OfflineDbBoundary extends React.Component<BoundaryProps, BoundaryState> {
state: BoundaryState = {};

// Must return state, and render() must stop rendering the failing subtree. Returning
// null here would re-render the same children, they would throw again, and React
// would give up and unmount the whole app.
static getDerivedStateFromError(error: unknown) {
if (!(error instanceof SqliteClientError)) {
// Not one of ours - re-throw so it reaches whatever boundary owns it.
throw error;
}
return { code: error.code };
}

componentDidCatch(error: unknown) {
if (!(error instanceof SqliteClientError)) {
return;
}

if (error.code === 'OFFLINE_DB_UNREADABLE') {
// The recommended recovery: the contents are a cache, so drop the database and
// let it rebuild. Only actions queued while offline are lost.
try {
SqliteClient.deleteDatabase();
} catch (deleteError) {
console.warn('[SampleApp] could not delete the offline database', deleteError);
}
this.props.onRetry();
return;
}

// No usable key, so a new database would be plaintext. Run online-only instead.
console.warn(`[SampleApp] offline encryption unavailable (${error.code}); going online-only`);
this.props.onGiveUp();
}

render() {
return this.state.code ? null : this.props.children;
}
}
70 changes: 52 additions & 18 deletions package/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Channel, OfflineDBState } from 'stream-chat';
import { useClientMutedUsers } from './hooks';
import { useAppSettings } from './hooks/useAppSettings';
import { useCreateChatContext } from './hooks/useCreateChatContext';
import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
import { useIsOnline } from './hooks/useIsOnline';

import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext';
Expand All @@ -24,7 +25,6 @@ import init from '../../init';

import { NativeHandlers } from '../../native';
import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants';
import { OfflineDB } from '../../store/OfflineDB';

import type { Streami18n } from '../../utils/i18n/Streami18n';
import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter';
Expand All @@ -45,6 +45,50 @@ export type ChatProps = Pick<ChatContextValue, 'client'> &
* Enables offline storage and loading for chat data.
*/
enableOfflineSupport?: boolean;
/**
* Encrypts the offline database at rest with SQLCipher, using the key this
* resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it
* unset keeps the offline database unencrypted, which is the default.
*
* Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher.
* Add the following to your application's `package.json` and rebuild the native
* app - without the flag the key is accepted and then silently ignored:
*
* ```json
* { "op-sqlite": { "sqlcipher": true } }
* ```
*
* **Wrap `<Chat>` in an error boundary.** If the database cannot be opened with
* the encryption you asked for, `<Chat>` throws a {@link SqliteClientError}
* from render instead of continuing without it. The SDK deliberately takes no
* recovery action of its own - it never deletes data, and never silently falls
* back to an unencrypted or absent cache. Discriminate on `code`:
*
* - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the
* key changed, or the database predates encryption). **Recommended recovery:
* `SqliteClient.deleteDatabase()`, then re-mount `<Chat>`.** The contents are a
* cache and are refetched from the server; the exception is actions queued while
* offline, which are lost - prompt the user first if that matters to you.
* - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a
* launch before first unlock). The database is untouched. **Recommended
* recovery: re-mount to retry** once the key is readable - for example when the
* app next returns to the foreground.
* - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key
* would be ignored and the database written in plaintext. Not recoverable at
* runtime; it needs the build flag above and a new binary. **Recommended
* recovery: re-mount with `enableOfflineSupport={false}`** so nothing is
* persisted unencrypted.
*
* The key must be **stable for the lifetime of the database file**. There is no
* rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a
* rebuild. To rotate without paying that, rotate a key-encryption key and keep the
* database key it protects unchanged (envelope encryption).
*
* Switching encryption on, or back off, leaves a database from the other mode on
* disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it
* from your boundary is all that is needed.
*/
getOfflineDbEncryptionKey?: () => Promise<string | undefined>;
/**
* Optional positive cap on the number of events a single `/sync` response may
* contain before the offline sync manager skips replaying those events into
Expand Down Expand Up @@ -172,6 +216,7 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
client,
closeConnectionOnBackground = true,
enableOfflineSupport = false,
getOfflineDbEncryptionKey,
i18nInstance,
isMessageAIGenerated,
maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT,
Expand Down Expand Up @@ -241,23 +286,12 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {

const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel);

useEffect(() => {
if (!(userID && enableOfflineSupport)) {
return;
}

const initializeDatabase = async () => {
if (!client.offlineDb) {
client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit }));
}

if (client.offlineDb) {
await client.offlineDb.init(userID);
}
};

initializeDatabase();
}, [userID, enableOfflineSupport, client, maxSyncEventsLimit]);
useInitializeOfflineDb({
client,
enabled: enableOfflineSupport,
options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit },
userID,
});

useEffect(() => {
if (!client) {
Expand Down
Loading
Loading