Skip to content

Example: Encrypted chat

This content is for the 0.2.0-alpha.3 version. Switch to the latest version for up-to-date documentation.

A working group chat: join (or create) a named channel, exchange fan-out encrypted messages with history, and share files — all through client.space. New members are admitted by the app’s keeper, so joining works even when no one else is online.

src/useChat.ts
import { useEffect, useRef, useState } from "react";
import { ChannelNotFoundError, type SpaceMessageEvent } from "@muhkoo/connect";
import { client } from "./muhkoo";
// The chat app owns its message body shape — the SDK treats it as opaque. We
// carry an `edited` flag in the body so it persists in history.
interface ChatBody { contents: string; edited?: boolean }
interface ChatMessage {
handle: number; // server storage handle — stable identity, addresses edit/delete
from: string;
text: string;
edited: boolean;
at: number;
}
const toMessage = (e: SpaceMessageEvent): ChatMessage => {
const b = e.message.body as ChatBody;
return { handle: e.handle, from: e.from, text: b?.contents ?? "", edited: !!b?.edited, at: e.handle };
};
export function useChat(channel: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [connected, setConnected] = useState(false);
const [missing, setMissing] = useState(false); // channel doesn't exist yet
const spaceRef = useRef<Awaited<ReturnType<typeof client.space.joinChannel>> | null>(null);
const seen = useRef(new Set<number>());
useEffect(() => {
let disposed = false;
setMessages([]); seen.current = new Set(); setConnected(false); setMissing(false);
// Dedup by handle — the fan-out loops back to the sender, and an edit
// re-seals a fresh Message (new message.id) under the same handle.
const add = (e: SpaceMessageEvent) => {
if (seen.current.has(e.handle)) return;
seen.current.add(e.handle);
setMessages((prev) => [...prev, toMessage(e)]);
};
const replace = (e: SpaceMessageEvent) =>
setMessages((prev) => prev.map((m) => (m.handle === e.handle ? toMessage(e) : m)));
const remove = (handle: number) =>
setMessages((prev) => prev.filter((m) => m.handle !== handle));
(async () => {
let space;
try {
space = await client.space.joinChannel(channel); // keeper admits us
} catch (err) {
if (err instanceof ChannelNotFoundError) { setMissing(true); return; }
throw err;
}
if (disposed) return;
spaceRef.current = space;
space.onMessage(add);
space.onMessageEdited(replace); // authoritative edit, same handle
space.onMessageDeleted((e) => remove(e.handle));
setConnected(true);
// Backfill persisted history (edits already applied in place server-side).
const { messages: hist } = await space.history({ limit: 100 });
for (const e of [...hist].reverse()) add(e);
})();
return () => { disposed = true; spaceRef.current?.disconnect(); spaceRef.current = null; };
}, [channel]);
// No local echo — the fan-out broadcast comes back to us and renders via onMessage.
const send = (text: string) =>
spaceRef.current?.sendMessage({ contents: text, edited: false } satisfies ChatBody);
const edit = (handle: number, text: string) =>
spaceRef.current?.editMessage(handle, { contents: text, edited: true } satisfies ChatBody);
const remove = (handle: number) => spaceRef.current?.deleteMessage(handle);
const create = async () => { await client.space.createChannel(channel); setMissing(false); };
return { messages, connected, missing, send, edit, remove, create };
}

Upload to the space’s encrypted shard storage, then send the manifest as a normal message so members can fetch + decrypt it:

async function shareFile(space, file: File) {
const { manifest } = await space.putFile(file, { name: file.name, type: file.type });
await space.sendMessage({ _t: "file", manifest });
}
// On the receiving side, detect the envelope in onMessage:
async function maybeRenderFile(space, body: unknown) {
if (body && typeof body === "object" && (body as any)._t === "file") {
const { data } = await space.getFile((body as any).manifest);
return URL.createObjectURL(new Blob([data], { type: (body as any).manifest.type }));
}
return null;
}
src/Chat.tsx
import { useState } from "react";
import { useChat } from "./useChat";
export function Chat({ channel, me }: { channel: string; me: string }) {
const { messages, connected, missing, send, edit, remove, create } = useChat(channel);
const [draft, setDraft] = useState("");
if (missing) return <button onClick={create}>Create #{channel}</button>;
return (
<div>
<header>{connected ? "🟢 connected" : "⚪️ joining…"}</header>
<ul>
{messages.map((m) => (
<li key={m.handle}>
<b>{m.from}:</b> {m.text} {m.edited && <small>(edited)</small>}
{m.from === me && (
<>
{" "}
<button onClick={() => edit(m.handle, prompt("Edit:", m.text) ?? m.text)}></button>
<button onClick={() => remove(m.handle)}>🗑</button>
</>
)}
</li>
))}
</ul>
<form onSubmit={(e) => { e.preventDefault(); if (draft.trim()) { send(draft.trim()); setDraft(""); } }}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Message…" />
<button type="submit" disabled={!connected}>Send</button>
</form>
</div>
);
}
  • Messages are fan-out encrypted: one ciphertext per message, sealed with the channel’s group key. The server persists it as history and relays it, but never sees plaintext or the key.
  • Identify messages by handle, not message.id — the handle is the server’s stable storage key. It’s what you pass to editMessage/deleteMessage, and it survives edits (an edit re-seals a fresh Message under the same handle).
  • Don’t echo your own sends — a fan-out message (and every edit/delete) broadcasts to everyone including the sender, so it returns via the listeners (dedupe by handle).
  • Edit/delete are author-only, enforced server-side. Edit replaces the stored ciphertext in place; delete is a hard delete (gone from history, so late joiners never see it). The edited flag lives in your message body, so “(edited)” persists across reloads — the SDK/server don’t know about it.
  • Create vs join is explicit: joinChannel throws ChannelNotFoundError for an unknown name; createChannel mints + registers it (and admits the keeper so others can join later).
  • Files are encrypted + erasure-coded into space-scoped shards; only the manifest (shared as a message) unlocks them.