Telegram github commits and releases
4.53K subscribers
601 files
20.3K links
Broadcast from the most important Telegram clients' repositories
Download Telegram
morethanwords/tweb/master229ee614 files, +24/-4
Support stars_purchase_blocked

Stars can be unavailable for purchase at all. Opening the topup for a
purchase — an insufficient balance or a gift — now shows an alert
instead, and the plain Stars screen keeps the balance and the history,
but drops the topup options and the gift button.

Also remove the duplicate stars_purchase_blocked from the app config.

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

morethanwords/tweb/master31620b12 files, +1/-3
Fix sticker viewer not looping stickers

Passing `emoji` to wrapSticker to get the right skin tone also disabled
looping, since the loop was suppressed for any non-custom emoji. Move
that decision to the only call site that needs it — big emoji bubbles.

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

morethanwords/tweb/masterd064fdb4 files, +122/-26
Fix a drag selection starting inside an album

Pressing next to an album and moving the cursor selected the whole album
before reaching another bubble, for two reasons.

The very first move ran getElementsBetween with first === last, and the
album override expanded its endpoints anyway, so an empty range came back
as every item of the album — enough elements to start the selection.

An album bubble also carries the group's main mid, so crossing from one of
its items to the bubble itself (the spacing between the photos, the
caption, the free space beside it) counted as a second element. A grouped
bubble is now one drag unit: only an item-to-item move inside it makes a
range, and that range stays granular as before.

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

morethanwords/tweb/master41263772 files, +25/-1
Fix an ordinary forum opening as a navigation tab

getChat() returns any Chat, so isCommunity was true for every forum
channel and shouldOpenForumAsNavigationTab pushed it into the sidebar
slider instead of the floating forum drawer.

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

morethanwords/tweb/master85f27ea1 files, +4/-8
Enable the object URL lifecycle in production

The kill switch was on in production builds while the revoke-on-evict
lifecycle waited to be dogfooded. Drop the `import.meta.env.PROD` gate so
shared blob URLs get evicted and revoked everywhere, leaving
?noObjectUrlRevoke=1 as the only way back to the leak-forever behaviour —
which also makes ?forceObjectUrlRevoke=1 pointless, so it goes.

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

#webk
🫡3
morethanwords/tweb/master08d07c24 files, +149/-21
Fix a forum row rendering two '@' badges for one mention

