npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

xmtp-chat-rn

v0.0.6

Published

XMTP 1:1 messaging for React Native — client lifecycle, hooks, and chat primitives.

Downloads

718

Readme

xmtp-chat-rn

npm CI license

Wallet-native 1:1 messaging for React Native, built on XMTP: client lifecycle, hooks, and chat primitives.

If your app already has wallets, this gives you chat without a messaging vendor. Identity is the wallet — no user directory to sync, no separate auth. Messages are end-to-end encrypted with MLS, so nothing in the middle can read them, including you. And because XMTP is a protocol rather than a product, your users are reachable from other XMTP clients, not only from your app.

Whether that last point is a feature or a surprise depends on what you are building. It is the honest difference between this and a hosted chat API, and worth deciding about before adopting either.

Scope

Direct messages between two wallets. Groups are not supported — the client, the hooks, the inbox listing and the notification stream all assume exactly one counterparty per conversation, and adding groups means changing all four rather than adding a surface.

What is here: client lifecycle (creation, per-address idempotence, installation-cap recovery, wedged-MLS reset), the conversation and unread hooks, optimistic send with delivery state, replies, reactions and read receipts, consent-based blocking, attachments over host-supplied storage, background push registration, a registry for a host's own content types, and seven chat components (bubble meta, quoted message, reaction pills, swipe-to-reply, scroll-to-latest, message actions, failed-send notice).

What is not: a chat screen. The components are primitives a host composes; the screen shell, the bubble bodies and any product-specific banners stay in the host.

Compared to a hosted chat API

Stream and Sendbird will do things this does not. They ship groups, typing indicators, moderation, search and threads, plus a dashboard and a support contract. If you need those, buy them — this is not a drop-in replacement and pretending otherwise wastes your time.

What they cannot do is let someone message your user from a different app, or avoid holding your users' messages, or bill nothing per monthly active user.

Two costs this does not remove. Attachments need storage you run — the package encrypts them and speaks XMTP's standard format, but the ciphertext has to live somewhere, and you pay for that bucket. And background push needs a server: a hosted API bundles it, whereas XMTP push means running something that listens and forwards. "No per-MAU vendor" is the accurate claim, not "no infrastructure".

Getting started

Three steps: configure once at startup, render a thread from the hook, and give the package your content types if you have any.

1. Configure

Call these once at module scope in your entry file, before anything renders. xmtpConfig() throws if a hook reaches it unconfigured — an unconfigured client would register against the wrong XMTP network and build an inbox nobody can reach, so this fails loudly rather than silently.

import { configureXmtpChat, configureChatTheme, configureXmtpPush } from 'xmtp-chat-rn';

configureXmtpChat({
  env: 'production',        // 'dev' | 'production' | 'local'
  enabled: true,            // your feature flag; false disables the unread count
  cards: MY_CARD_TYPES,     // [] if you have no custom content types
  devInstallationPrune: __DEV__, // optional, see Installation cap
});

configureChatTheme({ colors: { accent: '#3b6ea5' } });   // optional
configureXmtpPush({ serverUrl, probeUrl });              // optional, see Push

2. Create a client

You build the XMTP Signer; the package holds no wallet dependencies. address travels alongside it because per-address idempotence is checked before any await, and getIdentifier() is async.

import { getOrCreateXmtpClient, dropXmtpClient } from 'xmtp-chat-rn';

await getOrCreateXmtpClient({ address, signer });   // idempotent per address
await dropXmtpClient();                             // on sign-out

Repeat calls for the same address share one in-flight client. A call for a different address tears the previous one down first — without that, two concurrent sign-ins burn two of XMTP's ten per-inbox installation slots.

When creation fails

Creation is one signature-bearing async step, and it fails for reasons outside your app: a network drop, a wallet signature that stalls or is declined, the installation cap below. The package keeps the failure and the identity behind it, so any surface can recover without re-supplying a signer:

import { useXmtpClientStatus } from 'xmtp-chat-rn';

const { status, retry } = useXmtpClientStatus();
if (status.state === 'failed') {
  return <Retry onPress={retry} />;       // status.error has the reason, for logs
}

