@wtfalch/chat
v0.3.0
Published
A Matrix chat client behind the agora's token exchange.
Readme
@wtfalch/chat
Team chat, on Matrix. Mirrors @wtfalch/email's
shape: a client the agora gives a way to get a token, a React section built on
it, and a fake of both for stories and tests.
Exports
| Export | What it holds |
| --- | --- |
| @wtfalch/chat | createChatClient: a matrix-js-sdk MatrixClient behind the token exchange, refreshed on its own, exposing a small typed API |
| @wtfalch/chat/react | The Chat component: rooms, a timeline, a composer, direct messages, threads, reactions and mentions |
| @wtfalch/chat/fake | A real createChatClient, wired to a fake exchange and a fake homeserver instead of the network |
| @wtfalch/chat/chat.css | Styles, on @wtfalch/design tokens |
The client
import { createChatClient } from '@wtfalch/chat';
const chat = createChatClient({
exchangeUrl: 'https://chat.example.com/_chat/exchange',
getAccessToken: () => agora.currentZitadelToken(),
deviceId: agora.deviceId(),
storeName: agora.companyId(),
});
await chat.start();
chat.onRoomsChanged((rooms) => render(rooms));createChatClient takes only a way to get a current ZITADEL access token
(getAccessToken) -- it knows nothing else about the agora, and never imports
an Agora type. On start(), it POSTs exchangeUrl with that token and a
device id, and gets back a Matrix homeserver, user id and access token (the
exact contract is apps/chat-service/README.md and
apps/chat-service/src/exchange.ts). storeName names its local IndexedDB
store, so two companies open in the same browser never share one.
There is no Matrix refresh token, so this refreshes itself two ways: a timer
ahead of expires_at, and a listener for Synapse's M_UNKNOWN_TOKEN. Both
re-run the exchange and carry on without the caller doing anything.
Crypto stays off: encryption is not in the first ship, so this never calls
initRustCrypto(). matrix-js-sdk only downloads its Rust crypto WASM when
that is called, so skipping it means the WASM module is never loaded, in a
browser or under vitest -- no shim or polyfill needed either way.
Sync, reconnect and unread counts (#14).
The room list follows matrix-js-sdk's own /sync loop: a new room, a rename,
or the account joining or leaving one all land through onRoomsChanged,
never a poll. If Synapse ever refuses the client's stored sync position (seen
after a long enough time offline), this wipes the local store and starts a
full sync on its own -- no duplicated or missing events either way. Each room
summary carries unreadCount, Matrix's own notification count; chat.
totalUnreadCount() sums it across every room, for the agora's badge.
chat.markRoomRead(roomId) sends m.read for the newest event and clears
that room's count right away -- call it when a room is opened and again when
the person reaches the newest message. chat.connectionStatus() (and
onConnectionStatusChanged) is 'connected', 'catching-up' or
'offline', for a status the UI can render.
Mute (#5).
chat.muteRoom(roomId, muted) writes (or, unmuting, removes) a room-scoped
push rule carrying the dont_notify action, through matrix-js-sdk's own
setRoomMutePushRule -- scoped 'global', so muting mutes every device
signed into the account, not just this one. room.muted is what a list
dims. Muting only ever changes that: unreadCount keeps climbing regardless,
because a muted room stops notifying and nothing else.
Presence and typing (#7).
Both are ephemeral and both are Matrix-native, which is why they are one
ticket: chat.presence(userId) reads someone's m.presence as 'online',
'busy', 'away' or 'offline', and chat.onPresenceChanged(listener)
hears about it changing. chat.sendTyping(roomId, typing) sends m.typing;
chat.onTypingChanged(roomId, listener) hears who else is typing in a room.
@wtfalch/design's Presence and Typing components draw whatever these
return.
Presence is off in production. Every company's Synapse is generated by
bootstrap/steps/chat-instance.ts:142 in wtfalch/app-template, which
still hardcodes it off, tracked at that project's
#352. Until that
lands, presence() reports 'offline' for everyone, always -- not because
nobody is online, but because Synapse never says. The test stack turns it
on, so the integration suite proves all four states against a real server.
sendTyping and onTypingChanged are unaffected and work today.
Announcing idleness (#20).
chat.announcePresence('away' | 'online') is the only way 'away' ever
happens. Left alone a syncing client re-asserts 'online' on every poll,
so a tab open overnight reads as available until morning. The SDK cannot
judge this for itself -- a cron job calling timeline() looks exactly like
a person reading -- so the app calls it from what it alone can see: the
Page Visibility API, a locked screen, real keystrokes.
It changes the set_presence the sync itself carries, not only the
PUT .../presence/status that Synapse's next sync would undo, and it is
re-applied across this client's own restarts. sendTyping, by contrast,
is a single call and not a heartbeat -- Matrix auto-expires a typing state
after its own timeout, so a composer that wants "typing" to keep showing
through a long pause has to call this again itself on further keystrokes.
Direct messages (#16).
chat.people() is everyone in the company space but the signed-in person --
the space is where a company's membership lives, and a guest is never in it,
so a guest can never be offered. chat.startDirectMessage(userIds) opens the
direct room with exactly those people and resolves with its id: an existing
one is found through the account's own m.direct data and reopened, and only
a set of people with no room yet creates one. A different set of people is a
different room, which is why changing a group's people starts a new direct
message rather than editing the old one.
The room it creates carries is_direct and an invite list and nothing else.
That is not a style choice: Synapse's wtfalch_chat_gate module refuses a
member-created room that also carries a name, a topic, initial_state or
creation_content, because a room dressed as a channel is the manage app's
to make (chat plan section 5).
chat.hideDirectMessage(roomId) takes a direct room out of rooms() with a
com.wtfalch.hidden room tag rather than a leave, so its history is exactly
where it was and the next startDirectMessage with the same people brings it
straight back.
Threads (#17). A thread
is named by the message that started it -- Matrix threads are one level, so a
thread has no id of its own, and a reply always relates to the root rather
than to the reply above it. chat.timeline(roomId, threadId) is that
thread's own timeline, root first;
chat.sendMessage(roomId, text, { threadId }) replies into it;
chat.paginate(roomId, threadId) walks its history. A
reply is never in the room's own timeline: what the room shows is the count
and the people on the root, through event.thread.
onTimelineChanged(roomId, listener) takes no thread id, deliberately. A
reply changes both the thread it is in and the count on its root, so a caller
reading either has to hear about it; one subscription per room says that
once.
Reactions and mentions (#18).
event.reactions is what people have reacted with, most-reacted first and
then by key, so everyone sees the same row in the same order.
chat.toggleReaction(roomId, eventId, key) adds the signed-in person's
reaction or takes it away -- one call, because that is the one thing anybody
means by pressing a reaction. Taking one back is a redaction of the event
that was sent, which is the only way Matrix has. Concurrent toggles converge
because Matrix does the converging: two people pressing at once end up with
both events or neither, never with a count nobody can explain.
chat.roomMembers(roomId) is who a message here can mention -- the room's
own membership, not the company's, because mentioning somebody who is not
here would notify nobody. sendMessage(roomId, text, { mentions }) writes
m.mentions.user_ids, and event.mentionsMe reads it back. The text alone
never decides: two people can share a display name.
Edit and delete (#19).
chat.editMessage(roomId, eventId, text) replaces the text of the signed-in
person's own message, as Matrix's m.replace: a new event carrying
m.new_content, related to the original. The original keeps its place in the
timeline and keeps its id, so an edit never reorders a conversation and never
breaks a thread hanging off the message. event.edited says the reader is
not looking at what was first written; event.body is already the new text.
The m.replace event itself is never drawn as a second message.
chat.deleteMessage(roomId, eventId) redacts. The event stays in the
timeline and event.deleted says so, with body empty: a renderer draws a
tombstone in its place rather than dropping the row, which would move
everything under a reader mid-scroll, and a thread whose root was deleted
keeps every reply.
A thread whose root was deleted keeps every reply, and the root still holds
them up as a tombstone. That needs saying because matrix-js-sdk does not do
it on its own: it rebuilds a thread's reply count from the root's bundled
summary, which Synapse stops sending once the root is redacted, so the client
that wrote the replies can watch its own Thread object be torn down while
the person who wrote the root keeps theirs. The replies are untouched in the
room's relations either way -- a redaction takes one event's content, never
anything relating to it -- so that is what this package reads when the thread
is gone. On a client that lost its thread that way, a reply that had been
edited before the root was deleted can read as its original text until the
next reload; the reply itself is never lost.
Both refuse a message somebody else sent, and event.mine is what a UI draws
that rule from. Ownership is the whole rule: Matrix ignores an m.replace
from anybody but the original sender, and the default power levels let a
member redact their own event and nobody else's.
Moderation (#29). A member
reports a message with reportMessage(roomId, eventId, reason). The admin SDK
reads reports with listReports(), removes anyone's message from a channel
with redactMessage(roomId, eventId), and bans a person from a channel with
banPerson(roomId, subject, reason?) until unbanPerson(roomId, subject).
Admins act as @chat-admin, and only in channels the service created.
Pinned messages (#8).
chat.pinMessage(roomId, eventId) and chat.unpinMessage(roomId, eventId) add
to and take from Matrix's own m.room.pinned_events state, one list the whole
room shares; chat.pinnedMessages(roomId) reads it back, each pinned id
reduced the same way timeline() reduces a message, in the order the state
lists them.
Ordinary members can call these
(wtfalch/agora#255 overrides
the chat plan's "nobody but @chat-admin ... pins", for pinning only):
apps/chat-service's powerLevels() grants m.room.pinned_events its own
power level, 0. That reaches only a room created after the change lands --
an existing room's power levels are never backfilled, so a member's pin in an
older channel still fails. pinMessage rejects rather than resolving
quietly when that happens: a silently-ignored pin reads as "the button does
nothing," and this package would rather a caller catch a real error than
draw a pin that never took.
Redacted text does leave the database, but not at once. A redaction
empties the event for every client immediately; Synapse replaces its own
stored copy with the redacted form after redaction_retention_period, which
is 7 days by default. Until then the original text is still in Synapse's
Postgres, where a server administrator could read it. Anything that has to be
gone now is not a redaction (chat plan section 8).
Safe rendering. formatted_body is a stranger's HTML and is never drawn
without sanitizeHtml first -- an allow-list walked in a document with no
browsing context, unknown tags unwrapped, scripts and forms dropped with
their contents, a link's scheme parsed rather than pattern-matched, an image
only ever this homeserver's own media, and caps on depth and node count. Its
tests carry the payloads: javascript: behind case, whitespace and HTML
entities, svg onload, object/embed/form/base/meta, every on*
handler, the <scr<script>ipt> trick, foreign image sources, and messages
built out of nothing but depth or siblings.
The fake
import { fakeChat } from '@wtfalch/chat/fake';
const { client, server, dispose } = fakeChat();
await client.start();
client.rooms(); // the sample rooms, joined
dispose();Like @wtfalch/email's fakeMail, this is a fake transport, not a fake
client: createChatClient and matrix-js-sdk run unchanged, against a fake
exchange and homeserver answered by intercepting fetch. FakeChatServer
(also exported) gives a test finer control -- seeding rooms (with a starting
notificationCount), seeding the company space (isSpace) and who is in it
(members, invited), adding, renaming or delivering someone leaving a room
mid-test, sending a message into one, invalidating the current token to
prove a token recovery, or refusing the next poll's position to prove a
full-sync recovery. It answers /createRoom, m.direct account data and
room tags too, so a direct message can be started, reopened and hidden
against it -- and it refuses a createRoom carrying a name, the same way
the Synapse gate does, so a client that started sending one would fail here
rather than in production. It also takes sends (server.sent is what went
out, relation and all), seeds a room with messages, delivers a threaded
reply, and answers /event and /relations, which is what matrix-js-sdk
asks for while it builds a thread. It echoes every accepted send back
through sync carrying its transaction id, so a reaction shows up the way it
does against a homeserver, and it answers redactions -- which is how a
reaction is taken back.
Integration tests
test/integration/ runs createChatClient against the real stack
apps/chat-service/test/stack brings up -- a real chat-service, Synapse and
MAS, through the same helpers chat-service's own integration suite uses.
Bring the stack up first (bash apps/chat-service/test/stack/up.sh), then
pnpm test:integration; bash apps/chat-service/test/stack/down.sh when
done.
Plan
.plans/2026-09-17-chat.md, section 9.
Tickets #11,
#14,
#15,
#16,
#17 and
#18.
Files
Pick files in the composer, paste them, or drop them onto it. Each file can be
up to 50 MiB (52,428,800 bytes, displayed as 50 MB), matching Synapse's 50M
limit. Larger files are refused before any upload starts. Uploads show progress
and can be cancelled; failures keep the file for Retry. After upload, the normal
message retry sends the existing media reference without uploading the bytes again.
Stopping the client aborts unfinished uploads.
client.sendFile(roomId, file, { threadId }) returns a temporary upload row id.
After upload it becomes a Matrix message with its own event id. Read the current
id from timeline() before calling retryMessage or cancelMessage.
event.attachment describes an image or file; event.upload carries byte progress
until the upload completes. Images preview inline and all files have a named
Download link. A failed download offers Retry download.
Media bytes are fetched with the session bearer token from apps/chat-service's
GET /_chat/media/{serverName}/{mediaId}?roomId=...&eventId=..., never from
Synapse's /_matrix/client/v1/media/download/* directly, including images inside
formatted messages. loadMedia(mxcUri, roomId, eventId) returns a blob URL or
null; every ChatAttachment carries the eventId of its message. Callers must
revoke returned URLs. The React views revoke theirs on replacement and unmount,
including downloads that finish after unmount. Uploads use Matrix's separate
authenticated /_matrix/media/v3/upload endpoint. Only references on the
session's own homeserver are accepted.
Files are locked to their channels (#32).
Synapse serves a file to any valid token that names its media id, whatever room
it was posted in. The media route in apps/chat-service (src/media.ts) makes
the check Synapse does not. It serves the file only when the caller is currently
joined to roomId, eventId is a message in that room that references the
file, and that message was sent by the person who uploaded it. An ex-member, or
someone who re-posts a kept media id in a room of their own, is refused. A
deleted message's file is refused too. The deleted file itself stays stored.
This only holds when the reverse proxy in front of Synapse does not route
Synapse's own media download and thumbnail paths to clients. No malware
scanning or file search is included.
Every file in a room (#9).
chat.roomFiles(roomId, { limit, before }) lists a room's files without paging
its timeline: GET .../messages takes Matrix's own contains_url filter, and
Synapse answers with only file-bearing events no matter how much plain
conversation sits between them, confirmed live against this package's own test
Synapse. It resolves with { files, next }. files are ChatAttachments, the
same shape event.attachment already carries, and bytes still only ever come
through loadMedia. limit stops once that many are collected; omitted, this
walks pages until Synapse says there is nothing older.
Loading more files (#33).
next is the cursor for the files after these. Pass it back as before to load
the next page; it is null once there is nothing older. It resumes exactly
after the last file returned, so none is skipped or repeated. The last page can
come back empty.
First-ship local proof
See the browser/Electron fixture for the repeatable local preflight. It uses the real Synapse/MAS stack and a fake issuer, and records outstanding UI and deployment acceptance separately. The first ship has no push notifications or message/file search.
Invitations and read receipts
rooms() includes membership: 'invite' entries as well as joined rooms.
Invitations appear in their own section and contribute one to the attention
count until answered or withdrawn. acceptInvitation(roomId) joins the room;
declineInvitation(roomId) leaves it. Accepting a DM records the recipient's
m.direct account data so it stays a direct message across devices and reloads.
The React timeline calls markRoomRead when the visible room is at the bottom.
Reading a thread, viewing older history, or leaving a tab in the background does
not clear unread room messages.
readBy(roomId, eventId) names who has seen a message
(#31): every joined person whose
read receipt is on that message or a newer one. It leaves out the signed-in
person and the message's sender. onTimelineChanged fires when a receipt
moves, so a view re-reads it then. Only public read receipts count.
Administration in Manage
@wtfalch/chat/admin exports createChatAdmin, the typed management
client, and hasChatAdminRole for already-verified identity claims. The admin
entry imports neither React nor Matrix. Configure a canonical agora origin and
supply the signed-in administrator's fresh ZITADEL access token per request:
import { createChatAdmin } from '@wtfalch/chat/admin';
const admin = createChatAdmin({ agoraUrl: 'https://agora.example.test', getAccessToken });
const page = await admin.listChannels({ limit: 25 });It exposes channel list/details/create/update/archive/delete, private-channel
invitations/removals, moderation (banPerson, unbanPerson, redactMessage,
listReports), Chat-user deactivation, a person's Matrix sessions
(listSessions(subject) and revokeSession(subject, sessionId),
#30) and audit pagination. Redirects are
refused; tokens never appear in URLs or rendered errors. Service authorization
still verifies the token, organisation and Chat project's admin role on every
request. Request the Chat project audience and qualified project-role claims
when Manage signs in; a same-named role from another project is insufficient.
@wtfalch/chat/admin/react exports ChatAdminPanel plus its action
contracts. Import @wtfalch/chat/admin/admin.css after design styles.
The SDK owns the controls; Manage supplies an organisation-scoped load and
run action, its verified gates, and a roster mapping issuer subjects to names.
Keep access tokens in server actions. Every action must check both host tenant
membership and the Chat project role itself; hiding a menu entry is not a gate.
Mail follows the same separation through @wtfalch/email/postmaster.
The agora is the sole user-facing app and HTTP origin for Mail and Chat. Admin panels live in Manage; no separate Chat web host is inferred or provisioned. Deployment and real-identity acceptance remain separate from local checks.
