How Telegram Desktop Contact Sync and Last Seen Privacy Work: A Deep Dive into the Source Code

Telegram Desktop implements contact synchronization through the AddContactBox class which sends MTPcontacts_ImportContacts requests, while last seen privacy is managed by the UserPrivacy API for server-side rules and GlobalPrivacy for the client-side "hide read-time" flag.

Telegram Desktop maintains separate subsystems for managing your address book and privacy preferences. Understanding how Telegram Desktop contact sync and last seen privacy work requires examining the interplay between MTProto API calls, local data models in Data::Session, and specialized controllers in the settings UI. This article explores the actual implementation in the telegramdesktop/tdesktop repository.

Contact Synchronization Architecture

The contact synchronization pipeline converts phone numbers into UserData objects through a series of MTProto requests and local state updates. The system keeps a specialized index of contacts that have no active chats to populate UI components throughout the application.

The Import Flow in AddContactBox

When a user adds a contact manually, the UI collection happens in Telegram/SourceFiles/boxes/add_contact_box.cpp. The AddContactBox class constructs an MTP_inputPhoneContact object and dispatches an MTPcontacts_ImportContacts request through the session's API wrapper.

// boxes/add_contact_box.cpp – line 57
_addRequest = _session->api().request(
    MTPcontacts_ImportContacts(
        MTP_vector<MTPInputContact>(1,
            MTP_inputPhoneContact(
                MTP_flags(0),
                MTP_long(_contactId),
                MTP_string(phone),
                MTP_string(firstName),
                MTP_string(lastName),
                MTPTextWithEntities()))));

The server responds with MTPcontacts_ImportedContacts, which triggers Data::Session::processUsers to update the local user cache. This mechanism ensures that contact information is immediately available to other UI components without waiting for the next full sync cycle.

Updating UserData and Session State

Once the server response arrives, processUsers iterates through the returned user vector and updates the corresponding UserData objects. The critical state change occurs in UserData::setIsContact, defined in Telegram/SourceFiles/data/data_user.cpp.

// data/data_user.cpp – line 241
if (user->isContact() || user->session().supportMode()) {
    // UI will display the user in the contacts list.
}

This flag determines whether the user appears in contact selection dialogs, share sheets, and the main contacts list. The synchronization happens atomically: the MTProto response updates the local model, and the UI observes these changes through reactive programming patterns (rpl::producer streams).

Maintaining the contactsNoChatsList Index

Data::Session maintains a specialized index called contactsNoChatsList that tracks contacts who do not have an active chat thread. This optimization prevents the UI from scanning the entire user database when displaying pure contact lists (as opposed to chat lists).

// data/data_session.cpp – line 5089
not_null<Dialogs::IndexedList*> Session::contactsNoChatsList() {
    return &_contactsNoChatsList;
}

The index updates whenever UserData::setIsContact changes a user's contact status. This design allows components like the "New Message" dialog or "Share" menus to display contacts instantly without filtering the full dialog list.

Last Seen Privacy Implementation

Last seen privacy operates through two distinct mechanisms: server-side privacy rules that control who can view your online status, and a client-side flag that hides read receipts from other users.

Server-Side Privacy Rules via UserPrivacy

The core privacy logic resides in Telegram/SourceFiles/api/api_user_privacy.cpp. Telegram represents last seen settings as MTP_inputPrivacyKeyStatusTimestamp, which maps to internal UserPrivacy::Rule structures containing the option (Everyone, Contacts, Close Friends, Nobody) plus exception lists.

The conversion between MTProto TL types and internal C++ objects happens through two key functions:

  • RulesToTL: Converts a UserPrivacy::Rule into a vector of MTPInputPrivacyRule objects for transmission to the server
  • TLToRules: Parses server responses into UserPrivacy::Rule structures with populated exception lists
// api/api_user_privacy.cpp – rule → TL
return MTP_vector<MTPInputPrivacyRule>(std::move(result));

When saving changes, the system calls UserPrivacy::save(Key key, const Rule &rule), which constructs and sends an MTPaccount_SetPrivacy request. This ensures that privacy preferences persist across all Telegram clients linked to the account.

Client-Side Hide Read-Time Flag in GlobalPrivacy

The "hide read time" feature (hiding the "last seen just now" timestamps in chats) operates differently from standard privacy rules. It is stored in the account's globalPrivacySettings rather than the standard privacy key system.

The Api::GlobalPrivacy class in Telegram/SourceFiles/api/api_global_privacy.cpp manages this setting:

  • updateHideReadTime(bool) sends MTPaccount_SetGlobalPrivacySettings with the f_hide_read_marks flag
  • hideReadTime() returns an rpl::producer<bool> stream for UI observation
// api/api_global_privacy.cpp – line 224
| (hideReadTime ? Flag::f_hide_read_marks : Flag())

This separation allows the feature to function as a global account setting independent of the per-key privacy rule system.

