Telegram github commits and releases
4.54K subscribers
601 files
20.3K links
Broadcast from the most important Telegram clients' repositories
Download Telegram
UnigramDev/Unigram/develop390bfc85 files, +44/-62
Take the manager locks off the event and callback paths

winrt::event synchronises itself, so m_lock only served to serialise callbacks
against subscribe and unsubscribe — at the price of a lock order inversion with
the managed side, which unsubscribes from inside its own lock while a tgcalls
thread holds this one inside managed code that takes that same lock. Nothing
closed the cycle today, but it was there to be closed.

VoipManager keeps no lock at all now. VoipGroupManager keeps one for the only
state that is really shared, the encrypt and decrypt delegates, and copies them
out rather than holding it across the call: the managed side blocks on a TDLib
round trip in there, and it was doing that while holding the same mutex as every
audio level update and every subscribe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/developd94416d5 files, +22/-3
Stop both managers from their destructor

Stop is what the managed side calls, but nothing guarantees every path gets
there, and destroying a manager without it skips tgcalls' own teardown and
leaves a screencast still capturing loopback audio. Stop is idempotent, so
calling it again from the destructor costs nothing when it already ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/developd75db132 files, +5/-4
Use std::mutex in the loopback capture

winrt::slim_mutex is not deprecated, but every other mutex in Telegram.Native
and Telegram.Native.Calls is std::mutex and this one is uncontended anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/developb4767514 files, +57/-41
Cut the per-frame interop out of the group call callbacks

The E2E path rebuilt its result with a push_back loop over an IIterator, which
is two COM calls per byte on every frame of a conference call; one GetMany into
a pre-sized buffer does it instead. Audio levels appended to the IVector one
participant at a time, ten times a second for the length of the call, and are
now filled into a reserved vector and handed over whole.

Same treatment for ReceiveSignalingData. SetRequestedVideoChannels stops
building its vector when there is no instance to give it to, and Protocol's
comparator stops copying both strings on every comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/develop278ca6e4 files, +61/-42
Clear the remaining hygiene items in the call managers

The tgcalls version registrations had internal linkage but lived in a header, so
a second includer would have registered every version twice; they move into
VoipManager.cpp. The #ifndef _WIN32 arm of the config initialiser could never
have compiled and is gone.

A screencast runs a second group manager alongside the main one and both opened
tgcalls_group.txt, interleaving into one file; the screencast writes its own now.

EmitJoinPayload no longer completes when there is no instance to emit from. The
empty payload it used to hand back only got the caller as far as a join the
server would reject, and it did it on the calling thread rather than the one the
success path answers on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop5fb96f25 files, +309/-1
Base service message for community add/remove

UnigramDev/Unigram/develop03513e42 files, +22/-4
Let a null incoming video sink reach tgcalls

Skipping out early on null was wrong twice. It left m_incomingVideoOutput
pointing at the sink the caller had just asked to detach, and it swallowed the
one thing a null is good for: setIncomingVideoOutput also assigns _currentSink,
which every newly negotiated video channel is handed on creation, so a channel
appearing after the detach re-attached the old sink.

Null flows through as an empty shared_ptr now. The dedupe check handles it
without a special case, since two nulls in a row compare equal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop16ee8b22 files, +26/-2
Set responseTimestamp on every broadcast part status

NotReady is precisely the status tgcalls reads responseTimestamp on, to decide
where to restart a stream that has not begun yet, and it was only ever set on
the Success path — so it arrived as zero. The caller had been supplying it all
along and we dropped it.

It is also the one timestamp in this API measured in seconds rather than
milliseconds; tgcalls multiplies it back up by 1000. We passed milliseconds
straight through, a thousand times too large. Converting at the tgcalls boundary
keeps the WinRT surface consistently milliseconds, which is what
requestCurrentTime already expects.

Found by re-reading the commit that started reporting empty parts as NotReady,
which is what made this reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/develop30ce5405 files, +502/-195
Close the lock leak in the ClientService critical sections

Thirteen Monitor.Enter/Monitor.Exit pairs had no try/finally, so anything that
threw in between leaked the lock for good. The worst is UpdateChatLastMessage,
which constructs a MessageAlbumLastMessageService while holding the Chat: one
throw there locks that chat forever, the next UpdateChatPosition for it blocks
the TDLib receive thread, and since that thread is the only one draining
td_receive, every update in the app stops arriving with no crash to show for it.
GetChatFolders takes lock (chat) from the UI thread, so the chat list wedges too.

