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/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
UnigramDev/Unigram/develop0f035f13 files, +56/-9
Make VideoAnimation::Stop actually able to stop a decode

Nothing in the app has ever called Stop, so neither of these has been
exercised. They matter now because the cancellation survey points at Stop as
the lever for aborting a preview render, and it would not have worked.

stopped was a plain bool, written by Stop from whichever thread wants to abort
and read by the decode loop and by the IO callbacks running under it. It is
now std::atomic<bool>. Every use is a plain load or store, so only the
declaration changed. Stop still does not take m_lock, and that is deliberate:
taking it would block until the decode it is trying to interrupt had finished.

readCallback returned 0 once stopped. ffmpeg reads 0 as "no bytes this call"
rather than as a failure, so it can keep asking and spin instead of unwinding
the demuxer. It returns AVERROR_EOF now. seekCallback had the same shape, where
returning 0 reports a successful seek to offset 0 and sends the demuxer back to
the start rather than letting it fail out.

Not built: Telegram.Native needs the vcpkg ffmpeg setup, so this is unverified
by a compiler. std::atomic<bool> needs <atomic>, which is now included.

Left alone next door: seekCallback's live path has the same 0 return when
SetFilePointerEx fails, reporting a successful seek to the start. Same class of
bug, but on the path that plays video rather than the one that aborts it, so it
wants someone who can run it.

Also closes 2.3 in the review doc as won't-fix, with the reasoning. Probing a
media item twice is real, but the probe answers what a file is and how big
before the item can be published, and the thumbnail answers what it looks like
on realization and has to stay releasable. Every way of collapsing the two puts
back what moving the thumbnails off the models took away.

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

UnigramDev/Unigram/develop200e65f1 files, +20/-10
Correct what probing a video actually costs

The note closing 2.3 said the video path pays a real avformat_open_input plus
find_stream_info twice. That overstates it: VideoAnimation already has a light
mode and the probe already uses it.

LoadFromFile takes preview and probe flags that exist for this. preview sets
AVFMT_FLAG_NOBUFFER so nothing is buffered, and probe skips the frame and
packet allocation along with the no-video-stream bail. StorageVideo.CreateAsync
and StorageAudio.CreateAsync both ask for both, so the first pass is headers
only. The photo probe is the same shape, since BitmapDecoder.CreateAsync reads
a header rather than decoding.

So what runs twice is a header read plus a real decode, not two decodes. The
conclusion is unchanged and the reasoning is stronger: the design already has a
cheap probe mode and deliberately uses it, which is about as good as a lazy,
releasable thumbnail gets.

Also records an inconsistency noticed while checking, without acting on it:
ImageHelper loads with preview false, so its format context does buffer, but
passes preview true to RenderSync, which is what raises the retry count to 50.
Whether NOBUFFER helps or hurts a single frame grab is a measurement.

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

#unigram
UnigramDev/Unigram/develop18da5f63 files, +51/-27
Stop materializing the draft on every caret move

OnSelectionChanged allocated the whole message as a string on every keystroke
and every caret move, and then mostly didn't use it.

TryGetAutocomplete never read the text or query it was handed at all - only its
sticker branch reads the text, and only when the whole message is a single
emoji, so it reads it there. The inline bot search is dead without a bot to
address, so it doesn't run without one. And SearchByInlineBot only matches a
username that starts the message, which CharacterAt answers without reading the
rest.

That leaves the common path - typing ordinary text - reading no text at all.

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

UnigramDev/Unigram/develop469d6de2 files, +45/-4
Say why the frame grab does not ask for NOBUFFER

ImageHelper loads video with preview false while passing preview true to
RenderSync, which reads like an oversight and is not one. The flag sets
AVFMT_FLAG_NOBUFFER, which is what the header-only probe in
StorageVideo.CreateAsync wants, but unbuffered often fails to grab the first
frame -- the whole point of these three call sites. Right for a probe, wrong
for a frame grab. Now stated at each of them, since it is exactly the sort of
inconsistency someone tidies up later.

Also adds task 8 to the review doc: enqueue a share as it is typed rather than
after. SendMessagesView exists only to send what was shared, and still types
the whole set before building and sending message after message, so nothing
reaches the network until the slowest file has been probed. For a share of
large videos that is the probe delaying the upload rather than the UI, and the
first file could be going out while the last is still being read.

The shape is there for it: ChooseChatsViewModel.SendWithChat resolves options
and topic synchronously and never looks at the content, so it can be called
once per chat up front and each message sent to the captured chats as it is
built. Four things need deciding rather than wiring, and are written down --
grouping needs lookahead to close an album, the caption currently rides
positionally on the last item, the progress bar divides by a total that would
grow underneath it, and cancel gains a window where it must stop the probe as
well as delete what was already sent.

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