status.state is idle (nothing requested, or signed out), initializing, ready or failed. retry (also retryXmtpClient()) re-runs creation for the identity that failed, joins an attempt already in flight, and resolves null rather than rejecting when it fails again. Without it, a failed creation stays failed until the app restarts.

A creation that hangs rather than fails — a signature request nobody answers, a network call that never returns — is abandoned after clientCreateTimeoutMs (default 60 000; null disables it). The attempt rejects with XmtpClientCreateTimeoutError (recognise it with isXmtpClientCreateTimeoutError), status turns failed, and the next call or retry starts a fresh attempt. If the abandoned attempt succeeds later and nothing has started since, its client is adopted and announced; if a newer attempt exists, the late client is ignored.

Deciding when to retry unprompted is yours: creation may need a signature, and for a wallet that signs in another app, an automatic retry is an app switch nobody asked for.

Hang post-create wiring on readiness, not on your own call. Anything that needs the client — message notifications, push registration — belongs in onXmtpClientReady, which runs for every client that comes up, whichever surface started or retried it:

onXmtpClientReady((client, address) => {
  startMessageNotifications(client);
  registerPush(client, address);
});

Wiring placed after await getOrCreateXmtpClient(...) runs only for that one call, so a client a retry recovers comes up without it. Subscribe at startup, before creating a client — one that is already ready is not replayed.

Installation cap

XMTP allows ten installations per inbox, and each wiped-database reinstall (an emulator pm clear, a fresh simulator) registers a new one, so a dev wallet eventually hits the cap and Client.create rejects with already registered 10/10 installations. With devInstallationPrune: true, the package recovers from exactly that failure: it revokes the inbox's oldest installation (by createdAt) — only as many as free one slot — and retries create once. Leave it off in production builds: revocation needs a wallet signature, and the oldest installation may be the user's other phone.

A sign-in that succeeds never revokes anything, on any configuration. A revoked installation keeps working locally and appears to send, but every recipient drops its messages, so revoking another device's installation is silent, total message loss for that device.

The XMTP SDK's JS layer reads a global Buffer while encoding the signature it hands to the native client, and Hermes does not provide one. Install it in your entry file, before anything else imports the SDK:

import { Buffer } from 'buffer';
if (typeof (globalThis as any).Buffer === 'undefined') {
  (globalThis as any).Buffer = Buffer;
}

Without it, client creation fails and the SDK reports it as User rejected signature — which sends you looking at your wallet rather than at the runtime. Some apps get a Buffer transitively (react-native-quick-crypto provides one); do not rely on that.

3. Render a thread

import { useConversation, describeMessage, ReactionPills, BubbleMeta } from 'xmtp-chat-rn';

const { messages, reactions, send, toggleReaction, isLoading } = useConversation(peerAddress);

// messages is newest-first, ready for an inverted FlatList.
// Each carries `kind`; yours will be 'text' plus whatever cards you registered.

The package returns data and primitives, not a screen — you compose the shell, the bubble bodies and any product-specific banners.

Inbox and unread

const { conversations, refresh, reload } = useConversations();
const unread = useUnreadCount();

ConversationSummary.last is a MessageDescription, not a string — the package renders no copy. Map it in your own layer:

function previewText(d: MessageDescription): string {
  switch (d.kind) {
    case 'text':     return d.text;
    case 'reaction': return d.fromMe ? `You reacted ${d.emoji}` : `Reacted ${d.emoji}`;
    case 'card':     return d.preview ?? d.fallback;
    case 'none':     return '';
  }
}

Use isPreviewable(d) for "is there anything to show" — an un-reaction and a card with no fallback both describe as nothing, which is what keeps an unread dot from appearing beside a blank row.

Read receipts

Off unless you ask for them:

configureXmtpChat({ env: 'production', enabled: true, cards: [], readReceipts: true });

With the flag on, opening a thread that has something unread in it — or reading a message that arrives while it is open — sends XMTP's readReceipt to the counterparty. Telling someone when you read their message is a product decision with a privacy cost and cannot be taken back per message, so the default is the quiet one.

Receiving is not gated by the flag. A counterparty's receipt always decodes and promotes your own text bubbles to delivery: 'read', which BubbleMeta renders as an accent-coloured double check. Receipts never appear in the thread, never count toward unread, and never wake the device (the wire type sets shouldPush: false). A receipt sent by a counterparty covers everything sent before it, so it promotes the whole prefix rather than one bubble; card messages keep no delivery state, so read shows on text bubbles only.