All thirteen are plain lock blocks now. The ten synchronous ones are a straight
wrap. The three paging methods could not be, because await is illegal inside
lock — which is exactly why they were written with a hand-placed Monitor.Exit
before the await in the first place. Each now decides under the lock how much is
still to be loaded and either builds its result and returns inside the lock, or
falls out of it and awaits with nothing held. Same semantics, same lock ordering,
one exit path instead of three.

The MOCKUP blocks in those three methods moved but were left as they are. MOCKUP
is not defined in any configuration and the blocks reference an undefined index
variable, so they have not compiled in a long time; quietly repairing dead code
that cannot be tested does not belong in a lock-safety change.

UpdateChatDraftMessage was not in the review that prompted this — it turned up
grepping for Monitor. after converting the twelve that were listed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop533cbd93 files, +335/-147
Close the lock leak in the two topic services

The same Monitor.Enter/Exit-without-try/finally as the ClientService partials,
in the six pairs the earlier pass did not reach: it only covered ClientService.*,
and these two services are separate files owned by it. Four in ForumTopicService,
two in DirectMessagesChatTopicService, and the freeze mode is the same — a throw
between a pair leaks _order for good, and the topic list for that chat never
sorts again.

LoadForumTopicsAsync was the interesting one. It called tsc.SetResult inside the
monitor, so the continuation waiting on it — GetForumTopicsAsyncImpl, which takes
_order itself — ran inline while the lock was held. Monitor being recursive is
the only reason that was a surprise rather than a deadlock, and a throw in there
would have skipped the Exit. It now builds the result under the lock and
completes the task after releasing it.

The two paging methods got the same restructure as the three before them: decide
under the lock how much is still to load, then either answer from the cache
inside it or fall out and await with nothing held. That is five copies of this
method carrying the same bug, which is a better argument for merging them than
the duplicated line count ever was.

UpdateTopicOrder keeps publishing outside the lock, where the hand-placed
Monitor.Exit already put it. The aggregator publish still inside the lock in
LoadForumTopicsAsync is a separate finding and is left alone here.

Also records the review of both services in clientservice-review.md: seventeen
findings, of which these six are the first fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/developc6038343 files, +104/-74
Close the lock leak in the group call participants and chat positions

An app-wide grep for Monitor.Enter after the topic services turned up four more
files with the same unprotected pairs. Two of them are worth fixing now.

TdExtensions.GetPosition and GetOrder are the ones that matter. Each takes an
early Monitor.Exit inside a loop and another after it, and what they lock is the
Chat itself — the same objects ClientService.OnResult locks on the receive
thread. A throw inside AreTheSame on the UI thread would have leaked that chat's
monitor and stopped update delivery for the whole app, which is the same failure
the ClientService pass was closing. return inside lock releases correctly, so
both lose their early exits entirely.

VoipGroupCallParticipants turns out to be a sixth copy of the paging method,
with the same two shapes as the five already converted, so it gets the same
treatment: decide under the lock how many participants are still to load, then
answer from the cache inside it or fall out and await with nothing held. Its
three return paths are unchanged, including the null for a response that is
neither Ok nor Error.

That the count of this method went from three to five to six as the search
widened is a better argument for merging them than the duplicated line count.

DiceView and VideoNoteContent have the same bug and are left for now: both are
in UI code rather than on the receive thread, so they can wedge a control but
not the update pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/developd9a6e672 files, +15/-2
Ignore a second VoipManager::Start

Assigning over m_impl would destroy the running instance without tgcalls' own
teardown. Nothing calls Start twice today, but it was the last hole left in the
Start/Stop lifecycle after the destructors went in.

Keeping the instance in flight is the right way round: it is the one serving the
live call, and a second descriptor would be for that same call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop3321c862 files, +46/-6
Sort the offered call protocols numerically

The versions were compared as strings, so the registered set

2.7.7 5.0.0 7.0.0 8.0.0 9.0.0 10.0.0 11.0.0

came out as 9 8 7 5 2.7.7 11 10 — the two newest protocols at the end of a list
the server reads newest first. Filed during the review as latent, on the
assumption the majors were single digits. They have not been for a while.

Compared component by component as numbers now, checked against that set and
for the strict weak ordering std::sort requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/develop5b731d11 files, +48/-17
Reverse the ConcurrentDictionary recommendation in the review

The review ranked swapping ReaderWriterDictionary for ConcurrentDictionary as
the largest measurable win in ClientService. That was reasoning about the cost
of one lookup without ever asking how many lookups happen, and counting them
turns the recommendation around.

