Back to portfolio

Mattermost Encryption Fork

End-to-end encryption, retrofitted onto a platform that never planned for it.

Active 2026

A conversation where a single message is encrypted, showing the ENCRYPTED badge, purple styling, and an encrypted-placeholder card for anyone without the key.
Any message can be encrypted on its own. It arrives as ciphertext and is decrypted inside a Web Worker before it renders, so key material never touches the main thread. Recipients see plaintext with purple styling; everyone else sees the placeholder card.

Context

Mattermost is an open-source team chat platform, a Go server with a React/TypeScript webapp. Like nearly every chat product, it was built assuming the server can read every message. Search depends on that. So does a surprising amount of the rendering path.

This fork removes the assumption. Messages get encrypted in the browser before they ever reach the server, so the server never sees plaintext. Nearly 1,000 commits so far, all landed while the platform kept moving underneath the fork.

The hard part

Greenfield encryption is a design exercise. Retrofitting it onto a mature codebase means digging out assumptions nobody ever wrote down — search, scroll math, and the rendering path all quietly expect plaintext.

The message list was the worst offender. Mattermost's webapp expects post content to exist the moment a component renders, and it uses that content to calculate heights and hold your scroll position. Encrypted posts arrive as ciphertext and resolve asynchronously. Decrypt them in place and the channel jumps around while you're reading it.

And then there's key handling. Decrypt on the main thread and your private keys share a JavaScript context with every third-party dependency and any script that manages to get onto the page. That's a bad neighborhood for key material.

Approach

All decryption runs inside a Web Worker, behind a scheduler that decides what gets decrypted and when. The UI posts ciphertext in and gets plaintext back; key material never touches the main thread.

The scroll problem got its own fix: encrypted-aware scroll position handling, so the message list accounts for content that changes size after it resolves instead of yanking the channel out from under your cursor.

Testing was its own subproject. Jest has no idea what a Web Worker is, so shipping the scheduler meant first building a Jest-compatible Web Worker test harness. On an encryption path, "it seemed to work when I clicked around" is not a test plan.

Beyond encryption

Encryption is why the fork exists, but not where it stopped. A dozen features now ship behind independent flags, each toggled from the System Console and degrading gracefully when off. A selection:

Discord-style replies. Queue posts with a Reply click, then one message links back to all of them with curved SVG connectors. Falls back to plain blockquotes when the flag is off.

A message with two curved connector lines linking back to the quoted replies it answers.

Mention highlighting. Posts that @mention you get a colored left border and a tinted row, with priority-tier colors and an inline avatar rendered next to the mention.

A chat message that @mentions the current user, highlighted with a blue left border and an inline avatar.

Spoiler tags. ||text|| renders as a click-to-reveal redaction box, and any image, video, or file can be tagged a spoiler and blurred until clicked.

Chat messages with spoiler text hidden behind black redaction boxes.

View reactions. A "who reacted" dialog that groups reactors by emoji with per-user badges and an All view — attribution the stock client only shows on hover.

A dialog listing who reacted to a post, grouped by emoji with per-user badges.

Configurable post menu. Each user picks which buttons sit on the hover bar, adds a Shift-held expanded tier, reorders them, and pins quick-reaction emoji — all persisted per user.

The post-menu settings panel with Always, Shift, and Off toggles per action and quick-reaction slots.

Custom channel icons. A searchable icon picker in channel settings, spanning six bundled libraries — Material Design, Lucide, Tabler, Feather, Font Awesome, and brand icons — shown in place of the default glyph.

A channel sidebar where each channel shows a distinct custom icon from a different icon library.

Guilded-inspired chat sounds. Sound effects for messages and reactions, with a dedicated Sounds settings tab, master volume, and per-event sound pickers.

The Sounds settings tab with a master volume slider and a sound picker for each event.

Guilded chat layout. An optional layout mode with a persistent team rail, a dedicated direct-message page, and an always-on Members/Threads panel — gated behind a flag, a per-user preference, and a viewport check.

The Guilded-style layout with a left team rail, channel list, center conversation, and a persistent members panel.

Mute for time. Mute a channel for a preset or custom duration; the server persists the expiry and auto-unmutes when it passes.

The channel mute menu expanded to a submenu of preset durations and a custom date-and-time option.

Error-log dashboard. Every user's API and JavaScript errors stream over WebSocket into a searchable admin console, backed by an in-memory buffer with server-side redaction.

An admin dashboard showing live API and JavaScript errors with counts and grouping.

Renameable threads, followed threads nested under their channel in the sidebar, immediate removal of deleted-message placeholders, and full-size image and inline video handling round out the set. Sibling repos extend the same work to the mobile app and the Cloudron packaging that deploys it.

What shipped

Encrypted messaging, working end to end, with the rest of the fork's features live behind their own flags.

The fork is public. The worker scheduler and the scroll handling are the parts worth seeing — happy to walk through either.

Built with

Go React TypeScript Web Workers E2E Encryption Jest