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 does this, due to the privacy tradeoff it requires - GIF providers prohibit 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. Because the user's
client fetches directly from that CDN, this exposes the end user's IP to the CDN.
On top of that, no Matrix client renders third-party URLs inline; only images
uploaded to the homeserver (m.image with an MXC URI) display as inline media.
Between the privacy tradeoff and the lack of inline third-party rendering, no
other Matrix client has shipped a native GIF picker. Cinny's maintainer lays
out this same reasoning - both the IP exposure and the re-hosting problem - in
cinnyapp/cinny#1557.
Crust is built for people coming from Discord, where GIFs are a core part of the chat experience. That makes native GIF support worth the privacy tradeoff, so we make it intentionally in Crust.
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 - desktop-first is the whole point: a dense, keyboard-first, three-pane layout)
- A bridge management UI (bridges are a server-side operator concern, configured out-of-band)
- An SSO/OIDC client (password auth only - our homeserver, Continuwuity, has no OIDC login support yet; this is a "not yet," not a "never," and worth revisiting if that changes)
- A clone of Element's full feature surface (features are chosen intentionally rather than to reach 100% parity - this rules out labs and niche admin surfaces, not mainstream features like threads or polls, which remain fair game)
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:8080 -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:8080/;
}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:unit # jsdom project only (fast, no Chromium download)
pnpm test:watch # vitest in watch mode
pnpm test:browser # browser-mode only (headless Chromium via Playwright)Most tests run in jsdom (the unit project). Layout-dependent tests live in
*.browser.test.tsx files (the browser project) 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, so pnpm test fails without that download - use pnpm test:unit for the fast path
that skips it.
CI (.github/workflows/ci.yml) runs pnpm test:unit in the main job and the
browser project in a separate job that installs Chromium first, so both
projects run on every pull request and push to main (a failing test fails
the CI check). Image publishing is gated on CI: the publish job in
.github/workflows/ci.yml needs the test jobs and only runs for a push to
main (or a v* tag / manual dispatch), so a red CI run never ships the GHCR
:latest image, and pull requests (including forks) can't publish.