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.
A chat hook
Section titled “A chat hook”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 };}Sharing a file
Section titled “Sharing a file”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;}The component
Section titled “The component”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, notmessage.id— the handle is the server’s stable storage key. It’s what you pass toeditMessage/deleteMessage, and it survives edits (an edit re-seals a freshMessageunder 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
editedflag lives in your message body, so “(edited)” persists across reloads — the SDK/server don’t know about it. - Create vs join is explicit:
joinChannelthrowsChannelNotFoundErrorfor an unknown name;createChannelmints + 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.