A full ChatCell refresh reaches a ReaderWriterDictionary eight to twelve times.
The rate of those refreshes is bounded by UI control realization — a 64px row
under a hard flick, plus aggregator refreshes of the dozen visible cells — which
puts the whole app somewhere around 0.5k to 6k lookups a second. At the ~30ns a
lock-free read would save, that is under two tenths of a millisecond per second,
or a few hundredths of one percent of a core. Wrong by a factor of a hundred it
still would not reach two percent, and at one operation per 200 microseconds the
cross-core contention that motivated the idea never happens either.

ConcurrentDictionary would cost a node allocation per entry to buy that back,
which is the wrong side of the trade in this repo. The custom class stays.

What survives is that ReaderWriterDictionary.Find allocates a closure and a LINQ
enumerator on every call to do what a foreach does for free, which is worth
fixing on its own and needs no type change.

Two things the count corrected on the way: GetChatActions is already a
ConcurrentDictionary, and GetChatFolders uses a plain Dictionary under its own
lock. Neither was ever in scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop4efdfba11 files, +85/-130
Pass call payloads as arrays instead of IVector<byte>

Every byte crossing this boundary ends up base64 encoded into TDLib JSON, so an
IVector was the wrong shape at both ends: native built a COM collection, the
projection built an RCW, and the managed side called ToArray to get back the
byte[] the generated TDLib request wanted anyway.

UInt8[] instead. The projection copies once in each direction and managed hands
the array straight to TDLib. On the E2E path, which runs per frame, that removes
two COM objects, two RCWs and one of three copies.

Signaling stops being an event. It only ever had one listener, so a delegate
does the same job without an args runtime class per packet, and
SignalingDataEmittedEventArgs goes away entirely.

The frame transform also stops waiting on a shared semaphore. tgcalls builds one
frame transformer per simulcast layer and one per incoming channel, each on its
own thread, so several transforms run at once as soon as a call has two people
in it — a second Release before the first Wait would have thrown
SemaphoreFullException, and a waiter could be woken by another frame is answer.
Holding m_lock across the delegate used to serialise them and hide this; it
stopped doing that when that lock came off the callback path. Each transform now
waits on its own signal, with a timeout so a silent TDLib drops the frame rather
than wedging a media thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/develop8fd02b84 files, +248/-83
Guard the ForumTopicService collections, and finish Clear()

Two findings from the same review, landing together because the review doc
records both and the commit hook commits whole files, so splitting them would
mean checking one item off before its fix existed.

ForumTopicService kept six of its eight collections in plain Dictionary, List
and HashSet: _topics, _messages, _pinnedTopicIds, _deletedTopicIds,
_pendingNewTopics and _pendingLastReadInboxMessageId. Every Update* method
writes them from the TDLib thread while GetTopic and GetTopics read them from
the UI thread — and GetTopic writes too, since recording a pending request is
what stops a miss from sending one getForumTopic per enumeration. Only _order
and _unreadTopicIds were guarded. A concurrent mutation of a Dictionary does not
throw, it spins.

All eight now sit behind one private lock, absorbing the two existing lock
objects so there is a single domain and no ordering between them to get wrong.
A ReaderWriterDictionary would have matched DirectMessagesChatTopicService, but
it covers only the two Dictionary fields; the List, the SortedSet and the three
HashSets would still need a lock, leaving two domains and real compounds
spanning both — UpdatePinnedTopics reads _pinnedTopicIds then _topics, Order
reads _deletedTopicIds and _pinnedTopicIds, the batch load touches four at once.
One lock makes those atomic and a lock cycle impossible. The dictionary reads go
through TryGetTopic/TryGetTopicByMessage so the backing store stays cheap to
change if that is revisited.

Critical sections stay small and publishes stay outside them, which shrinks the
separate finding about publishing under the lock rather than growing it:
SetPinnedForumTopics and UpdatePinnedTopics now collect under the lock and
reorder outside it, leaving only the batch load still publishing while held. The
ForumTopic objects themselves are still handed to the UI and mutated by the
update methods unsynchronised, exactly as ClientService does with Chat and User;
this fixes container corruption, which is the part that spins forever.

Clear() was missing eight caches. _activeStories was the one that bit: the story
ordering was cleared but the stories were not, so after logout GetActiveStories
still served the previous account's state. The three download sets moved into a
ClearDownloads helper next to their declarations, since they need _downloadsLock
and the reason they must not outlive an authorization belongs where they are
declared — file ids only mean anything within one session, and those sets are
also the only state here that grows for the life of the process.

Checked by enumerating every private field across the seven partials and diffing
against what Clear() touches, rather than by re-reading it: what it now leaves
alone is the injected dependencies and the lock objects, and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop330db012 files, +17/-5
Fix monospace font lookup

