Telegram github commits and releases
4.54K subscribers
601 files
20.3K links
Broadcast from the most important Telegram clients' repositories
Download Telegram
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
UnigramDev/Unigram/composer-typing-performancea8f394e5 files, +172/-90
Give SendFilesPopup one input shape

Showing the popup before the files were typed left it with a three step
construction protocol: build it with an empty list, set Validating, then call
Probe from the caller's Loaded handler, all driven by a SendFilesAsync that
took an items/files pair where exactly one was allowed to be null. Miss a step
and you get a silently empty popup or an unguarded send, and nothing enforces
it. The two construction sites differ enough for that to be a real trap -- the
edit path passes a single already-typed item, has no guard at all, and reads
Items[0] back the moment the popup closes.

StorageMediaSource is now the only thing the popup is given. FromMedia exposes
everything through Ready; FromFiles leaves Ready empty and delivers through
LoadAsync. Count is known up front either way.

The constructor seeds Items from Ready rather than loading them, so the edit
path still has its item before the popup opens and Items[0] cannot throw, and
the popup calls LoadAsync from its own Loaded, which is a no-op when the source
is already complete. Callers can no longer forget to start it.

Two things fall out. The expected count comes from the source at construction
instead of being patched in later, so the title states the size of a drop from
the first frame. And the up-front guard loop runs over Ready, which is empty
for files, so the last of the two-flavour branching disappeared rather than
moving somewhere else.

The guard is a constructor argument instead of a settable property. The edit
path passes null, which is honest: that item's permissions were checked when it
was first sent.

SendFileExecute's media overload now takes IReadOnlyList, since IList does not
convert to it and the source needs the read-only form. Its one caller passes an
array.

The constructor's Logger.Info line enumerates ready items, so on the drop path
it would have gone blank. It records the pending count instead -- that line
ships with crash reports.

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

#unigram
UnigramDev/Unigram/composer-typing-performanceb4cfc424 files, +139/-49
Grow SendFilesPopup albums instead of rebuilding them

CompareItems compared album contents, so an album that gained a photo was a
different item and the diff removed and re-inserted it. That recycled the
container, and the recycle handler releases the album's thumbnails on the way
out -- so every batch of arriving photos re-decoded every thumbnail the album
already had. The remeasure was the visible symptom; this was the cost.

ChatDiffHandler shows the intended contract: CompareItems is identity and
UpdateItem carries the new content across. StorageAlbum now has an Ordinal, its
position among the albums of a view, and that is what CompareItems compares. An
album that gains a photo is the same album with new contents, and its container
survives.

UpdateItem moves the new media onto the retained instance and refreshes the
realized panel. Before it only invalidated layout, which meant that whenever it
did fire the panel went on rendering the old contents -- latent, since the old
CompareItems almost never let it fire.

StorageAlbumPanel.UpdateMessage reuses its children rather than clearing them
and allocating a Button per item with a fresh Click subscription. Growth only
appends now, and a surviving item keeps the thumbnail it already had, because
changing Button.Content updates the template root's DataContext instead of
rebuilding it.

Remove_Click invalidates the removed item's thumbnail, since a container
recycling underneath it no longer does.

Also restores the width and height diagnostic. The constructor's Logger.Info
line names each item's dimensions, which is what album layout bugs get
diagnosed from, and the drop path had reduced it to a count because nothing is
typed that early. It now logs the pending count there and logs the dimensions
again once everything has landed.

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

UnigramDev/Unigram/composer-typing-performance411fbcd5 files, +15/-6
Stop leading text inputs with the packaged emoji font

EmojiTextThemeFontFamily was built emoji-first, so every text input resolved
against ms-appx:///Assets/Emoji/*.ttf before falling back to the text font. The
editor resolves the font once per run and breaks runs at every space, so that
cost about a millisecond per word: pasting 17,000 characters into the composer
froze the UI for 2.7 seconds. Measured identical through SetText and through
RichEdit's own paste, and identical for one line or for hundreds, so it is the
font resolution rather than the insertion.

Leading with the text font instead costs the emojis whose base character it
already covers - keycaps, the copyright and trademark signs and so on -
rendering as plain glyphs. That is confined to input controls: message bubbles
render from XamlAutoFontFamily, which is untouched, so what gets sent and what
gets displayed are both unaffected.

