- Don't be stupid
- Don't be evil
- RAM is expensive - think twice before increasing its usage
TeleMatrix is a Matrix messenger that wears Telegram Desktop's skin: a C++20 / Qt 6
Widgets front end (a partial, pixel-oriented clone of the tdesktop UI) over a Rust
back end built on matrix-rust-sdk, linked in as a static library across a small C
FFI.
Guiding principle: when implementing any UI task, port the Telegram Desktop implementation as-is. Don't reinvent painting logic, layout math, or style values — copy them. Only approximate when a direct port hits a hard wall (Qt6 API drift, lib_ui abstractions we don't have). See Porting from tdesktop.
macOS is the primary, only-actively-tested platform. Windows (MSVC/NSIS) and Linux
(DEB/RPM) build + packaging paths exist and must stay alive and portable — do not treat
the non-macOS #[cfg]/#ifdef branches as dead code.
These trip up every new agent. Internalize them before touching anything.
- Compile-check Rust with
cargo build --profile dev-ffi(fromrust/) — the same profile CMake's Debug build uses, so it shares a warm, already-built target tree instead of compiling a separate one like a barecargo buildwould. - Comments are terse. Comment only genuinely non-obvious / hard-won logic. No obvious or narrating comments. Match the density of the surrounding code.
- Commit only when asked. Don't commit or push unless the user requests it.
- Background / network features need an explicit opt-out setting.
- Use
st::constants (insrc/styles/style_constants.h) for every color, size, and font. Never hardcode visual values. - clangd errors about
QHash/QImage/QStringtemplates are include-path noise — ignore them; trust the actualcmake --buildresult.
C++ Qt 6 app (src/)
├── app/ AppController (intro↔main transition), AppMainWindow, AppMainWidget (2-panel)
├── intro/ Login flow: IntroWidget → IntroStart → IntroLogin (+ verification, recovery-key)
├── dialogs/ Chat list: DialogsWidget → DialogsInner → DialogsRow + dialogs_layout (custom paint);
│ folders management boxes
├── history/ Timeline: HistoryWidget → ScrollArea → HistoryList (custom paint) + HistoryInput
│ (composer); history_message.cpp = bubble paint helpers; media/ = viewers,
│ inline video; reactions/pinned/jump affordances
├── media/ Media pipeline glue (thumbnails, video streaming client)
├── protocol/ ProtocolBridge (QObject wrapping the Rust FFI) + flat C++ structs
├── settings/ Settings pages (appearance, notifications, sessions, …)
├── storage/ Local app storage glue
├── styles/ style_constants.h (all st:: colors/fonts/sizes), style_*.h per component
├── theme/ Light/dark theme parsing + palette
├── ui/ TeleMatrix's own Qt widget layer (RpWidget, ScrollArea, FlatLabel, InputField,
│ layers/, text/, widgets/, toast, qr_code_image). Ported/adapted from tdesktop's
│ lib_ui but on plain Qt signals/slots — NO rpl streams.
└── window/ Top-level window plumbing
Rust back end (rust/src/) — service-oriented, ~60 modules. Highlights:
├── ffi.rs, protocol.rs, matrix.rs, types.rs FFI surface, ProtocolClient, SDK wiring, shared structs
├── lib.rs crate root / runtime
├── Sync & session sliding_sync_service, sync_loop_service, session_lifecycle_service,
│ session_storage_service, session_task_service, auth_service, account_service
├── Rooms list room_summary_service, room_list_service, unread_count_service,
│ folder_service / room_folders, presence_typing_service
├── Timeline timeline_service, timeline_window(_service), timeline_cache_service,
│ timeline_update_service, timeline_conversion_service, timeline_navigation_service
├── Media media_stream/ (loopback streaming proxy), media_transfer_service,
│ media_cache_service, media_blob_store, video_thumbnail_service,
│ upload_* (limit/progress/tasks)
├── Messaging message_action_service, room_action_service, room_creation_service,
│ room_invite_service, room_member_service, recent_emoji(_service)
├── E2EE & secrets encryption_service, verification_service, keychain.rs, secret_vault.rs,
│ encrypted_sqlite, store_guard, session_storage_service
├── Search search_index, search_service, search_backfill (local encrypted FTS)
├── Notifications notification_service, notification_settings_service
└── Previews preview_service/_store/_fetch_signal, link_preview_rules
FFI header: generated by the standalone `rust/cbindgen_runner` crate (CMake runs it via
`cargo run`, always regenerating) into build/generated/protocol/protocol_ffi.h. It is
deliberately NOT a build.rs job (that would skip regeneration whenever the crate is
up to date), and there is no committed shadow header. `rust/build.rs` exists but does
one unrelated thing: on macOS it emits an rpath to Qt's libav for cargo-linked test
binaries.
Data flow. Rust services run on a tokio runtime. Results cross the C ABI as flat
Ffi* structs; ProtocolBridge converts them to Qt types and emits Qt signals on the
main thread. C++ never calls the SDK directly — everything goes through tm_* functions.
Custom painting everywhere. Chat-list rows and message bubbles paint with QPainter
directly (no per-item QML/widgets), mirroring tdesktop's paint-level approach.
Prerequisites and exact versions live in BUILDING.md. Common pins: Qt 6.10.1,
rustc 1.96.0 (pinned in rust-toolchain.toml), matrix-sdk 0.18. The Rust
toolchain channel MUST stay in sync with the dtolnay/rust-toolchain refs in
.github/workflows/.
There is exactly ONE build tree: build/. On macOS it must resolve official Qt, not
Homebrew's — Homebrew ships no libffmpegmediaplugin.dylib, so video cannot stream.
Configure hard-fails otherwise.
# Configure (also builds the Rust lib + FFI header). The Qt prefix is needed only
# on the first configure of a fresh tree; CMake caches it.
cmake -B build -DCMAKE_PREFIX_PATH="$HOME/Qt/6.10.1/macos"
cmake --build build --target TeleMatrix -j # build the app
open build/TeleMatrix.app # run (macOS)- CMake drives the Rust build: Debug →
cargo build --profile dev-ffi; Release (and unspecified) →cargo build --release. Both setpanic = "abort"so a Rust panic can never unwind across the FFI boundary (that's UB).dev-ffiis a debuggable dev build that also aborts. - macOS universal builds compile per-arch and
lipothe staticlibs together. macOS libav is Qt's (universal, LGPL), not Homebrew's — headers vendored inthird_party/ffmpeg-7.1,.pcfiles generated by CMake, sonames asserted at configure time. - To compile-check just the Rust side without CMake:
cd rust && cargo build --profile dev-ffi. On macOS exportPKG_CONFIG_PATH="$PWD/../build/ffmpeg-pkgconfig"first (see BUILDING.md).
Two suites, both real. The app and the test runners share one telematrix_core OBJECT
library (so tests link the exact objects the app ships).
- C++ (Qt Test): one
tst_*.cppper unit undertests/, wired intests/CMakeLists.txt.cmake --build build -j && ctest --test-dir build --output-on-failure - Rust: in-crate
#[cfg(test)]unit tests plus integration tests inrust/src/integration_tests/(services arepub(crate)). These drive a mock homeserver via matrix-sdk'stestingutilities (MatrixMockServer+ wiremock), enabled only as a dev-dependency — the shipped staticlib never compilestesting.cargo testuses the plain unwindingdevprofile.
CI (.github/workflows/) runs both. rustfmt/clippy are advisory (the tree isn't
warning-clean). Keep new code warning-free regardless.
- C++20, tdesktop style: tabs to indent,
camelCasemethods,_memberprivate fields,kCamelCaseconstants. Files follow tdesktop names (dialogs_layout.cpp,history_message.cpp). - Prefer
auto(auto,const auto,const auto &) over spelling out deduced types; rely on clear names, matching upstream style. - Rust: standard
rustfmt. Keep the service-per-file structure; don't dump new logic intomatrix.rs. - No new dependencies unless strictly necessary (both Cargo and CMake).
- Terse comments (see hard constraints). Explain why, only when non-obvious.
- Commits: one logical change each. Prefixes in use:
perf,feat,fix, and area scopes like(rust),(media). Commit only when asked. - Localization: UI strings are Qt
.ts/.qm.enis the source fallback (its.qmhas zero translations → shows thetr()literal);esis the only real target. Add strings viatr()and regenerate with thelupdate/lreleasetargets.
Telegram Desktop is the primary reference for all UI work. Read the upstream source and copy paint/layout/style directly rather than inventing it. When upstream uses lib_ui APIs we don't have, adapt to plain Qt:
rpl::…streams →QObject::connectsignals/slotsscrollTopValue()→QScrollBar::valueChangedmoveToRight(x, y)→move(parent->width() - x - width(), y)
References (not vendored in this repo — consult the upstream repos):
- Telegram Desktop — https://github.com/telegramdesktop/tdesktop, under
Telegram/SourceFiles/. - desktop-app libs — vendored as submodules in
lib/(lib_ui,lib_base,lib_rpl,lib_crl), used for select resources (e.g.lib_ui/fonts) and as the port source forsrc/ui/. Ports like the connecting-state radial animation come fromlib_ui.
Key upstream files by component (paths under Telegram/SourceFiles/):
| Component | tdesktop path |
|---|---|
| Chat-list painting | dialogs/ui/dialogs_layout.cpp |
| Chat-list styles | dialogs/dialogs.style |
| Message bubbles | history/view/history_view_message.cpp |
| Message list | history/view/history_view_list_widget.cpp |
| Chat styles | ui/chat/chat.style |
| Corner / jump-down buttons | history/view/history_view_corner_buttons.cpp, ui/controls/jump_down_button.cpp |
| Login screens | intro/intro_widget.cpp |
| Color palette | ui/colors.palette |
| Icons | Telegram/Resources/icons/ |
Upstream is written against three systems we don't use. Recognize each and map it to our equivalent while porting; don't carry the upstream form into our code.
- RPL (reactive streams) → Qt signals/slots. A
rpl::producer<T>is a stream ofTover time. You'll see pipelines built with transforms (rpl::map,rpl::filter), combiners (rpl::combine— the lambda receives the values unpacked;rpl::merge— same type), and sources (rpl::single,rpl::duplicateto reuse a producer). They're consumed by a starter —rpl::on_next(alsoon_error/on_done) — bound to anrpl::lifetimethat owns the subscription. To port: a subscription becomes aQObject::connectto a signal, the transform/filter becomes logic in the slot, and lifetime becomes object ownership (the connection dies with the receiver). Common concrete mappings:scrollTopValue()→QScrollBar::valueChanged; a value-producing getter → a getter plus asomethingChanged()signal.src/ui/is deliberately plain Qt — never introduce rpl. tr::localization → Qttr(). Upstream keys live inlang.stringsand are read astr::lng_key(...): passtr::nowas the first arg for an immediateQString(tr::lng_key(tr::now, lt_tag, value)), or omit it for a reactiverpl::producer<QString>. Plurals use{count}with| tr::to_count(); rich text uses projectors (tr::marked,tr::richfor**bold**/__italic__,tr::bold,tr::italic,tr::link,tr::url). To port: add a Qttr()literal to the.ts/.qmcatalog (enfallback,estarget) and regenerate with thelupdate/lreleasetargets; do placeholders and formatting the Qt way. Ignore the reactive (rpl) variants — resolve the string where it's used..stylefiles →st::constants. Upstream defines visuals in.stylefiles (e.g.dialogs/dialogs.style,ui/chat/chat.style, palette inui/colors.palette) with typed fields:pixels(10px),color,icon{{ "path/stem", color }}(multi-part icons layer bottom-up),margins(t,r,b,l),size,point,align,font(14px semibold), and inheritanceFoo: Bar(base) { … }. The reason it's data, not code:pxvalues auto-scale at non-100% interface scale — raw integer literals in code do not. To port: move every such value intost::constants insrc/styles/style_constants.hand referencest::…; never bake a dimension, color, or font into a.cpp.
Distilled from past regressions — re-learning these costs hours.
- E2EE decryption is fragile and SDK-reactive.
client.event_cache().subscribe()at session start is mandatory — without it, decryption is silently dead on every backend. Don't remove it.- Decryption is driven by the SDK's own retry (R2D2 / timeline retry). Do not
reintroduce a custom redecryption loop — a previous one fought the SDK and caused
UTDs. The decryption glow is per-item from
UtdCause; backup access is the precondition. - Scrutinize crypto changes in "refactor" commits — past refactors silently regressed decryption (OneShot backup strategy, generation-gated redecryption).
- All secrets flow through
keychain.rs. It's the single choke point behind aSecretBackendenum: platform keychain (macOS Keychain / Windows Cred Manager / Linux Secret Service) or a master-password vault (secret_vault.rs, Argon2id + XChaCha20) — a user-selectable choice on every platform (first-run intro step + Settings), no longer a Linux-only fallback. Optional biometric unlock (Touch ID / Windows Hello) wraps the vault key into avault_biometric.binsidecar viasrc/app/platform/biometric_auth.*; that sidecar is dropped whenever the vault key rotates or is cleared. Don't reach around it. A constructor that gates on an unreachable keychain must not wipe the session. - FFI panic safety. Release +
dev-ffiabort on panic by design.tm_destroymust drainnum_alive_tasks() == 0beforeshutdown_timeout— otherwise a deadpoolJoinError::Cancelledpanics across the boundary (fatal underpanic=abort). Don't "simplify" that drain away. One-shot FFI callbacks tied to a data struct are deferred to avoid a teardown UAF. - Rooms-list previews:
extract_event_body(inroom_summary_service.rs) is ALSO the notification gate — making an event type "previewable" there makes it notify. Room-list-only preview logic belongs inextract_last_event. Perf LRU can evict the fallback timeline cache, so blank previews are stickied (preserve_preview_if_blank/merge_sticky_previews). - Perf posture: resident timeline windows are LRU-capped;
per-room state is released on room close; paint invalidation is region-narrowed. Hover
affordances (reply/reaction pills) hang outside the row rect — narrow repaint regions must
include
affordanceDirtyRegion()or the pills ghost. - Notifications are per-event (Rust → FFI → C++ signal, gated on
origin() == Sync+ push-rule eval). Room invites are a separate path. Mute uses raw push-rule PUTs (the SDK API 413s on DELETE) and must re-enable the rule explicitly. - Forwarding must reuse the original
MediaSource(file/key/iv) — rebuilding::plainfrom the mxc string breaks encrypted media. - Video streams through a Rust
127.0.0.1loopback proxy (HTTP Range + on-the-fly seekable AES-CTR decrypt), not full-download; it stops on logout. Transient cold-cache read timeouts look like corruption but aren't — they auto-retry. - Logout renames stores into
.trash/(atomic) then deletes off-path; a startup sweep reclaims leftovers. The_loggingOutlatch must reset on login/restore.
- C++: create
.h/.cppunder the rightsrc/subdir and add both to thetelematrix_coresource list inCMakeLists.txt(the app + tests both link it).Q_OBJECTtypes are moc'd automatically byCMAKE_AUTOMOC. Testable helpers belong intelematrix_core, not the app-only source list, so atst_*.cppcan link them. - Rust: add a new
service.rsmodule (one responsibility per file) andmodit inlib.rs; expose new capability across the FFI via atm_*function inffi.rs.
- Don't hardcode colors/sizes/fonts — use
st::constants. - Don't use raw Qt widgets where a
src/ui/equivalent exists. - Don't reinvent painting/layout — port from tdesktop.
- Don't break compilation to "clean up later."
- Don't add Telegram-specific concepts that Matrix doesn't have (MTProto, channels, bots, premium, stories).
- Don't modify
lib/submodule files. - Don't reintroduce a custom E2EE redecryption loop, or remove the event-cache subscribe.