UnigramDev/Unigram/develop26179e52 files, +12/-6
Resolve only the unknown sources on the second pass

When any ssrc could not be matched, the retry walked the whole request again and
re-added every source the first pass had already resolved. It stayed harmless
only because tgcalls asks for one ssrc at a time, which is exactly the assumption
the comment above it leans on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/develope11f6323 files, +244/-90
Fan out the batched TDLib requests, and triage the rest of P2

Applying the rate check that reversed the ConcurrentDictionary item to the rest
of the performance section. Three of the eight items survive it, two are fixed
elsewhere in kind, and two are closed as not worth doing.

The one that gets stronger under scrutiny is the batched requests, because it is
latency rather than throughput and it sits on a path a person waits on.
GetMessageProperties was issued one message at a time, so selecting a hundred
messages meant a hundred sequential round trips before the toolbar could decide
which actions to offer. That and the three others — reactions, custom emoji
sticker sets, message effects — now issue their requests together and await them
as a group.

Two things worth knowing about that change. GetMessageEffectsAsync keeps its
results in request order by indexing them by position: the effect drawer and the
reaction menu both display them in the order they asked for, and the obvious
rewrite of appending fetched results after cached ones silently reorders them.
And the caches are still written in one loop after the group completes, on one
thread, so this does not worsen the open finding about _cachedReactions being an
unsynchronised Dictionary. GetAllReactionsAsync was a verbatim copy of
GetReactionsAsync and now calls it.

OwnedStarCount and OwnedGramCount sent a request on every read until the update
landed, and they are read from bindings, which re-evaluate. Guarded now, and
reset in Clear() so a new authorization fetches again.

GetChatFolders allocated a closure over this on every chat cell that showed a
folder tag. That is now a field built once. The O(n log n) framing in the review
was overstated: a chat is usually in one or two folders, so the sort ran about
one comparison, and a chat in none allocates nothing at all. Rewriting it to
walk the folder list instead would have been slower, since it turns the common
empty case from scanning two entries into scanning every folder.

ReaderWriterDictionary.Find wrapped its predicate in a lambda for
FirstOrDefault, allocating a closure and an enumerator per call.

Closed without changes, both recorded in the review with the arithmetic: the
105-case type switch in OnResult costs 100-200ns against an update rate of tens
per second normally and thousands during a sync, so a dispatch table buys
nothing; and the service construction inside GetChats cannot move to
UpdateSupergroup, because that update carries a supergroup id and there is no
supergroup-to-chat index to get back to the Chat, making GetChats the only place
that notices a supergroup which became a forum after its updateNewChat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop9b56b0c1 files, +44/-23
Record the review decisions, and what m_impl is actually racing

Five open questions were accepted as they stand and one deferred upstream, so
the list of genuinely open items is down to three.

The m_impl item was filed as theoretical and is not. VoipManager is safe, since
every managed call site including Dispose runs under _managerLock. VoipGroupCall
has no such lock and reaches Dispose from TDLib update thread while the UI thread
calls in, so Stop resetting m_impl there is a use-after-free.

Also corrects the reason I gave for leaving it alone: guarding m_impl would not
put a lock back on the path into managed code, because the callbacks never touch
m_impl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/developa0b07631 files, +37/-10
Close the unbounded growth item: _files is TDLib's model

Fela's read is right, and the reason is sharper than "a TDLib issue". The
contract is id-to-instance identity: updateFile carries a file id and ParseFile
mutates the existing instance in place, so every binding already holding that
File sees the change. TDLib never retires a file id within a session and never
signals that one is finished with. Drop an entry and the next update for that id
mints a new instance while the UI holds the old one, and that thumbnail stops
updating for good. Eviction is only safe when nothing holds the entry, which
means weak references plus a sweep plus a dereference per update on the receive
thread — a lot of machinery for the size involved.

Which the item never stated. One entry is three objects and three strings, the
strings dominating at roughly 700-1000 bytes, and every photo contributes an id
per size variant. Ten thousand files is about 8MB and a hundred thousand about
80MB: real, but not the multi-GB growth being chased elsewhere.

The three download sets were in this item only because they sit next to each
other in the file. They are ours rather than TDLib's, and they are hundreds of
KB at the top end. All four are already dropped on an authorization change by
the earlier Clear() fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop404313f2 files, +50/-12
Retry a forum topic that failed to load for a transient reason

UpdateNewTopic returned on any non-ForumTopic response before removing the id
from _pendingNewTopics, and GetTopic only requests a topic it has not already
asked for. So one failed load hid a topic for the rest of the session: the
method returned null forever and nothing ever asked again.

