Messaging clients
applesauce-extra/messages has two direct message clients:
LegacyMessagesClientfor NIP-04 kind 4 messages, which most older clients still use.WrappedMessagesClientfor NIP-17 private messages sent in NIP-59 gift wraps, which hide who is talking to whom.
Both clients use your app's RelayPool and encrypted-event EventStore. The wrapped client delegates receiving and decryption to GiftWrapReceiver, which writes validated plaintext into a separate RumorStore or AsyncRumorStore.
How to use it
Create one client per account and protocol:
import { LegacyMessagesClient, WrappedMessagesClient } from "applesauce-extra/messages";
const legacy = new LegacyMessagesClient({ pubkey, signer, eventStore, pool });
const wrapped = new WrappedMessagesClient({ pubkey, signer, eventStore, pool });
await wrapped.start();Set up the event store's profile and relay list loaders first; the clients use them to find each participant's inbox relays. The signer must belong to pubkey. Without a signer the client can still show cached messages but can't send or decrypt new ones.
When the user switches accounts, dispose the clients and create new ones. Disposing a client never closes the shared pool or event store.
Conversations
client.conversation(...) returns a handle with drafts and sending methods. Legacy conversations take one pubkey; wrapped conversations take a list of participants, so group chats work too. Wrapped reads use ordinary NIP-01 filters over the shared rumor store:
const chat = wrapped.conversation([alice, bob]);
chat.timeline$({ since, until }).subscribe(renderMessages);
await chat.send({ content: "Hello" });The two protocols load conversations differently:
- Legacy messages can be filtered by correspondent, so a conversation can load on its own without starting the whole inbox. Subscribe to
conversation.watch$to load while a view is open. - Wrapped messages only reveal their participants after decryption. The receiver loads envelopes for the account; conversation handles query the shared rumor store without creating local copies or loading relays.
Legacy messages are kind 4 events with encrypted content; read the plaintext with getEncryptedContent() or EncryptedContentModel. Wrapped conversations return the decrypted kind 14 and 15 rumors directly.
Query stored rumors
Use wrapped.rumorStore for the existing store APIs: getEvent, getByFilters, getTimeline, filters, timeline, and model. wrapped.messageFilters selects the account's kind-14 and kind-15 rumors, including sent messages. Queries never invoke the decryption signer.
const filters = wrapped.messageFilters.map((filter) => ({ ...filter, since, until }));
const messages = await wrapped.rumorStore.getTimeline(filters);
const incoming$ = wrapped.rumorStore.filters(filters, true);
const replies$ = chat.replies$(parentId, { since, until });chat.getMessages(filter) reads one snapshot; chat.timeline$(filter) watches a selected window. Both apply chat.matches(rumor) after the store query because NIP-01 filters cannot express exact participant-set equality. chat.filter exposes the candidate filter for custom store models.
A limit applies to candidate events before exact participant matching, so the resulting conversation list can be shorter or empty. It also limits only the initial store timeline query, not later matching arrivals. Use explicit since/until windows for history views; there is no automatic full-inbox array or custom cursor API.
List conversations
Both clients keep a conversation index with one entry per conversation: its id, lastMessageId, and lastActivity. Messages are recorded as they arrive, from the event store for legacy and the rumor store for wrapped. Page through it newest first by pulling from one conversations() iterator as the list scrolls. conversationInserts$ emits conversations added to the index and conversationUpdates$ emits conversations whose newest message changed, so a list can move both to the top:
const pages = wrapped.conversations();
const page = [];
for (let count = 0; count < 20; count++) {
const { value, done } = await pages.next();
if (done) break;
page.push(value); // One conversation summary per iterator step
}
wrapped.conversationInserts$.subscribe((summary) => moveToTop(summary));
wrapped.conversationUpdates$.subscribe((summary) => moveToTop(summary));The application chooses its page size and retains the iterator for the next page. A conversation moved to the top can still arrive in a later page, so skip ids that are already listed when appending. Unsubscribe from insert/update notifications when the list view closes.
Legacy summaries also include the correspondent, so a row can open legacy.conversation(summary.correspondent).
The index defaults to memory. Pass a conversationIndex store to persist it, so the list renders before any message is loaded or decrypted again. A store implements get(id), set(entry), and list(), which returns entries newest first as an array or an async iterable. In the browser, IndexedDBConversationIndexStore stores the index in IndexedDB. Give each account and protocol its own database:
const conversationIndex = await IndexedDBConversationIndexStore.open(`legacy-conversations:${pubkey}`);
const legacy = new LegacyMessagesClient({ pubkey, signer, eventStore, pool, conversationIndex });The application owns the index connection: dispose clients using it before calling conversationIndex.close() when that account's storage owner ends.
Legacy clients index matching events already in the in-memory event store as well as new arrivals. To backfill older persisted events, call legacy.indexConversations({ since, until }); it scans cacheRequest in pages and returns zero when no cache is configured. Wrapped clients automatically index only new rumor arrivals; call wrapped.indexConversations({ since, until }) to scan rumors already in the shared rumor store.
lastMessage is undefined when the message isn't loaded. Failed index writes appear on legacy's conversationIndexError$ and as storage errors on wrapped's errors$.
Loading history
start() keeps live messages flowing and loads a recent window. To go further back, call loadHistory() with a time range, or subscribe to sync$() to show progress:
const result = await legacy.loadHistory({ since, until });A finished load can still include relays that failed, and loaded messages may still be locked. Check both before telling the user their history is complete. For infinite scroll in a legacy conversation, use conversation.createTimelineLoader() with the timeline loader.
Decryption and signer prompts
The clients never ask the signer to decrypt in the background unless you opt in. Reading the wrapped rumor store only reveals messages already processed by the receiver. Unlock from a user action, or enable background decryption once the user agrees:
await wrapped.unlock(); // decrypt what's loaded now
wrapped.setAutoUnlock(true); // keep decrypting new messagesdecryptionState$ reports how many messages are locked, unlocked, or failed, which is useful for an "Unlock messages" button. Retry a failed message with retryDecryption(event).
For explicit wrapped processing, use wrapped.receiver.unlock(envelope) or iterate wrapped.receiver.readInbox({ signal }). Observe receiver outcomes through receive$ and stored messages through the rumor store. Legacy readMessages$() retains its active-decryption behavior.
Legacy receiving modes
NIP-04 messages are received in two ways, each with its own receiver:
legacy.receiveris aLegacyInboxReceiver. It reads the whole account from the owner's own inbox, DM and outbox relays.conversation.receiveris aLegacyConversationReceiver. It reads one conversation from both participants' relays, so messages the peer only sent to their own outboxes are still found.
Both receivers share one LegacyMessageDecryptor, so an event loaded by both is decrypted once, with one signer prompt at a time. They have the same explicit processing methods as the gift-wrap receiver:
for await (const result of legacy.receiver.readInbox({ signal })) {
if (result.status === "failed") showFailure(result.failure);
}Use standalone receivers when your application owns receiving without needing client drafts or sending. Share a decryptor and the inbox's authentication options across receivers:
import { LegacyMessageDecryptor, LegacyInboxReceiver, LegacyConversationReceiver } from "applesauce-extra/messages";
const decryptor = new LegacyMessageDecryptor({ pubkey, signer, decryptionCache });
const inbox = new LegacyInboxReceiver({ pubkey, signer, eventStore, pool, decryptor });
const receiver = new LegacyConversationReceiver({
pubkey,
correspondent,
eventStore,
pool,
decryptor,
authOptions: inbox.authOptions,
});
const subscription = receiver.watch$.subscribe(showOutcome);
// From an Unlock button:
await receiver.unlock();Release subscriptions and receivers when their owner ends:
subscription.unsubscribe();
receiver.dispose();
inbox.dispose();watch$ owns loading while subscribed; start() keeps it running until stop(). For an inbox view, subscribe to inbox.watch$ too. Neither receiver closes the shared pool or event store. Legacy client conversation handles also have stop() and dispose(); wrapped handles have no disposal lifecycle, so release their query subscriptions instead.
Sending and drafts
Check discover(...) before offering to send so the UI can explain when a participant has no inbox relays. Wrapped messages need kind 10050 inbox relays for every participant, including the sender.
Messages support replies with replyTo and optional expirations. File attachments are covered in Encrypted files.
Wrapped sends use the explicit expiration and envelopeExpiration request fields. They do not query message history to infer an expiration policy.
Conversations keep a draft in draft$ with setDraft(), loadDraft(), and clearDraft(). Pass draftStorage to keep drafts between sessions. Sending doesn't clear the draft, so clear it yourself after a successful send. Legacy conversations also have prepareDraft(), which signs the current draft and clears it only if the user hasn't edited it in the meantime.
Retrying failed sends
Both clients split sending into prepare and publish steps so a failed publish can be retried without signing or encrypting again. Never call send() again to retry; that creates a new message.
For wrapped messages, keep the prepared handle and publish it again. The retry skips recipients whose relays already accepted it:
const prepared = await wrapped.prepareSend({ recipients: [alice], content: "Hello" });
try {
await prepared.publish();
} catch (error) {
showRetryButton(() => prepared.publish());
}Wrapped handles only live in memory. Legacy messages can go further: pass publicationStorage and unsent messages survive a reload. Load them again with conversation.loadPublications() or pendingPublications$, then call publish(messageId) or discardPublication(messageId).
A relay accepting a message means it was published, not that the recipient has read it.
Integration
Share an application receiver
The wrapped client creates its own GiftWrapReceiver by default. If your app already has a receiver for the account, pass it in so both features share one inbox:
const receiver = new GiftWrapReceiver({ pubkey, signer, eventStore, pool });
const wrapped = new WrappedMessagesClient({ pubkey, signer, eventStore, pool, receiver });
await receiver.start();The app then owns loading and decryption: wrapped.start() won't open subscriptions, and wrapped.setAutoUnlock() changes the setting for everyone using the receiver. Use receiverOptions for receiver-specific construction policies such as processing concurrency.
The legacy client works the same way with a LegacyInboxReceiver. Its conversations reuse the injected receiver's decryptor and relay authentication:
const receiver = new LegacyInboxReceiver({ pubkey, signer, eventStore, pool });
const legacy = new LegacyMessagesClient({ pubkey, signer, eventStore, pool, receiver });Share a rumor store
Pass rumorStore at the top level to use an application-owned RumorStore or database-backed AsyncRumorStore. The client and receiver expose the same instance:
const rumorStore = new AsyncRumorStore({ database });
const wrapped = new WrappedMessagesClient({ pubkey, signer, eventStore, pool, rumorStore });
await wrapped.receiver.unlock(envelope);
const rumor = await rumorStore.getEvent(messageId);
wrapped.dispose(); // The application still owns rumorStoreWithout an injected store or receiver, the receiver creates and owns an in-memory RumorStore. With an injected receiver, the client uses receiver.rumorStore; supplying a different rumorStore is an error.
Wrapped conversation handles have no disposal lifecycle. Unsubscribe to release a query view and pass an operation signal to cancel a query or send. The client owns draft state and cleans it up on disposal; conversation queries remain usable while the shared rumor store is usable.
Relay authentication
Relays that require NIP-42 authentication add a request to authRequests$. Authenticating signs an event, so treat it as its own user action, separate from decrypting:
legacy.authRequests$.subscribe(showAuthRequests);
await legacy.authenticate(relayUrl); // approve one relay
await wrapped.authenticateAll(); // or every pending relayUse onAuthRequired to apply an app-wide policy instead of asking each time.
Storage
The app decides what's stored and how it's protected. All of these are optional:
| Option | Stores |
|---|---|
cacheRequest | Encrypted messages loaded from a local event cache |
decryptionCache | Decrypted plaintext, so the signer isn't asked again |
rumorStore | Validated wrapped rumors, queried through normal store APIs |
draftStorage | Drafts |
publicationStorage | Legacy messages waiting to be published |
draftFileStorage | Wrapped draft attachments, unencrypted |
conversationIndex | Conversation ids, newest message ids, and timestamps |
Storage keys aren't namespaced by account or protocol, so give each client its own adapter. Plaintext caches and drafts are private data; encrypt them at rest.
Examples: Legacy messages, Gift-wrap messages
For forward-secret messaging with multiple devices, see Double-ratchet messaging. For hosting messaging inside napplets, see napplet messaging.
Best Practices
- Share one client per account and protocol, and let each view unsubscribe from only what it opened.
- Keep rendering passive; unlocking, authenticating, and sending should come from user actions.
- Retry a failed send through its prepared handle or message ID, never by sending again.
- Pass
{ signal }to cancel long operations when a view closes. - Report relay coverage and decryption progress separately; a finished load isn't a readable inbox.