Only three styles used the resource, all of them text input, and four further
inputs were leading with ContentControlThemeFontFamily, which is emoji-first
too and so was just as slow. They all take EmojiTextThemeFontFamily now.

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

#unigram
UnigramDev/Unigram/composer-typing-performance0827fe22 files, +59/-0
Add a profiler for scoped timings

Begin and End are [Conditional("INSTRUMENTATION")] like Instrumentation.Register,
so a probe can sit permanently at a call site and compiles out of every build
that doesn't define the symbol. A helper returning a timestamp cannot do that:
the reading itself would stay in the hot path, which is where these get placed.

Scopes nest and are reported indented, so an outer scope shows what it contains.
End unwinds to its own label, so a return between a pair reports the abandoned
scopes rather than silently charging the rest of the run to them.

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

UnigramDev/Unigram/composer-typing-performancefc25f0d1 files, +122/-109
Probe the document once instead of walking it on every keystroke

UpdateFormat, UpdateCustomEmoji and UpdateBlocks each walked the whole document
on every keystroke. The editor breaks character format runs at every space, so
the first of those was O(words) round trips into the text host per character
typed.

TOM reports a property as undefined when it isn't uniform over a range, so a
single read over the whole story says whether there is anything to walk for at
all: no hidden text means no custom emoji, a uniform SpaceAfter of zero means no
blockquote, and a document already at the regular size has no font size to fix.

Alongside that: the ranges these walk with are kept and moved rather than
allocated on each pass, since every ITextRange is a COM object; applying
entities in SetText reuses one range instead of one per entity; and UpdateBlocks
removed stale blockquote decorations with an index loop that skipped every other
element.

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

UnigramDev/Unigram/composer-typing-performancedc8291a1 files, +26/-6
Stop allocating per character in the emoticon replacement

OnCharacterReceived runs for every character typed. It built a byte array and a
string just to recover the first char of the code point, then used Max and a
capturing FirstOrDefault over the candidates. The first char comes straight off
the code point now and the two LINQ calls are loops.

The Consolas check moved behind the candidate lookup, so a keystroke that can't
end an emoticon no longer pays for a call into the text host, and EndsWith is
ordinal.

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

UnigramDev/Unigram/composer-typing-performanceeb2e5551 files, +14/-12
Don't recreate every custom emoji source on each keystroke

UpdateEntities assigned a new CustomEmojiFileSource to every player on every
text change. Each one sends getCustomEmojiStickers from its constructor and
re-runs AnimatedImage.Load. It only assigns when the id actually differs now.

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

#unigram
UnigramDev/Unigram/composer-typing-performance3ea56fa1 files, +17/-2
Stop splitting the whole message to find the inline bot

SearchInlineBotResults runs on every keystroke, and split the entire message on
spaces to look at its first word - plus two substrings before that - all of it
dead unless an inline bot is being addressed. That check comes first now, and
the first word is taken with IndexOf.

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

UnigramDev/Unigram/composer-typing-performancee758fe91 files, +57/-32
Don't ask for a link preview when the text can't hold a URL

CheckMessageBoxEmpty sent getLinkPreview on every keystroke whatever the text:
the whole message serialized to JSON, through the TDLib queue, and a dispatcher
hop back into another full GetText to check staleness, plus the four whole
string copies Format() makes.

A URL always carries either an explicit scheme or a dot before its top level
domain, so a scan that can be wrong about a link being there but never about one
not being there now gates all of it. The clear-the-preview block that appeared
twice became one method.

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

UnigramDev/Unigram/composer-typing-performance208b8e62 files, +76/-8
Paste through a batched insert, and don't relabel a link that is one already

The insert and the caret move that follows it each lay the document out, so they
run inside BatchDisplayUpdates.

Pasting a URL over a selection labelled it by way of SetText, which rewrote the
selection with its own text so it could re-derive a range from offsets. It sets
the link on the range directly instead, which drops two side effects: SetTextImpl
ran the text through IsLongerThanMaxLength, so replacing a selection with itself
could be truncated near the limit, and it destroyed anything hidden inside the
selection.

It no longer labels a selection that is a link already, whether it carries one or
is one. Nothing marks a typed URL as a link while composing - entities are
resolved at send - so replacing one link with another turned the old URL into the
label of a link pointing at the new one.

