Architecture of Flying Paper
Thu, Aug 20, 2026 · 4 min read
When I started designing the Flying Paper Telegram client, I stumbled upon a software design issue. A software design issue like this, left unsolved, makes the program chaotic and unresponsive — leading to duplicated code and fewer features.
Receiving responses
TDLib sends requests asynchronously and sending requests never blocks the program, but waiting for a response does. If that wait happens on the GTK UI thread, the interface freezes, because GTK's main loop has to keep processing events to stay responsive and a blocking call stalls everything, including rendering. So the program needs a dedicated thread that receives responses and queues them.
Each widget owns a specific part of the UI and displays data that ultimately comes from TDLib. TDLib lets you attach a request identifier to every request and guarantees the response carries that same identifier back, so a widget can send its own request and be the only one that receives the answer.
std::unordered_map<std::uint64_t, Callback> handlers;
void send(Request request, Callback callback) {
std::uint64_t id = ++this->query_id_counter;
if (callback) {
std::lock_guard<std::mutex> lock(handlers_mtx);
this->handlers.emplace(id, std::move(callback));
}
this->client_manager->send(this->client_id, id, std::move(request));
}
By storing a callback for each request sent, the receiver thread can look it up when a response arrives and dispatch it once the request identifiers match.
Callback callback;
{
std::lock_guard<std::mutex> lock(handlers_mtx);
auto it = handlers.find(request_id);
if (it != handlers.end()) {
callback = std::move(it->second);
handlers.erase(it);
}
}
if (callback)
peel::GLib::idle_add(
[cb = std::move(callback), obj_copy = shared_obj]() {
cb(obj_copy);
return G_SOURCE_REMOVE;
});
Some widgets need updates that are received from the backend, not in response to a request. A widget can subscribe to an object type identifier and have its own callback dispatched whenever an update matching that identifier arrives.
Sharing data between widgets
Widgets may need to share information between each other; they will have to pass data as parameters if they are close to each other in the UI Tree. It will become redundant that every widget must hold a parameter and pass it to another widget that may not need this data. I needed a map to store a key of the data that allows two components in different parts of the UI to access and share data at the same memory in runtime. The widgets need a context to share data between each other.
std::unordered_map<std::string, std::shared_ptr<void>> contexts;
template <typename T>
RequestHandle request_context(std::string name, Request request) {
std::lock_guard<std::mutex> lock(registry_mtx);
RequestHandle request_id = next_request_id++;
if (contexts.contains(name)) {
auto ctx = contexts[name];
peel::GLib::idle_add([_ctx = ctx, request]() {
request(_ctx);
return G_SOURCE_REMOVE;
});
return request_id;
}
pending_requests[name].push_back({request_id, request});
return request_id;
}
template <typename T>
std::shared_ptr<T> set_context(std::string_view name, T &ctx) {
auto ptr = std::make_shared<T>(ctx);
std::lock_guard<std::mutex> lock(registry_mtx);
std::string key(name);
contexts[key] = ptr;
if (pending_requests.contains(key)) {
for (auto &pair : pending_requests[key]) {
peel::GLib::idle_add([_ptr = ptr, _task = pair.second]() {
_task(_ptr);
return G_SOURCE_REMOVE;
});
}
pending_requests.erase(key);
}
return ptr;
}
Two widgets at runtime can share one or more contexts. A context can be requested when a signal is fired or at the composition of the widget. Since data might not be ready when it is requested, a callback is saved and dispatched when context data is set.
Conclusion
With this design, the program guarantees asynchronous execution without freezing the UI, and widgets can now share information through a context allowing a safe data exchange. Subscriptions let a widget react to updates it never asked for. And contexts let two widgets in different parts of the UI share the same data without ever knowing about each other or passing parameters down the tree. Together, these three mechanisms mean no widget calls another widget directly, and no widget ever blocks waiting on TDLib.