UnigramDev/Unigram/developc22b1985 files, +569/-546
Add article option to attach

UnigramDev/Unigram/develop03736741 files, +1/-1
Fix font family

UnigramDev/Unigram/develop9aea06e3 files, +14/-1
Better chat view inst

UnigramDev/Unigram/developf42aeeb1 files, +7/-1
Add transcode logging

UnigramDev/Unigram/developd3ec4f11 files, +66/-13
Optimize slide panel

#unigram
UnigramDev/Unigram/develop19633c71 files, +4/-0
Copy rich messages as formatted text

UnigramDev/Unigram/develop98836981 files, +4/-0
Ship tdjson symbols in the appxsym (#3338)

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>

UnigramDev/Unigram/develop1cb16192 files, +45/-16
Fix NullReferenceException when the chat list loads before its template (#3339)

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
UnigramDev/Unigram/develop8ab18d32 files, +90/-30
Keep the originating description when an unhandled error is a bare E_FAIL (#3340)

* 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.

* 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
telegramdesktop/tdesktop/nightly651f9ba3 files, +23/-23
Switched nightly Windows CI with optimized binaries without arm64.

#tdesktop
🫡2
telegramdesktop/tdesktop/nightly6d512312 files, +23/-15
Switched nightly Windows CI with optimized binaries without arm64.

#tdesktop
🫡2
UnigramDev/Unigram/theme-current-share-target7a4368b1 files, +11/-2
Register the theme before reading its settings

Theme.Current is thread-static and assigned in exactly one place, and that
assignment sat behind a call that can fail:

try
{
_isolatedStore = ApplicationData.Current.LocalSettings.CreateContainer(...);
Current ??= this;
...
}
catch { }

When CreateContainer throws, the catch is silent and the theme never registers
itself. Every later Theme.Current dereference - and they are unguarded across
the message tree, FormattedTextBlock, MessageBubblePanel, ChatBackgroundControl
- is then a NullReferenceException on that view thread.

Reported by crash telemetry on 12.9.1, through OnShareTargetActivated:
SharePage's constructor calls ChatBackgroundControl.Update, which reads
Theme.Current.ChatBackground and dies. The share target view is driven before
it is initialized - the same race as #3320, which moved this crash one frame
later rather than removing it - and ApplicationData is evidently not always
reachable that early.

That the constructor ran at all is not in doubt: SharePage.xaml resolves
StaticResource EmptyHyperlinkButtonStyle from CommonStyles.xaml, a merged
dictionary of Application.Resources that follows <common:Theme /> in the merge
list, so InitializeComponent could not have returned unless the resources of
this view - the theme among them - had been inflated.

Assign Current before the try. It depends on nothing inside it, and losing a
persisted preference should not cost the view its theme. The catch now logs,
since the whole diagnosis rests on inferring which call threw, and an empty
catch is what made that necessary.

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

#unigram
UnigramDev/Unigram/business-hours-continue1cfe0032 files, +98/-9
Implement BusinessHoursViewModel.ContinueImpl

The Done button on Settings > Telegram Business > Opening Hours is bound to
BusinessFeatureViewModelBase.Continue, which calls the abstract ContinueImpl.
BusinessHoursViewModel never implemented it and threw NotImplementedException,
so pressing Done crashed the app every time.

Build a BusinessOpeningHours from the per-day ranges and send it with
SetBusinessOpeningHours, following the other business feature view models:
early-out when nothing changed, toast an error, otherwise go back. Override
HasChanged so the unsaved-changes prompt the base class already carries a
string for actually fires.

The time zone is now resolved on every navigation rather than only when the
user already had opening hours saved, falling back to the first zone at the
current UTC offset, since otherwise a first-time user would have none to send.

Reported by crash telemetry on 12.9.1.

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

#unigram
UnigramDev/Unigram/dwrite-collection-keyc1bf81a2 files, +40/-11
Give the DWrite custom font collection a valid key

CreateCustomFontCollection takes the key size in bytes, but it was given
path1.size() + path2.size(), the summed character length of the two package
paths (~240) — while the key itself is a two-element array of pointers, 16
bytes. DWrite copied ~240 bytes out of a 16-byte stack array on every
PlaceholderImageHelper construction, so the key it stored was partly unrelated
stack memory, and how much varied with the install path length.

The key also pointed at two locals that die when the function returns, so the
pointers DWrite kept were dangling by the time it could hand them back to
CreateEnumeratorFromKey. The paths and the pointer array now live inside the
CustomFontLoader, which stays alive as long as it is registered, and the size
passed is sizeof that array. CustomFontFileEnumerator honours collectionKeySize
instead of assuming two entries.

Close() now unregisters the loader it registered on the shared DWrite factory,
after releasing the collection built from it. The factory is process-wide, so
without this every window thread left a loader and a custom collection on it for
the rest of the session.

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

#unigram