IsValidUrl rejects on whitespace before handing the string to the entity parser,
since it only accepts an entity spanning the whole string anyway.

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

UnigramDev/Unigram/composer-typing-performance8fd78571 files, +122/-0
Write down what the composer review turned up

Unfixed findings in FormattedTextBox and ChatTextBox, ordered by severity, with
the ones fixed while investigating listed at the end so the file reads as the
current state. Check an item off in the same commit as its fix.

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

#unigram
UnigramDev/Unigram/exception-reporting01940da1 files, +36/-17
Read the rest of the stowed exception record

GetStowedException2 required ExceptionForm 1 and returned null otherwise, so
three fields the record always carries went unused:

- ResultCode, the HRESULT the error was stowed with, before propagation
flattened it to E_FAIL. This is the field that actually distinguishes one
failure from another.
- ThreadId, the thread it originated on, which is not necessarily the one whose
stack ends up in the report.
- ErrorText, which form 2 carries instead of a stack, and which was discarded
along with the whole record.

It also returned null when no frame resolved a module base, dropping the above
and the nested record with them. Now a FatalError is returned either way.

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

#unigram
👍1
UnigramDev/Unigram/voice-record-feedbacke6b041a1 files, +30/-3
Say something when voice recording cannot be engaged

CheckDeviceAccessAsync swallowed every exception from MediaCapture.InitializeAsync
and returned false regardless, so a device that fails to initialise - absent,
already in use, or a driver that refuses - was indistinguishable from the ordinary
"consent prompt raised, press again" case. The button simply did nothing, with no
message and no log entry, which is what #2093 asked about in 2020. The catch in
CheckAccessAsync above it still carried a "TODO: notify user".

Both now log and surface the error. Strings.UnknownErrorCode already exists, so no
new resource is needed.

Release() also gained a log line on the branch that drops a locked-mode send.
Nobody has reproduced #3262, and that is the only path which can swallow it.

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

#unigram
UnigramDev/Unigram/develop2414af25 files, +118/-28
Publish arriving media a whole album at a time

An album was filling up a batch at a time, so it visibly reflowed as each photo
landed. Results are now buffered by their position in the source and only the
longest settled run is published, truncated to a multiple of the album size
while media are still arriving.

It is every complete album available rather than one per pass, so a drop that
types quickly still lands in a single flush and only a genuinely slow one
degrades into album-sized steps. The final flush publishes the tail, including
a part-filled last album. A drop that asked for files rather than media is not
chunked at all, since file rows do not reflow.

Publishing a run rather than a batch also fixes the order. Flush sorted within
a batch but appended batches as they arrived, so a slot that resolved late
landed after everything behind it -- the picked order was wrong across batches,
and album membership with it. A run is required anyway: album membership is
positional, so a slot that has not settled could still turn out to be a
document and split the album behind it.

ProbeAsync therefore reports every index, passing null for a file it could not
type. A failure used to be silent, which would leave a permanent hole that the
run could never get past.

The album limit is now StorageAlbum.MAX_ITEMS instead of a bare 9 in the two
places the popup groups by.

Trade-off: a slow file holds back everything behind it, where those items used
to appear without it, out of order. The popup and its title still appear
immediately, which was the complaint this all started from.

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

UnigramDev/Unigram/developc7d8a182 files, +98/-38
Stop UpdatePanel redoing settled work on every pass

The container walk rebuilt a whole effect graph per media item per pass: a
GaussianBlurEffect, an effect factory, a brush, a backdrop brush and a sprite
visual. Compiling the graph is the expensive half and it never varies, so the
factory is now built once and every spoiler brush comes from it. Per popup
instance rather than static, since the compositor belongs to the window.

The same walk also reassigned a fresh ParticlesImageSource and re-set or
re-nulled the backdrop child visual on every item every time, whether or not
anything had changed. Both now only touch the tree when the state they
represent actually flipped.

And the walk itself ran once per caller. The album panels each raise Loading
and every arriving batch raises this again, so the calls come in bursts and
each was waiting on the same layout pass to do the same work. A call that finds
one already waiting now returns: the walk reads live state rather than anything
captured when it was scheduled, so the one in flight already covers whatever
the callers behind it changed. The flag clears before the walk, so a call that
arrives during one still gets its own.