Clearing the entry on every failure would have been the worse bug. The
suppression is load-bearing — a topic that genuinely does not exist would
otherwise be requested again on every enumeration of the list, one round trip
per scroll, forever. It is also the only thing currently keeping the bogus
int.MaxValue lookup in GetTopics from repeating.

So the retry is scoped to failures that repeating can fix: code 500 and above,
or below zero, meaning server or transport. Every 4xx stays suppressed, since it
says the request is wrong or the topic is gone. Keying on 404 alone would not
have been enough, because TDLib reports a missing object as 400 at least as
often, which would have left the storm open through the more common code.

UpdateNewTopic now takes the id it asked for, a failure response carrying none
of its own. The call site inside UpdateDeleteMessages names its lambda parameter
inner, the enclosing callback having already bound response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/develop29258042 files, +28/-14
Pass AUTOCONVERTPCM as a stream flag, not as the periodicity

Initialize takes it in StreamFlags; it sat in hnsPeriodicity, which shared mode
requires to be 0. Inherited from the Microsoft sample.

Measured rather than assumed, with a harness that activated the same process
loopback client three ways: as shipped, corrected, and with the flag dropped
entirely. All three return S_OK with the same 480 frame buffer, so shared mode
ignores periodicity instead of validating it and screen audio was never at risk.
The control run also shows the flag itself does nothing here, since process
loopback converts to whatever format is asked for.

A tidy-up then, not a fix. The measurement is in the comment so the next reader
does not have to repeat it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop73e57c52 files, +36/-9
Refresh every topic a delete touched, not just the first

The break sat inside the loop over the deleted ids, so only the first topic
whose last message had gone got asked for a new one. Clearing a chat's history,
or deleting everything one member ever sent, takes out the last message of many
topics at once, and all but one were left showing a preview of a message that no
longer exists — and sorted by it, so they held their old position in the list
too.

Each _messages entry is still handled at most once: the entry is removed as it is
handled, so a later id in the batch resolves to a different one. That is per
entry rather than per topic, because LoadForumTopicsAsync can leave a stale entry
behind for a topic it reloads — noted in the review as its own item, since it
means the one-entry-per-topic property cannot be leaned on. Hitting a stale entry
costs a redundant refresh, never a wrong one.

The cost of the fix is that a delete taking out the last message of n topics now
issues n getForumTopic calls where it issued one. That is bounded by the topics
actually affected, and the alternative of reloading the whole list is a much
larger change to the batch-load path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/developa643a234 files, +12/-3014
Fix record button on Windows 10

UnigramDev/Unigram/develop025878b1 files, +5/-0
DIsable quote for rich messages

UnigramDev/Unigram/develop59428853 files, +258/-0
Add claude hooks

#unigram
UnigramDev/Unigram/developc715efe3 files, +30/-8
Stop asking the server for the synthetic topic row

Both GetTopics implementations yield a synthetic row for a sentinel id — the
"All topics" entry, or the new-topic prompt in a bot chat — and then fall
through to GetTopic with that same sentinel. In the forum service that fires
getForumTopic for id 2147483647, and the failure then sits in _pendingNewTopics
for the life of the service, since a 4xx is deliberately not retried. One wasted
round trip per forum opened, and a permanently poisoned entry.

A continue in each. The direct-messages one was harmless today only because its
GetTopic is cache-only rather than fetching, which is not a property worth
relying on.

Left alone deliberately: the four allocations per enumeration for that synthetic
row. The review called it a constant to hoist into a field, and it is not one.
Its label comes from Strings.AllTopicsShort, which is a live Resource.GetString
call, and the app applies updateLanguagePackStrings at runtime while a
ForumTopicService lives until logout. A hoisted field would keep showing the
previous language for the rest of the session. Four allocations on a list
enumeration is not worth a visibly wrong string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop90c86231 files, +23/-16
Downgrade GetChatFromMessageSenderAsync: it is not a bug

The review claimed the method returns null for every chat sender as though the
chat were meant to be returned. Read on its own it does one coherent thing:
resolve a user sender to its private chat, creating it if needed, and return null
for anything else. For a MessageSenderChat both paths return null, found or not
found, so there is no inconsistency and nothing is dropped.

What is really there is a vestigial first line. TryGetChat(messageSender, out
chat) has its return value discarded and its out value can only be non-null in
exactly the case the following if excludes, so it is dead on every path — and it
is what makes the method read as though chat senders were handled.

