A self-hosted, opinionated Matrix client for Discord-style communities.
Built for the strange.pizza community; open-sourced under Apache-2.0. Public deploy at strange.pizza/crust.
Status: Post-Phase 6 — polish and notifications. Phases 0–6 shipped. Cutover from Cinny is planned but not yet scheduled.
We wanted to send GIFs on Matrix. No client could do it — every GIF provider prohibits re-hosting, and no Matrix client renders third-party URLs inline. So we built one that does. Everything else (spaces-first nav, real mentions, E2EE, calls) is table stakes for a chat client we'd actually use daily.
- Spaces-first navigation (no global room list dumping ground)
- Real
@mentionswith proper Matrix metadata - Per-room and per-space unread badges computed from sync state (no
/v3/notificationsdependency) - E2EE by default for DMs, with SAS verification and key backup
- Custom emoji and image packs (MSC2545)
- GIF search (Giphy / Klipy) — opt-in, TOS-compliant
- Desktop notifications with per-room levels (default / all / mentions-only / mute)
- Native MatrixRTC voice/video calls (LiveKit-backed, end-to-end encrypted); optional fallback to embedded Element Call iframe
- Strict Content Security Policy from day one
Every GIF provider (Giphy, Tenor, Klipy) prohibits downloading and re-hosting
their content — GIFs must be served from the provider's CDN. But no Matrix
client renders third-party URLs inline; only images uploaded to the homeserver
(m.image with an MXC URI) display as inline media. These two requirements are
mutually exclusive, which is why no Matrix client has shipped a native GIF
picker.
Crust resolves this honestly:
- Send: the selected GIF is sent as a normal text message containing the provider's CDN URL. This complies with provider TOS.
- Render: Crust recognizes GIF provider URLs in the timeline and renders them inline, fetching directly from the CDN.
- Cross-client: recipients using other Matrix clients see a clickable link instead of an inline GIF. This is a trade-off we're transparent about.
- Encrypted rooms: the URL is encrypted in the message body, but when Crust fetches the GIF for display, the user's IP is visible to the CDN. This is the same trade-off as URL previews.
GIF search is off by default. The operator enables it in config.json by
providing a provider API key. Content rating defaults to g.
For local development, copy .env.example to .env.local and set
VITE_GIF_API_KEY (and VITE_GIF_ENABLED=true) there instead of editing
config.json — .env.local is gitignored so your key won't be committed.
Any valid VITE_GIF_* value overrides the matching field in config.json;
unset, empty, or otherwise invalid values are ignored.
- A mobile app (responsive enough to use, not optimized)
- A bridge management UI
- An SSO/OIDC client (password-auth only in v1)
- A replacement for Element's full feature surface
TypeScript · Solid · Vite · Tailwind v4 · Kobalte · matrix-js-sdk · Biome
pnpm install
pnpm devCrust is a static site. Build the Docker image and serve it behind any reverse proxy.
docker build -t crust .
docker run -p 8080:80 -v $(pwd)/config.json:/usr/share/nginx/html/config.json:ro crustMount your own config.json to set the default homeserver, enable GIF search,
etc.
To host the app at https://example.com/crust/ instead of the domain root,
build with the VITE_BASE_PATH Docker build arg (trailing slash required):
docker build --build-arg VITE_BASE_PATH=/crust/ -t crust .This bakes /crust/ into the asset URLs and the in-app router. The container
still serves at its own root (/), so put a reverse proxy in front that
strips the /crust prefix before forwarding to the container — for example
with nginx:
# Redirect the bare mount point so /crust → /crust/ (nginx's location
# match below only handles trailing-slash URLs).
location = /crust {
return 301 /crust/;
}
location /crust/ {
proxy_pass http://crust:80/;
}You can also override the base path for a local build outside Docker:
VITE_BASE_PATH=/crust/ pnpm buildCrust itself is one container. Voice/video calls require self-hosting Element Call + LiveKit (separate containers). Push notifications require a Web Push gateway (also separate). All optional.
pnpm test # full suite (jsdom + browser projects)
pnpm test:watch # vitest in watch mode
pnpm test:browser # browser-mode only (headless Chromium via Playwright)Most tests run in jsdom. Layout-dependent tests live in *.browser.test.tsx
files and run inside a real headless Chromium so ResizeObserver, RAF
cadence, and scroll math behave like a real browser. The browser project
requires Playwright's Chromium download (pnpm exec playwright install chromium) on first run.