Two findings from the review doc turned out to be wrong and are corrected
there rather than acted on. UpdateLayoutAsync does not force a layout pass, it
waits for the next one. And the regenerated StorageDocument wrappers did not
churn the diff, because the fallback comparison matches on path and type -- so
caching them would have bought a bug risk in two invalidation sites for no
measurable gain.

Also adds a task for reordering media inside an album, which nothing currently
allows: the ListView reorders rows, but the media within one are Buttons in a
bare Grid with no items control involved.

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

#unigram
UnigramDev/Unigram/develop3a593f15 files, +15/-6
Stop leading text inputs with the packaged emoji font

EmojiTextThemeFontFamily was built emoji-first, so every text input resolved
against ms-appx:///Assets/Emoji/*.ttf before falling back to the text font. The
editor resolves the font once per run and breaks runs at every space, so that
cost about a millisecond per word: pasting 17,000 characters into the composer
froze the UI for 2.7 seconds. Measured identical through SetText and through
RichEdit's own paste, and identical for one line or for hundreds, so it is the
font resolution rather than the insertion.

Leading with the text font instead costs the emojis whose base character it
already covers - keycaps, the copyright and trademark signs and so on -
rendering as plain glyphs. That is confined to input controls: message bubbles
render from XamlAutoFontFamily, which is untouched, so what gets sent and what
gets displayed are both unaffected.

Only three styles used the resource, all of them text input, and four further
inputs were leading with ContentControlThemeFontFamily, which is emoji-first
too and so was just as slow. They all take EmojiTextThemeFontFamily now.

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

UnigramDev/Unigram/develop126020c2 files, +59/-0
Add a profiler for scoped timings

Begin and End are [Conditional("INSTRUMENTATION")] like Instrumentation.Register,
so a probe can sit permanently at a call site and compiles out of every build
that doesn't define the symbol. A helper returning a timestamp cannot do that:
the reading itself would stay in the hot path, which is where these get placed.

Scopes nest and are reported indented, so an outer scope shows what it contains.
End unwinds to its own label, so a return between a pair reports the abandoned
scopes rather than silently charging the rest of the run to them.

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

UnigramDev/Unigram/develop9260e391 files, +122/-109
Probe the document once instead of walking it on every keystroke

UpdateFormat, UpdateCustomEmoji and UpdateBlocks each walked the whole document
on every keystroke. The editor breaks character format runs at every space, so
the first of those was O(words) round trips into the text host per character
typed.

TOM reports a property as undefined when it isn't uniform over a range, so a
single read over the whole story says whether there is anything to walk for at
all: no hidden text means no custom emoji, a uniform SpaceAfter of zero means no
blockquote, and a document already at the regular size has no font size to fix.

Alongside that: the ranges these walk with are kept and moved rather than
allocated on each pass, since every ITextRange is a COM object; applying
entities in SetText reuses one range instead of one per entity; and UpdateBlocks
removed stale blockquote decorations with an index loop that skipped every other
element.

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

#unigram
UnigramDev/Unigram/developcb585911 files, +26/-6
Stop allocating per character in the emoticon replacement

OnCharacterReceived runs for every character typed. It built a byte array and a
string just to recover the first char of the code point, then used Max and a
capturing FirstOrDefault over the candidates. The first char comes straight off
the code point now and the two LINQ calls are loops.

The Consolas check moved behind the candidate lookup, so a keystroke that can't
end an emoticon no longer pays for a call into the text host, and EndsWith is
ordinal.

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

UnigramDev/Unigram/develop584d7d21 files, +14/-12
Don't recreate every custom emoji source on each keystroke

UpdateEntities assigned a new CustomEmojiFileSource to every player on every
text change. Each one sends getCustomEmojiStickers from its constructor and
re-runs AnimatedImage.Load. It only assigns when the id actually differs now.

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

UnigramDev/Unigram/develop2e3d2831 files, +17/-2
Stop splitting the whole message to find the inline bot

SearchInlineBotResults runs on every keystroke, and split the entire message on
spaces to look at its first word - plus two substrings before that - all of it
dead unless an inline bot is being addressed. That check comes first now, and
the first word is taken with IndexOf.

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

UnigramDev/Unigram/developf8b16ac1 files, +57/-32
Don't ask for a link preview when the text can't hold a URL