Custom content types

A card is one CardType descriptor; pass the list to configureXmtpChat.

const invoice: CardType<'invoice', Invoice, 'invoice'> = {
  kind: 'invoice',                 // the ChatMessage discriminant
  payloadKey: 'invoice',           // the property the payload renders under
  codec: new InvoiceCodec(),       // your JSContentCodec
  is: (m) => m.contentTypeId?.startsWith('acme.example/invoice') ?? false,
  isValid: (c): c is Invoice => !!c && typeof (c as Invoice).total === 'string',
  // Optional. Omit `preview` and the codec's own text fallback is used; omit
  // `notification` and the card is silent, which is right for a passive card
  // that rides along with a message already carrying the push.
};

ChatMessage<Cards, Extra> derives its bubble union from the registry, so a payload-key typo fails typecheck at the site that reads it. Send one with sendCard(peerAddress, invoice, payload).

Attachments

Attachments use XMTP's standard remote attachment, so any XMTP client can open the ones you send and you can open theirs. The file is encrypted on the device; you supply where the ciphertext is stored.

Everything below needs attachments configured. Without it, sendAttachment and openAttachment throw AttachmentsNotConfiguredError, and an inbound remote attachment from another client degrades rather than appearing as a kind this app can't render: in the thread it's a kind: 'text' bubble carrying the codec's fallback string, and in the inbox it's { kind: 'card', cardKind: 'remoteAttachment', preview: null, fallback } — the same treatment an inline static attachment (sent by another client) always gets. This is what lets a host upgrade to a version of this package that supports attachments without wiring up storage on day one, and without its existing bubble/inbox code crashing on a kind it has never seen.

import { configureXmtpChat, createPresignedPutUploader } from 'xmtp-chat-rn';
import { File, Paths } from 'expo-file-system';

configureXmtpChat({
  // ...
  attachments: {
    // Your server returns a signed PUT for the object key; never ship bucket
    // credentials in the app. `file.byteLength` is the native SDK's reported
    // size, which is the PLAINTEXT attachment's size, not the ciphertext's —
    // the ciphertext PUT to `uploadUrl` is somewhat larger (the encoded-
    // content wrapper plus the GCM auth tag). Treat it as approximately the
    // stored size; do not have your presign server sign an exact
    // `Content-Length` computed from it, or the PUT will fail the signature.
    upload: createPresignedPutUploader((file) =>
      api.post('/attachments/presign', { key: file.contentDigest, bytes: file.byteLength }),
    ),
    download: async (url) => (await File.downloadFileAsync(url, Paths.cache, { idempotent: true })).uri,
    maxBytes: 25_000_000, // the default
  },
});

The download line targets the expo-file-system API that ships with Expo 55; any function that writes the URL to a local file and returns its file:// URI works. downloadFileAsync names the local file after the URL's last path segment, which is fine when your URL is keyed by digest or CID (unique per file) but will collide if yours isn't — use a destination you know is unique in that case.

Both createPresignedPutUploader and createProxyUploader bound their upload fetch with timeoutMs, defaulting to DEFAULT_UPLOAD_TIMEOUT_MS (60s) so a hung upload fails the bubble instead of leaving it pending forever; pass 0 or a negative value to disable it. (createIpfsUploader's pin is your own function, so it isn't bounded here — apply your own timeout inside it if you want one.)

Send from the thread hook, and render with useAttachment:

const { sendAttachment } = useConversation(peerAddress);
const [asset] = (await launchImageLibraryAsync()).assets; // any picker that reports a size works
// `byteLength` — the picker's own reported plaintext size (`fileSize` on an
// expo-image-picker asset) — is what makes `maxBytes` a real memory guard:
// with it, an oversized pick is rejected before native encryption reads the
// whole file into memory. Omit it and the limit still applies, just later.
await sendAttachment({
  fileUri: asset.uri,
  mimeType: asset.mimeType ?? 'image/jpeg',
  filename: asset.fileName ?? 'photo.jpg',
  byteLength: asset.fileSize,
});

