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

@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.