CheckMessageBoxEmpty sent getLinkPreview on every keystroke whatever the text:
the whole message serialized to JSON, through the TDLib queue, and a dispatcher
hop back into another full GetText to check staleness, plus the four whole
string copies Format() makes.

A URL always carries either an explicit scheme or a dot before its top level
domain, so a scan that can be wrong about a link being there but never about one
not being there now gates all of it. The clear-the-preview block that appeared
twice became one method.

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

#unigram
UnigramDev/Unigram/develop7b2af212 files, +76/-8
Paste through a batched insert, and don't relabel a link that is one already

The insert and the caret move that follows it each lay the document out, so they
run inside BatchDisplayUpdates.

Pasting a URL over a selection labelled it by way of SetText, which rewrote the
selection with its own text so it could re-derive a range from offsets. It sets
the link on the range directly instead, which drops two side effects: SetTextImpl
ran the text through IsLongerThanMaxLength, so replacing a selection with itself
could be truncated near the limit, and it destroyed anything hidden inside the
selection.

It no longer labels a selection that is a link already, whether it carries one or
is one. Nothing marks a typed URL as a link while composing - entities are
resolved at send - so replacing one link with another turned the old URL into the
label of a link pointing at the new one.

IsValidUrl rejects on whitespace before handing the string to the entity parser,
since it only accepts an entity spanning the whole string anyway.

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

UnigramDev/Unigram/developb44e2b31 files, +122/-0
Write down what the composer review turned up

Unfixed findings in FormattedTextBox and ChatTextBox, ordered by severity, with
the ones fixed while investigating listed at the end so the file reads as the
current state. Check an item off in the same commit as its fix.

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

UnigramDev/Unigram/develop900d34b2 files, +25/-8
Unfreeze the editor when GetFormattedText throws

It set _updateLocked, batched display updates when clearing, and undid both
after the walk - with nothing in between guarding against an exception. Any
throw left the editor frozen and UpdateCustomEmoji switched off for the rest of
the control's life, since it early-returns on _updateLocked. Neither has a
symptom that points anywhere near here.

Split into GetFormattedTextImpl the way SetText already was, so the finally
undoes both.

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

UnigramDev/Unigram/develop0b4e29b2 files, +119/-80
Scope the undo group so it can't be left open

Eight Begin/EndUndoGroup pairs, none of them guarded. Anything thrown in
between left the counter standing, and UpdateFormat is gated on it reaching
zero again - so one failed TOM call anywhere in the composer stopped blockquote
font sizes being normalized for the rest of the control's life, with nothing to
point at why.

BeginUndoGroup returns a disposable scope now. InsertBlockquote takes the same
Impl split as SetText and GetFormattedText, since its pair is conditional on
batch, and InsertEmoji's display batch is guarded the same way - a group that
closes while the editor stays frozen isn't much better.

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

#unigram
UnigramDev/Unigram/develop66ceb872 files, +20/-7
Subscribe to CharacterReceived once

Loaded subscribed and Unloaded unsubscribed, but Loaded can come twice without
an Unloaded in between, and CoreWindow outlives every control on it. A second
subscription would replace the emoticon twice for one keystroke and hold the
box - and everything it references - for the rest of the session.

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

UnigramDev/Unigram/develop39724861 files, +5/-8
Downgrade the Text hidden-text finding: it is deliberate

The URL of a hyperlink lives only in the hidden run TOM keeps it in, so reading
with NoHidden would mean no link preview for a text-url entity. Recorded as
intentional so it doesn't get "fixed" later.

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

UnigramDev/Unigram/develop8f8a0635 files, +164/-103
Stream files added to an open SendFilesPopup too

Add_Click and the popup's own drop handler still typed their files with the
blocking CreateAsync, so adding to an open popup stalled exactly the way the
initial drop used to before it was streamed.

The one counter the load pipeline used was doing two jobs, which is why it
could not take a second batch. It is now two. _allocated is the next free slot
in the index space and only grows, so an appended batch lands behind everything
already picked and the contiguous-run logic keeps working across batches.
_expected is what the title claims while items are in flight and drops to zero
when nothing is. Batches can overlap -- files dropped while the first is still
typing -- so the loading state became a count rather than a flag.

