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/localised-error-messages33f31b31 files, +75/-0
Translate more localised system error messages

Windows returns system error text in the user's language, so one fault
arrives at the crash backend as a different message per locale and splits
into a separate group for each. TranslateText maps those back onto the
canonical English wording before the report is sent, and it only ever
covers the languages someone has already looked at.

This adds the languages seen in recent reports for the faults it already
handles, and the faults it did not handle at all: file sharing violations,
disk full, paging file too small, insufficient system resources, service
disabled, data area too small, invalid operation identifier, out of range
access, interface not registered, invalid registry value and missing
Unicode mapping.

Every string maps onto the wording the same fault produces on an English
system, or onto the documented Windows text. The handful that could not
be established that way are left untranslated.

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

#unigram
UnigramDev/Unigram/develop06e29f03 files, +25/-7
Tear down the chat preview from the flyout's Closed event (#3353)

* Tear down the chat preview from the flyout's Closed event

ChatCell.ShowPreview hosts a second ChatView inside a MenuFlyout and hung its only
teardown on chatView.Unloaded, which never fires for a flyout-hosted view: the log
tail of the crash runs for another 109 seconds without the handler's "Unloaded"
line ever appearing. ChatView.OnCollectionChanged therefore stays subscribed to the
view model's Items after the flyout's XAML peers are gone, and the next collection
change reads Messages.ItemsPanelRoot through a separated RCW, throwing
InvalidComObjectException.

Teardown now hangs off the flyout's own Closed event, with Unloaded kept as a
secondary trigger, and ChatView.Deactivate ignores a second call so the order of
the two doesn't matter. The two forum topic cells already tore down from
flyout.Closing and so never had the hole; they move to Closed for a single pattern.

Reported by crash telemetry on 12.9.1.

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

#unigram
UnigramDev/Unigram/localised-error-messages75089061 files, +34/-0
Normalize the ASTA "application is busy" message

RPC_E_SERVERCALL_RETRYLATER comes with a detail sentence naming the ASTA
thread that was timed out. The id is different on every hang, and the
sentence is localized, so one fault reports under a new group each time
and splits again per language.

Rewrite it to a canonical English sentence keeping only the IID and the
method index, which are the parts that say which call hung. The GUID and
the number after it are matched structurally, since the labels around
them are translated. Anything the pattern does not match is left alone.

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

#unigram
UnigramDev/Unigram/capture-popup-focus88966271 files, +15/-13
Track focus rather than window activation in ChooseCapturePopup

The popup refreshes its list of capturable windows when the user comes back
from arranging the one they want to share. It reached that signal through
WindowContext.ForXamlRoot(XamlRoot).Activated, subscribed in Loaded and
detached in Unloaded, and both halves are unsafe:

- ForXamlRoot is a lookup in a dictionary the window is removed from when its
view is consolidated, even though the XAML tree is still alive, so it returns
null and the subscribe throws.
- XamlRoot is null by the time the popup unloads, and can throw on access, so
the detach throws too - and when it does, the handler is left attached to a
WindowContext that outlives the popup.

Focus tracks window activation just as reliably and needs neither. GotFocus is
raised when the window is activated and LostFocus when it is deactivated, so a
lost-then-got pair is the round trip worth refreshing on; a bare GotFocus is
not, since one arrives simply from opening the popup and enumerating every
window on the system is not free.

This is also the only ForXamlRoot call site in the app that dereferenced the
result directly - every other one assigns to a local and checks it.

Reported by crash telemetry on 12.9.1.

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