Kept in the doc at P3 with a note on how the wrong conclusion was reached: the
reasoning followed the cached branch, saw the value discarded, and stopped,
without checking that the uncached branch returns null too — which is what shows
the behaviour is uniform and deliberate. Reading the call sites afterwards made
it look confirmed, since a channel receiver really does get null; that just is
not a defect, because the purchase uses the sender directly and succeeds, and the
chat only picks which toast is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/develop6de92913 files, +30/-47
Fix leak instrumentation

#unigram
UnigramDev/Unigram/tdjson-symbols079689f1 files, +4/-0
Ship tdjson symbols in the appxsym

tdjson.dll is added straight to ReferenceCopyLocalPaths, which is an output
of reference resolution rather than an input, so ResolveAssemblyReference
never sees it and never runs the related-file discovery that picks up a .pdb
sitting beside a resolved reference. RLottie gets its symbols for free that
way; tdjson has to name the file.

The appxsym is built from AppxPackagePayload filtered to .pdb, and payload
includes copy-local items, so naming it here is enough. PDBs are removed
from the .appx itself afterwards, so this does not grow the installed app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
telegramdesktop/tdesktop/nightly314d2f11 files, +17/-12
Switched nightly Windows CI with optimized binaries without arm64.

#tdesktop
🫡3
telegramdesktop/tdesktop/nightly4da78541 files, +17/-12
Switched nightly Windows CI with optimized binaries without arm64.

#tdesktop
🫡2
UnigramDev/Unigram/chatlist-template-init03bdd042 files, +45/-16
Fix NullReferenceException when the chat list loads before its template

OnLoaded dereferenced ScrollViewer.ContentTemplateRoot, but ScrollViewer is
only assigned in OnApplyTemplate, which runs on the first measure pass. A
control that is in the tree and never measured raises Loaded with the template
parts still null, and the handler threw.

The element it wants is the ItemsPresenter the template already declares, so
name it and read it with GetTemplateChild like the other parts. That drops the
dependency on the ScrollViewer's ContentPresenter having realized, and the
setup can then run from whichever of OnApplyTemplate and Loaded arrives second.

Returning early instead would have left _trackerOwner null for the session and
the swipe-between-folders carousel silently dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
telegramdesktop/tdesktop/nightlyee687013 files, +23/-28
Switched nightly Windows CI with optimized binaries without arm64.

#tdesktop
🫡2
UnigramDev/Unigram/exception-reporting599c5e72 files, +55/-14
Keep the originating description when an unhandled error is a bare E_FAIL

A large share of unhandled errors reach OnUnhandledExceptionDetected as an
E_FAIL with no message and a stack that only shows Propagate() rethrowing it.
GetStowedException already tries to recover the real context, but it returns
null unless every step succeeds, so those reports carry nothing.

Read the description out of IRestrictedErrorInfo before giving up, and return
what was recovered even when the stowed frames are unavailable. The call was
already written out in a comment and left unused on the assumption that the
propagated managed exception would carry the details - which is exactly what
fails here.

WatchDog no longer overwrites the recovered string with the empty one from the
propagated exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/composer-typing-performanced7b14491 files, +31/-8
Reconcile the review checkboxes with what actually landed

The E2E delegate signature item was still marked open although the byte[] work
closed it, and the transform semaphore it exposed was not written down at all.
The five questions Fela ruled on are marked decided rather than outstanding, and
mono screencast audio as deferred upstream.

Two genuinely open items remain: m_impl unguarded in VoipGroupManager, and the
unmeasured buffer between the WASAPI and WebRTC clocks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/composer-typing-performancee8aa3251 files, +41/-8
Re-aim the NewDictionary finding at Clear()

The item claimed the mutating indexer getter, plus _haveFullChatList being set
outside the monitor, amounted to a cross-thread race. It does not. All seven
callers of GetChatListAsync and GetStoryListAsync are UI-layer and the repo has
no ConfigureAwait(false), so those continuations resume on the UI thread, and
every use of the mutating getter is already inside a lock. No second thread reads
those dictionaries under the monitor at all. Demoted to P3 as a trap for the next
reader rather than a defect.

The real cross-thread party is Clear(), which runs on the TDLib receive thread
and takes two locks while emptying eight collections that every other accessor
guards — _chatList and _haveFullChatList, _storyList and _haveFullStoryList,
_savedMessages, _savedMessagesTags, _suggestedActions and _chatFolders2. A logout
arriving while the UI has a chat list load in flight can clear a SortedSet out
from under an enumerator.

Which the earlier Clear() fix missed: it audited which fields were cleared and
never asked whether clearing them was synchronised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/composer-typing-performance1632e052 files, +47/-14
Hold the locks while Clear() empties the collections they guard