LoadAsync takes one initial flag rather than a set of behaviour switches, since
the two things that differ are really the same question: only the batch the
popup was opened for runs the caller's guard, and only that one closes the
popup when it comes back empty.

An appended batch is deliberately no more checked than it was before it
streamed. The guard is all or nothing, so one rejected late arrival would close
a popup that already has a caption and a screen of files in it. Doing that
properly means dropping the offending item and saying why, which is a
behaviour decision rather than wiring, and stays open as 6.6.

The editing branch still types its one file inline: it replaces a single
message, so there is nothing to stream.

Also removes a block that existed three times over. Copying a bitmap out of a
data package into the temporary folder and typing it appeared verbatim in
SendFilesPopup, DialogViewModel and SendMessagesView, IsScreenshot included,
and the OfType<StorageFile> gather in two of them. Both now sit on StorageMedia
next to the other factories, which also concentrates the folders-are-dropped
limitation of 6.7 in one place.

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

#unigram
UnigramDev/Unigram/develop95cca4f4 files, +97/-51
Group SendFilesPopup the way the send path does

The popup grouped items into albums itself, splitting only on the ten item
limit. Sending groups them again with GetItemsView, which also splits a muted
video out, splits a WEBP document out to work around the server ignoring
force_file inside an album, and never mixes media with documents or audio. So
the albums on screen were not the messages that would be sent, and
Send_ContextRequested decided whether to offer "send without grouping" against
a third grouping, computed with a hardcoded albumAllowed and forceDocuments
rather than the popup's own.

GetItemsView is now the only place a StorageAlbum is constructed anywhere.
UpdateCollection calls it and adapts the result, because grouping to send and
grouping to draw are not quite the same question:

Documents and audio albums expand into rows. They are grouped for sending, but
there is no mosaic to draw for them, so the grouping is invisible either way
and the rows are what the popup always showed.

A standalone photo or video gets a one item album. GetItemsView leaves a muted
video bare because it is sent as its own message, and rendering that literally
would drop it out of the mosaic and into a document row the moment the user
hits mute. Sending cannot tell the difference: a one item album and a bare item
take the same path.

Permissions go in as allowed rather than as the chat's. GetItemsView silently
drops an item whose type is not permitted, which is right when sending and
wrong when drawing. Everything in Items already cleared the guard, and the edit
path has no guard at all, so filtering here could only blank out an item the
popup exists to show.

StorageAlbum carries its type now, so the popup can tell a mosaic from a row
without deriving it again.

Mute_Click never refreshed anything, which was harmless while the popup ignored
IsMuted and is not any more. Nothing binds IsMuted, so it has to ask. The rest
of what GetItemsView reads is either immutable or already refreshes: Items
through OnCollectionChanged, IsFilesSelected through ToggleIsFilesSelected and
MakeContentPaid, IsAlbum only from SendWithoutGrouping, which hides the popup.

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

UnigramDev/Unigram/develop8e488b92 files, +16/-301
Remove the video compression that never ran

StorageVideo carried a compression model ported from Android: Compression,
MaxCompression, CanCompress, GetEncodingAsync, both ToString overloads,
UpdateWidthHeightBitrateForCompression, and fifteen fields behind them.

Nothing outside the file referenced any of it, and it could not have worked
anyway. Every original* field it reads is assigned only in commented-out lines
in the constructor, so they are permanently zero: MaxCompression always
resolves to 1, CanCompress is always false, and ToString divides by a zero
duration. LoadPreview, called from the constructor, existed only to compute
those values.

What is left is Width and Height, TotalSeconds, Duration, IsMuted and
GetGeneration, which is the whole of what the app ever asked a StorageVideo
for.

IsMuted's setter was resetting Compression and raising CanCompress; it is now
just the set. That matters slightly more than it reads, since muting a video
now moves it out of its album and the refresh is done by the caller.

The commented-out assignments in the constructor go too. They set the original*
fields, so keeping them would describe members that no longer exist. Anyone
reviving video compression starts from the Android implementation rather than
from this.

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

#unigram
UnigramDev/Unigram/develop206109c3 files, +6/-2
Update libvlc build script

UnigramDev/Unigram/develop45d151b2 files, +32/-13
Probe a batch of files concurrently

The batch CreateAsync overload was still the serial loop the popup stopped
using, and the share target had become its only remaining caller, so sharing
thirty photos typed them one at a time before anything was sent.

