Read @FORK.md for this project's fork-management rules (remotes, forked-submodule registry, ayu code surface, sync workflows).
For fork-management tasks use the project skills under .claude/commands/: /sync-tg, /sync-ayu.
This guide defines repository-wide instructions for coding agents working with the Telegram Desktop codebase.
Avoid building the project.
If you're asked to create a Pull Request, then clearly state in PR description that it was AI generated.
In this repository "task" is a specific term. It always means one work record in
the sibling ai-tdesktop repository, never a TODO comment, a checklist item,
or a unit of work invented during the current conversation. "The tasks", "the
queue", "the board", "what's most pressing", and a bare task slug all refer to
that queue.
- The queue lives in
../ai-tdesktop, a sibling of this checkout, with one record per directory undertasks/YYYY/MM/DD/<slug>/. A task id is that dated path, for example2026/07/18/fix-community-forward, and it is the only durable link between a source commit and its task. Linked slot worktrees live in../ai-tdesktop-worktrees. Read../ai-tdesktop/AGENTS.mdbefore doing anything inside that repository. - Each record holds
task.md— one#title line, then one self-contained paragraph that is the task's description wherever it is summarized — andstate.yamlwithstatus,type,depends_on,claimed_by, and dates. Open statuses aretodo,in-progress,blocked, andsplit-required;approvedis completed history, andsplit.yaml/superseded.yamlmark retired records. - To browse the queue, read those files directly. Do not run
python3 ai.py: it is a full-screen browser for the user's own terminal and refuses to run in a pipe. A compact open listing:
cd ../ai-tdesktop && for state in tasks/*/*/*/*/state.yaml; do
status=$(sed -n 's/^status: //p' "$state")
case "$status" in todo|in-progress|blocked|split-required)
dir=$(dirname "$state")
printf '%-14s %-70s %s\n' "$status" "${dir#tasks/}" \
"$(sed -n '1s/^# //p' "$dir/task.md")";;
esac
done | sort- When ranking open work by urgency, use
in-progress, thenblocked, thensplit-required, then readytodo(noclaimed_by, and everydepends_onalreadyapproved), thentodostill waiting on a dependency. Say who owns claimed work. This checkout's tag is theTelegram/build/ai-machine-tagvalue plus the checkout folder name, such asmacbook-tdesktop, and work claimed by another checkout is never taken over without an explicit human reassignment. - Browsing is read-only. Listing, summarizing, comparing, or recommending tasks
never edits
state.yaml, publishes a lifecycle commit, or starts implementation, no matter how small the task looks. - Acting on an existing queue task goes through the workflow skills instead of
by hand:
perform-task <slug or full id>starts or resumes and performs exactly one known task, andcontinue <batch specification>runs the batch of tasks the specification selects. Source commits owned by a task use the three-line form described under## Commits. - A request the user makes in conversation is ordinary work: do it directly in
this checkout. Do not write it to the ignored
../ai-tdesktop/inbox/inbox.md, and do not runprocess-inboxorcontinueover it, unless the user asks for that. The queue's planning, review and evidence campaign costs hours, so spending it on a request the user expected you to just do is a real error, not thoroughness. - You may offer the queue when the work genuinely earns it: a large or ambiguous change, one whose correctness needs a real testing campaign, or one that is safety-, security- or data-safety-critical. Make the offer in one line, say why, and route it only after the user approves. Absent approval, implement it directly and say what you skipped.
- Never guess between similarly named tasks. Report the matching full ids and let the user choose.
This checkout may be opened in Codex Desktop through the Windows UNC path \\wsl.localhost\{distro}\home\{user}\Telegram\tdesktop, while the real Linux path is /home/{user}/Telegram/tdesktop. Treat it as a WSL/Linux checkout first, not as a native Windows checkout.
- Prefer running repository-aware commands through WSL:
wsl.exe -d {distro} --cd /home/{user}/Telegram/tdesktop -- <command>- PowerShell can read and write files through the UNC path, but native Windows tools may see different ownership, path, executable, or line-ending behavior than Linux tools.
- Git from PowerShell over
\\wsl.localhost\...can fail withdetected dubious ownership. Use WSL Git instead. Do not change global Gitsafe.directorysettings unless the user explicitly asks for that. - Keep path styles matched to the shell. Use
/home/{user}/Telegram/tdesktop/...with WSL commands, and quoted\\wsl.localhost\{distro}\home\{user}\Telegram\tdesktop\...paths with native Windows commands. Avoid passing UNC paths to Linux tools or Linux paths to native Windows tools unless the tool explicitly supports them. - If a command behaves strangely from the PowerShell UNC working directory, retry the same command through
wsl.exe -d {distro} --cd /home/{user}/Telegram/tdesktop -- ...before concluding the repository or command is broken. - Recursive searches and repo inspection are usually faster and more faithful through WSL, for example
wsl.exe -d {distro} --cd /home/{user}/Telegram/tdesktop -- rg .... - Do not assume the WSL host has the build toolchain installed directly. In this setup, WSL may not have
cmake, while Windows may havecmake, and the configuredout/tree may still target the Linux Docker toolchain. Do not run native Windowscmake --build outagainst a Linux/Docker build tree. - For WSL/Linux builds, use the Docker build entry point from the repository root:
Telegram/build/docker/centos_env/build_debug.sh. The Docker daemon must be reachable from WSL; checkingdocker infois fine, but do not start a build unless the user asked for one. - Existing build outputs may be Linux binaries, for example
out/Debug/Telegramas an ELF executable, notTelegram.exe. Verify the build tree before assuming which platform produced it. - Be careful with text file line endings. In a WSL/Linux checkout, files should remain LF-only unless the file already uses another convention. CRLF finishing applies only to native, non-WSL Windows runs/checkouts. Do not let PowerShell or Windows tools silently rewrite WSL files to CRLF. If a file becomes mixed, normalize it back to the convention appropriate for the current checkout, without adding a UTF-8 BOM.
The build system expects this directory layout:
L:\Telegram\ # BuildPath
L:\Telegram\tdesktop\ # Repository (you work here)
L:\Telegram\Libraries\ # 32-bit dependencies (Linux/macOS)
L:\Telegram\win64\Libraries\ # 64-bit dependencies (Windows)
L:\Telegram\ThirdParty\ # Build tools (NuGet, Python, etc.)
Dependencies are located relative to the repository: ../Libraries, ../win64/Libraries, or ../ThirdParty.
For explicitly requested dependency updates and backports, use the dependency
repositories' master branches directly. Do not create codex/* or other topic
branches in Libraries/patches, Libraries/tg_owt, or Libraries/tg_angle.
The scheduled dependency monitor remains advisory and does not implement fixes.
- Before editing each dependency repository, inspect its working tree and
current branch, fetch
origin, and verify that the checkout is onmaster, tracksorigin/master, and is synchronized with the fetched remote branch. Switch tomasterwhen necessary and fast-forward it when possible. Review divergence before integrating it; preserve unrelated edits, untracked library checkouts and local commits. Do not reset them away or publish unrelated local commits implicitly. - Commit fixes to our copied dependencies, including WebRTC and ANGLE, directly
in
tg_owtandtg_angle. For external dependencies such as Qt and FFmpeg, commit patch files toLibraries/patchesand apply them consistently through the applicable prepare, Docker and Snap recipes. Keep intentional differences between platform versions and do not add patch copies inside tdesktop. - Keep each independent fix in a separate commit and retain its upstream provenance. Review applicability, prerequisites and patch interactions before committing. Respect the requested validation scope; do not rebuild dependencies unless the user explicitly requests it.
- Push
mastertoorigin/masterwithout force, fetch again and verify that every new dependency commit is reachable from the publishedorigin/master. Only then update the applicable tdesktop revisions to those published SHAs. If integration changes a SHA, update every recipe that consumed the old one. A commit available only on a local or topic branch is not a finished update.
From repository root, run:
cmake --build out --config Debug --target TelegramThat's it. The out/ directory is already configured. The executable will be at out/Debug/Telegram.exe.
From WSL, run through the Linux Docker build environment:
Telegram/build/docker/centos_env/build_debug.shImportant: When running cmake from a shell that doesn't support cd, use quoted absolute paths:
cmake --build "l:\Telegram\tx64\out" --config Debug --target TelegramNever build Release - it's extremely heavy and not needed for testing changes.
On macOS, never run an Xcode clean against out/ - not --target clean,
not --clean-first with any target, not xcodebuild ... clean and not a
Clean in the Xcode app. Xcode's build-products folder for Telegram is
out/Debug, which also holds the portable account folders. When out/Debug
carries the com.apple.xcode.CreatedByBuildSystem extended attribute,
--target clean deletes out/Debug with every folder in it, and
--target Telegram --clean-first does the same before it builds. Without the
attribute, --target clean refuses to delete out/Debug and ends
** CLEAN FAILED ** (exit 65). It ends the same way when it refuses only
another, unmarked products folder and deletes a marked out/Debug, so exit 65
alone does not prove that out/Debug survived. To force a full recompile,
follow the out/build deletion in "### Forcing a full recompile on macOS"
under "## Troubleshooting" exactly as written there, its precondition and its
checks before and after the rebuild included. Never mark out/Debug deletable
with xattr -w com.apple.xcode.CreatedByBuildSystem true, as the note in
Xcode's refusal suggests, and never move, rename or remove any out/Debug
entry to make a clean succeed.
- Requires Visual Studio 2022
- Must run from appropriate Native Tools Command Prompt:
- "x64 Native Tools Command Prompt" for
win64 - "x86 Native Tools Command Prompt" for
win - "ARM64 Native Tools Command Prompt" for
winarm
- "x64 Native Tools Command Prompt" for
- Dependencies:
../win64/Libraries(64-bit) or../Libraries(32-bit)
- Requires Xcode
- Dependencies:
../Libraries/local/Qt-* - First-time configure of a new
out/tree: set theQTenvironment variable to the Qt version this checkout actually has, for exampleexport QT=6.11.1when../Libraries/local/Qt-6.11.1is the installed one. The value names a directory, so it is the checkout's version, not a constant. - Reconfiguring an existing
out/tree: do not exportQT. Runenv -u QT cmake -S . -B outinstead.cmake/external/qt/package.cmakewrites an exportedQTinto theqt_requestedcache entry withFORCE, so it overwrites the version the tree was already configured with instead of being ignored. A value that disagrees with the configured tree then fails the regeneration, and the generated project keeps the source list it was last generated with: newly added sources are never compiled — which surfaces later as undefined-symbol link errors rather than as a configure error — and newly added.stylemodules leave the generatedstyle_*.hincludes stale.
- Build dependencies in
../Libraries - Set
QTenvironment variable if needed
Telegram/build/version- Version informationout/- Build output directory
Ensure the repository is in L:\Telegram\tdesktop. The build system requires ../win64/Libraries to exist.
On Windows, use the correct Visual Studio Native Tools Command Prompt matching your target (x64/x86/ARM64).
After an incremental Xcode build that regenerated lang.strings outputs, the
app can link a new generated key lookup with stale objects that still use an
older kKeysCount. The characteristic failure is:
- the Debug log stops immediately after
Lang Info: Loaded cached, keys: ...; - stderr and
tdata/workingmay be empty; - a fresh
~/Library/Logs/DiagnosticReports/Telegram-*.ipsshowsSIGABRTfromstd::vector<unsigned char>::operator[], thenLang::Instance::applyValue(),fillFromSerialized(), andLocal::readLangPack().
If this exact startup failure repeats twice, do not change the implementation,
test overlay, or portable account. Rebuild every object once by following the
out/build deletion in "### Forcing a full recompile on macOS" exactly as
written there, its precondition and checks included, never with an Xcode clean
(see the macOS statement in "### Build Commands" under
"## Build Configuration"). Then rerun the same test once. If the signature
persists after that clean rebuild, continue normal crash diagnosis or report
the blocker. Do not loop clean rebuilds.
Start only when no build of this checkout is running and this checkout's exact
Telegram process is stopped. Never stop any other process for this procedure.
Outside the autonomous continue / perform-task workflow, stopping that
process follows "### Build output locks": the user may be testing it, so ask
the user first and never terminate it without explicit permission. Then
rebuild every object by deleting the intermediate tree out/build, never with
an Xcode clean (see the macOS statement in "### Build Commands" under
"## Build Configuration").
The generated project keeps every intermediate (object files, precompiled
headers and Xcode's build database) under out/build, its SYMROOT, and
only build products in out/Debug and the other per-target Debug folders.
Deleting out/build therefore works the same whether or not out/Debug
carries the com.apple.xcode.CreatedByBuildSystem attribute, and it touches
nothing in out/Debug. From the repository root, record what out/Debug
holds, delete the intermediates and confirm that out/build is gone:
test -d out/Telegram.xcodeproj &&
ls -A out/Debug | sort > out/language-rebuild-mark.txt &&
rm -rf out/build &&
test ! -e out/build &&
echo 'out/build is gone'It must print out/build is gone: no intermediate object survived. Object
files elsewhere in out/, such as CMake's compiler checks or a release
build's tree, belong to other builds; leave them alone. This step has no
accepted failure: an error, other output or no output stops the procedure.
Then run one full Debug rebuild, which has to compile every object again:
cmake --build out --config Debug --target TelegramIt must end ** BUILD SUCCEEDED **. Then check that the rebuild compiled every
object and that nothing in out/Debug was lost:
test -f out/language-rebuild-mark.txt &&
find out/build -name '*.o' ! -newer out/language-rebuild-mark.txt | wc -l &&
find out/build -name '*.o' | wc -l &&
ls -A out/Debug | sort | comm -23 out/language-rebuild-mark.txt -It must print 0 (no object file is older than the mark written before
out/build was deleted), then a nonzero object count, and nothing else (every
entry out/Debug held before is still there). Anything else stops the
procedure; report what it printed.
An exact checkout executable may be running because the user is testing it. Do not terminate it or delete locked build outputs without explicit permission. Report the exact locked path and ask the user to close that checkout's Telegram or debugger before rebuilding.
- Always use Debug builds - Release builds are extremely heavy
- Don't build Release configuration - it's too heavy for testing
Production translation units stay free of debug machinery. The permanent test
harness lives in Telegram/SourceFiles/test/, and the disposable -testagent
overlay owns per-task instrumentation; production code carries at most a thin
single-call seam (a Test::Fire()-style waitpoint or a live-object
publication at a construction seam).
- Do not add
#ifdef _DEBUGblocks, debug-only types, debug state, mutexes, counters, or observation structs to production headers or sources. When a behavior cannot be observed without such machinery, that is a harness gap: extend thetest/helpers or the overlay instead. - Never commit debugging machinery interleaved with working code in the same
translation unit. A permanent helper belongs under
test/; a temporary one belongs in the overlay and is never retained. - Exceptions exist — a few
#ifdef _DEBUGhooks are deliberately kept in production files — but they are exceptions and each new one needs a solid, stated reason. "The test needed it" is not one.
- On Windows, keep project text files with CRLF line endings.
- Do not save source, header, build/config, style, or localization files as UTF-8 with BOM. Use UTF-8 without BOM.
- When rewriting project text files for normalization, preserve file content otherwise and do not introduce a BOM.
- Subject: one concise, plain-language line summarizing the change, ~50-60 characters, matching the style of recent
git logsubjects. This is usually the entire message. - Add a short plain-language body only when the subject can't carry it (what was done, not the technical how) — a line or two at most.
- Never add a
Co-Authored-By:line or any tool/assistant attribution trailer. - Never add
Autotask:/attempt or other workflow markers. Commits should read like normal project history.
Both app-level (Core::Settings) and session-level (Main::SessionSettings) use sequential binary serialization via QDataStream. Key rules:
- New fields must ALWAYS be appended at the end of the stream, never inserted in the middle
- Reading new fields must be guarded with
!stream.atEnd()and provide a meaningful default/fallback - Inserting in the middle breaks reading of data saved by older versions (the new read code consumes bytes that belong to subsequent fields)
- For simple flags and values, prefer using the generic KV prefs facility (
writePref<Type>/readPref<Type>) instead of adding to the binary stream -- this avoids serialization ordering issues entirely
Telegram/SourceFiles/AGENTS.md is the source atlas: every area of the
tree with its paths and responsibility. A directory may also
have its own AGENTS.md: the map of that area's modules, placement rules,
invariants, and things to leave alone. Before adding code, read the atlas
entry of the area you touch and the nearest map, and put new code where they
say. A change that needs a different shape amends the map in the same task.
How the atlas and maps are written and how changes are checked against them
is in .agents/skills/code-health/SKILL.md.
Comments are rationed:
A comment is one line; two or three only when the block opens with // WHY:. A commit may add two comment lines plus one such exception, and a trailing comment is a line too; only } // namespace X closers and #endif // X labels are free. Say why, never what. Hooks enforce it, and Telegram/SourceFiles/test/ is exempt.
Do not remove existing comments just to satisfy this rule. Preserve comments unless your change makes them incorrect or truly obsolete; when moving or refactoring code, move the useful comment with it. Inline comments that label positional arguments for generated or schema-driven APIs (for example TL/MTP constructors) are useful because the field names are not visible in the call itself.
Never cite line numbers in comments, README.md files or other checked-in text. Name the function, member, constant or type, and its file when that helps: write Runner::finish() in test_runner.cpp, not test_runner.cpp:NNN or :NNN-MMM. Line numbers drift with every edit and nothing updates them. This covers submodule, Qt and other third-party sources too. Quoted log or crash text stays verbatim.
// BAD - don't do this:
// Get the user's name
auto name = user->name();
// Check if premium
if (user->isPremium()) {
// GOOD - no comments needed, code is self-explanatory:
auto name = user->name();
if (user->isPremium()) {
// ACCEPTABLE - one line, and it carries a reason:
_limit = kDefaultLimit; // the server rejects anything larger
// ACCEPTABLE - the exception: opens with WHY, three lines at most,
// once per commit.
// WHY: the server sends the id before the peer exists, so the row is
// created empty and filled on the next update, or the list flickers
// on every reconnect.Style and formatting rules are in REVIEW.md — see that file for empty-line-before-closing-brace, operator placement in multi-line expressions, if-with-initializer, and other mechanical style rules.
Never discard a result with a cast: static_cast<void>(...) and (void)expr are banned; instead of silencing [[nodiscard]], fix the design.
Use auto for type deduction:
Prefer auto (or const auto, const auto &) instead of explicit types:
// Prefer this:
auto currentTitle = tr::lng_settings_title(tr::now);
auto nameProducer = GetNameProducer();
// Instead of this:
QString currentTitle = tr::lng_settings_title(tr::now);
rpl::producer<QString> nameProducer = GetNameProducer();Use trailing return types only when the normal form is too long:
Prefer the normal return type form when the opening line fits comfortably, roughly around 77 characters or less:
// GOOD:
[[nodiscard]] TextWithEntities FlattenSummaryBlocks(
const std::vector<Block> &blocks);Do not use one-line trailing return types, or put the trailing return type after ) on the same line. If it fits on one line with trailing syntax, the normal form would be shorter and easier to read:
// BAD:
auto ComputeTitle() -> QString;
// BAD:
[[nodiscard]] auto FlattenSummaryBlocks(
const std::vector<Block> &blocks) -> TextWithEntities;Use auto with a trailing return type only when the normal opening line
{attributes} {return-type} {class-name::}{function-name(} would be too long, or would force the return type onto its own line. Put the arrow and return type on the next line so the return type remains easy to find:
// BAD:
not_null<HistoryView::Controls::ComposeAiButton*>
HistoryView::Controls::SetupCaptionAiButton(SetupCaptionAiButtonArgs &&args);// GOOD:
auto HistoryView::Controls::SetupCaptionAiButton(
SetupCaptionAiButtonArgs &&args)
-> not_null<HistoryView::Controls::ComposeAiButton*>;This applies to both declarations and definitions.
Use _q for QString literals:
Prefer the project literal u"..."_q instead of the verbose QStringLiteral("...") macro when creating QString values:
// Prefer this:
auto text = u"Settings"_q;
// Instead of this:
auto text = QStringLiteral("Settings");Never use Q_OS_LINUX for platform checks in new code:
Telegram Desktop distinguishes at most three platforms: Windows / macOS / all-other. The "all-other" branch covers Linux, the BSD variants and more — and this is almost always the branch you want. Q_OS_LINUX narrows it to Linux alone, silently excluding the non-Linux Unix platforms, which is almost never intended. For the all-other branch use !defined Q_OS_WIN && !defined Q_OS_MAC at compile time, or its runtime equivalent Platform::IsLinux() — which, despite the name, means exactly !defined Q_OS_WIN && !defined Q_OS_MAC ("everything except Windows and macOS"), not Linux specifically:
// BAD - excludes FreeBSD and other non-Linux Unix:
#ifdef Q_OS_LINUX
UnixSpecificCode();
#endif // Q_OS_LINUX
// GOOD - the all-other branch, compile time:
#if !defined Q_OS_WIN && !defined Q_OS_MAC
UnixSpecificCode();
#endif // !Q_OS_WIN && !Q_OS_MAC
// GOOD - the all-other branch, runtime (same meaning, NOT Linux-only):
if (Platform::IsLinux()) {
UnixSpecificCode();
}Q_OS_LINUX is only for the rare case where you genuinely want exactly Linux and not the other Unix-like systems — usually you don't. The few existing uses (Telegram/SourceFiles/core/sandbox.cpp, Telegram/SourceFiles/platform/linux/specific_linux.cpp) are such genuinely Linux-only code paths and stay as-is.
Treat CMake LINUX as the all-other platform:
In this project, cmake/validate_special_target.cmake sets LINUX in the
final else() after checking WIN32 and APPLE. It therefore means
NOT WIN32 AND NOT APPLE, including non-Linux Unix platforms; it does not
mean exactly Linux. For the usual three-way platform split, write:
if (WIN32)
set(platform_source platform/win.cpp)
elseif (APPLE)
set(platform_source platform/mac.mm)
else()
set(platform_source platform/linux.cpp)
endif()
target_sources(my_target PRIVATE ${platform_source})Do not add a separate fallback branch after if (LINUX) as though LINUX
were one platform among several remaining platforms. There are no remaining
platforms in this project's CMake platform model.
Prefer cppgir wrappers over the GLib C API:
When implementing all-other-platform code with GLib, GObject, or GIO, use the
generated cppgir C++ bindings under gi::repository as much as possible.
Prefer their GLib, GObject, and Gio types, ownership handling, results,
and callbacks over raw g_*, g_object_*, and g_io_* APIs. Use the C API
only when cppgir does not expose the required functionality or at a narrow
interop boundary that genuinely requires raw GLib types, and keep that raw
API surface as small as possible.
Generate typed D-Bus bindings from introspection XML:
For a D-Bus interface known at build time, prefer the CMake generate_dbus
function from cmake/external/glib/generate_dbus.cmake over handwritten
GDBusProxy calls, stringly typed method and signal names, or manually
maintained C wrappers. Its signature is:
generate_dbus(
target_name
interface_prefix
namespace
interface_file)target_name is the existing target that will use the bindings,
interface_prefix is the common D-Bus interface prefix passed to
gdbus-codegen, namespace names the generated API, and interface_file is
the D-Bus introspection XML file. Include the helper and call it inside the
all-other-platform branch:
include(${cmake_helpers_loc}/external/glib/generate_dbus.cmake)
generate_dbus(
my_target
org.example.
Example
${src_loc}/platform/linux/org.example.Service.xml)The helper runs gdbus-codegen, generates proxy, skeleton, and object-manager
types, produces GIR metadata, wraps that metadata with cppgir, and links the
result into target_name. Consume the resulting typed API from
gi::repository::Example (using the namespace argument from the example);
do not edit or separately list files under the build gen directory. Use
generic GLib D-Bus calls only when the interface is genuinely dynamic or
cannot be represented by suitable introspection XML.
API definitions use TL Language:
Telegram/SourceFiles/mtproto/scheme/mtproto.tl- MTProto protocol (encryption, auth, etc.)Telegram/SourceFiles/mtproto/scheme/api.tl- Telegram API (messages, users, chats, etc.)
Standard pattern using api(), generated MTP... types, and callbacks:
api().request(MTPnamespace_MethodName(
MTP_flags(flags_value),
MTP_inputPeer(peer),
MTP_string(messageText),
MTP_long(randomId),
MTP_vector<MTPMessageEntity>()
)).done([=](const MTPResponseType &result) {
// Handle successful response
// Multiple constructors - use .match() or check type:
result.match([&](const MTPDuser &data) {
// use data.vfirst_name().v
}, [&](const MTPDuserEmpty &data) {
// handle empty user
});
// Single constructor - use .data() shortcut:
const auto &data = result.data();
// use data.vmessages().v
}).fail([=](const MTP::Error &error) {
// Handle API error
if (error.type() == u"FLOOD_WAIT_X"_q) {
// Handle flood wait
}
}).handleFloodErrors().send();Key points:
- Always refer to
api.tlfor method signatures and return types - Use generated
MTP...types for parameters (MTP_int,MTP_string, etc.) - For multiple constructors, use
.match()or check.type()againstmtpc_constants then call.c_constructorName():// Using match: result.match([&](const MTPDuser &data) { ... }, [&](const MTPDuserEmpty &data) { ... }); // Or explicit type check: if (result.type() == mtpc_user) { const auto &data = result.c_user(); // asserts on type mismatch }
- For single constructors, use
.data()shortcut - Include
.handleFloodErrors()before.send()in rare cases where you want special case flood error handling - Silently ignore HTTP 406 errors in UI: the server uses 406 to mean "show nothing to the user". Guard toasts with
MTP::IgnoreError(error)or useMTP::ShowErrorFallback(show, error)(both inmtproto/mtproto_response.h) which showserror.type()as a toast unless the error should be ignored.
api().request(...) callbacks are owned by the session, not by whatever created
them. A .done() / .fail() handler stays alive for the whole session lifetime,
so a handler that captured a widget, a box, a controller, or any shorter-lived
state still runs after that state is gone. A plain [=] capture warns about
nothing, which makes this one of the easiest ways to write a use-after-free here.
Capturing only plain values or session-owned objects is fine. When anything captured can die before the session does, pick one of three:
1. Guard the callback with crl::guard. The request is always sent; the
handler is skipped when the context is gone. Use when the call itself must reach
the server and only the local reaction is optional.
api().request(MTPmethod(
...
)).done(crl::guard(this, [=](const MTPResult &result) {
// runs only while `this` is still alive
})).send();Accepted guards, in rough order of how often they are used: a raw pointer or
not_null to any QObject-derived type — widgets, boxes, controllers — where the
QPointer is created on the spot, so passing this is the normal case; a raw
pointer or not_null to a base::has_weak_ptr type; QPointer, QWeakPointer,
QSharedPointer; base::weak_ptr, base::weak_qptr; std::weak_ptr,
std::shared_ptr; and base::binary_guard.
2. Remember the mtpRequestId and cancel it. Cancel when the result stops
being relevant, and in the destructor. The request may never reach the server —
if it is still queued when cancelled, or connectivity dies first, it is simply
dropped — so never use this when the call itself has to happen.
_requestId = api().request(MTPmethod(
...
)).done([=](const MTPResult &result) {
_requestId = 0;
...
}).send();
// when the result is no longer relevant, and in the destructor:
api().request(base::take(_requestId)).cancel();3. Own an MTP::Sender. Its destructor cancels everything it sent that is
still in flight, so request lifetime follows the owner with no bookkeeping. Same
delivery caveat as (2). Prefer this for a widget, box, or controller that issues
more than a request or two.
// header
MTP::Sender _api;
// constructor initializer list
, _api(&session->mtp())
// requests sent through it die with the owner
_api.request(MTPmethod(
...
)).done([=](const MTPResult &result) {
...
}).send();Choosing between them: if the server must see the request, use (1). If it only
matters while its owner is alive, use (3) — or (2) when a single request does not
justify a Sender member.
UI styles are defined in .style files using custom syntax:
using "ui/basic.style";
using "ui/widgets/widgets.style";
MyButtonStyle {
textPadding: margins;
icon: icon;
height: pixels;
}
defaultButton: MyButtonStyle {
textPadding: margins(10px, 15px, 10px, 15px);
icon: icon{{ "gui/icons/search", iconColor }};
height: 30px;
}
primaryButton: MyButtonStyle(defaultButton) {
icon: icon{{ "gui/icons/check", iconColor }};
}
Built-in types:
int- Integer numbers (e.g.,maxLines: 3;)bool- Boolean values (e.g.,useShadow: true;)pixels- Pixel values withpxsuffix (e.g.,10px)color- Named colors fromui/colors.paletteicon- Inline icon definition:icon{{ "path/stem", color }}margins- Four values:margins(left, top, right, bottom)size- Two values:size(width, height)point- Two values:point(x, y)align- Alignment:align(center),align(left)font- Font:font(14px semibold)double- Floating point
Multi-part icons (layers drawn bottom-up):
myComplexIcon: icon{
{ "gui/icons/background", iconBgColor },
{ "gui/icons/foreground", iconFgColor }
};
Borders are typically separate fields, not a single property:
chatInput {
border: 1px; // width
borderFg: defaultInputFieldBorder; // color
}
Never hardcode sizes in code:
The app supports different interface scale options. Style px values are automatically scaled at runtime, but raw integer constants in code are not. Never use hardcoded numbers for margins, paddings, spacing, sizes, coordinates, or any other dimensional values. Always define them in .style files and reference via st::.
// BAD - breaks at non-100% interface scale:
p.drawText(10, 20, text);
widget->setFixedHeight(48);
auto margin = 8;
auto iconSize = QSize(24, 24);
// GOOD - define in .style file and reference:
p.drawText(st::myWidgetTextLeft, st::myWidgetTextTop, text);
widget->setFixedHeight(st::myWidgetHeight);
auto margin = st::myWidgetMargin;
auto iconSize = st::myWidgetIconSize;Duration constants: Animation durations should NOT go in .style files, this is a legacy approach. Prefer constexpr auto kName = crl::time(N) in an anonymous namespace in the relevant .cpp file.
#include "styles/style_widgets.h"
// Access style members
int height = st::primaryButton.height;
const style::icon &icon = st::primaryButton.icon;
style::margins padding = st::primaryButton.textPadding;
// Use in painting
void MyWidget::paintEvent(QPaintEvent *e) {
Painter p(this);
p.fillRect(rect(), st::chatInput.backgroundColor);
}Strings are defined in Telegram/Resources/langs/lang.strings:
"lng_settings_title" = "Settings";
"lng_confirm_delete_item" = "Are you sure you want to delete {item_name}?";
"lng_files_selected#one" = "{count} file selected";
"lng_files_selected#other" = "{count} files selected";
Immediate (current value):
auto currentTitle = tr::lng_settings_title(tr::now);
auto currentConfirmation = tr::lng_confirm_delete_item(
tr::now,
lt_item_name, currentItemName);
auto filesText = tr::lng_files_selected(tr::now, lt_count, count);Reactive (rpl::producer):
auto titleProducer = tr::lng_settings_title();
auto confirmationProducer = tr::lng_confirm_delete_item(
lt_item_name,
std::move(itemNameProducer));
auto filesTextProducer = tr::lng_files_selected(
lt_count,
countProducer | tr::to_count());Key points:
- Pass
tr::nowas first argument for immediateQString - Omit
tr::nowfor reactiverpl::producer<QString> - Placeholders use
lt_tag_name, valuepattern - For
{count}: immediate usesint, reactive usesrpl::producer<float64>with| tr::to_count() - Every plural form,
#oneincluded, keeps{count}: many languages put more than 1 into their "one" category (Russian: 21, 31, ...), so a#onephrase written for exactly 1 is wrong for them. When exactly 1 deserves different wording, add a separate non-plural key and pick it in code, so every language gets that special case - Move producers with
std::movewhen passing to placeholders - Rich text projectors — these
tr::helpers serve double duty: as the last argument (projector) they set the return type toTextWithEntities, and as placeholder values they wrap individual substitutions in formatting. Always prefer them overUi::Text::Bold(),Ui::Text::RichLangValue, etc. — see REVIEW.md for the full mapping.tr::marked— basic projection, convertsQStringtoTextWithEntitiestr::rich— interprets**bold**/__italic__markup in the stringtr::bold,tr::italic,tr::underline— wrap text in that formattingtr::link— wrap as a clickable linktr::url(u"https://..."_q)— returns a projection that converts text to a link pointing to the given URL; can be passed torpl::mapor directly to atr::lng_...call
// As last argument (projector): auto title = tr::lng_export_progress_title(tr::now, tr::bold); auto text = tr::lng_proxy_incorrect_secret(tr::now, tr::rich); // As placeholder value wrapper + projector: auto desc = tr::lng_some_key( tr::now, lt_name, tr::bold(userName), lt_group, tr::bold(groupName), tr::rich); // Nested tr::lng as placeholder: auto linked = tr::lng_settings_birthday_contacts( lt_link, tr::lng_settings_birthday_contacts_link(tr::url(link)), tr::marked);
Producers represent streams of values over time:
auto intProducer = rpl::single(123); // Emits single value
auto lifetime = rpl::lifetime(); // Manages subscription lifetimestd::move(counter) | rpl::on_next([=](int value) {
qDebug() << "Received: " << value;
}, lifetime);
// Without lifetime parameter - MUST store returned lifetime:
auto subscriptionLifetime = std::move(counter) | rpl::on_next([=](int value) {
// process value
});auto strings = std::move(ints) | rpl::map([](int value) {
return QString::number(value * 2);
});
auto evenInts = std::move(ints) | rpl::filter([](int value) {
return (value % 2 == 0);
});rpl::combine - combines latest values (lambdas receive unpacked arguments):
auto combined = rpl::combine(countProducer, textProducer);
std::move(combined) | rpl::on_next([=](int count, const QString &text) {
qDebug() << "Count=" << count << ", Text=" << text;
}, lifetime);rpl::merge - merges producers of same type:
auto merged = rpl::merge(sourceA, sourceB);
std::move(merged) | rpl::on_next([=](QString &&value) {
qDebug() << "Merged value: " << value;
}, lifetime);Other pipeline starters — besides rpl::on_next, there are:
rpl::on_error([=](Error &&e) { ... }, lifetime)— handle errorsrpl::on_done([=] { ... }, lifetime)— handle stream completionrpl::on_next_error_done(nextCb, errorCb, doneCb, lifetime)— handle all three
The Error template parameter defaults to rpl::no_error: rpl::producer<Type, Error = no_error>.
Key points:
- Explicitly
std::moveproducers when starting pipelines - Pass
rpl::lifetimetoon_...methods or store returned lifetime - Use
rpl::duplicate(producer)to reuse a producer multiple times - Combined producers automatically unpack tuples in lambdas (works with
rpl::map,rpl::filter, andrpl::on_next)
rpl::on_next stores the handler by value. Each emission invokes a copy of that stored handler, so a nested emit does not re-enter the running callable. Writes a mutable lambda makes to its own captures land on the copy and are discarded, so they never become per-subscription state.
// BAD - compiles, runs, and grew is just value > 0 every time:
events | rpl::on_next([last = 0](int value) mutable {
const auto grew = (value > last);
last = value;
if (grew) {
// ...
}
}, lifetime);
// GOOD - state lives beside the handler:
const auto last = lifetime.make_state<int>(0);
events | rpl::on_next([=](int value) {
const auto grew = (value > *last);
*last = value;
if (grew) {
// ...
}
}, lifetime);Keep per-subscription state in lifetime.make_state<T>(), behind a shared_ptr, or on the object that owns the subscription. A mutable lambda that is one-shot — rpl::take(1), a self-destroying subscription, a crl::on_main or crl::async callback, a done or error callback — is not this trap.