Clear() runs on the TDLib receive thread, from OnResult on
AuthorizationStateClosed, and was taking two locks while emptying nine
collections that every other accessor guards. A logout landing while the UI had a
chat list load in flight could clear a SortedSet out from under an enumerator, or
a Dictionary mid-lookup.

Each group now takes the same lock its readers do: _chatList with
_haveFullChatList, _storyList with _haveFullStoryList, _savedMessages,
_savedMessagesTags, _suggestedActions, _chatFolders with _chatFolders2, and
_timezones. They are taken one after another and never nested, so no lock
ordering is introduced.

_timezones was not in the list this started from. Enumerating every lock target
across the partials found it, which is the check worth repeating when a field is
added — the same lesson as the coverage audit, one level down.

This is what the earlier Clear() fix missed: it audited which fields were
cleared, and never asked whether clearing them was synchronised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/composer-typing-performancebdf43043 files, +50/-13
Make the three loose dictionaries thread-safe, mostly by type

_chatAccessibleUntil, _cachedReactions and _preparedLogsFileIds were plain
collections shared between the UI thread and the TDLib one. The first two are
written and read from the UI and emptied by Clear() on the receive thread; the
third is worse, and not merely latent: PrepareLogs does ??= new() then Add() on
the UI thread while UpdateFile assigns the field null on the TDLib thread, so a
call landing between those two statements dereferences null.

Fela's point on approach is right, so the rule is now explicit rather than picked
per site. A standalone dictionary whose operations are single calls becomes a
ReaderWriterDictionary, like the rest of the caches. A lock is only for what that
type cannot express: a set rather than a dictionary, a field assigned null, or an
operation that has to be compound.

So _chatAccessibleUntil and _cachedReactions are ReaderWriterDictionary now and
carry no explicit locking at all — the call sites got shorter rather than longer.
_preparedLogsFileIds fails all three tests, so it keeps a lock and says why where
it is declared. Its remove-then-maybe-reset is one critical section now, with the
Client.Execute that restores the verbosity hoisted out of it.

That same rule is what put ForumTopicService on a single lock earlier: six of its
eight collections are sets, a list and a sorted set, with compounds spanning
them, so the type would have covered two of eight and left two lock domains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/composer-typing-performance43afd241 files, +18/-2
Close D3: the _haveFullList write races with nothing

The item said it was the same shape as the _haveFullChatList finding above it,
and it was — including being wrong for the same reason, so it inherits the
correction rather than the bug.

_haveFullList is a plain bool with three references: the declaration, one read
inside the lock and one write outside it. Its only caller is TopicListViewModel,
and with no ConfigureAwait(false) anywhere in the repo that continuation resumes
on the UI thread, the same one that took the lock. The TDLib thread reaches this
class only through UpdateDirectMessagesChatTopic, which never touches the field.
Granting a second thread anyway, a bool write is atomic, so the worst case is one
redundant loadDirectMessagesChatTopics rather than corruption.

The same reasoning clears ForumTopicService._haveFullList, which has the
identical shape and was never raised. In both, the await completes on a
TaskCompletionSource set from the TDLib thread, but the continuation posts back
to the UI context — which is what makes the lock-then-unlocked-write sequence
single-threaded in the first place.

No code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/composer-typing-performance806ba3d2 files, +299/-192
Guard the group call managers against Dispose

VoipGroupCall reaches Dispose from Update on TDLib update thread while the UI
thread calls SetVolume, AddIncomingVideoOutput and the rest, and stopping a
manager frees the tgcalls instance the other thread may be part way into. Its
sibling VoipCall has always serialised this behind _managerLock; VoipGroupCall
declared the same field but only ever wrapped a block of commented out code
copied along with it.

Held now around every call that reaches the instance, and around the teardown in
Dispose and EndScreenSharing. Left outside: the IsMuted and
IsNoiseSuppressionEnabled getters, which read a native field rather than
touching m_impl, and the null checks that only decide whether to start
something.

Two spots needed more than a wrapper. The EmitJoinPayload continuations run
after awaits, so they take the lock again where they call in, since C# cannot
hold one across an await. And UpdateParticipant dereferenced _manager
unconditionally from the update thread, which was a null reference waiting to
happen quite apart from the race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

UnigramDev/Unigram/composer-typing-performancea94e5312 files, +16/-37
Flatten buttons text

UnigramDev/Unigram/composer-typing-performance4731dca2 files, +34/-5
Check a file against the disk only the first time it is seen

ParseFile called NativeUtils.FileExists after every parse, for files it had seen
thousands of times as readily as for new ones. Every parsed object carries files
— a history page is hundreds of them, nearly all already cached — and this runs
on the thread draining td_receive, so each one spent a syscall re-answering a
question already answered.