The unread badge turning into an '@' was decided from the unread topics while
the separate mentions badge was decided from the unread messages, so a forum
with a single unread topic holding more than one unread message rendered both
at once (#628). In "view as messages" both came from the same count, which is
why only one of the badges cleared per mode.

Both now come from one helper, which keeps them mutually exclusive by
construction instead of by the two counts happening to agree, and decides
"there is more unread than this mention" from the message count — the forum
tab's All Chats row suppresses its unread badge, so the mentions badge is the
only indicator it has. The Community row counted its chats the same way and
gets the same treatment.

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

morethanwords/tweb/master108d3f316 files, +386/-121
Speed up the chat list

The collapsed-Community projection key walked the whole peer store with
Object.values(). That store is a reactive proxy, so the walk allocated a signal
per peer and subscribed the memo to all of them: ~1.1s of main thread on a
2000-chat account, re-run whenever any peer changed at all — an unrelated user
coming online was enough. It now looks up only the known Community ids, the way
the worker's getCollapsedCommunityPeerIds already did.

Every row of the main list also carried the Community child badge as a custom
element, so the virtual list tore its reactive root down and rebuilt it on every
scroll pass — 93% of a row's whole mount cost, paid whether or not the account
belongs to a single Community. The badge's lifetime now follows the dialog
element instead of DOM connectedness.

Rows that arrived already loaded were revealed one per ~8ms timer, so a window
of them needed as many serial hops to show up; the whole ready batch goes at
once now. The shrink check kept its debounce — it must not cut the list out from
under a running scroll — but re-armed the timer on every row mount and unmount,
where one timer re-checking on each tick debounces the same way for a fraction
of the traffic. The awaiting-task debug name was built on every single invoke
even though only the stuck watchdog and the debug log ever read it.

Touching the projection key turned up the predicate behind it spelled out in
seven places, three of them inverted, and the peer-to-Community lookup in four
more; isCollapsedCommunity() and getLinkedCommunityId() now hold both. The copy
in the Community's own settings tab is left alone on purpose and says why: it
mirrors the flag for a toggle rather than asking whether the Community folds
chats into our list, so it must not demand membership.

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

morethanwords/tweb/master81ac5d4300 files, +1362/-1374
Build

#webk
🫡4
morethanwords/tweb/claude/story-viewer-not-opening-owlr574954a9c3 files, +98/-5
Fix the stories list not opening the viewer in Firefox

The stories row mounts folded and is only ever unfolded by the wheel handler in
useCollapsable, which read the direction off `wheelDeltaY`. That property is a
non-standard WebKit/Blink leftover Firefox has never implemented, so there it
came back `undefined`, `-wheelDeltaY` was NaN, and `NaN < 0` resolved every
wheel to STATE_FOLDED — including the guard right below it, which then saw no
state change and returned. The row could not be expanded at all, and a folded
one carries `disable-hover` (`pointer-events: none !important`), so clicking a
story did nothing and the viewer never opened. It reads `deltaY` now: the
standard equivalent, same sign, and the sign is all onMove takes from it. The
profile avatar carousel uses the same hook and was collapsed the same way.

Reproducing this also turned up the segments-folded memo being declared after
the element that renders the items: building it maps `peers()` through `Item`
straight away, so a list whose provider already holds its peers read the memo
inside its temporal dead zone and threw instead of rendering a single item. No
current call site constructs it that way — both pass their peers in later — so
the declaration just moves up above the element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNLQh1QbfpPyLmQk9vhECq

#webk
🫡3
morethanwords/tweb/masterb9d75a010 files, +123/-40
Polish the Community chats panel

A bot linked to a Community shows up in "Chats you can view" as an ordinary
dialog row, and until its chat is started it has no last message at all, so the
row was left with an empty subtitle. It falls back to the peer's own status now
— "bot", the string the request-to-join and hidden sections already use and
what tdesktop puts under such a row. The fallback lives in one place with the
botforum's "no messages yet" and applies only to rows that belong to a
Community list, so the main chat list keeps its blank subtitle.

The header carried the Community name alone, while every forum tab has a second
row under its title. It now says how many chats the Community has, counted from
the linked peers the same way the Community row in the chat list counts them.
The avatar moved out of the title into a slot the forum tab itself provides, so
the title and the subtitle keep the shared sidebar-header styles instead of a
private copy that had to override their padding; that shared inline padding is
2px wider now, for every forum header.

Opening a chat from the panel rebuilt its row. The projection runs several
times per open — the dialog, the peer and the read state all move — and every
pass reappended each row to its list, reinserted the hidden-chat icon and
rewrote the dataset. Reappending a node that is already in place still detaches
it, taking everything rendered inside it down with it. Rows are positioned by
index now, which does nothing while the order holds, and the icon and the
dataset are only written when they actually change: an open went from 29 DOM
mutations to the three the click itself makes.

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

morethanwords/tweb/master75772888 files, +146/-23
Stop offering menu actions that aren't available

Editing a Community was offered to every member of it. hasRights ran a
Community through the group branch, where change_info is a member right that
the default banned rights can only take away, and on top of that granted
change_info outright when the Community carried no rights object at all — so
canEditCommunity, which starts at change_info, was true for anyone. A Community
is administered like a channel: tdesktop maps it to a channel without the
megagroup flag, so its canEditInformation() asks for the granted admin right or
the creator, and tweb's own Community admin editor already lists change_info
among the four rights an admin is given. hasRights agrees now — change_info and
invite_users on a Community need that admin right.

That leaves a member looking at a menu button that opens nothing. The toggle
already refuses to open a menu whose every item failed its verify, but the
button itself stayed on screen. The Community tab rechecks its items whenever
the Community changes and hides the trigger while none of them pass; the check
is the one the shared media header was already doing, moved next to
filterButtonMenuItems so both go through the same code.

"View as topics" had the same shape of problem: its only condition was the
dialog's view_forum_as_messages flag, which outlives the forum it was set for,
so the action showed up for chats that are not forums at all and would have
opened a topics list that does not exist. The chat list menu and the chat
header now ask the peer whether it is a forum first, the way tdesktop's
addViewAsTopics does.

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

#webk
🫡3
morethanwords/tweb/masterfa6ec9f2 files, +10/-179
Fix the emoji panel jumping to the window edge in production

The picker attached to an input field (folder name, topic title) opened
pinned to the right edge of the window instead of next to its button —
but only in a built bundle, never in dev, which is what made it so
puzzling.

lightningcss lowers logical inset properties into separate `:lang()`
rules emitted after the declaration block, and those carry a higher
specificity. So `.is-standalone`'s `inset-inline-start: auto` came back
as `left: auto` behind the `left: min(…, var(--left))` authored right
below it, and the panel fell through to `right: 0` from the base rule.
The vertical anchor kept working, which is why it looked like only the
horizontal position was broken. Sass alone doesn't do that rewrite, so
dev always resolved the physical `left` last and looked correct.

Anchor the standalone panel with physical properties only, matching the
physical viewport coordinates JS measures into --top/--left, and reset
`right` explicitly — in RTL it's `left` that gets dropped when the box
is over-constrained.

The same trap was swallowing an optical nudge in the empty-chat
placeholder: `margin-inline-end` + `margin-left` in one block leaves RTL
with the end margin and no nudge at all. Made it `margin-inline-start`
so it mirrors. Also dropped a verbatim 178-line duplicate of
`.empty-bubble-placeholder` that a merge left behind in cef780979 —
comparing the compiled rule sets before and after confirms it changed
nothing but the margin.

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

morethanwords/tweb/masterc8e1a973 files, +98/-5
Fix the stories list not opening the viewer in Firefox

The stories row mounts folded and is only ever unfolded by the wheel handler in
useCollapsable, which read the direction off `wheelDeltaY`. That property is a
non-standard WebKit/Blink leftover Firefox has never implemented, so there it
came back `undefined`, `-wheelDeltaY` was NaN, and `NaN < 0` resolved every
wheel to STATE_FOLDED — including the guard right below it, which then saw no
state change and returned. The row could not be expanded at all, and a folded
one carries `disable-hover` (`pointer-events: none !important`), so clicking a
story did nothing and the viewer never opened. It reads `deltaY` now: the
standard equivalent, same sign, and the sign is all onMove takes from it. The
profile avatar carousel uses the same hook and was collapsed the same way.

Reproducing this also turned up the segments-folded memo being declared after
the element that renders the items: building it maps `peers()` through `Item`
straight away, so a list whose provider already holds its peers read the memo
inside its temporal dead zone and threw instead of rendering a single item. No
current call site constructs it that way — both pass their peers in later — so
the declaration just moves up above the element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNLQh1QbfpPyLmQk9vhECq

#webk
🫡3
morethanwords/tweb/master50529551 files, +114/-51
Fix the stories store losing track of its current story

Clicking a story in the sidebar list threw before the viewer could open:
`actions.set` looked the clicked peer up with `indexOf`, and on a miss wrote the
resulting -1 straight into `index`, so `state.peer` read undefined and the
`checkForNearestSkipped` prefetch dereferenced it. That prefetch is an
optimization — it must never be able to break opening a story. It bails on a
missing peer now, `set` falls back to looking the peer up by id, and every
writer of `index` clamps into the list instead of parking it out of bounds
(`deletePeer` used to leave it past the end, `addPeers` could write -1).

What put a peer out of the list in the first place was `addPeers`: it built a
peerId-to-index map once, then spliced the array inside the loop. From the
second peer of a batch on, those positions were stale, so `peers[idx] = peer`
overwrote an unrelated entry — a reordering update over `[p1, p2, p3]` yields
`[1, 1, 2]`, p3 gone and p1 duplicated. The position is looked up per iteration
now.

Two more things that were quietly not working:

`load()` never paginated. `loadPromise` is assigned synchronously, so the
recursive call inside `.then` hit the in-flight guard and got handed the very
promise it was called from — one page, always, and `loaded` stuck at false. It
forces the next page now, only the latest run may clear the guard, and a server
that keeps `has_more` on an unchanged state no longer spins us on one page.

`getNearestStories` fed only the peer index back into `getNearestStory`, which
re-derives the story index from the store — so all three prefetch iterations
returned the same story. It passes the story index it reached along now.

Also: `onStoryNew` sized its index shift against `state.peer` (a different peer
than the one it was inserting into) and `onStoryDeleted` decremented on
`index >= storyIndex`, producing -1 when both were 0. Both go through one
`shiftStoryIndex` helper. Album snapshots take a generation guard so a slow
reply for an abandoned album can't clobber the current one, async callbacks and
the story timer stop at disposal, and `freezedSorting` / `changeTimeout` move
out of the store — a `Set` is not wrappable and a timer id has no business
notifying subscribers.

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

#webk
🫡4
morethanwords/tweb/master06cdebc1 files, +11/-5
Stop counting an empty media type in the shared media subtitle

The Media tab's header subtitle joined both counters unconditionally, so a chat
that has photos and no videos read "12313 photos, 0 videos" — naming a type it
has nothing of. It now lists only the types with a non-zero count.

Dropping the zeros outright can leave nothing to print, though: the filter menu
can narrow the tab to photos only in a chat whose photos count is 0, and an
empty subtitle looks broken. So when no enabled type has anything, it falls
back to a single zero entry ("0 photos") rather than to blank.

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

#webk
🫡3
morethanwords/tweb/masterd2040a016 files, +636/-120
Give Community chats the context menu every other dialog gets

A chat row inside a Community panel had almost no context menu: the menu kept a
whitelist, and an item was shown there only if it carried a `communityMode`,
which left Mute/Unmute and Remove from Community and nothing else. But such a
row IS a dialog row — the whitelist is backwards. It is an opt-OUT now: the row
gets the standard items, and only what makes no sense there is dropped.

Add to Folder, Pin/Unpin and Archive act on the chat's place in OUR chat list,
so they are offered only while the Community isn't folded into a single row —
folded, those chats have no place of their own to act on. Everything else is
left to its own verify, which already reduces to what a given row can do: a
chat we only watch has no dialog, so nothing that acts on one survives.

The "chats you can request to join" and "hidden chats" sections had no menu at
all — they are drawn by CommunityPeerDialogList, which never passed
`withContext` to setListClickListener, and their rows carried none of the
dataset the menu reads. Both are wired now, so an admin can unlink a chat from
there too; the rest is verified away, since we can neither open nor read those
chats. Preview is the one item that had to learn the difference: it now also
shows for a `viewable` chat, which has no dialog but does have history in the
row (shift+click already previewed it), and stays hidden for the two kinds we
have no access to.

Remove from Community wore the trash icon, the same one as the Delete item
right below it. It unlinks a chat rather than deleting it, so it takes the
cross — as it does on Android, and as "remove from album" / "remove ads" do
here. Both places that offer the action also had a full copy of the same
confirm-then-unlink flow; they share one now.

Mute on the Community row never appeared, however the Community was set up:
`!managers.appCommunitiesManager.isCommunityMuted(id)` negates a Promise, which
is always false. Unmute worked because it returned the promise instead of
negating it — hence "there is an Unmute but never a Mute". It awaits now.

Muting a whole Community was also reachable from that row alone, i.e. only
while "Show as One Chat" was on. The Community panel's menu offers it too now.
Its popup was a stray as well: PopupPeer draws its avatar from `peerId`, and a
Community can't go through that path (its avatar is the decorated one), so the
popup was passed no peer at all and showed a bare title where every other mute
popup shows an avatar. PopupPeer takes an avatar element now, and the popup
builds the same Community avatar the chat list uses.

Unrelated, from the same panel: the header title and subtitle each held a
wrapper div, because `<Portal mount>` always creates its own container. Both
carry `text-overflow: ellipsis`, which a block child defeats — a long Community
name ran past the header instead of ellipsizing. They mount through a small
InlinePortal helper now, which inserts the children directly into the host.

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

#webk
🫡3
morethanwords/tweb/master1ddddac2 files, +39/-2
Fix big emoji disappearing in production builds

An emoji-only message stopped rendering big and animated once built — in dev it
was always fine, which is what made it look like a settings or server problem.

src/vendor/emoji/regex.ts held lone surrogates in its string value: the upstream
pattern splits astral emoji across alternations, so '\uD83C[\uDFFB-\uDFFF]' has
a high surrogate with nothing to pair with. Rolldown folds that constant into
the bundle and re-encodes it as UTF-8, where a lone surrogate has no
representation — in index.worker-*.js every one of them came out as U+FFFD
followed by the leftover "d83c", 1246 times. Evaluating the shipped pattern
confirms it: 😳 🔥 👍 😀 no longer match, while still do, because those
are BMP and were never written as surrogates.

So the worker's FULL_REG_EXP produced no messageEntityEmoji for any astral
emoji, totalEntities carried none, and bubbles' emojiStrLength === strLength
test could never hold — bigEmojis stayed 0, no .emoji-big, no animated sticker.
Only the worker chunk was affected, which is why the input's own suggestions
kept working: those are parsed on the main thread.

The literal is pure ASCII now — every \u and \x backslash is doubled, so the
escapes stay text and RegExp resolves them itself when the pattern compiles.
Same matching, and nothing left in the string for a bundler to mangle. A test
pins both halves: the source stays ASCII, and parseEntities still returns an
emoji entity spanning the whole emoji. A clean build has zero U+FFFD in any
chunk.

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

morethanwords/tweb/master1d0ec111 files, +7/-0
Stop rebuilding the emoji suggestions for an emoji already suggested

Typing 😳 into an input that already ended in 😳 tore the suggestion strip down
and built it again from scratch: a fresh searchCustomEmoji, a new container for
every item, replaceWith, and the scroll back at zero — all to arrive at exactly
what was on screen.

checkAutocomplete only guards against an unchanged value, but the emoticon is
derived from it: getCustomEmojiSuggestionEmoticon slices from the emoji entity
that ends at the caret, so a longer value can still name the same emoji.
checkEmoticon took it from there unconditionally.

It returns early now when the emoticon matches the one already on screen. The
helper has to be visible for that — hidden means the strip was torn down and
must be rebuilt, and it also covers an in-flight request that a hide cancelled.
A different emoticon, or a `:query` search (which clears this.emoticon), still
renders as before.

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

#webk
🫡3
morethanwords/tweb/mastere12782511 files, +297/-1
Show a video chat plate under the topbar for group calls you haven't joined

The topbar already had a plate for RTMP live streams, but an ordinary group
video chat had no entry point besides the header button. Add a sibling plate
laid out like the other clients: icon and title on one side, the join button
on the other, and a three-face preview of who is already in the call centred
on the plate.

The stack is centred with equal-basis side columns rather than absolute
positioning, so it sits on the real centre (as tdesktop paints it at
`width / 2`) while a long call title can never run under it. On handhelds the
stack shrinks so holding the centre does not clip the participants counter.

Visibility follows every other client: `call_active` + `call_not_empty`, the
call is not an RTMP stream (that one belongs to the live plate), and we have
not joined it ourselves. The joined edge comes from the controller's
`instance` event and the closing edge from the instance's own `state` event,
since the controller keeps its reference after a hang-up.

`getGroupCallPreview` gives the plate the call plus the first N participant
peers in one hop, so the whole participants map never crosses the worker
boundary, and only chats that actually have a call subscribe to the full peer.

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

morethanwords/tweb/master558c0f72 files, +59/-25
Lay the Requests plate out like the translation one

The join-requests plate was the only floating plate still built as a
full-bleed clickable row: faces pinned to the start, title floating in
the middle, close button inside the same ripple area. Give it the shape
every other plate already has — the stretched, centred pill of the
translation plate, with the faces taking the start slot exactly the way
the close button takes the end one, so neither shifts the label off the
plate's centre. The width formula both plates now share moved into
`--pc-centered-button-width`.

Along the way:

- the label never carried `pinned-requests-title`, so its primary colour
and bold weight were dead CSS; it inherits both from the pill now, and
gets `text-overflow-no-wrap` so a large count ellipsizes instead of
wrapping out of the fixed 48px plate;
- the controller reported `height: 52` while the plate has always been
48 CSS px, which made `setFloating` reserve 4px too much;
- avatars go 32 -> 40px, matching the buttons on the plate, and shrink
back on handhelds where the centred label would otherwise run under
them (same trade-off as `.pinned-group-call`);
- the close button is no longer nested inside the clickable area, so
dismissing the plate can't also open the requests tab;
- `<Show>` is keyed and the label is updated from an effect: `set()`
writes a fresh `RequestData` on every `chat_requests` update, and a
plain `Show` kept the previous count and faces on a visible plate.

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

morethanwords/tweb/master5801aa2300 files, +788/-788
Build

#webk
🫡3
morethanwords/tweb/master6cd749c6 files, +348/-169
Fix stories never opening in production builds

Clicking a story did nothing on the built app while dev was fine — `actions.set`
threw before it could hand the peer to the viewer:

TypeError: Cannot read properties of undefined (reading 'index')
at getNearestStory <- getNearestStories <- checkForNearestSkipped <- set

The logic was right, the bundle was not. `getNearestStory`'s parameter defaults
read `state`, and the minifier bound that read to `ep` — clientPip's module-level
`state`, normally `undefined` — while the body of the very same function kept the
correct name. The source map settles it: minified `ep` maps to store.tsx:133, name
`state`, so one source symbol was emitted under two names. viewer.tsx had it twice
as well, `wasPlaying = !stories.paused` reading `Kd.paused` off a premium-feature
descriptor, so closing any popup resumed a story that was paused.

Both resolve their defaults in the function body now, where the minifier gets the
binding right. rolldown 1.2.3 still miscompiles the old shape, so this is not
something an upgrade fixes.

scripts/check-bundle-mangling.mjs catches the class rather than the pattern, since
the trigger never reproduced in isolation: for every identifier in a default value
— parameter, destructuring, class field — it asks the source map which source
variable it came from and checks how that variable is spelled everywhere else in
the chunk. One dominant spelling plus a stray one is the signature. It finds
exactly these four in the shipped bundle and nothing else, and now runs at the end
of `pnpm build`. It also fails on any U+FFFD, which is how the astral emoji regex
died in the worker chunk.

Vite 8.2.1 comes along: rolldown 1.2.3 no longer rewrites lone surrogates in
folded constants, the trap that one cost us.

`build.cssTarget` is explicit now. `target: 'es2020'` says nothing about browsers,
so lightningcss lowered every logical property into a pair of `:lang()` rules,
emitted after the block they came from and with a higher specificity — a physical
`left` authored next to `inset-inline-*` silently lost, which is what pinned the
emoji panel to the window edge. Naming versions that support logical properties
natively drops 13680 `:lang()` selectors to 1824 and 157 KiB off the CSS. RTL is
unaffected: it runs off `documentElement.dir`, which index.ts sets for every
language.

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

morethanwords/tweb/mastere3730e1300 files, +1278/-1325
Build

#webk
🫡3
UnigramDev/Unigram/develop00351ee2 files, +7/-2
Fix emojis color in inline buttons

UnigramDev/Unigram/develop960fa0d1 files, +51/-30
Fix reply markup button templates

#unigram
UnigramDev/Unigram/ephemeral-command-null-captionecb8bef1 files, +1/-1
Fix crash when sending non-text content with ephemeral bot commands

CreateSendMessage maps anything that is not an InputMessageText to a null
caption, then dereferenced it unconditionally. Sending a sticker (or any
other media) in a chat where HasEphemeralBotCommands is set crashed with a
NullReferenceException.

Reported by crash telemetry on 12.9.0.0.

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

#unigram
🫡2
UnigramDev/Unigram/translate-font-errors72085fa1 files, +17/-0
Translate font and render target error messages

DirectWrite's two font-file errors were missing from TranslateText entirely, so
every locale reported them as its own crash group. The render target message was
already translated, but only for a few locales.

Adds the localized variants observed in crash reports:

- font file not found: de, es, fr, it, pt, ru, tr, zh-Hans
- font file access denied: fr, ru
- wrong render target: es, fr, ru

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

#unigram
UnigramDev/Unigram/gallery-convertof-null64b9ee61 files, +6/-0
Fix crash when opening the gallery

ConvertOf is an x:Bind function binding over ViewModel.SelectedItem, and
Bindings.Update runs from Load before an item has been selected, so the very
first evaluation dereferenced a null GalleryMedia.

ConvertCaption in the same file already guards its input the same way.

Reported by crash telemetry on 12.9.0.0.

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

#unigram
UnigramDev/Unigram/search-tabs-negative-indexa2af3cb1 files, +3/-1
Fix crash when searching while no tab is selected

Query guarded the upper bound of SelectedTab but not the lower. SelectedTab is
bound two-way to the tab selector's SelectedIndex, which is -1 whenever there is
no selection, so Tabs[SelectedTab] threw ArgumentOutOfRangeException if the tabs
were repopulated while the user was still typing.

Reported by crash telemetry on 12.9.0.0.

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

#unigram
UnigramDev/Unigram/fasttext-fontsize-zerof2a73c61 files, +9/-1
Fix crash when rendering the wallpaper preview bubbles

The plain-run fast path wrote whatever font size had changed, including 0, and
XAML rejects a FontSize of 0 with E_INVALIDARG. GetOrCreateRun already treats
non-positive as "inherit" and clears the property instead; the fast path now
does the same.

MessageBubble.Mockup reaches this: it calls SetText without a font size, so the
default of 0 replaces the size the bubble was previously rendered with, and the
transition is exactly what triggers the write.

Reported by crash telemetry on 12.9.0.0.

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

#unigram
UnigramDev/Unigram/fasttext-fontsize-zero8dfbb0a1 files, +3/-9
Use the computed font size in the fast path

Per review: _fontSize is the raw value, fontSize is what AutoFontSize resolved
it to, and the slow path already passes the resolved one to GetOrCreateRun. The
fast path writing the raw value is the actual defect, so write the resolved one
instead of clearing the property — clearing would inherit the parent's size
rather than applying the theme size.

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

#unigram
UnigramDev/Unigram/copytext-null-formattedtexte0fae491 files, +8/-0
Fix crash when copying an empty selection or an absent note

CopyText(XamlRoot, FormattedText) dereferenced its argument immediately, but its
callers pass values that are legitimately null: TextSelectionManager's
GetSelectedText returns null when nothing is selected, BlockQuote passes the
result of FormattedTextBlock.GetSelectedText, and ProfileHeader passes
UserFullInfo.Note, which is null when the contact has no note.

The method is async void, so the NullReferenceException was posted to the
dispatcher and terminated the app instead of being catchable.

Reported by crash telemetry on 12.9.0.0.

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

#unigram
UnigramDev/Unigram/settingsexpander-collapse-guardecf9d061 files, +3/-1
Fix the expander never collapsing its content

The scoped batch's Completed handler compares _tracker against a value captured
with a post-increment, so the comparison was (n + 1) == n and never held. The
handler is what applies Visibility.Collapsed after the collapse animation, and
PopupRoot.Visibility is set to Visible unconditionally just above it, so
collapsed content stayed visible and kept participating in layout — hidden only
by PopupHost.Height = 0 and the negative margin.

Pre-increment gives the guard its intended meaning: apply the final visibility
unless a newer toggle has started since this batch began.

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

#unigram
UnigramDev/Unigram/settingspanel-negative-height52187f91 files, +5/-1
Never report a negative desired height from SettingsPanel

The IsHeader branch subtracts the trailing gap that follows the last child, but
the accumulator starts at 0 when IsFooter is also set, so a panel with nothing
visible reported -16. XAML rejects a negative desired size.

Both flags are set together on ShareGroupCallPopup, ChatInviteLinkInfoPopup and
FolderPage, so the combination is reachable; no crash report is currently tied
to it, this is a latent defect found while reading the layout code.

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

#unigram