function AttachmentBubble({ message }) {
  const { status, load } = useAttachment(message.attachment);
  const file = message.localFile ?? (status.state === 'ready' ? status.file : null);
  const mimeType = message.localFile?.mimeType ?? (status.state === 'ready' ? status.file.mimeType : undefined);
  // The filename comes from the MESSAGE, not the decrypted file: the native
  // SDK writes whatever filename was baked into the ciphertext at encryption
  // time — the picker's temp name — into `status.file.filename`, regardless
  // of what we put on the wire. `message.attachment.filename` (or, before
  // upload finishes, `message.localFile.filename`) is the one the sender
  // actually picked.
  const filename = message.attachment?.filename ?? message.localFile?.filename;
  if (file && mimeType?.startsWith('image/')) return <Image source={{ uri: file.fileUri }} />;
  if (file) return <FileRow filename={filename} />; // any non-image type
  if (status.state === 'failed') return <Retry onPress={load} />;
  return <Spinner />;
}

Branch on mime type, not on whether a URI exists: <Image> only makes sense for image/*, and other types (PDFs, audio, arbitrary files) need a file row showing the filename instead. A message's own localFile and a loaded status.file both carry mimeType, so the check works before and after upload finishes. A pending attachment's id is a local id (not yet the network message id), same as pending text — don't offer reactions or replies on it until it reconciles.

sendAttachment behaves like send: a pending bubble at once, failed with tap-to-retry on a network error (a retry reuses the finished upload). The two rejections are AttachmentTooLargeError and AttachmentsNotConfiguredError (no attachments configured), both of which also remove the bubble — neither is fixed by retrying.

maxBytes is a real memory guard only when you pass byteLength on the LocalAttachmentFile — the plaintext size a picker like expo-image-picker reports as fileSize. With it, an oversized pick is rejected before sendAttachment ever reaches native encryption. Without it, the only check left runs AFTER the native SDK has already encrypted the file — which means it already read the whole thing into memory — so maxBytes is then just a backstop, not something that stops a large pick (video, a big PDF) early.

Your storage URL must be:

  1. https:// — the SDK accepts no other scheme, so IPFS goes through a gateway.
  2. Permanent — a presigned GET expires within 7 days and breaks old messages.
  3. Readable without auth headers — other clients send a bare GET.
  4. CORS-open to GET from any origin — browser clients such as xmtp.chat cannot fetch it otherwise.

What storage can see

The stored file is ciphertext. Each file gets its own random key (AES-256-GCM), and that key travels only inside the XMTP message, which MLS encrypts to the conversation. So the storage provider, and anyone who finds the URL, can download the file but not read it.

Public ciphertext still reveals the file's size, when it was uploaded, and whatever your storage account and the uploader's IP address tie it to. The filename and type are not exposed; they travel inside the encrypted message.

What the sender can learn

useAttachment's default (autoLoad: true) means the recipient's device fetches whatever https URL a message names as soon as the bubble mounts — with no tap, and independent of read receipts. A sender who controls that URL's server (their own bucket, or an IPFS gateway they operate) therefore learns the recipient's IP address and roughly when they opened the thread, even with read receipts off and even if the recipient never "reads" the message in any UI sense. This is the same tracking-pixel shape as email, just over a storage GET instead of an <img> fetch.

Pass autoLoad: false (and gate loading behind a tap) for threads with a sender the user hasn't accepted, or more broadly whenever the app can't vouch for who controls the storage a given attachment names.

Separately: the native SDK writes each decrypted file to the OS temp directory on every open, and nothing in this package deletes it. A host that wants decrypted plaintext not to outlive the session should clean up the fileUri a status.state === 'ready' result names.

S3/R2 or IPFS

S3 or R2 (recommended). Deleting the object revokes access for everyone, even if a message key later leaks from a compromised device. R2 charges no egress, which matters because every recipient downloads every file.

Use createPresignedPutUploader (above) when your server can hand the device a signed URL to PUT straight to the bucket. If instead your server holds the storage binding itself — e.g. a Cloudflare Worker with an R2 binding, where no presigned URL is ever minted and no storage credential exists anywhere the device can see — use createProxyUploader and let the ciphertext flow through your own endpoint:

import { configureXmtpChat, createProxyUploader } from 'xmtp-chat-rn';

configureXmtpChat({
  // ...
  attachments: {
    upload: createProxyUploader({
      endpoint: 'https://worker.example.com/attachments/upload',
      headers: async (file) => ({ authorization: `Bearer ${await getAccessToken()}` }),
      // Defaults to POST, and to reading `{ url }` from a JSON response —
      // both match a typical Worker route.
    }),
    download: async (url) => (await File.downloadFileAsync(url, Paths.cache, { idempotent: true })).uri,
  },
});

createProxyUploader sends two headers describing different things, and neither stands in for the other: x-attachment-digest is the SHA-256 of the request body — the ciphertext itself — so it's authoritative and safe to use as the object's storage key, but only once your server has verified it against the bytes received (below); trusting it as sent lets a caller claim any key, including someone else's. x-attachment-bytes is the native SDK's reported PLAINTEXT size, not this body's length, so it's approximate and non-authoritative — don't compare it to Content-Length or enforce a storage quota with it.

// Worker route: verify the digest before trusting it as the R2 key. The
// header is client-supplied and therefore untrusted input — keying storage
// by it unverified lets any authenticated caller claim another user's key
// and overwrite their ciphertext. Hashing needs the whole body, so this
// reads it into memory rather than streaming straight to R2; for very large
// files, stream to a temp key first and rename after the hash matches.
export default {
  async fetch(req: Request, env: Env) {
    const claimedDigest = req.headers.get('x-attachment-digest');
    if (!claimedDigest) return new Response('missing digest', { status: 400 });
    const bytes = await req.arrayBuffer();
    const hash = await crypto.subtle.digest('SHA-256', bytes);
    const digest = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join('');
    if (digest !== claimedDigest) return new Response('digest mismatch', { status: 400 });
    await env.ATTACHMENTS.put(digest, bytes);
    return Response.json({ url: `https://cdn.example.com/${digest}` });
  },
};

A server that instead trusts the header as sent (skipping the hash check) must namespace keys per authenticated user — e.g. ${userId}/${digest} — so a forged digest can only overwrite that same user's own object, not another user's.

IPFS (opt-in). Use createIpfsUploader({ pin, gateway }), where pin stores the ciphertext through your server and resolves the CID, and gateway is your dedicated https:// gateway (public gateways throttle). Understand the trade first: an IPFS file cannot be reliably deleted. Unpinning does not remove copies other nodes and gateways have cached. The file is therefore only as private as its key, for as long as any copy exists — if a recipient's device or chat database leaks years later, the file is readable then. MLS forward secrecy does not help, because it protects message keys, not a file already public on IPFS.

Not supported yet: rendering the individual files of several files sent in one message (XMTP's multi remote attachment, MultiRemoteAttachmentCodec) — that codec is registered like the other two, so the message decodes and shows its text fallback as an ordinary bubble/inbox row ({ kind: 'card', cardKind: 'multiRemoteAttachment', preview: null, fallback } in the inbox), but the files themselves are not fetched or rendered. An inline static attachment from another client (bytes on the wire, no upload) gets the same treatment: it decodes, but only far enough to show its text fallback rather than the image or file itself.

Background push

Registration is gated on a reachability probe, and that gate is not optional: the native XMTPPush call runs on the shared XMTP runtime, so against a port with no route it hangs and wedges the client — the inbox stops syncing entirely. Supply both URLs; probeUrl is separate because the probe target differs from the host the native client dials.

configureXmtpPush({ serverUrl, probeUrl });
await registerXmtpPush(client, fcmToken);   // after you obtain a device token

Decrypted push previews

A push carries ciphertext, so a notification says "New message" unless something opens the MLS database and processes the envelope. decryptPushedMessage does that, from an Android headless JS task or an iOS Notification Service Extension:

const preview = await decryptPushedMessage(
  { topic: data.topic, encryptedMessage: data.encryptedMessage },
  {
    buildClient,                               // no-signer Client.build with your codecs
    getActiveClient: () => getActiveXmtpClient(),
    dropClient,                                // the SDK's dropClient
    render: async (decoded, client) => ({ title: …, body: … }),
  },
);
// null => show generic copy

Those two field names match XMTP's reference notification server; encryptedMessage is the base64 GroupMessage protobuf, not its inner payload. A server emitting other names maps them here.

It returns null for every miss — no client, unknown conversation, decrypt error, timeout — so one fallback branch covers all of them. Rendering is yours because the content types are: render is where a custom card becomes a title and a body, and the same function should back the foreground notification so both paths read identically.

Two constraints the deps encode. Only one client may hold the MLS database, so a live client is reused when the app is alive and only a client the receiver built is dropped. And background work is killed on a budget, so the decrypt races a timeout (6s by default); if the timeout wins, processMessage still settles in the background and the OS reclaims the handle.

Expo SDK ceiling

@xmtp/[email protected] does not compile against Expo SDK 57. Its XMTPModule.kt registers 160 entries in a single definition() block, and Expo's DSL functions are inline, so that whole registry expands into one JVM method. Under expo-modules-core 57 it crosses the JVM's 64 KB per-method limit and the Kotlin compiler stops:

MethodTooLargeException: Method too large:
expo/modules/xmtpreactnativesdk/XMTPModule.definition ()Lexpo/modules/kotlin/modules/ModuleDefinitionData;

No compiler flag, heap size or Gradle setting moves that — 64 KB is a limit of the class file format itself. Expo 55 compiles it, which is what the example app and this package's devDependencies pin. Expo 56 is untested here; the break may land there rather than at 57.

This is upstream, not something a host can configure around: xmtp/xmtp-react-native#777 reports it, and 5.7.0 is the newest published version. That repository's last commit was 2026-03-14 — as were the last commits to the iOS and Android SDKs — while libxmtp and xmtp-js continue to ship. Treat an upstream fix as a bonus rather than a plan: a host that needs a newer Expo will have to split definition() itself with patch-package.

Example app

example/ is a runnable two-screen app — an inbox and a thread, with replies, reactions, delivery state and one custom content type. It installs this package from npm the way any consuming app does, so what it exercises is the published artifact rather than the source beside it. See example/README.md for how to run it; it needs a dev build, because every peer dependency here is native.

Status

Extracted from a production React Native app, where it ships today. It has 28 test suites / 248 tests covering the client lifecycle, message description, delivery state, reactions, read receipts, attachments, the push reachability gate, the card registry, the theme and the components.

It builds with react-native-builder-bob — CommonJS, ESM and declarations under lib/ — and typechecks and tests standalone, so it needs no resolver overrides in the consuming app.

npm install xmtp-chat-rn

Version 0.0.6 adds attachments. The API is settled enough to use and not yet frozen. It is 1:1-only by design (see Scope), and the attachment path has not yet been exercised on a device.

It ships no native code of its own — no podspec, no ios/, no android/ — so it adds nothing for CocoaPods or Gradle to build. Every native requirement is a peer dependency the host installs itself.

Platform hooks

configureXmtpChat takes an optional platform object for host-supplied native plumbing. All of it is optional, and an absent object behaves exactly like a platform that provides none:

platform: {
  dbDirectory: () => appGroupContainerPath(),   // iOS App Group, so an extension can open the same MLS db
  setSharedItem: (key, value) => …,             // mirrors keys an extension reads
  migrateDbIfNeeded: () => …,                   // one-time relocation of an existing db
}

The keys written through setSharedItem are xmtp.dbEncryptionKey and xmtp.activeAddress. If a notification extension reads them, those names are a contract — renaming either breaks decrypted push previews with nothing failing.

Copy and theming

The package emits no user-facing strings — components take their labels as props with English defaults, and describeMessage returns a structured description the host renders.

Theming is the same shape: configureChatTheme(override) merges a host's tokens over brand-free defaults, and components read the result at render time. Call it once during startup, before the first chat surface renders.

The contract is scoped to what these components actually draw — twelve colours, five spacings, three radii, two text styles and one shadow — rather than a whole design system. Anything omitted keeps its default, so a host can set three colours and ignore the rest.

The accent is four tokens, not two. accentSoft is a background tinted toward the accent; onAccent and onAccentMuted are content drawn on top of it. A pale accent makes those look interchangeable, but a dark or saturated one needs them to move in opposite directions.