ProcessFile, on the old type-crossed parser, only ever checked a file id it had
not seen. That is the right shape and ParseFile now matches it, which drops the
steady-state cost from per-file-per-update to once per file id per session.

Nothing real is lost by not repeating it. TDLib sends no update when a file
disappears behind its back, so the repeat check only caught an external delete
when some unrelated update happened to arrive for that same file — luck rather
than detection. The reliable path is GetFileAsync catching FileNotFoundException
where the file is actually used. What the first-sight check is genuinely for is
the cache having been cleared between sessions, and that still works.

This does not help the startup replay, where every file is a first sight by
definition. Whether that burst is worth deferring off the receive thread is
worth counting before building anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/composer-typing-performancea6396fa6 files, +325/-79
Move the SendFilesPopup thumbnails off the models

StorageMedia._preview was assigned in four places and released in none, and
the models outlive the popup — SendFileExecute hands them to the send loop —
so every decoded thumbnail stayed alive for the whole send. A photo costs
roughly a megabyte and a video twice that, so a large drop retained tens of
megabytes with nothing left to free them.

Preview/Refresh/RefreshAsync are gone. The decode moves verbatim into
StorageThumbnailCache, owned by the popup, which pushes into the ImageBrush
instead of being pulled through a binding. Entries are dropped as their album
container is recycled, so the live set is now bounded by what the ListView has
realized rather than by how many files were picked, and cleared outright on
unload.

The cache also coalesces: Refresh() was async void fired from the Preview
getter with no in-flight guard, and _preview stayed null across the await, so
every container that asked before the first decode returned started another
decode of the same file — and the album panel rebuilds all of its children on
every UpdatePanel.

A decode that finishes after its entry was released, invalidated or cleared is
dropped rather than cached. That is what stops a post-recycle result from
resurrecting a bitmap nothing is left to release, and a pre-crop image from
landing after Invalidate.

The StorageVideo.Refresh override goes with it. It re-ran LoadPreview() after
a crop, which only feeds MaxCompression/Compression — dead, since every
original* field it reads is assigned solely in commented-out lines. The
constructor still runs it, so the initial state is unchanged.

Closing the popup still cannot stop a decode already under way: neither
BitmapImage.SetSourceAsync nor the video path takes a cancellation token. Only
the retention is fixed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram
UnigramDev/Unigram/composer-typing-performancef0946794 files, +371/-38
Show SendFilesPopup before the files have been typed

Dropping a lot of files left the user staring at nothing. Every file was
probed before the popup was constructed, one after another, and a probe is a
file open plus a header decode for photos or a whole ffmpeg open for video and
audio. Nothing was on screen until the last one finished.

StorageMedia.ProbeAsync types files concurrently, capped so a large drop cannot
open hundreds of decoders at once, and reports each result with its original
index as it lands. Every await in the chain captures the caller's context, so
the callback arrives on the UI thread.

SendFileExecute no longer probes. Both overloads funnel into SendFilesAsync,
which takes either already-typed items or raw files; with files it builds the
popup empty and starts probing from the Loaded handler. That hook is load
bearing: OpenAsync queues behind any other dialog and only creates its closing
task once it reaches the front, so a Hide from a result that resolved earlier
would have had nothing to close.

Results are buffered and flushed on a low priority dispatch, so everything
resolving within one UI turn is appended by a single AddRange -- one
CollectionChanged, one UpdateView, one UpdatePanel. Each batch is sorted by
original index, so the picked order survives whenever probing is fast enough to
land in one flush, which is the common case.

The permission and size guard moved rather than disappeared. One Validating
function holds the original messages: a loop up front for already-typed items,
a callback for probed ones. The first failure cancels probing and closes the
popup, and the error is raised after OpenAsync returns, which is already where
the caption is restored.

Three consequences. The title counts what is still coming rather than what has
landed, so it shows the drop's real size instead of ticking up, and stays on
the Files declension until types are known. The requested media/files mode
cannot be resolved against an empty list, so UpdateView settles it as the first
items arrive, until the user picks a mode themselves. Send is disabled while
probing and Accept returns early, since Enter bypasses the button and sending
half a drop would silently discard the rest.

The batch CreateAsync overload keeps its serial loop for the callers that still
need it, but its try-catch moved inside the loop: one unreadable file used to
discard every file after it.

Known edge: if every file fails to probe the popup appears briefly and then
closes itself, where before it never appeared. The alternative is waiting for
the first result, which is the stall this removes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

#unigram