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
50 changes: 34 additions & 16 deletions examples/SampleApp/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { ChatScreen } from './src/screens/ChatScreen';
import { GroupChannelDetailsScreen } from './src/screens/GroupChannelDetailsScreen';
import { LoadingScreen } from './src/screens/LoadingScreen';
import { MenuDrawer } from './src/components/MenuDrawer';
import { OfflineDbBoundary } from './src/components/OfflineDbBoundary';
import { NewDirectMessagingScreen } from './src/screens/NewDirectMessagingScreen';
import { NewGroupChannelAddMemberScreen } from './src/screens/NewGroupChannelAddMemberScreen';
import { NewGroupChannelAssignNameScreen } from './src/screens/NewGroupChannelAssignNameScreen';
Expand Down Expand Up @@ -269,26 +270,43 @@ const DrawerNavigatorWrapper: React.FC<{
const streamChatTheme = useStreamChatTheme();
const streami18n = new Streami18n();

// `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);

return (
<GestureHandlerRootView style={{ flex: 1 }}>
<OverlayProvider value={{ style: streamChatTheme }} i18nInstance={streami18n}>
<Chat
client={chatClient}
enableOfflineSupport
// @ts-expect-error - the `ImageComponent` prop is generic, meaning we can expect an error
ImageComponent={FastImage}
isMessageAIGenerated={isMessageAIGenerated}
i18nInstance={streami18n}
{/*
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.
*/}
<OfflineDbBoundary
key={`${attempt}-${offlineSupport}`}
onGiveUp={() => setOfflineSupport(false)}
onRetry={() => setAttempt((value) => value + 1)}
>
<StreamChatProvider>
<AppOverlayProvider>
<UserSearchProvider>
<DrawerNavigator />
<Toast />
</UserSearchProvider>
</AppOverlayProvider>
</StreamChatProvider>
</Chat>
<Chat
client={chatClient}
enableOfflineSupport={offlineSupport}
// @ts-expect-error - the `ImageComponent` prop is generic, meaning we can expect an error
ImageComponent={FastImage}
isMessageAIGenerated={isMessageAIGenerated}
i18nInstance={streami18n}
>
<StreamChatProvider>
<AppOverlayProvider>
<UserSearchProvider>
<DrawerNavigator />
<Toast />
</UserSearchProvider>
</AppOverlayProvider>
</StreamChatProvider>
</Chat>
</OfflineDbBoundary>
</OverlayProvider>
</GestureHandlerRootView>
);
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 @@ -5,6 +5,7 @@ import { Channel, OfflineDBState } from 'stream-chat';

import { useAppSettings } from './hooks/useAppSettings';
import { useCreateChatContext } from './hooks/useCreateChatContext';
import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
import { useIsOnline } from './hooks/useIsOnline';
import { useMutedUsers } from './hooks/useMutedUsers';

Expand All @@ -22,7 +23,6 @@ import { useStreami18n } from '../../hooks/useStreami18n';
import init from '../../init';

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

import type { Streami18n } from '../../utils/i18n/Streami18n';
import { version } from '../../version.json';
Expand All @@ -42,6 +42,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>;
/**
* Instance of Streami18n class should be provided to Chat component to enable internationalization.
*
Expand Down Expand Up @@ -143,6 +187,7 @@ const ChatWithContext = (props: PropsWithChildren<ChatProps>) => {
client,
closeConnectionOnBackground = true,
enableOfflineSupport = false,
getOfflineDbEncryptionKey,
i18nInstance,
ImageComponent = Image,
isMessageAIGenerated,
Expand Down Expand Up @@ -211,23 +256,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 }));
}

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

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

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