#unigram
UnigramDev/Unigram/developd78de991 files, +15/-13
Track focus rather than window activation in ChooseCapturePopup (#3359)

The popup refreshes its list of capturable windows when the user comes back
from arranging the one they want to share. It reached that signal through
WindowContext.ForXamlRoot(XamlRoot).Activated, subscribed in Loaded and
detached in Unloaded, and both halves are unsafe:

- ForXamlRoot is a lookup in a dictionary the window is removed from when its
view is consolidated, even though the XAML tree is still alive, so it returns
null and the subscribe throws.
- XamlRoot is null by the time the popup unloads, and can throw on access, so
the detach throws too - and when it does, the handler is left attached to a
WindowContext that outlives the popup.

Focus tracks window activation just as reliably and needs neither. GotFocus is
raised when the window is activated and LostFocus when it is deactivated, so a
lost-then-got pair is the round trip worth refreshing on; a bare GotFocus is
not, since one arrives simply from opening the popup and enumerating every
window on the system is not free.

This is also the only ForXamlRoot call site in the app that dereferenced the
result directly - every other one assigns to a local and checks it.

Reported by crash telemetry on 12.9.1.

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

#unigram
UnigramDev/Unigram/localised-error-messages23f2cb71 files, +11/-4
Match the HRESULT suffix instead of the first parenthesis

TranslateMessage cut each line at the first '(' so that the HRESULT .NET
appends to a COM message would not stop the sentence in front of it matching.
The sentence can contain parentheses of its own, though, and across the
recorded reports that boundary lands mid-sentence far more often than it lands
on the suffix - in TDLib assert text, in EETypeRva casts, in captured stack
traces. It only ever worked because a head that had been truncated matched
nothing and was concatenated back unchanged.

Match the suffix itself, anchored at the end. Nothing that previously reached
TranslateText stops reaching it, and the Portuguese RPC_E_WRONG_THREAD message
can now be mapped: its text reads "empacotada (marshall) para outro módulo",
so the first parenthesis was inside the sentence.

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

#unigram
UnigramDev/Unigram/hresult-message-lookup5b288a11 files, +133/-9
Key localised error messages on the HRESULT, not on the sentence

Windows returns system error text in the user's language, so a single fault
lands in a different crash group in every locale. Matching those sentences one
by one only ever covers the languages somebody has already noticed, and only
after the group has fragmented. The HRESULT behind them is the same number
everywhere.

ProcessException now passes the exception's type name and HResult into
TranslateMessage, and the first line is rebuilt from a code lookup when the
type is Exception or COMException. The first line is the only one that can be
system text - a WinRT error puts its originating description on a line of its
own after it - and it is also the only part the group hash reads.

Any other type keeps the sentence match. Managed exceptions carry generic
codes: NullReferenceException is E_POINTER, ArgumentException is E_INVALIDARG,
InvalidCastException is E_NOINTERFACE. Rewriting those from the code would
replace .NET's own text with system wording and merge unrelated groups.

FatalError has no HResult of its own, so there the code is read back out of the
"(Exception from HRESULT: 0x...)" suffix when .NET appended one, and otherwise
nothing changes.

The sentence table stays. It is the fallback for every code the lookup does not
list, and it is the only cover for the DirectWrite and Direct2D families:
CoreApplication.UnhandledErrorDetected flattens those to E_FAIL while the
message keeps the original wording, so the code cannot be taken at its word.
E_FAIL and the COR_E_* codes are left out of the lookup for that reason.

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

#unigram
UnigramDev/Unigram/localised-error-messagesff66f601 files, +1/-1
Map ERROR_NOT_ENOUGH_MEMORY to its own English text

"Zur Verarbeitung dieses Befehls..." is 0x80070008, whose English ends "to
process this command"; it was returning 0x8007000E's "to complete this
operation" and so merged two distinct codes into one group.

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

#unigram
morethanwords/tweb/masterc7f79a41 files, +3/-2
Resolve min peers by username

morethanwords/tweb/masterce0600f4 files, +6/-4
Fix PWA manifest ID for Telegram link scopes

morethanwords/tweb/masteree72f012 files, +119/-10
Fix folder recovery after state clear

morethanwords/tweb/master05424f9300 files, +615/-615
Build

#webk
🫡3
UnigramDev/Unigram/searchcollection-diff-snapshotd6fac752 files, +28/-5
Snapshot the collections before diffing them off-thread

SearchCollection computes its diff on the thread pool:

await incremental.LoadMoreItemsAsync(0);
var diff = await Task.Run(() => DiffUtil.CalculateDiff(this, source, ...));

Both arguments are collections the UI thread owns and is still appending to,
and CalculateDiff starts by copying them. So the pool thread runs Array.Copy
over a list whose backing array and count are being replaced underneath it, and
the reported ArgumentException comes straight out of Array.CopyImpl.

The guard that was meant to prevent this is a plain bool. UpdateImpl sets
_loading before awaiting, and LoadMoreItemsAsync returns early while it is set
- but LoadMoreItemsAsync also clears it when it finishes, so one that was
already in flight when UpdateImpl set it hands the door back while the diff is
still running. The next scroll then appends to the very collection being
copied. That interleaving needs a load to be in flight at the moment an update
starts, which is why it is rare and why the report shows half a minute of
continuous scrolling behind it.

Snapshot both on the UI thread and diff the snapshots. The diff only ever
needed the sequences, the indices it produces stay valid because items are
appended rather than inserted, and nothing else has to be synchronised.

The same lines were copied into ChatTextBox's autocomplete, so all four sites
are changed; only the first is evidenced by a crash.

Reported by crash telemetry on 12.9.1.

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

#unigram
UnigramDev/Unigram/develop7a026d53 files, +14/-5
Read the name, not FileType, to know what a file is

A file dragged straight out of a ZIP archive arrives with an empty FileType,
though its Name still carries the extension. HasExtension compared FileType and
so answered false for everything in one.

That decided more than it looks. StorageMedia.CreateAsync dispatches on it
before probing, so a photo out of a ZIP fell through to StorageDocument and was
sent as a file: no thumbnail, no album, no compression. GetItemsView uses it for
the WEBP split that works around the server ignoring force_file inside an album,
so a WEBP out of a ZIP would have stayed in its album and failed the whole
upload -- the exact failure that workaround exists to prevent. GenerationService
uses it to pick the audio generation mode.

HasExtension now falls back to Name when FileType is empty, rather than always
reading Name, which keeps the exact-match behaviour everywhere FileType is
populated. It cannot turn a correct false into a true either: a file with no
extension has none in its name.

Two more places read FileType directly and had the same blind spot.
MessageFactory decides whether a WEBP might be a sticker and whether a TGS
might be an animation. EditMediaPopup decides whether to open the trim toolbar
or the crop one, which meant a video out of a ZIP got the photo editor and
InitializeVideo never ran. Both go through HasExtension now.

EditMediaPopup's matters twice over: ImageHelper.OpenReadAsync already carries a
comment saying it and EditMediaPopup must agree about what a video is, and
fixing only HasExtension would have made them disagree for exactly these files.

The remaining FileType reads are TDLib's StorageStatisticsByFileType and
NetworkStatisticsEntryFile, which are unrelated types.

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

UnigramDev/Unigram/developb5cdb0e1 files, +4/-3
Say what the trim strip scales to

Math.Max(ratioY, ratioY) compares a value with itself and ratioX above it was
computed and never used, which reads like a typo for Math.Max(ratioX, ratioY).
It is not one, and making that change would break the strip.

The 40 is the height of the filmstrip, not a box to fit inside. Each frame is a
Border in a row of grid columns filling 296 pixels, so it has to be 40 tall
with its width following the aspect -- which is what dividing by pixelHeight
gives. Taking the larger ratio would pin a portrait video's width to 40 instead
and leave every frame taller than the strip.

So the scale is written as what it is, and the dead ratioX goes with the
no-op Max.

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

#unigram
UnigramDev/Unigram/develop6df8bf538 files, +269/-1546
Consume libvlc and webrtc as prebuilt binaries

Both were built from source as part of setting up a checkout. libvlc meant an 8.8 GB submodule,
Docker and the VideoLAN contrib toolchain; webrtc meant depot_tools, a ~1.5 hour sync, ~20 GB of
disk and a multi-hour build, ending in absolute paths to C:\webrtc hardcoded in two project
files. Both change about once a year.

They are vcpkg overlay ports now, downloading an archive for the architecture being built and
verifying it against a recorded SHA512. The sources, the scripts that produce the archives and
the patches they need moved to UnigramDev/deps, and the archives are published as releases there,
each naming the fork commit it was built from.

The libvlc port also has to place the plugin tree, which cannot be flattened: the plugins' paths
are recorded in the generated plugins.dat cache, so they reach the package as
plugins\<category>\<name>.dll through Content items rather than the flat copy the other
dependencies use.

VcpkgAutoLink is off. It put every .lib in the installed tree on every link line, which since
webrtc arrived meant Telegram.Native scanning a 339 MB static library it has no reference into.
Each project now names what it links, derived from what the binaries actually import.

tgcalls and Telegram.Native.Calls include libyuv by its full path. webrtc carries a patched copy
whose ConvertToI420 takes three extra parameters, and vcpkg's libyuv port supplies the stock one,
so a plain <libyuv.h> resolved to whichever include directory came first -- which was decided by
a hardcoded path that no longer exists. The three compiled files that used the ambiguous form now
say which library they mean.

Libraries/vlc and Libraries/webrtc are gone, submodule included. The VLC checkout the former held
is an ordinary clone of the fork now, kept outside the repository.

The instructions for building without WebRTC go too. Removing the project reference and the
ENABLE_CALLS directive still compiles, but webrtc is listed in vcpkg.json, so the restore installs
it for the triplet either way -- the download that section existed to avoid is spent before any
project is built. Making it work again means a manifest feature, not a set of manual edits.

duplicated-libraries.md records what the app now carries more than one copy of -- two ffmpeg
builds, and opus and libyuv both as DLLs and inside webrtc.lib -- with the measurements behind it.

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

UnigramDev/Unigram/develop51f817c4 files, +40/-20
Merge remote-tracking branch 'origin/develop' into develop

#unigram
UnigramDev/Unigram/develop74966861 files, +121/-5
Stop system error messages from fragmenting crash groups (#3357)

* Translate more localised system error messages

Windows returns system error text in the user's language, so one fault
arrives at the crash backend as a different message per locale and splits
into a separate group for each. TranslateText maps those back onto the
canonical English wording before the report is sent, and it only ever
covers the languages someone has already looked at.

This adds the languages seen in recent reports for the faults it already
handles, and the faults it did not handle at all: file sharing violations,
disk full, paging file too small, insufficient system resources, service
disabled, data area too small, invalid operation identifier, out of range
access, interface not registered, invalid registry value and missing
Unicode mapping.

Every string maps onto the wording the same fault produces on an English
system, or onto the documented Windows text. The handful that could not
be established that way are left untranslated.

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

* Normalize the ASTA "application is busy" message

RPC_E_SERVERCALL_RETRYLATER comes with a detail sentence naming the ASTA
thread that was timed out. The id is different on every hang, and the
sentence is localized, so one fault reports under a new group each time
and splits again per language.

Rewrite it to a canonical English sentence keeping only the IID and the
method index, which are the parts that say which call hung. The GUID and
the number after it are matched structurally, since the labels around
them are translated. Anything the pattern does not match is left alone.

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

* Match the HRESULT suffix instead of the first parenthesis

TranslateMessage cut each line at the first '(' so that the HRESULT .NET
appends to a COM message would not stop the sentence in front of it matching.
The sentence can contain parentheses of its own, though, and across the
recorded reports that boundary lands mid-sentence far more often than it lands
on the suffix - in TDLib assert text, in EETypeRva casts, in captured stack
traces. It only ever worked because a head that had been truncated matched
nothing and was concatenated back unchanged.

Match the suffix itself, anchored at the end. Nothing that previously reached
TranslateText stops reaching it, and the Portuguese RPC_E_WRONG_THREAD message
can now be mapped: its text reads "empacotada (marshall) para outro módulo",
so the first parenthesis was inside the sentence.

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

* Map ERROR_NOT_ENOUGH_MEMORY to its own English text

"Zur Verarbeitung dieses Befehls..." is 0x80070008, whose English ends "to
process this command"; it was returning 0x8007000E's "to complete this
operation" and so merged two distinct codes into one group.

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

---------

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

#unigram
UnigramDev/Unigram/develop7d92ca71 files, +133/-9
Key localised error messages on the HRESULT, not on the sentence (#3360)

Windows returns system error text in the user's language, so a single fault
lands in a different crash group in every locale. Matching those sentences one
by one only ever covers the languages somebody has already noticed, and only
after the group has fragmented. The HRESULT behind them is the same number
everywhere.

ProcessException now passes the exception's type name and HResult into
TranslateMessage, and the first line is rebuilt from a code lookup when the
type is Exception or COMException. The first line is the only one that can be
system text - a WinRT error puts its originating description on a line of its
own after it - and it is also the only part the group hash reads.

Any other type keeps the sentence match. Managed exceptions carry generic
codes: NullReferenceException is E_POINTER, ArgumentException is E_INVALIDARG,
InvalidCastException is E_NOINTERFACE. Rewriting those from the code would
replace .NET's own text with system wording and merge unrelated groups.

FatalError has no HResult of its own, so there the code is read back out of the
"(Exception from HRESULT: 0x...)" suffix when .NET appended one, and otherwise
nothing changes.

The sentence table stays. It is the fallback for every code the lookup does not
list, and it is the only cover for the DirectWrite and Direct2D families:
CoreApplication.UnhandledErrorDetected flattens those to E_FAIL while the
message keeps the original wording, so the code cannot be taken at its word.
E_FAIL and the COR_E_* codes are left out of the lookup for that reason.

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

#unigram
UnigramDev/Unigram/collection-adapters-reviewf17fd301 files, +162/-0
Write up the collection adapters

SearchCollection and IncrementalCollectionView are two implementations of the
same adapter, differing by one real feature - the search query driver - and
duplicating everything else. Reading them after the crash fixed in #3361 turned
up a list of defects in both, plus a few in MediaCollection, none of which is
worth losing.

Records what the adapters are for, who uses which, what is wrong with each, and
a direction: converge on IncrementalCollectionView's per-index morph and leave
SearchCollection owning only the query. Open decisions are listed as open, and
the one performance claim that has not been measured says so.

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

#unigram
morethanwords/tweb/master267f5393 files, +212/-4
Fix false pin limit from archived pins leaking into the main folder

Folder-scoped messages.getDialogs / messages.getPinnedDialogs answers come
without dialog.folder_id, so archived dialogs were filed into folder 0 and their
pins piled up in pinnedOrders[0]. Nothing ever pruned them and the order is
persisted, so toggleDialogPin reported the limit as reached with only a few
chats actually pinned.

Pass the requested folder to saveDialog again (as an argument, so a known dialog
keeps its cached folder), thread it through applyDialogs, and drop foreign
folder entries from pinnedOrders on load to repair existing state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

morethanwords/tweb/master79b177c3 files, +71/-1
Don't count folded Community members against the pin limit

A member chat of a folded Community is hidden from the real folders, so its pin
holds a slot the user can neither see nor unpin there — the limit popup fires
with fewer rows on screen than the limit. tdesktop drops such a history from the
folder's local pinned list when the Community folds, so its limit ignores them
too. Count only the visible pins, leaving pinnedOrders as server truth so the
pins survive unfolding; the server still answers the actual request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

morethanwords/tweb/master41848437 files, +230/-267
Fix inline bluff spoilers in Safari

morethanwords/tweb/master08e3a633 files, +5/-5
Rename getPinnedOrdersCount to getVisiblePinnedCount

Sitting next to getPinnedOrders, it read as that array's length — the one thing
it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

morethanwords/tweb/master2a06488300 files, +789/-785
Build

#webk
🫡3
telegramdesktop/tdesktop/nightly50be7b21 files, +8/-0
[iv-editor] Fixed caret loss after undo of structural change.

telegramdesktop/tdesktop/nightly3b5470c1 files, +2/-2
[iv-editor] Fixed last line skip for vertical caret move in field.

telegramdesktop/tdesktop/nightly18a5e801 files, +11/-6
[iv-editor] Replaced item start with last line for caret moving up.

telegramdesktop/tdesktop/nightlyf429af81 files, +2/-1
[iv-editor] Fixed missing redraw for ordered list turned into bullet.

telegramdesktop/tdesktop/nightlyc28679b3 files, +18/-18
[iv-editor] Added subscript, superscript and marked to selected text.

telegramdesktop/tdesktop/nightlyd0fca682 files, +19/-4
[iv-editor] Added mutual exclusion for subscript and superscript.

telegramdesktop/tdesktop/nightlyb7a09671 files, +4/-7
[iv-editor] Replaced next cell with cell below on Enter inside table.

telegramdesktop/tdesktop/nightly994d2092 files, +73/-5
[iv-editor] Fixed loss of list items wrapped into quote.

telegramdesktop/tdesktop/nightlyff2d8ca3 files, +11/-1
[iv-editor] Fixed Enter with emoji suggestion having no selection.

telegramdesktop/tdesktop/nightlyba487621 files, +2/-1
Added discard of article draft without confirmation with pressed Ctrl.

telegramdesktop/tdesktop/nightly8ee340a2 files, +7/-0
Added discard of rich draft without confirmation with pressed Ctrl.

telegramdesktop/tdesktop/nightly4ce8b541 files, +28/-0
Fixed checkbox colors of rich text messages for dark chat themes.

telegramdesktop/tdesktop/nightly9408ac92 files, +63/-4
Added ability to replace of rich message media from clipboard.

#tdesktop
🫡2
UnigramDev/Unigram/formatted-text-block-review1fa885f3 files, +347/-6
Anchor rendered indices to the paragraph a block starts at

The fast path left _indexMap null on the grounds that a plain single run maps
1:1 onto StyledText offsets. That only holds for a block whose first paragraph
is the message's first: MessageTextBlock gives every plain paragraph sandwiched
between typed ones its own block, and those render a slice of a shared
StyledText starting somewhere in the middle.

With no map, the converters returned the rendered index unchanged, so copying a
selection from such a block returned text from the start of the message, word
and paragraph expansion resolved against the wrong paragraph, and the search
highlight landed past the end of the block.

_origin is set beside _first/_last so it is correct before every early return
and on both paths - the slow path can also end up with an empty map when every
entity is dropped by the length guard, and that takes the same fallback.

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

UnigramDev/Unigram/formatted-text-block-review505777e2 files, +24/-12
Give the code block tokenization an identity that does not restart

_templateExecuted was doing two jobs: the "text has been applied" flag OnLoaded
tests, and the token that ProcessCodeBlock compares against after its await.
OnUnloaded resets it, so the second job was an ABA - execution 1 is handed out
again after every unload, and a tokenization started before it resumes, matches,
and calls ClearCollection on inlines whose Paragraph is by then back in the
shared pool, wiping whichever block dequeued it.

The async guard now uses its own never-reset counter. That leaves the flag as a
flag, so it is a bool.

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

UnigramDev/Unigram/formatted-text-block-reviewd33e0827 files, +34/-22
Build the monospace font chain once, in Theme

Nine sites spelled out "Cascadia Mono, Consolas" and built a FontFamily from it.
FormattedTextBlock meant to memoize its own, but the local it returned was never
assigned, so it allocated one per monospace entity - and ProcessCodeBlock, being
recursive, one per node of the token tree. Six of the others sit inside a loop
over entities, so they allocated one per code span they rendered.

Theme already owns XamlAutoFontFamily and rebuilds the families that embed it in
UpdateEmojiSet, which is the only place the chain can change, so the family
belongs there and everything reads Theme.Current.MonospaceFontFamily.

Those six spelled the chain without the fallback tail, and now inherit it: a
character the monospace faces don't cover renders through the text and emoji
fonts instead of the system default.

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

#unigram
UnigramDev/Unigram/formatted-text-block-review70384c92 files, +25/-6
Stop building the syntax colour tables per text block

_light and _dark held 28 entries each and were instance fields, so every
FormattedTextBlock in the app carried both - the chat list, replies, web page
previews, all of them - to serve the rare one that renders a code block. Color
is a value type and the tables are never written to, so they are static.

The brushes built from them can't be shared the same way, since a
DependencyObject belongs to the thread that created it and the app runs a XAML
thread per view, but they can wait until a code block asks for one.

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

UnigramDev/Unigram/formatted-text-block-review44cb66d2 files, +24/-7
Set the I-beam on the way in, not on every pointer move

_textSelectionIBeam was assigned and never read, so an Extended block set
CoreWindow.PointerCursor - a marshalled call, plus a CoreCursor allocation - for
every pointer sample over its text.

Testing it is not enough on its own: a Hyperlink puts its own Hand cursor up, so
once the pointer crosses one, ours is no longer what is on screen. The flag is
cleared there, or moving back onto text would skip the I-beam and leave the Hand.

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

UnigramDev/Unigram/formatted-text-block-review1c806582 files, +31/-12
Measure an expandable quote once per width, not once per pass

MaxLines is a native text layout over the whole quote, and it ran on every
measure - so on every window resize and every InvalidateMeasure the bubble
takes - to answer a question whose inputs had not moved.

The text is compared by reference rather than by value, which is exact here:
GetParts returns the same instance until a relative date rewrites the paragraph,
and that is the one thing that can change the answer at a fixed width.

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

UnigramDev/Unigram/formatted-text-block-review464a06f2 files, +42/-22
Allocate the entity collections only for text that has entities

Seven collections were built in the field initializers of a control that the
chat list, replies, web page previews and every message bubble instantiate.
Links, dates and spoilers are rare enough that those three are now null until
something is added; the active-element lists are not, because every rendered
block fills them.

All four active collections become List<T>: nothing can be added twice - each
element is newly constructed or dequeued from the pool, Recycle clears the
lists, and the one mid-render hand-back removes before it enqueues - so the
hashing bought nothing but the cost of a Contains that runs once per code block.

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

#unigram
UnigramDev/Unigram/formatted-text-block-review63c66d92 files, +39/-11
Keep the relative date timer from arming itself 68 years out

An item that was not due yet contributed its remaining time truncated to whole
seconds, so anything under a second contributed nothing. That is the normal
state of the one-second bucket - the timer can fire a hair early - and when it
was the only item, the minimum stayed at int.MaxValue and every relative
timestamp on the thread stopped updating until something else subscribed.

Rounds up now, and the return clamps, so an empty or fully-skipped set falls
back to a second rather than to the sentinel.

TextDate also stops being a record: .NET Native has no records, and as a
dictionary value keyed by its own Element it never needed value equality. The
EntityType member went with it, since only the constructor ever read it.

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

UnigramDev/Unigram/formatted-text-block-review93bf4d42 files, +25/-3
Retry the highlighters on load instead of losing them

ApplyHighlighters returns without doing anything when the inner RichTextBlock is
not loaded, and OnApplyTemplate calls SetText before that is true. Nothing put
them back: OnLoaded returns early on the very flag SetText had just set. If the
guard ever holds - and it is there because it once did - the spoiler cover for a
first render is simply never applied.

A flag is the answer that is correct whichever way the ordering actually falls:
no change if the block is already loaded, and the highlighters arrive a moment
late rather than never if it isn't.

Revealing a spoiler no longer calls SetQuery(string.Empty) either. SetText
reapplies the highlighters on its own now, so all that line still did was forget
the search term the user is looking at.

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

UnigramDev/Unigram/formatted-text-block-review633e9582 files, +87/-132
Write the run property reset once

The two GetOrCreateRun overloads carried the same sixty lines of "set this or
clear it" for a pooled Run, which is the part that has to stay in step with what
NativeUtils does for a new one.

They are not merged into one: on the path that builds a Run, the range overload
hands the offset and length to NativeUtils and never materializes the substring,
so forwarding one to the other would have cost exactly the thing it exists for.
Each keeps its Run_Text line and its native call, and shares the rest.

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

UnigramDev/Unigram/formatted-text-block-review3bc3ea22 files, +26/-4
Make Clear reset the highlighters it claims to reset

Clear nulls the query and the spoiler, but the highlighters themselves live on
the inner RichTextBlock and it never touched that collection - and it ignored
the cached, marked and selection ones entirely.

It runs from MessageService.Recycle, on the Text block of every service message,
under a contract spelled out at the call site: whatever isn't reset is inherited
by the next message in that container. So a recycled service message could come
back wearing the previous one's spoiler cover.

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

#unigram
UnigramDev/Unigram/formatted-text-block-review18269b13 files, +92/-55
Say what these actually are

Spoilers build a Span and called it hyperlink, twice; UpdateSpoilers iterated
TextStyleSpoiler structs as hyperlink; RelativeDateService took a TextStyleRun
called Yolo through three signatures.

The two foreground property handlers were the same five lines under different
names - neither of which described what they do, which is recolour the links
still carrying the outgoing brush.

OnApplyTemplate drops _blocks once it has handed the XAML-declared Paragraphs to
the RichTextBlock, so re-templating cannot try to re-parent them, and skips a
non-Paragraph entry instead of adding null.

The Selectable header claimed an inline object counts as one unit in highlighter
space. It counts zero - the ZWNJ emitted next to it is the unit - which is what
WalkInlines does and what the index map assumes.

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

UnigramDev/Unigram/formatted-text-block-reviewefffd552 files, +16/-2
Offset the query highlight by the host content in inline mode

An inline-mode block renders into a Span the host owns, behind whatever it put
in front - a sender name in a chat cell - while TextHighlighter indices count
from the start of the RichTextBlock. The spoiler ranges correct for that; the
query highlight did not, so it would have landed short by the length of the
prefix.

Would have, because nothing gives an inline-mode block a query today: the only
live query is MessageBubble's, and that goes to MessageTextBlock. This is right
by symmetry with the spoiler branch rather than by observation.

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

UnigramDev/Unigram/formatted-text-block-review6d3a3e51 files, +48/-2
Say why the five open items are open

Each of the remaining items is a decision rather than a patch, so the entry now
carries what I found while working through the rest: SetFontSize runs into the
undecided quote sizing question the TODO at :1127 already flags, the non-pooled
teardown has two fixes that each cost something real, and the last two want a
profiler and the layout cycle audit respectively.

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

#unigram