It is now an ordered wrapper over ProbeAsync: concurrent, with results written
into a positional array so the order the caller gave survives, and failures
dropped at the end. Same contract, so no call site changes.

Order matters to the one caller: SendMessagesView attaches the caption to the
last item of the grouping, and GetItemsView needs the whole set before it can
group at all.

Streaming does not apply there and is not attempted. That view is an animation
and a progress bar, swapped in after the chats have been picked, so there is no
list to append into as items land -- only the pipelining was missing.

Its second stage is left alone: each InputMessageContent is still built
serially through MessageFactory, which for a large share is plausibly the
bigger delay, but parallelising it touches TDLib generation, ordering and
resource pressure at once and wants to be its own change.

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

UnigramDev/Unigram/develop4230adc3 files, +67/-36
Clear the dead branches out of SendFilesPopup, and log what it swallowed

FileItem_PointerEntered read an item it never used, through
ItemFromContainer on the template root rather than the container, so it was
always null anyway. Its glyph assignment was also unguarded where the matching
PointerExited uses ?.; both do now.

OnContainerContentChanging had a branch setting AspectView.Constraint that
cannot run. The template selector only ever returns FileItemTemplate or
AlbumTemplate, both rooted on a plain Grid; MediaItemTemplate, the one rooted
on an AspectView, is only used as a Button.ContentTemplate inside
StorageAlbumPanel and so is never a container's ContentTemplateRoot.

The popup's two catch { } now record what they caught. Reading a data package
is someone else's data over remote calls, so they have to keep swallowing, but
silently meant a paste that did nothing left nothing to look at. The per-file
catch in ProbeAsync logs too: the per-type factories already return null for
the expected "this is not a photo" case, so reaching that handler is a
surprise. Their own catch { return null; } is left alone, being both the
documented contract and, at one entry per unsupported file, a good way to flood
a 200 entry ring buffer that ships with crash reports.

Also folds the glyph expression, which appeared three times, into GlyphFor.

Three findings from the review doc do not survive a second look and are
corrected there rather than acted on. FindName on a template root is a
namescope table lookup rather than a tree walk, and the filename substrings run
once per row realization. Extensions.Prepend is misnamed and appends, so the
log line is linear and in natural order, not quadratic and reversed. And the
LINQ passes in IsMediaAllowed and TitleText use static lambdas the compiler
caches, on a path that runs about ten times per popup. Churning code for a cost
that is not there is how a file gets worse.

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

#unigram
UnigramDev/Unigram/developdc192a32 files, +12/-8
Time the typing indicator with ticks, not the clock

It ran DateTime.Now twice on every keystroke. Now resolves the local time zone
on each call, which Logger.cs already documents, but the bigger point is that
this measures an interval: the clock can jump backwards on an NTP correction,
and the gate would then stay shut until it caught up. Logger.TickCount is
milliseconds since boot, read once.

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

UnigramDev/Unigram/develop9c8aa442 files, +14/-7
Ask for the quick reply shortcuts once, not per typed character

GetCommands runs for every autocomplete query that doesn't match the previous
one, so typing /start sent six identical loadQuickReplyShortcuts requests.

Answering the TODO that sat on the line: it is needed. GetQuickReplyShortcuts
returns empty until the update this asks for arrives, and the only other caller
is the business replies page, which a user may never open. So it stays - once
per box rather than once per character.

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

UnigramDev/Unigram/developdc1f2514 files, +22/-11
Track the quick reply shortcuts request in the service

Following up 9c8aa44: the flag belongs next to the state it guards, not in one
of the callers. LoadQuickReplyShortcuts is an IClientService method now, so the
business replies page gets the same treatment, and Clear() resets it with the
shortcuts it goes with.

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

UnigramDev/Unigram/develop99d1ad73 files, +39/-14
Don't allocate a cancellation source just to cancel

CancelEmoji cancelled the pending emoji query and allocated a replacement in
the same breath, and SetAutocomplete calls it on nearly every keystroke - with
nothing to hand the new token to. It also dropped the cancelled source without
disposing it, unlike CancelInlineBotToken next door.

Cancelling and starting are separate now. CaptionTextBox carries a copy of the
same code, so it gets the same split.

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

#unigram