The LastSeenPrivacyController UI Layer

The settings interface binds these APIs together through LastSeenPrivacyController in Telegram/SourceFiles/settings/settings_privacy_controllers.cpp. This controller creates the toggle for hiding read times and wires it to the GlobalPrivacy API:

// settings_privacy_controllers.cpp – line 96-104
const auto privacy = &controller->session().api().globalPrivacy();
hideReadTimeButton->toggleOn(privacy->hideReadTime())
    ->toggledValue() | rpl::on_next([=](bool value) {
        _hideReadTime = value;
    });

When the user clicks Save, the controller checks for changes and commits them:

// settings_privacy_controllers.cpp – line 73-75
if (privacy->hideReadTimeCurrent() != _hideReadTime) {
    privacy->updateHideReadTime(_hideReadTime);
}

The controller registration happens in Telegram/SourceFiles/settings/sections/settings_privacy_security.cpp, which maps the "Last Seen" settings key to this specific controller implementation.

Practical Code Examples

Importing Contacts Programmatically

To import a contact manually through the API layer:

// Assume we have a running Main::Session* session.
auto request = session->api().request(
    MTPcontacts_ImportContacts(
        MTP_vector<MTPInputContact>(1,
            MTP_inputPhoneContact(
                MTP_flags(0),
                MTP_long(base::RandomValue<quint64>()), // client-side id
                MTP_string(u"+1234567890"_q),          // phone
                MTP_string(u"John"_q),                 // first name
                MTP_string(u"Doe"_q),                  // last name
                MTPTextWithEntities()))));

request.done([=](const MTPcontacts_ImportedContacts &result) {
    // The server returned the imported users.
    session->data().processUsers(result.data().vusers());
}).send();

Reading Current Last Seen Rules

To observe privacy rule changes reactively:

auto &privacy = session->api().userPrivacy();
privacy.value(Api::UserPrivacy::Key::LastSeen)
    | rpl::start_with_next([](const Api::UserPrivacy::Rule &rule) {
        // `rule.option` tells you Everyone/Contacts/CloseFriends/Nobody.
        // `rule.always.peers` & `rule.never.peers` hold exception lists.
    }, lifetime);

Toggling Hide Read-Time

To programmatically toggle the read-time hiding feature:

auto &global = session->api().globalPrivacy();
global.hideReadTime() | rpl::start_with_next([](bool hidden) {
    qDebug() << "Hide read-time currently:" << hidden;
}, lifetime);

// To change it:
global.updateHideReadTime(true);   // hide the read-time indicator

Summary

  • Contact synchronization flows through AddContactBox → MTPcontacts_ImportContacts → Data::Session::processUsers → UserData::setIsContact, with the global contactsNoChatsList index maintaining UI performance.
  • Last seen privacy splits into server-side rules managed by Api::UserPrivacy (converting between TL types and internal Rule objects) and the client-side "hide read-time" flag managed by Api::GlobalPrivacy.
  • The settings UI binds these APIs through LastSeenPrivacyController, which handles both the privacy rule selection and the additional hide-read-time toggle.
  • All contact data lives in Data::Session, making it available to dialogs, share sheets, and peer menus throughout the application lifecycle.

Frequently Asked Questions

How does Telegram Desktop store contacts locally?

Telegram Desktop stores contacts as UserData objects within Data::Session, accessible via the global session instance. The contactsNoChatsList index in data/data_session.cpp maintains a specialized list of contacts without active chats, while the isContact flag on individual UserData objects determines contact status. This design separates the contact relationship (who is in your address book) from the chat history (who you have messaged).

What is the difference between UserPrivacy and GlobalPrivacy?

Api::UserPrivacy manages server-side privacy rules like last seen visibility settings, sending MTPaccount_SetPrivacy requests with TL-encoded rules for specific privacy keys. Api::GlobalPrivacy handles account-wide settings stored in globalPrivacySettings, such as the hide-read-time flag, using MTPaccount_SetGlobalPrivacySettings. The former controls who can see your data; the latter controls whether specific metadata (like exact read times) is transmitted to other users at all.

How does the app handle privacy rule exceptions for specific users?

The UserPrivacy::Rule structure contains always.peers and never.peers vectors that store exception lists. When converting between TL types and internal rules in api/api_user_privacy.cpp, the system preserves these exception UserIDs and ChatIDs. When the server evaluates whether to show your last seen status to a specific viewer, it checks these exception lists after applying the base rule (Everyone, Contacts, etc.), allowing granular control over privacy even within broad categories.

Where does the contact list UI populate its data from?

The contact list UI consumes data from Session::contactsNoChatsList(), which returns a Dialogs::IndexedList pointer maintained in data/data_session.cpp. This index updates reactively whenever UserData::setIsContact changes a user's contact status, ensuring that menus like "Share to Contact" or "New Message" display current contact information without requiring manual refreshes or redundant database queries.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →