morethanwords/tweb/master • 9aaf3e0 • 1 files, +13/-14
Open the sender's profile from the gift popup
The "From" row rendered a TablePeer without a click handler, so the only peer
you could reach from a gift was the "Owner" row — yourself, on your own gifts.
tdesktop builds that row with MakePeerTableValue, whose peer part is clickable
and whose "send a gift" button is separate, and .peer already carries
cursor: pointer here.
Route every peer click in the popup through one openPeer helper so the sender,
the owner, the releaser and the original-details peers all navigate and close
the parent popup the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • effb731 • 3 files, +15/-2
Don't stack the Community chevron on the avatar's unread badge
A narrow chat list — collapsed sidebar, or an open forum tab — hides .row-row
and moves the unread badge onto the avatar, landing right on the Community child
badge and covering the count. tdesktop drops the whole corner badge there:
Row::updateCornerBadgeShown returns kNoneLayer as soon as hasUnreadBadgesAbove,
which PaintRow sets when the row is narrow and paints badges over the userpic.
Extend the rule that already clears the auto-delete timer for the same reason,
so the group call icon — the other occupant of that corner, and the one that
wins over the chevron in tdesktop too — pushes it out as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • b86253e • 1 files, +9/-0
Duplicate the bot's Report button in the Mini App menu
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 469b191 • 2 files, +14/-0
Wrap unbreakable text in contenteditable fields on Firefox
A long digit run in the phone number field spilled out of its box in Firefox
(bugs.telegram.org/c/63932). Blink and WebKit set overflow-wrap: break-word on
editables in their UA stylesheet, Gecko doesn't, so the rule that already
mirrors that stylesheet's white-space: pre-wrap has to carry it too — for every
contenteditable field, not just the phone one.
Cap the phone field itself at 36 digits while here, the same limit tdesktop
uses (kMaxPhoneCodeLength + kMaxPhoneTailLength); E.164 tops out at 15, and
without a cap junk input just grows the field line by line everywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
Open the sender's profile from the gift popup
The "From" row rendered a TablePeer without a click handler, so the only peer
you could reach from a gift was the "Owner" row — yourself, on your own gifts.
tdesktop builds that row with MakePeerTableValue, whose peer part is clickable
and whose "send a gift" button is separate, and .peer already carries
cursor: pointer here.
Route every peer click in the popup through one openPeer helper so the sender,
the owner, the releaser and the original-details peers all navigate and close
the parent popup the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • effb731 • 3 files, +15/-2
Don't stack the Community chevron on the avatar's unread badge
A narrow chat list — collapsed sidebar, or an open forum tab — hides .row-row
and moves the unread badge onto the avatar, landing right on the Community child
badge and covering the count. tdesktop drops the whole corner badge there:
Row::updateCornerBadgeShown returns kNoneLayer as soon as hasUnreadBadgesAbove,
which PaintRow sets when the row is narrow and paints badges over the userpic.
Extend the rule that already clears the auto-delete timer for the same reason,
so the group call icon — the other occupant of that corner, and the one that
wins over the chevron in tdesktop too — pushes it out as well.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • b86253e • 1 files, +9/-0
Duplicate the bot's Report button in the Mini App menu
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 469b191 • 2 files, +14/-0
Wrap unbreakable text in contenteditable fields on Firefox
A long digit run in the phone number field spilled out of its box in Firefox
(bugs.telegram.org/c/63932). Blink and WebKit set overflow-wrap: break-word on
editables in their UA stylesheet, Gecko doesn't, so the rule that already
mirrors that stylesheet's white-space: pre-wrap has to carry it too — for every
contenteditable field, not just the phone one.
Cap the phone field itself at 36 digits while here, the same limit tdesktop
uses (kMaxPhoneCodeLength + kMaxPhoneTailLength); E.164 tops out at 15, and
without a cap junk input just grows the field line by line everywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
🫡3
morethanwords/tweb/master • 6f3299c • 6 files, +162/-26
Update the account badge when the messages are read elsewhere
The badge next to every other account in the main menu counts the notifications
shown for it, and that count lives in a storage shared by all tabs — but only
the tab that had shown a notification could take it back: cancel() decremented
it exclusively while the key was still in its in-memory notificationsShown. A
reload, a second tab, or a read that came from another client dropped that
claim, and since start() only zeroes the account you are on, the badge of the
others stayed stuck for good.
Keep the pending notification keys themselves instead of a bare number and
derive the count from them. Cancelling becomes idempotent and shared, so
whichever tab hears about the read first is the one that drops the key. The
cancels arrive one per message, so they are batched into a single write.
notification_cancel can only be fired for messages that are in memory, which is
not the case for a peer whose history hasn't been loaded yet — exactly what a
read arriving right after a restart hits. So onUpdateReadHistory now also emits
notification_cancel_up_to and the whole read range is dropped at once. Thread
reads are left out of it: they share the message id space with the rest of the
peer, and applying one as a range would cancel notifications of threads nobody
has read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 37e6736 • 3 files, +157/-10
Load every topic in a big forum
The topic list froze after the first server page: a forum with 197 topics
showed 101 and stopped there. Topics were paginated with the folder's global
offset date, which is derived from the peer's own history — something a topic
list has nothing to do with — so for a forum it stayed 0, every next page
repeated the very first request, and the response brought nothing new.
Paginate the way the official clients do instead: with the offsets of the LAST
topic of the previous page — its top message's date and id, plus the topic id.
messages.getForumTopics gets the whole triple now, so topics sharing a
top-message date can't collapse the cursor either, and offset_topic finally
carries a server id (the search path used to send the local, offset-encoded
one, which doesn't even fit an int32). The botforum special case that patched
the same freeze with the topic's creation date folds into this path.
Forum topics also stop writing the folder's global offset date: it came from
the forum's own history, so once that history was cached it was not only
useless as a cursor but could drop topics in pushDialog.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • c934ddd • 8 files, +57/-9
Don't crash on a mid without a message behind it
A cached (non-global) search returns only mids, so the tab resolves each one
via apiManagerProxy.getMessageByPeer. A mid can outlive its message — deleted,
or a synthetic migration bound inserted by mergeHistoryResult — and the
resulting hole was fed straight into the consumers, throwing on message.mid.
Skip such holes in ListLoader before processItem, in both search-result
renderers and the shared media tab, and stop looking up a message just to read
back a mid the search key already holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
Update the account badge when the messages are read elsewhere
The badge next to every other account in the main menu counts the notifications
shown for it, and that count lives in a storage shared by all tabs — but only
the tab that had shown a notification could take it back: cancel() decremented
it exclusively while the key was still in its in-memory notificationsShown. A
reload, a second tab, or a read that came from another client dropped that
claim, and since start() only zeroes the account you are on, the badge of the
others stayed stuck for good.
Keep the pending notification keys themselves instead of a bare number and
derive the count from them. Cancelling becomes idempotent and shared, so
whichever tab hears about the read first is the one that drops the key. The
cancels arrive one per message, so they are batched into a single write.
notification_cancel can only be fired for messages that are in memory, which is
not the case for a peer whose history hasn't been loaded yet — exactly what a
read arriving right after a restart hits. So onUpdateReadHistory now also emits
notification_cancel_up_to and the whole read range is dropped at once. Thread
reads are left out of it: they share the message id space with the rest of the
peer, and applying one as a range would cancel notifications of threads nobody
has read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 37e6736 • 3 files, +157/-10
Load every topic in a big forum
The topic list froze after the first server page: a forum with 197 topics
showed 101 and stopped there. Topics were paginated with the folder's global
offset date, which is derived from the peer's own history — something a topic
list has nothing to do with — so for a forum it stayed 0, every next page
repeated the very first request, and the response brought nothing new.
Paginate the way the official clients do instead: with the offsets of the LAST
topic of the previous page — its top message's date and id, plus the topic id.
messages.getForumTopics gets the whole triple now, so topics sharing a
top-message date can't collapse the cursor either, and offset_topic finally
carries a server id (the search path used to send the local, offset-encoded
one, which doesn't even fit an int32). The botforum special case that patched
the same freeze with the topic's creation date folds into this path.
Forum topics also stop writing the folder's global offset date: it came from
the forum's own history, so once that history was cached it was not only
useless as a cursor but could drop topics in pushDialog.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • c934ddd • 8 files, +57/-9
Don't crash on a mid without a message behind it
A cached (non-global) search returns only mids, so the tab resolves each one
via apiManagerProxy.getMessageByPeer. A mid can outlive its message — deleted,
or a synthetic migration bound inserted by mergeHistoryResult — and the
resulting hole was fed straight into the consumers, throwing on message.mid.
Skip such holes in ListLoader before processItem, in both search-result
renderers and the shared media tab, and stop looking up a message just to read
back a mid the search key already holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
🫡3
morethanwords/tweb/master • b185aad • 6 files, +114/-27
Stop the ESG search from freezing on a failed fetch
Reading a rejected Solid resource throws, so the effect that publishes the
results aborted before ever setting them: the previous query's results stayed
on screen with no spinner and no error, and every following query looked like
it never started. GIF search reached that state on its own - the input had no
debounce, so each keystroke was an inline bot query and typing a couple of
words earned a FLOOD_WAIT long enough to fail every search for minutes.
Follow tdesktop's gifs_list_widget for the request side: a 400ms debounce
(kSearchRequestDelay), a per-query cache that also collects the pages scrolled
into a query and is shared with the emoji category buttons, and the search bot
taken from help.getConfig's gif_search_username instead of a hardcoded @gif.
A cached query is answered synchronously and skips the debounce.
Also stop the exiting search results from being stranded when their cross-fade
animation is cancelled - Promise.all on animation.finished had no catch, so the
container was never released and every later result stacked on top of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 0af53a3 • 2 files, +159/-12
Stop re-rendering chatlist rows that did not change
Every dialog event — dialog_unread, dialogs_multiupdate, dialog_notify_settings,
and filter_update, which sweeps every cached dialog — runs updateDialog, which
runs the whole setLastMessage pipeline. Opening a chat fires two of them, so the
subtitle was torn down and rebuilt twice (wrapMessageForReply, custom emoji,
media thumbs and all) for a change that only cleared a badge.
setLastMessage now keeps a signature of everything the subtitle is rendered from
and skips the render when nothing moved. Since plenty of other places (community
dialogs, search results, sidebar pickers) write their own subtitle into
lastMessageSpan, a matching signature is only trusted while the rendered nodes
are still the ones in the DOM. textColor is left out of it: setDialogActiveStatus
recolors the already rendered custom emoji itself, so opening a chat must not
cost its row a re-render. The time label stays outside the guard — it is
relative to the current day.
Same story in setBadgeState, which replayed its transitions for unchanged state.
SetTransition keeps `animating` on the element for the full duration, and
.has-only-pinned-badge:not(.animating) drops the subtitle's trailing margin while
it is there, so every redundant update widened a pinned row's subtitle by 24px
and snapped it back — which reads as the ellipsis blinking. Badges whose
visibility did not change, and the only-pinned flag when it did not flip, no
longer animate.
Opening a pinned chat with unread messages: 2 subtitle rebuilds and 2 transition
passes before, 0 and 1 after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
Stop the ESG search from freezing on a failed fetch
Reading a rejected Solid resource throws, so the effect that publishes the
results aborted before ever setting them: the previous query's results stayed
on screen with no spinner and no error, and every following query looked like
it never started. GIF search reached that state on its own - the input had no
debounce, so each keystroke was an inline bot query and typing a couple of
words earned a FLOOD_WAIT long enough to fail every search for minutes.
Follow tdesktop's gifs_list_widget for the request side: a 400ms debounce
(kSearchRequestDelay), a per-query cache that also collects the pages scrolled
into a query and is shared with the emoji category buttons, and the search bot
taken from help.getConfig's gif_search_username instead of a hardcoded @gif.
A cached query is answered synchronously and skips the debounce.
Also stop the exiting search results from being stranded when their cross-fade
animation is cancelled - Promise.all on animation.finished had no catch, so the
container was never released and every later result stacked on top of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 0af53a3 • 2 files, +159/-12
Stop re-rendering chatlist rows that did not change
Every dialog event — dialog_unread, dialogs_multiupdate, dialog_notify_settings,
and filter_update, which sweeps every cached dialog — runs updateDialog, which
runs the whole setLastMessage pipeline. Opening a chat fires two of them, so the
subtitle was torn down and rebuilt twice (wrapMessageForReply, custom emoji,
media thumbs and all) for a change that only cleared a badge.
setLastMessage now keeps a signature of everything the subtitle is rendered from
and skips the render when nothing moved. Since plenty of other places (community
dialogs, search results, sidebar pickers) write their own subtitle into
lastMessageSpan, a matching signature is only trusted while the rendered nodes
are still the ones in the DOM. textColor is left out of it: setDialogActiveStatus
recolors the already rendered custom emoji itself, so opening a chat must not
cost its row a re-render. The time label stays outside the guard — it is
relative to the current day.
Same story in setBadgeState, which replayed its transitions for unchanged state.
SetTransition keeps `animating` on the element for the full duration, and
.has-only-pinned-badge:not(.animating) drops the subtitle's trailing margin while
it is there, so every redundant update widened a pinned row's subtitle by 24px
and snapped it back — which reads as the ellipsis blinking. Badges whose
visibility did not change, and the only-pinned flag when it did not flip, no
longer animate.
Opening a pinned chat with unread messages: 2 subtitle rebuilds and 2 transition
passes before, 0 and 1 after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
🫡3
morethanwords/tweb/master • 95933b1 • 3 files, +45/-19
Show typing in a dialog row that is built while the peer types
The indicator lives only in the DOM, and setTyping is called from exactly one
place — the peer_typings event. A DialogElement built after the peer started
typing therefore never gets it: switching folders shows the last message instead,
since every folder owns its list and builds its own rows, and the virtual list's
re-init has the same hole. Both recover only on the next typing update.
The apply half of setTyping moves into setDialogTyping, which initDialog now
calls once the row's subtitle is rendered — chained off the returned promise
rather than into it, so a row still appears as fast as before. All four dialog
lists already went through setTyping, so they share the one implementation.
getPeerTyping now asks for the typings before asking whether the peer is a bot.
Same answer either way, but it is consulted for every row that gets built now,
so the check that says "no" for almost every peer should be the one that costs a
single worker round-trip.
Verified against a real typing state in the worker: rebuilding a row mid-typing
showed the last message before, and shows the activity after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • ce37ebe • 1 files, +50/-12
Jump to the unread messages instead of the chat's end
The go-down button and re-clicking the open dialog in the chat list both land in
bubbles.setPeer with samePeer, where following the read cursor was gated on
!samePeer — so a chat left scrolled up with a pile of unread messages below was
always thrown to the very bottom, past all of them.
tdesktop answers this in HistoryWidget::insideJumpToEndInsteadOfToUnread with
`unread && itemTop(unread) <= visibleBottom`: go to the end only once the first
unread message is no longer below the viewport, otherwise scroll it to the top.
The gate now asks the same question, and the samePeer fast path — which had no
branch for followingUnread and so silently did nothing — scrolls to the
delimiter with position 'start'. It stays keyed on sameSearch, so leaving an
in-chat search still goes to the end as before.
The anchor is the existing firstUnreadBubble, which is tdesktop's unread bar:
frozen for as long as the chat is open, so a second press finds it on screen and
goes to the end instead of chasing the read cursor down the history a viewport
at a time. setUnreadDelimiter never attached it in a group the user administers,
though — its monoforum guard read canManageDirectMessages without isMonoforum,
and the creator of any group holds manage_direct_messages — so the divider was
missing there as well. Every other reading of that flag pairs the two.
Verified in a preview against a group with 18 real unread messages, from both
entry points: scrolled up with the delimiter far below the visible area, the
first press put it at the top of the viewport with 1753px of history still
below, and the second press landed at the very end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
Show typing in a dialog row that is built while the peer types
The indicator lives only in the DOM, and setTyping is called from exactly one
place — the peer_typings event. A DialogElement built after the peer started
typing therefore never gets it: switching folders shows the last message instead,
since every folder owns its list and builds its own rows, and the virtual list's
re-init has the same hole. Both recover only on the next typing update.
The apply half of setTyping moves into setDialogTyping, which initDialog now
calls once the row's subtitle is rendered — chained off the returned promise
rather than into it, so a row still appears as fast as before. All four dialog
lists already went through setTyping, so they share the one implementation.
getPeerTyping now asks for the typings before asking whether the peer is a bot.
Same answer either way, but it is consulted for every row that gets built now,
so the check that says "no" for almost every peer should be the one that costs a
single worker round-trip.
Verified against a real typing state in the worker: rebuilding a row mid-typing
showed the last message before, and shows the activity after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • ce37ebe • 1 files, +50/-12
Jump to the unread messages instead of the chat's end
The go-down button and re-clicking the open dialog in the chat list both land in
bubbles.setPeer with samePeer, where following the read cursor was gated on
!samePeer — so a chat left scrolled up with a pile of unread messages below was
always thrown to the very bottom, past all of them.
tdesktop answers this in HistoryWidget::insideJumpToEndInsteadOfToUnread with
`unread && itemTop(unread) <= visibleBottom`: go to the end only once the first
unread message is no longer below the viewport, otherwise scroll it to the top.
The gate now asks the same question, and the samePeer fast path — which had no
branch for followingUnread and so silently did nothing — scrolls to the
delimiter with position 'start'. It stays keyed on sameSearch, so leaving an
in-chat search still goes to the end as before.
The anchor is the existing firstUnreadBubble, which is tdesktop's unread bar:
frozen for as long as the chat is open, so a second press finds it on screen and
goes to the end instead of chasing the read cursor down the history a viewport
at a time. setUnreadDelimiter never attached it in a group the user administers,
though — its monoforum guard read canManageDirectMessages without isMonoforum,
and the creator of any group holds manage_direct_messages — so the divider was
missing there as well. Every other reading of that flag pairs the two.
Verified in a preview against a group with 18 real unread messages, from both
entry points: scrolled up with the delimiter far below the visible area, the
first press put it at the top of the viewport with 1753px of history still
below, and the second press landed at the very end.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
🫡3
morethanwords/tweb/master • 2d8f999 • 3 files, +45/-11
Name the second person typing instead of counting them as an other
getPeerTyping moves to the multi strings the moment a second typing arrives, so
two people in a group read as "Максим and 1 others are typing" — a count that
costs a name it already has, and reads wrong in the singular on top of that.
Both reference clients special-case the pair. tdesktop branches at typingCount
> 2 in HistoryView::SendActionPainter and otherwise formats lng_users_typing,
"{user} and {second_user} are typing"; iOS does the same in ChatListTypingNode
with DialogList.MultipleTypingPair. The count only starts at three.
A pair map, mirroring multi, now answers for typings.length === 2, and the two
peer titles resolve in parallel instead of the single one plus a count. It stays
keyed on the action, unlike iOS — which collapses every multiple to plain typing
— so the per-action multi strings tweb already has keep working for a pair as
well: two people both sending photos still say so, and a pair doing different
things falls back to typing exactly as three would. Multi now starts at three,
which also puts its own "and 1 others" out of reach.
Verified in a preview against a real group, driving getPeerTyping with stubbed
typings: one typing gives "Mira is typing", two give "Mira and Durger King are
typing", two both uploading photos give "... are sending photos", two doing
different things fall back to "... are typing", and three give "Mira and 2
others are typing".
The eight new keys ship in lang.ts and the generated langPack.strings, and need
uploading to the webk langpack to reach an existing install: checkLangPackForUpdates
merges a server difference only, and the local re-merge in index.ts sits behind
IS_BETA, so a client holding a cached langpack finds no entry and format() falls
back to printing the key itself. Three and up keep their existing strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
Name the second person typing instead of counting them as an other
getPeerTyping moves to the multi strings the moment a second typing arrives, so
two people in a group read as "Максим and 1 others are typing" — a count that
costs a name it already has, and reads wrong in the singular on top of that.
Both reference clients special-case the pair. tdesktop branches at typingCount
> 2 in HistoryView::SendActionPainter and otherwise formats lng_users_typing,
"{user} and {second_user} are typing"; iOS does the same in ChatListTypingNode
with DialogList.MultipleTypingPair. The count only starts at three.
A pair map, mirroring multi, now answers for typings.length === 2, and the two
peer titles resolve in parallel instead of the single one plus a count. It stays
keyed on the action, unlike iOS — which collapses every multiple to plain typing
— so the per-action multi strings tweb already has keep working for a pair as
well: two people both sending photos still say so, and a pair doing different
things falls back to typing exactly as three would. Multi now starts at three,
which also puts its own "and 1 others" out of reach.
Verified in a preview against a real group, driving getPeerTyping with stubbed
typings: one typing gives "Mira is typing", two give "Mira and Durger King are
typing", two both uploading photos give "... are sending photos", two doing
different things fall back to "... are typing", and three give "Mira and 2
others are typing".
The eight new keys ship in lang.ts and the generated langPack.strings, and need
uploading to the webk langpack to reach an existing install: checkLangPackForUpdates
merges a server difference only, and the local re-merge in index.ts sits behind
IS_BETA, so a client holding a cached langpack finds no entry and format() falls
back to printing the key itself. Three and up keep their existing strings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#webk
🫡3
morethanwords/tweb/master • 50da390 • 2 files, +30/-6
Stop naming a typing peer the tab has not loaded
getPeerTitle answers from the tab's peer mirror, and with onlyFirstName a peer
that is not in it falls back to I18n 'Deleted' — so a typing update that reaches
the tab ahead of the peer itself reads "Deleted is typing" in the chat list and
the topbar. A sender that is a chat rather than a user is worse still: no title
at all, leaving " is typing".
Typings the mirror cannot name are now dropped before anything renders, out of
the count as well, so every remaining string stays grammatical: three typers
with one unknown read as a pair, two with one unknown fall back to the single
name, and nothing at all is shown once no one is left. A private chat names
nobody — its string is a bare "typing" — so the check is skipped there and its
indicator is untouched.
The topbar already handled an empty answer, since setPeerStatus falls through to
getPeerStatus. The chat list did not: setDialogTyping only writes into a row
that has no indicator yet, and the peer_typings handler unsets only when the
array is empty, so a row already naming someone kept that name once
getPeerTyping started declining. setDialogTyping now reports the case and
setTyping restores the last message; a row that never had an indicator reports
false, so the plain build path never re-renders.
Verified in a preview against a real group, with typings stubbed onto the
mirror's blind spot: an unknown peer alone shows nothing, unknown + known shows
"Mira is typing", unknown + two known shows the pair, and a row already reading
"Mira is typing" goes back to its last message when only the unknown peer keeps
typing — while a row with no indicator takes no unsetTyping call at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • be66114 • 300 files, +755/-755
Build
#webk
Stop naming a typing peer the tab has not loaded
getPeerTitle answers from the tab's peer mirror, and with onlyFirstName a peer
that is not in it falls back to I18n 'Deleted' — so a typing update that reaches
the tab ahead of the peer itself reads "Deleted is typing" in the chat list and
the topbar. A sender that is a chat rather than a user is worse still: no title
at all, leaving " is typing".
Typings the mirror cannot name are now dropped before anything renders, out of
the count as well, so every remaining string stays grammatical: three typers
with one unknown read as a pair, two with one unknown fall back to the single
name, and nothing at all is shown once no one is left. A private chat names
nobody — its string is a bare "typing" — so the check is skipped there and its
indicator is untouched.
The topbar already handled an empty answer, since setPeerStatus falls through to
getPeerStatus. The chat list did not: setDialogTyping only writes into a row
that has no indicator yet, and the peer_typings handler unsets only when the
array is empty, so a row already naming someone kept that name once
getPeerTyping started declining. setDialogTyping now reports the case and
setTyping restores the last message; a row that never had an indicator reports
false, so the plain build path never re-renders.
Verified in a preview against a real group, with typings stubbed onto the
mirror's blind spot: an unknown peer alone shows nothing, unknown + known shows
"Mira is typing", unknown + two known shows the pair, and a row already reading
"Mira is typing" goes back to its last message when only the unknown peer keeps
typing — while a row with no indicator takes no unsetTyping call at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • be66114 • 300 files, +755/-755
Build
#webk
🫡3
UnigramDev/Unigram/formatted-text-block-dates • 6282a4f • 3 files, +49/-8
Release the relative dates without waiting for Unloaded
ClearBlocks drops its blocks and leaves teardown to the Unloaded event, and
FrameworkElementEx only raises OnUnloaded for an element it saw Loaded on. A
block subscribes from SetText, which runs from OnApplyTemplate, which runs on
the first measure - before Loaded. Dropped in between, it never releases, and
what it holds is a registration in the thread-static RelativeDateService: the
block, its paragraph and its runs pinned for the session, still being ticked.
The other half needs no assumption about when Loaded arrives. The service is
keyed by the Run, and Runs come from the shared pool, so one registration that
outlives its block made that Run unsubscribable for good - every later block
that dequeued it had its date silently never update. Subscribe replaces the
entry now instead of skipping, since a Run being resubscribed always means the
old registration is dead.
Runs still return to the pool through OnUnloaded alone. A missed pool return
costs an allocation rather than a pinned graph, and giving Clear a second path
into the pool wants the recycling audit rather than a guess.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Release the relative dates without waiting for Unloaded
ClearBlocks drops its blocks and leaves teardown to the Unloaded event, and
FrameworkElementEx only raises OnUnloaded for an element it saw Loaded on. A
block subscribes from SetText, which runs from OnApplyTemplate, which runs on
the first measure - before Loaded. Dropped in between, it never releases, and
what it holds is a registration in the thread-static RelativeDateService: the
block, its paragraph and its runs pinned for the session, still being ticked.
The other half needs no assumption about when Loaded arrives. The service is
keyed by the Run, and Runs come from the shared pool, so one registration that
outlives its block made that Run unsubscribable for good - every later block
that dequeued it had its date silently never update. Subscribe replaces the
entry now instead of skipping, since a Run being resubscribed always means the
old registration is dead.
Runs still return to the pool through OnUnloaded alone. A missed pool return
costs an allocation rather than a pinned graph, and giving Clear a second path
into the pool wants the recycling audit rather than a guess.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/voice-record-opus • a4413d4 • 6 files, +929/-141
Encode voice messages while they are recorded
A voice message was captured twice and encoded three times: a MediaFrameReader
already held every sample in memory to drive the blob, while LowLagMediaRecording
wrote the same audio to a WAV that was read back and re-encoded to Opus when the
message was sent.
OpusOutput.WriteFrame has been there the whole time and had no caller. The reader
now feeds it directly, so the file that lands on disk is already the file that
gets uploaded, and the duration is counted from the samples rather than taken
from MediaCaptureStopResult and truncated to a whole second.
The encoder is 48kHz mono and a frame source hands over whatever the endpoint
runs at, so this only applies when the format fits, or can be set to fit. It
falls back to the old path otherwise, because playing back at the wrong speed is
worse than transcoding.
Waveforms are sent now. They were computed and then dropped for Array.Empty,
which hid a bug in the accumulator: the bucket peak was never reset, so every
bucket held the running maximum of the whole recording and the waveform could
only climb.
The reader also had to stop dropping frames. Realtime acquisition and a 64ms
gate that returned before touching the buffer were fine for a blob and would
have cut holes in a recording, so it is Buffered now and only the level
notification is rate-limited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Encode voice messages while they are recorded
A voice message was captured twice and encoded three times: a MediaFrameReader
already held every sample in memory to drive the blob, while LowLagMediaRecording
wrote the same audio to a WAV that was read back and re-encoded to Opus when the
message was sent.
OpusOutput.WriteFrame has been there the whole time and had no caller. The reader
now feeds it directly, so the file that lands on disk is already the file that
gets uploaded, and the duration is counted from the samples rather than taken
from MediaCaptureStopResult and truncated to a whole second.
The encoder is 48kHz mono and a frame source hands over whatever the endpoint
runs at, so this only applies when the format fits, or can be set to fit. It
falls back to the old path otherwise, because playing back at the wrong speed is
worse than transcoding.
Waveforms are sent now. They were computed and then dropped for Array.Empty,
which hid a bug in the accumulator: the bucket peak was never reset, so every
bucket held the running maximum of the whole recording and the waveform could
only climb.
The reader also had to stop dropping frames. Realtime acquisition and a 64ms
gate that returned before touching the buffer were fine for a blob and would
have cut holes in a recording, so it is Buffered now and only the level
notification is rate-limited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/voice-record-opus • da12f1d • 1 files, +11/-6
Never renegotiate the capture format for a video message
The blob is animated for voice and video alike, so the frame reader runs in both
modes, and the format negotiation added with the sink was running in both too.
A video message records through MediaCapture. Changing the format of the audio
source it is about to record from, to suit an encoder that isn't involved, would
change what lands in the mp4 for the sake of a level meter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Never renegotiate the capture format for a video message
The blob is animated for voice and video alike, so the frame reader runs in both
modes, and the format negotiation added with the sink was running in both too.
A video message records through MediaCapture. Changing the format of the audio
source it is about to record from, to suit an encoder that isn't involved, would
change what lands in the mp4 for the sake of a level meter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
🤨1🤝1🫡1
telegramdesktop/tdesktop/nightly • da7d622 • 4 files, +12/-14
[iv-editor] Removed nested list insertion from inside list item.
telegramdesktop/tdesktop/nightly • f9c8b75 • 2 files, +11/-0
[iv-editor] Fixed line text disappearing after Tab in nested list.
telegramdesktop/tdesktop/nightly • 657716c • 4 files, +60/-0
[iv-editor] Fixed Backspace on blank list item restructuring previous item.
telegramdesktop/tdesktop/nightly • 4d6e521 • 3 files, +21/-2
[iv-editor] Fixed Tab and Shift+Tab not adopting list style of new level.
telegramdesktop/tdesktop/nightly • 2c1a5b1 • 2 files, +37/-12
[iv-editor] Fixed monospace in table cell inserting empty block after table.
telegramdesktop/tdesktop/nightly • b3d9cc7 • 2 files, +33/-3
[iv-editor] Fixed emoji shifting text offsets in inline editor field.
telegramdesktop/tdesktop/nightly • 83dda62 • 3 files, +53/-1
[iv-editor] Fixed Shift+Tab on nested line lifting whole list item.
telegramdesktop/tdesktop/nightly • 36b2d61 • 1 files, +6/-4
[iv-editor] Moved horizontal scroll of nested lists to outermost list.
telegramdesktop/tdesktop/nightly • 1179660 • 3 files, +8/-1
[iv-editor] Removed decimal item numbering for non-decimal lists.
#tdesktop
[iv-editor] Removed nested list insertion from inside list item.
telegramdesktop/tdesktop/nightly • f9c8b75 • 2 files, +11/-0
[iv-editor] Fixed line text disappearing after Tab in nested list.
telegramdesktop/tdesktop/nightly • 657716c • 4 files, +60/-0
[iv-editor] Fixed Backspace on blank list item restructuring previous item.
telegramdesktop/tdesktop/nightly • 4d6e521 • 3 files, +21/-2
[iv-editor] Fixed Tab and Shift+Tab not adopting list style of new level.
telegramdesktop/tdesktop/nightly • 2c1a5b1 • 2 files, +37/-12
[iv-editor] Fixed monospace in table cell inserting empty block after table.
telegramdesktop/tdesktop/nightly • b3d9cc7 • 2 files, +33/-3
[iv-editor] Fixed emoji shifting text offsets in inline editor field.
telegramdesktop/tdesktop/nightly • 83dda62 • 3 files, +53/-1
[iv-editor] Fixed Shift+Tab on nested line lifting whole list item.
telegramdesktop/tdesktop/nightly • 36b2d61 • 1 files, +6/-4
[iv-editor] Moved horizontal scroll of nested lists to outermost list.
telegramdesktop/tdesktop/nightly • 1179660 • 3 files, +8/-1
[iv-editor] Removed decimal item numbering for non-decimal lists.
#tdesktop
🫡2
morethanwords/tweb/master • c86de98 • 4 files, +165/-30
Stop archiving the pinned chats of the main folder
267f53961 passed the requested folder into saveDialog on the premise that a
folder-scoped answer carries no dialog.folder_id. Checked against the server, the
opposite holds: folder_id is stamped on every dialog outside the main folder —
messages.getDialogs by folder, the global one and messages.getPeerDialogs alike —
while the answer for folder 1 ALSO returns the pinned dialogs of folder 0, and
those come unmarked. Stamping the requested folder on them moved every pinned chat
into the archive on a cold cache, where the chat list's Archive row is the first to
load a folder (bugs.telegram.org/c/64652). f7ceb786f had already fixed the same
thing by deleting dialog.folder_id ??= setFolderId.
So the folder is read back from the dialog alone, and a missing folder_id now wins
over the cached one instead of deferring to it — an affected client puts the chats
back into the main folder, pin included, on its next dialogs answer.
That leaves the pin limit it was meant to fix, whose real cause is elsewhere: a pin
lives in the order of the folder the dialog is in, and when the move is learned from
a dialogs answer rather than from updateFolderPeers (offline while it happened,
another device), nothing dropped the peer from the order of the folder it left. The
orders are persisted and only re-seeded by a cold fetch of folder 0, so the dead
entry ate a slot forever. saveDialog cleans the old folder now, and updateFolderPeers
unpins in the folder being left rather than in the destination.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • b2ccd23 • 300 files, +594/-594
Build
#webk
Stop archiving the pinned chats of the main folder
267f53961 passed the requested folder into saveDialog on the premise that a
folder-scoped answer carries no dialog.folder_id. Checked against the server, the
opposite holds: folder_id is stamped on every dialog outside the main folder —
messages.getDialogs by folder, the global one and messages.getPeerDialogs alike —
while the answer for folder 1 ALSO returns the pinned dialogs of folder 0, and
those come unmarked. Stamping the requested folder on them moved every pinned chat
into the archive on a cold cache, where the chat list's Archive row is the first to
load a folder (bugs.telegram.org/c/64652). f7ceb786f had already fixed the same
thing by deleting dialog.folder_id ??= setFolderId.
So the folder is read back from the dialog alone, and a missing folder_id now wins
over the cached one instead of deferring to it — an affected client puts the chats
back into the main folder, pin included, on its next dialogs answer.
That leaves the pin limit it was meant to fix, whose real cause is elsewhere: a pin
lives in the order of the folder the dialog is in, and when the move is learned from
a dialogs answer rather than from updateFolderPeers (offline while it happened,
another device), nothing dropped the peer from the order of the folder it left. The
orders are persisted and only re-seeded by a cold fetch of folder 0, so the dead
entry ate a slot forever. saveDialog cleans the old folder now, and updateFolderPeers
unpins in the folder being left rather than in the destination.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • b2ccd23 • 300 files, +594/-594
Build
#webk
🫡3
UnigramDev/Unigram/develop • a234a07 • 5 files, +1124/-967
Rewrite the API generator that was only ever meant to be a spike
Same output, byte for byte: the generated TdDotNetApi.g.cs is identical before
and after, which is the only check available given the app itself cannot be
built here. Every emitter string literal was moved rather than retyped.
Split into the parts that were tangled together - SchemaGenerator drives, Schema/
parses td_api.tl, Emit/ writes the C#, Crc32 is the hash the generated switches
key on and must stay in step with ClientJson's copy.
The robustness this was missing:
- A malformed scheme threw, and the catch-all returned the exception text as
the generated source. One bad line became thousands of "type or namespace
not found" errors with no hint of the cause. Errors are now Diagnostics
(TDAPI001/TDAPI002) carrying the line number, and the rest of the file still
generates - a dropped //@field now reports "'localFile' has no
//@download_offset for its 'download_offset' field" and costs one class.
- The reader indexed past the last line unless the file ended blank, and its
constructor scanned for a comment that a malformed file need not contain.
- A doc line with a bare @key threw on the missing value.
- Name conversion built strings with += per character, and reachability used
List.Contains over ~3000 types.
Dropped along the way: a variable that was always empty and the dead branch it
guarded, an unreachable disjunct in the UpdateFile/File test, and a duplicated
abstract-type index built twice per run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 3b0a179 • 5 files, +92/-50
Scan to the terminator instead of bounds-checking every byte
td_receive returns a NUL-terminated buffer, and JSON has to escape a raw NUL, so
the terminator is an unambiguous end marker. TdJsonReader's scan loops now test
only for content and stop at it, with _index <= _length checked once per token
rather than once per byte. Reading past the buffer becomes impossible rather than
merely checked for.
4.1x against Utf8JsonReader on .NET Native, up from 3.7x. It does not recover the
43% the hardening cost on the desktop JIT, and the reason for that is still
unknown - two hypotheses measured and refuted, written up in the README. The
shipping toolchain is unaffected, so it is parked.
Fixtures.Load appends a terminator and GuardedBuffer.Place writes one inside the
committed region, keeping the guard page immediately after it. Verified the sweep
still has teeth: dropping the NUL test from ReadNumber access-violates over all
70,512 truncations.
Also records why generating FromJson only for received types had to be removed:
"received" is not the same as "reachable from a function return type", updates
only qualify because the scheme declares testUseUpdate, and richMessageSourceBlocks
qualifies not at all and is patched in by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 9293493 • 1 files, +7/-0
List spacing
UnigramDev/Unigram/develop • 6ef3146 • 1 files, +1/-1
Click events
#unigram
Rewrite the API generator that was only ever meant to be a spike
Same output, byte for byte: the generated TdDotNetApi.g.cs is identical before
and after, which is the only check available given the app itself cannot be
built here. Every emitter string literal was moved rather than retyped.
Split into the parts that were tangled together - SchemaGenerator drives, Schema/
parses td_api.tl, Emit/ writes the C#, Crc32 is the hash the generated switches
key on and must stay in step with ClientJson's copy.
The robustness this was missing:
- A malformed scheme threw, and the catch-all returned the exception text as
the generated source. One bad line became thousands of "type or namespace
not found" errors with no hint of the cause. Errors are now Diagnostics
(TDAPI001/TDAPI002) carrying the line number, and the rest of the file still
generates - a dropped //@field now reports "'localFile' has no
//@download_offset for its 'download_offset' field" and costs one class.
- The reader indexed past the last line unless the file ended blank, and its
constructor scanned for a comment that a malformed file need not contain.
- A doc line with a bare @key threw on the missing value.
- Name conversion built strings with += per character, and reachability used
List.Contains over ~3000 types.
Dropped along the way: a variable that was always empty and the dead branch it
guarded, an unreachable disjunct in the UpdateFile/File test, and a duplicated
abstract-type index built twice per run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 3b0a179 • 5 files, +92/-50
Scan to the terminator instead of bounds-checking every byte
td_receive returns a NUL-terminated buffer, and JSON has to escape a raw NUL, so
the terminator is an unambiguous end marker. TdJsonReader's scan loops now test
only for content and stop at it, with _index <= _length checked once per token
rather than once per byte. Reading past the buffer becomes impossible rather than
merely checked for.
4.1x against Utf8JsonReader on .NET Native, up from 3.7x. It does not recover the
43% the hardening cost on the desktop JIT, and the reason for that is still
unknown - two hypotheses measured and refuted, written up in the README. The
shipping toolchain is unaffected, so it is parked.
Fixtures.Load appends a terminator and GuardedBuffer.Place writes one inside the
committed region, keeping the guard page immediately after it. Verified the sweep
still has teeth: dropping the NUL test from ReadNumber access-violates over all
70,512 truncations.
Also records why generating FromJson only for received types had to be removed:
"received" is not the same as "reachable from a function return type", updates
only qualify because the scheme declares testUseUpdate, and richMessageSourceBlocks
qualifies not at all and is patched in by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 9293493 • 1 files, +7/-0
List spacing
UnigramDev/Unigram/develop • 6ef3146 • 1 files, +1/-1
Click events
#unigram
UnigramDev/Unigram/develop • 6948fd1 • 1 files, +10/-3
Fetch actual premium limit for folders count
UnigramDev/Unigram/develop • ab9ded8 • 4 files, +0/-0
Synchronize tlottie
UnigramDev/Unigram/develop • a612ce5 • 1 files, +29/-0
Update triage skill
UnigramDev/Unigram/develop • c788ba8 • 10 files, +749/-41
Generate a second set of parsers that read through TdJsonReader
SchemaGenerator now emits FromPtr_* alongside FromJson_*, from the same schema so
the two cannot drift. Opt-in via <TdPointerParser>true</TdPointerParser> and a
matching CompilerVisibleProperty; with it off the generated file is byte for byte
what it was, which is what makes changing the generator safe when the app itself
cannot be built here.
Fields dispatch on name length then an exact compare rather than CRC32 - few
enough per class for that to be cheap, and an unknown field cannot collide with a
known one. @type keeps the hash, because eighty constructors is the wrong shape
for a compare chain; TdJsonReader.ValueCrc32 walks raw memory and has to agree
with the values the generator bakes in.
Full parse on .NET Native, same payloads and the same objects out:
updateUserStatus 682.2 ns -> 335.6 ns 2.0x
updateFile 2.78 us -> 1.12 us 2.5x
updateNewMessage 8.15 us -> 3.76 us 2.2x
messages x50 436.3 us -> 181.7 us 2.4x
updateOption 1.41 us -> 493.2 ns 2.9x
Allocation is identical to the byte, so the parser stays copy- and
allocation-free. On the desktop JIT the two are at parity.
Validation runs the same assertions over both readers on every host and reports
ok including on .NET Native, so the pointer parsers agree with the netstandard2.0
System.Text.Json ones across nested objects, vectors, abstract dispatch, escapes
and unknown fields. The deep field-by-field comparison needs reflection and stays
on the desktop.
Two harness bugs fixed on the way: the shared validation had grown a reflective
comparison that .NET Native has no metadata for, and UwpHost called it outside its
try block - so it threw and the app sat on "running..." with no report and no
clue why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Fetch actual premium limit for folders count
UnigramDev/Unigram/develop • ab9ded8 • 4 files, +0/-0
Synchronize tlottie
UnigramDev/Unigram/develop • a612ce5 • 1 files, +29/-0
Update triage skill
UnigramDev/Unigram/develop • c788ba8 • 10 files, +749/-41
Generate a second set of parsers that read through TdJsonReader
SchemaGenerator now emits FromPtr_* alongside FromJson_*, from the same schema so
the two cannot drift. Opt-in via <TdPointerParser>true</TdPointerParser> and a
matching CompilerVisibleProperty; with it off the generated file is byte for byte
what it was, which is what makes changing the generator safe when the app itself
cannot be built here.
Fields dispatch on name length then an exact compare rather than CRC32 - few
enough per class for that to be cheap, and an unknown field cannot collide with a
known one. @type keeps the hash, because eighty constructors is the wrong shape
for a compare chain; TdJsonReader.ValueCrc32 walks raw memory and has to agree
with the values the generator bakes in.
Full parse on .NET Native, same payloads and the same objects out:
updateUserStatus 682.2 ns -> 335.6 ns 2.0x
updateFile 2.78 us -> 1.12 us 2.5x
updateNewMessage 8.15 us -> 3.76 us 2.2x
messages x50 436.3 us -> 181.7 us 2.4x
updateOption 1.41 us -> 493.2 ns 2.9x
Allocation is identical to the byte, so the parser stays copy- and
allocation-free. On the desktop JIT the two are at parity.
Validation runs the same assertions over both readers on every host and reports
ok including on .NET Native, so the pointer parsers agree with the netstandard2.0
System.Text.Json ones across nested objects, vectors, abstract dispatch, escapes
and unknown fields. The deep field-by-field comparison needs reflection and stays
on the desktop.
Two harness bugs fixed on the way: the shared validation had grown a reflective
comparison that .NET Native has no metadata for, and UwpHost called it outside its
try block - so it threw and the app sat on "running..." with no report and no
clue why.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • e41087d • 2 files, +25/-0
Two experimental build settings for the packaging path
ShouldUnsetParentConfigurationAndPlatform on the packaging project, so Telegram is compiled
once rather than twice. Building a solution or building in Visual Studio defaults it to true,
which drops Configuration and Platform from the globals used to build the referenced projects;
Microsoft.AppXPackage.Targets then asks the same projects for GetPackagingOutputs with both set
explicitly. MSBuild keys its cache on path plus global properties, so that is a second instance
and a second full build, the first against Telegram.csproj's own default platform of x86.
A Release x64 bundle built with it produces one compile and one .NET Native pass, but there is
no comparison run without it, and it has only been exercised from the command line rather than
the packaging wizard. Telegram.Stub is what to watch: the solution deliberately maps it away
from the parent platform, and that mapping is what unsetting the parent configuration was for.
DisableFastUpToDateCheck on the app, against the stale generated XAML. Visual Studio's own
heuristic decides whether to invoke MSBuild at all and does not track the generated files well,
so a .g.cs can end up older than the .xaml it came from and the build compiles yesterday's
generated code against today's source. SendFilesPopup.g.cs was 17 hours behind its .xaml and
only deleting obj cleared it. This trades a second or two per up to date build for asking
MSBuild rather than guessing.
Both are marked experimental in the files themselves, with what to watch for and what to revert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • b2784a4 • 1 files, +62/-15
Give the benchmark README a resume point
The findings had grown past the point where the state of the work was findable in
them, and the Open list still named things that are done. Adds "Where this
stands" - what is finished, the four steps to put the pointer parsers in the app,
what is open independently - and a section of traps worth not re-learning, the
first of which is that every wrong conclusion in this file came from comparing
numbers taken in different runs on a machine that varies 1.8x.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 1be32c9 • 1 files, +99/-0
Write down where the native dependency work stands
Resume point for the vcpkg manifest move and the switch to prebuilt libvlc and webrtc: what is
pinned and why, what is verified against what is not, what is left in order, and the traps that
cost time to find the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Two experimental build settings for the packaging path
ShouldUnsetParentConfigurationAndPlatform on the packaging project, so Telegram is compiled
once rather than twice. Building a solution or building in Visual Studio defaults it to true,
which drops Configuration and Platform from the globals used to build the referenced projects;
Microsoft.AppXPackage.Targets then asks the same projects for GetPackagingOutputs with both set
explicitly. MSBuild keys its cache on path plus global properties, so that is a second instance
and a second full build, the first against Telegram.csproj's own default platform of x86.
A Release x64 bundle built with it produces one compile and one .NET Native pass, but there is
no comparison run without it, and it has only been exercised from the command line rather than
the packaging wizard. Telegram.Stub is what to watch: the solution deliberately maps it away
from the parent platform, and that mapping is what unsetting the parent configuration was for.
DisableFastUpToDateCheck on the app, against the stale generated XAML. Visual Studio's own
heuristic decides whether to invoke MSBuild at all and does not track the generated files well,
so a .g.cs can end up older than the .xaml it came from and the build compiles yesterday's
generated code against today's source. SendFilesPopup.g.cs was 17 hours behind its .xaml and
only deleting obj cleared it. This trades a second or two per up to date build for asking
MSBuild rather than guessing.
Both are marked experimental in the files themselves, with what to watch for and what to revert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • b2784a4 • 1 files, +62/-15
Give the benchmark README a resume point
The findings had grown past the point where the state of the work was findable in
them, and the Open list still named things that are done. Adds "Where this
stands" - what is finished, the four steps to put the pointer parsers in the app,
what is open independently - and a section of traps worth not re-learning, the
first of which is that every wrong conclusion in this file came from comparing
numbers taken in different runs on a machine that varies 1.8x.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 1be32c9 • 1 files, +99/-0
Write down where the native dependency work stands
Resume point for the vcpkg manifest move and the switch to prebuilt libvlc and webrtc: what is
pinned and why, what is verified against what is not, what is left in order, and the traps that
cost time to find the first time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 0b0c96f • 11 files, +383/-95
Route files through the handler on the pointer path too
updateFile and file are re-entered through ClientResultHandler rather than
parsed inline, so a file is read into the instance the app already holds -
they arrive by the hundred on every history page, nearly always for an id
already seen. The Utf8JsonReader path has always done this; the pointer path
parsed them fresh, which would have quietly stopped the dedupe the day it was
switched on.
That interface lives in the app, so TdJsonReader and PtrClientJson move with
it to Telegram/Td/ and are linked into the three benchmark hosts instead of
the other way round. Nothing in PtrClientJson refers to generated code now -
the FromPtr(byte*, int) entry point is emitted beside DoFromPtr - so both
files build with TdPointerParser still off, which is where the app stays.
.NET Native validates and the ratios hold: updateFile 2.70 us against 1.09,
a 50-message page 444.7 us against 182.8. Generated output with the pointer
mode off is byte for byte unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • bdc68ee • 8 files, +5/-136
Drop x86 from the solution
x64 and ARM64 are the only architectures shipped - the bundle has been x64 alone
for a long time, and Telegram.Stub was already built for those two only - so the
x86 configurations were carried by every project without ever producing anything.
Out of the solution, the app, the packaging project, both native projects, the
vcpkg triplet selection and Build.ps1's default arch.
The C# project's Win32 conditions went with them. $(Platform) there is x86, x64 or
ARM64 and never Win32, so both branches of TelegramTdPlatform were unreachable and
the property itself unused; OpenSSLPlatform collapses to the one form that ever
applied. Telegram.csproj's default platform becomes x64, which is the fallback the
packaging comment above ShouldUnsetParentConfigurationAndPlatform refers to.
Libraries/rlottie/x86 held a winmd with no RLottie.dll or .pri beside it, so
nothing could have linked against it either way.
Not built - the change is confined to configurations that produced no output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Route files through the handler on the pointer path too
updateFile and file are re-entered through ClientResultHandler rather than
parsed inline, so a file is read into the instance the app already holds -
they arrive by the hundred on every history page, nearly always for an id
already seen. The Utf8JsonReader path has always done this; the pointer path
parsed them fresh, which would have quietly stopped the dedupe the day it was
switched on.
That interface lives in the app, so TdJsonReader and PtrClientJson move with
it to Telegram/Td/ and are linked into the three benchmark hosts instead of
the other way round. Nothing in PtrClientJson refers to generated code now -
the FromPtr(byte*, int) entry point is emitted beside DoFromPtr - so both
files build with TdPointerParser still off, which is where the app stays.
.NET Native validates and the ratios hold: updateFile 2.70 us against 1.09,
a 50-message page 444.7 us against 182.8. Generated output with the pointer
mode off is byte for byte unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • bdc68ee • 8 files, +5/-136
Drop x86 from the solution
x64 and ARM64 are the only architectures shipped - the bundle has been x64 alone
for a long time, and Telegram.Stub was already built for those two only - so the
x86 configurations were carried by every project without ever producing anything.
Out of the solution, the app, the packaging project, both native projects, the
vcpkg triplet selection and Build.ps1's default arch.
The C# project's Win32 conditions went with them. $(Platform) there is x86, x64 or
ARM64 and never Win32, so both branches of TelegramTdPlatform were unreachable and
the property itself unused; OpenSSLPlatform collapses to the one form that ever
applied. Telegram.csproj's default platform becomes x64, which is the fallback the
packaging comment above ShouldUnsetParentConfigurationAndPlatform refers to.
Libraries/rlottie/x86 held a winmd with no RLottie.dll or .pri beside it, so
nothing could have linked against it either way.
Not built - the change is confined to configurations that produced no output.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 0ef54e8 • 4 files, +110/-27
Parse the receive path off TDLib's buffer, behind TdPointerParser
Client.Receive and Client.Execute hand td_receive's pointer straight to
ClientJson.FromPtr: no copy into a managed buffer, and no byte-at-a-time scan
for the terminator, which was 9-12% of the old parse and would have been a
fifth of this one. TdJsonReader needs no length when the buffer is
NUL-terminated - TdJsonReader.NulTerminated says so at the call site - which
is what deletes the scan without patching td_receive to return it.
Both are alternatives, not replacements: the Utf8JsonReader code is still
there in the #else, and one property drives both halves. TdPointerParser sets
the constant Client.cs reads and turns on the generator mode that emits the
parsers it calls, so false puts the whole thing back. FromJson_* is emitted
either way and still parses what the instant view editor hands back.
Not built - Telegram.csproj is not buildable here. Client.cs, ClientJson.cs,
PtrClientJson.cs and TdJsonReader.cs do compile both ways, checked by a
throwaway project outside the repo; ClientService and everything downstream of
Receive have only been read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 0579837 • 5 files, +40/-6
Put the last three FromJson callers behind one string entry point
RichHtml and RichEditorCommands parse JSON that arrives as text from the
instant view editor, and each did its own Encoding.UTF8.GetBytes into a span.
ClientJson.FromJson(string) does it once, and follows TD_POINTER_PARSER like
everything else: it encodes one byte longer than the text and hands the
pointer reader its terminator, or falls back to the span overload.
These were the only callers of the Utf8JsonReader parsers left outside
Client.cs, so this is what has to happen before that set can be dropped from
the app altogether.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • e38afd9 • 8 files, +253/-9
Count what Client.Receive actually deserializes
The benchmark measures the parser over a fixed corpus in a loop, which says
how fast it can go and nothing about how much of it a session asks for.
TdThroughput counts updates, bytes and time inside the parse, and reports that
time as a share of wall clock - the number that decides whether the 2.4x is
worth anything outside a benchmark.
A runtime switch rather than [Conditional] like Instrumentation and Profiler,
because the interesting build is .NET Native Release, which is exactly the
configuration that never defines INSTRUMENTATION. Off it costs a static read
per update. On it costs two timestamps, plus a scan for the payload length on
the pointer path, which happens after the interval is closed so it slows
ingestion rather than flattering the rate.
Diagnostics > TDLib JSON turns it on and shows it; the counters are read when
the page opens rather than bound live, since a ticking readout would be a
second observer of the thread being measured.
Not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Parse the receive path off TDLib's buffer, behind TdPointerParser
Client.Receive and Client.Execute hand td_receive's pointer straight to
ClientJson.FromPtr: no copy into a managed buffer, and no byte-at-a-time scan
for the terminator, which was 9-12% of the old parse and would have been a
fifth of this one. TdJsonReader needs no length when the buffer is
NUL-terminated - TdJsonReader.NulTerminated says so at the call site - which
is what deletes the scan without patching td_receive to return it.
Both are alternatives, not replacements: the Utf8JsonReader code is still
there in the #else, and one property drives both halves. TdPointerParser sets
the constant Client.cs reads and turns on the generator mode that emits the
parsers it calls, so false puts the whole thing back. FromJson_* is emitted
either way and still parses what the instant view editor hands back.
Not built - Telegram.csproj is not buildable here. Client.cs, ClientJson.cs,
PtrClientJson.cs and TdJsonReader.cs do compile both ways, checked by a
throwaway project outside the repo; ClientService and everything downstream of
Receive have only been read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 0579837 • 5 files, +40/-6
Put the last three FromJson callers behind one string entry point
RichHtml and RichEditorCommands parse JSON that arrives as text from the
instant view editor, and each did its own Encoding.UTF8.GetBytes into a span.
ClientJson.FromJson(string) does it once, and follows TD_POINTER_PARSER like
everything else: it encodes one byte longer than the text and hands the
pointer reader its terminator, or falls back to the span overload.
These were the only callers of the Utf8JsonReader parsers left outside
Client.cs, so this is what has to happen before that set can be dropped from
the app altogether.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • e38afd9 • 8 files, +253/-9
Count what Client.Receive actually deserializes
The benchmark measures the parser over a fixed corpus in a loop, which says
how fast it can go and nothing about how much of it a session asks for.
TdThroughput counts updates, bytes and time inside the parse, and reports that
time as a share of wall clock - the number that decides whether the 2.4x is
worth anything outside a benchmark.
A runtime switch rather than [Conditional] like Instrumentation and Profiler,
because the interesting build is .NET Native Release, which is exactly the
configuration that never defines INSTRUMENTATION. Off it costs a static read
per update. On it costs two timestamps, plus a scan for the payload length on
the pointer path, which happens after the interval is closed so it slows
ingestion rather than flattering the rate.
Diagnostics > TDLib JSON turns it on and shows it; the counters are read when
the page opens rather than bound live, since a ticking readout would be a
second observer of the thread being measured.
Not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • c04e326 • 10 files, +217/-118
Build one parser or the other, never both
TdPointerParser was a flag that added the pointer parsers to the reader ones;
TdParsers is a choice between them - Reader, Pointer, or Both. The app has no
use for two parsers over one schema: it is 44,508 generated lines of .NET
Native compile time for a set nothing calls.
The property is what the generator reads, and it now emits either set or both.
The constants it pairs with, TD_READER_PARSER and TD_POINTER_PARSER, are what
the hand-written code reads, so ClientJson, Client and ClientService compile
exactly the half that was generated - the entry points, ParseObject, the
reading half of Utf8JsonExtensions, the interface overloads and their
implementations. The writing half stays in every mode: requests are serialized
with Utf8JsonWriter whichever parser was generated.
Both is Telegram.Benchmarks, whose whole argument is the two of them racing
over one corpus and agreeing field for field. Deleting the reader set from the
generator would delete the cross-check that says the pointer parsers are right,
so nothing is deleted - which also means Reader walks the app back in one edit.
Generated lines: Reader 149,712, Pointer 167,143, Both 211,651. Reader is still
byte for byte what shipped. All three compile - the throwaway project builds
Client.cs, ClientJson.cs and the generated file in each - except ClientService,
whose #if blocks nothing here can compile. The app stays on Both until the
pointer path has been built and run once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 2e431bf • 4 files, +53/-3
Charge file handling separately from parsing
Files are re-entered through ClientResultHandler mid-payload, so a dictionary
lookup, a FileExists syscall the first time an id is seen and an
EventAggregator publish all happen inside the interval Record measures. At
startup a chat list is hundreds of files never seen before, and every bit of
that was being charged to the parser.
CommitFile is timed on its own, the rates are over the parse with it taken
out, and the total is still shown beside them - that one is what an update
really costs, the other is what compares to the benchmark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • a812a15 • 4 files, +46/-2
Count the file existence checks separately too
FileExists is already GetFileAttributesExFromAppW, one metadata syscall behind
a WinRT hop - there is no faster call to swap in, so the only moves left are
making it rarer or moving it off the TDLib thread. Both depend on how many
there are, which nothing here knows: the check only runs for a file seen for
the first time whose download is already complete.
So it is counted and timed on its own before anything is redesigned around it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Build one parser or the other, never both
TdPointerParser was a flag that added the pointer parsers to the reader ones;
TdParsers is a choice between them - Reader, Pointer, or Both. The app has no
use for two parsers over one schema: it is 44,508 generated lines of .NET
Native compile time for a set nothing calls.
The property is what the generator reads, and it now emits either set or both.
The constants it pairs with, TD_READER_PARSER and TD_POINTER_PARSER, are what
the hand-written code reads, so ClientJson, Client and ClientService compile
exactly the half that was generated - the entry points, ParseObject, the
reading half of Utf8JsonExtensions, the interface overloads and their
implementations. The writing half stays in every mode: requests are serialized
with Utf8JsonWriter whichever parser was generated.
Both is Telegram.Benchmarks, whose whole argument is the two of them racing
over one corpus and agreeing field for field. Deleting the reader set from the
generator would delete the cross-check that says the pointer parsers are right,
so nothing is deleted - which also means Reader walks the app back in one edit.
Generated lines: Reader 149,712, Pointer 167,143, Both 211,651. Reader is still
byte for byte what shipped. All three compile - the throwaway project builds
Client.cs, ClientJson.cs and the generated file in each - except ClientService,
whose #if blocks nothing here can compile. The app stays on Both until the
pointer path has been built and run once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 2e431bf • 4 files, +53/-3
Charge file handling separately from parsing
Files are re-entered through ClientResultHandler mid-payload, so a dictionary
lookup, a FileExists syscall the first time an id is seen and an
EventAggregator publish all happen inside the interval Record measures. At
startup a chat list is hundreds of files never seen before, and every bit of
that was being charged to the parser.
CommitFile is timed on its own, the rates are over the parse with it taken
out, and the total is still shown beside them - that one is what an update
really costs, the other is what compares to the benchmark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • a812a15 • 4 files, +46/-2
Count the file existence checks separately too
FileExists is already GetFileAttributesExFromAppW, one metadata syscall behind
a WinRT hop - there is no faster call to swap in, so the only moves left are
making it rarer or moving it off the TDLib thread. Both depend on how many
there are, which nothing here knows: the check only runs for a file seen for
the first time whose download is already complete.
So it is counted and timed on its own before anything is redesigned around it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 5db4b60 • 1 files, +0/-14
Drop the ENABLE_CALLS switch
Every configuration defined it, so the one place that read it - a #if !ENABLE_CALLS
block declaring a stand-in VoipDataSaving enum in the Telegram.Native.Calls
namespace - has never compiled. The real enum comes from the component's own IDL.
The DefineConstants half of this went in with 5ca91366f.
Not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 44284ff • 5 files, +5/-152
Hoist GetUserDefaultLocaleName into Locale
Four popups carried a byte-identical copy each: the constant, both P/Invoke
branches and the wrapper, for one call apiece from their
GetRegionalSettingsAwareDecimalFormatter. Locale is where a question about the
user's locale belongs, and it is already on every one of those call paths.
Both branches move across as they were, except that the .NET 9+ one now takes a
char* over a fixed buffer rather than a Span<char>. The source-generated P/Invoke
only marshals Span<T> where runtime marshalling is disabled, which is not where a
UWP app sits, and char is not blittable while it is enabled - so the span could
not cross either way. That branch had never been compiled before now.
GetRegionalSettingsAwareDecimalFormatter itself is still duplicated four times.
Builds on the .NET 10 project; the .NET Native branch is unchanged but not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Drop the ENABLE_CALLS switch
Every configuration defined it, so the one place that read it - a #if !ENABLE_CALLS
block declaring a stand-in VoipDataSaving enum in the Telegram.Native.Calls
namespace - has never compiled. The real enum comes from the component's own IDL.
The DefineConstants half of this went in with 5ca91366f.
Not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 44284ff • 5 files, +5/-152
Hoist GetUserDefaultLocaleName into Locale
Four popups carried a byte-identical copy each: the constant, both P/Invoke
branches and the wrapper, for one call apiece from their
GetRegionalSettingsAwareDecimalFormatter. Locale is where a question about the
user's locale belongs, and it is already on every one of those call paths.
Both branches move across as they were, except that the .NET 9+ one now takes a
char* over a fixed buffer rather than a Span<char>. The source-generated P/Invoke
only marshals Span<T> where runtime marshalling is disabled, which is not where a
UWP app sits, and char is not blittable while it is enabled - so the span could
not cross either way. That branch had never been compiled before now.
GetRegionalSettingsAwareDecimalFormatter itself is still duplicated four times.
Builds on the .NET 10 project; the .NET Native branch is unchanged but not built.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 202abcf • 4 files, +484/-2
Build the app a second way, on .NET 10 and CsWinRT
Telegram.Modern.csproj sits beside Telegram.csproj and compiles the same sources
as a UWP XAML app on net10.0-windows10.0.26100.0. Zero errors, all 1250 sources
and all 473 pages, with PublishAot still off. .NET Native is untouched: nothing
here changes what Telegram.csproj builds.
It cannot be one project. UseDotNetNativeToolchain exists only in the legacy
project system and UseUwp only in SDK-style, and neither can produce the other's
output - the wall Telegram.Benchmarks.NetNative already hit.
Sdk.props is imported by hand because three properties have to precede it. The
output paths, so the two projects do not share obj\ and bin\, and
DefaultItemExcludes - without which the SDK's **/*.cs glob compiles
Telegram.csproj's own generated output, its XamlTypeInfo.g.cs and every page's
.g.cs and the ilc sources under bin\, as if they were app source. That was 417
errors that read like toolchain incompatibility and was not.
The local WinRT components are projected rather than referenced: a bare winmd
Reference, which is what Telegram.csproj does for RLottie, is NETSDK1130 here.
CsWinRT generates the projections instead, and Win2D's winmd goes in as an input
so that RLottie's CanvasBitmap signatures resolve, while Win2D itself keeps
coming from its own package.
TdParsers is mirrored by hand. The generator reads the property and the source
reads the constants, so when the two projects disagree the generated parser calls
helpers that were compiled out - 3055 errors in one generated file.
Two source fixes, both also valid for .NET Native:
- Extensions.cs takes using WinRT; inside the existing NET9_0_OR_GREATER guard.
IBuffer.As<T> is CsWinRT's cast helper and that namespace does not exist under
.NET Native, so the using cannot sit with the others.
- PlaceholderHelper passes (Window)null. CsWinRT gives every projected runtime
class an IObjectReference constructor, which makes a bare null ambiguous.
- Extensions.cs hoists a CancellationTokenRegistration out of its own
initializer: the local function that captures it is converted to a delegate
inside the expression that assigns it, which definite assignment rejects at a
modern LangVersion and the legacy project's 14.0 does not.
net10-port-todo.md carries the plan, what the three spikes established, and the
traps worth not re-learning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 7546df5 • 1 files, +15/-10
Record that the .NET 10 build is green
Phase 3 said the XAML pass was failing; it is not. WMC9999 did not survive a
clean pass and reads as incremental state, which is worth writing down either way
since it names no file when it happens.
Also folds in the two changes made since: AssemblyInfo.cs excluded from the modern
project rather than GenerateAssemblyInfo turned off, and the locale helper now
living in Locale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Build the app a second way, on .NET 10 and CsWinRT
Telegram.Modern.csproj sits beside Telegram.csproj and compiles the same sources
as a UWP XAML app on net10.0-windows10.0.26100.0. Zero errors, all 1250 sources
and all 473 pages, with PublishAot still off. .NET Native is untouched: nothing
here changes what Telegram.csproj builds.
It cannot be one project. UseDotNetNativeToolchain exists only in the legacy
project system and UseUwp only in SDK-style, and neither can produce the other's
output - the wall Telegram.Benchmarks.NetNative already hit.
Sdk.props is imported by hand because three properties have to precede it. The
output paths, so the two projects do not share obj\ and bin\, and
DefaultItemExcludes - without which the SDK's **/*.cs glob compiles
Telegram.csproj's own generated output, its XamlTypeInfo.g.cs and every page's
.g.cs and the ilc sources under bin\, as if they were app source. That was 417
errors that read like toolchain incompatibility and was not.
The local WinRT components are projected rather than referenced: a bare winmd
Reference, which is what Telegram.csproj does for RLottie, is NETSDK1130 here.
CsWinRT generates the projections instead, and Win2D's winmd goes in as an input
so that RLottie's CanvasBitmap signatures resolve, while Win2D itself keeps
coming from its own package.
TdParsers is mirrored by hand. The generator reads the property and the source
reads the constants, so when the two projects disagree the generated parser calls
helpers that were compiled out - 3055 errors in one generated file.
Two source fixes, both also valid for .NET Native:
- Extensions.cs takes using WinRT; inside the existing NET9_0_OR_GREATER guard.
IBuffer.As<T> is CsWinRT's cast helper and that namespace does not exist under
.NET Native, so the using cannot sit with the others.
- PlaceholderHelper passes (Window)null. CsWinRT gives every projected runtime
class an IObjectReference constructor, which makes a bare null ambiguous.
- Extensions.cs hoists a CancellationTokenRegistration out of its own
initializer: the local function that captures it is converted to a delegate
inside the expression that assigns it, which definite assignment rejects at a
modern LangVersion and the legacy project's 14.0 does not.
net10-port-todo.md carries the plan, what the three spikes established, and the
traps worth not re-learning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 7546df5 • 1 files, +15/-10
Record that the .NET 10 build is green
Phase 3 said the XAML pass was failing; it is not. WMC9999 did not survive a
clean pass and reads as incremental state, which is worth writing down either way
since it names no file when it happens.
Also folds in the two changes made since: AssemblyInfo.cs excluded from the modern
project rather than GenerateAssemblyInfo turned off, and the locale helper now
living in Locale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • f1a180b • 1 files, +49/-10
The first numbers that come from the app itself
.NET Native Release, one cold start on the pointer path: 15,431 updates,
25.0 MB, 0.25s of TDLib-thread time, 3.25% of an 8s startup.
Two things the corpus could not have said. The parse runs at 151 MB/s against
the benchmark's 377 for a payload of that size - same parser, same toolchain,
but a growing heap and a real mix of updates rather than one payload in a loop,
so the corpus number is a ceiling and it is 2.4x above what the app sees.
And the file existence checks did not get faster when everything around them
did: 109 us in Debug against 74 here, while the parse dropped 3.4x. That is
I/O and an AppContainer check rather than codegen, so they cannot be made
cheaper - only rarer or asynchronous. They were 11% of the thread's parse work
in Debug and are 34% of it now, and that share grows as the parser improves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 7c5ac15 • 2 files, +142/-17
Deploy and run the .NET 10 build under its own identity
It starts, and this is the first time any of it has run: registered as
38833FF26BA1D.UnigramNet10, "Unigram .NET 10", beside UnigramExperimental and the
store package rather than over either of them. A separate package family means a
separate LocalState, so the port cannot touch a real account.
The identity is patched into a copy of Package.appxmanifest in obj\ rather than
kept as a second manifest - three XmlPokes against 108 lines of capabilities,
extensions and file associations that would otherwise drift. The destination has
to come from BaseIntermediateOutputPath: IntermediateOutputPath is still empty
where it evaluates, which collapses the copy onto the source and rewrites the real
manifest in place, as it did once. Hence the Error guard in that target.
Content needs CopyToOutputDirectory. The legacy project system deployed Content
implicitly and SDK-style does not, with nothing said at build time: the first run
initialised TDLib, wrote its databases, and then died on
XamlParseException: Cannot locate resource from 'ms-appx:///Common/CommonStyles.xaml'
because neither that file nor anything under Assets\ had been laid down. Assets\**
is auto-included by the MSIX tooling, so it takes Content Update, not a second
Include.
Packaging also wanted EnableMsixTooling rather than a bare AppxPackage (the PRI
targets are otherwise half-configured and fail on IntermediateExtension), no
explicit PRIResource items (the SDK globs the .resw itself, and listing them too
is NETSDK1022), and the C++/WinRT binaries copied by hand, since projections are
not ProjectReferences and nothing else brings them along.
net10-port-todo.md carries the deploy and launch recipe, and where to read a crash:
the app's own ErrorReports json says what the event log's 0xc000027b will not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
The first numbers that come from the app itself
.NET Native Release, one cold start on the pointer path: 15,431 updates,
25.0 MB, 0.25s of TDLib-thread time, 3.25% of an 8s startup.
Two things the corpus could not have said. The parse runs at 151 MB/s against
the benchmark's 377 for a payload of that size - same parser, same toolchain,
but a growing heap and a real mix of updates rather than one payload in a loop,
so the corpus number is a ceiling and it is 2.4x above what the app sees.
And the file existence checks did not get faster when everything around them
did: 109 us in Debug against 74 here, while the parse dropped 3.4x. That is
I/O and an AppContainer check rather than codegen, so they cannot be made
cheaper - only rarer or asynchronous. They were 11% of the thread's parse work
in Debug and are 34% of it now, and that share grows as the parser improves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 7c5ac15 • 2 files, +142/-17
Deploy and run the .NET 10 build under its own identity
It starts, and this is the first time any of it has run: registered as
38833FF26BA1D.UnigramNet10, "Unigram .NET 10", beside UnigramExperimental and the
store package rather than over either of them. A separate package family means a
separate LocalState, so the port cannot touch a real account.
The identity is patched into a copy of Package.appxmanifest in obj\ rather than
kept as a second manifest - three XmlPokes against 108 lines of capabilities,
extensions and file associations that would otherwise drift. The destination has
to come from BaseIntermediateOutputPath: IntermediateOutputPath is still empty
where it evaluates, which collapses the copy onto the source and rewrites the real
manifest in place, as it did once. Hence the Error guard in that target.
Content needs CopyToOutputDirectory. The legacy project system deployed Content
implicitly and SDK-style does not, with nothing said at build time: the first run
initialised TDLib, wrote its databases, and then died on
XamlParseException: Cannot locate resource from 'ms-appx:///Common/CommonStyles.xaml'
because neither that file nor anything under Assets\ had been laid down. Assets\**
is auto-included by the MSIX tooling, so it takes Content Update, not a second
Include.
Packaging also wanted EnableMsixTooling rather than a bare AppxPackage (the PRI
targets are otherwise half-configured and fail on IntermediateExtension), no
explicit PRIResource items (the SDK globs the .resw itself, and listing them too
is NETSDK1022), and the C++/WinRT binaries copied by hand, since projections are
not ProjectReferences and nothing else brings them along.
net10-port-todo.md carries the deploy and launch recipe, and where to read a crash:
the app's own ErrorReports json says what the event log's 0xc000027b will not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 8fdb141 • 7 files, +139/-23
Check whether a file still exists off the TDLib thread
1,152 of these ran during app start and cost 74 us each - seven times what
parsing a whole update costs - which made them a third of everything the TDLib
thread spent parsing. They are I/O and an app container access check, so no
build makes them cheaper; the only thing wrong with them is where they happen.
Nothing waits on the answer. The single outcome is a DeleteFile that TDLib acts
on whenever it arrives, so first sight of a downloaded file now queues the path
and a drain does the syscalls. One drain at a time: these arrive in bursts of a
thousand and a work item each would be a thousand thread pool hops for calls
that queue behind one another on the disk anyway. The same queue takes the
other first-sight check, in ProcessFile.
TdExtensions.Update stays synchronous - its caller acts on the answer instead
of sending a request about it.
NativeFile.Exists replaces NativeUtils.FileExists at all three call sites: one
P/Invoke to GetFileAttributesExFromAppW, which is what the C++/WinRT method
called anyway. Verified against the real export - the struct marshals to the
36 bytes the C layout wants, and a directory still reads as existing, as it did
before. The native method is now unused and can go on the next Telegram.Native
rebuild.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 53efd5c • 1 files, +8/-5
Note the file check deferral in the resume section
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • c72b366 • 3 files, +18/-8
Give the modern build libvlc's plugins
UnigramUsesVcpkg is matched on project name, and Telegram.Modern was not in the
list, so neither the vcpkg runtime DLLs nor UnigramAddVlcPlugins applied to it.
libvlc.dll was there and its plugins were not, which is a native death with no
managed error report to show for it - the crash Fela hit on video.
Adding the project to that list is the whole fix; the plugin tree then arrives
through the same target Telegram.csproj uses, keeping the plugins\<category>\
shape that plugins.dat records.
It also means vcpkg supplies the shared runtime, which collided with the sixteen
ffmpeg and libvlc DLLs copied out of x64\Release\Telegram.Native (NETSDK1152).
Only Telegram.Native.dll, Telegram.Native.Calls.dll and their .pri files come from
there now - the arrangement Telegram.csproj already had. Telegram.Td.dll goes with
them: the shipping package does not carry it either.
Published, registered and launched: the app starts and stays up. Whether video
plays is Fela's to say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Check whether a file still exists off the TDLib thread
1,152 of these ran during app start and cost 74 us each - seven times what
parsing a whole update costs - which made them a third of everything the TDLib
thread spent parsing. They are I/O and an app container access check, so no
build makes them cheaper; the only thing wrong with them is where they happen.
Nothing waits on the answer. The single outcome is a DeleteFile that TDLib acts
on whenever it arrives, so first sight of a downloaded file now queues the path
and a drain does the syscalls. One drain at a time: these arrive in bursts of a
thousand and a work item each would be a thousand thread pool hops for calls
that queue behind one another on the disk anyway. The same queue takes the
other first-sight check, in ProcessFile.
TdExtensions.Update stays synchronous - its caller acts on the answer instead
of sending a request about it.
NativeFile.Exists replaces NativeUtils.FileExists at all three call sites: one
P/Invoke to GetFileAttributesExFromAppW, which is what the C++/WinRT method
called anyway. Verified against the real export - the struct marshals to the
36 bytes the C layout wants, and a directory still reads as existing, as it did
before. The native method is now unused and can go on the next Telegram.Native
rebuild.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 53efd5c • 1 files, +8/-5
Note the file check deferral in the resume section
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • c72b366 • 3 files, +18/-8
Give the modern build libvlc's plugins
UnigramUsesVcpkg is matched on project name, and Telegram.Modern was not in the
list, so neither the vcpkg runtime DLLs nor UnigramAddVlcPlugins applied to it.
libvlc.dll was there and its plugins were not, which is a native death with no
managed error report to show for it - the crash Fela hit on video.
Adding the project to that list is the whole fix; the plugin tree then arrives
through the same target Telegram.csproj uses, keeping the plugins\<category>\
shape that plugins.dat records.
It also means vcpkg supplies the shared runtime, which collided with the sixteen
ffmpeg and libvlc DLLs copied out of x64\Release\Telegram.Native (NETSDK1152).
Only Telegram.Native.dll, Telegram.Native.Calls.dll and their .pri files come from
there now - the arrangement Telegram.csproj already had. Telegram.Td.dll goes with
them: the shipping package does not carry it either.
Published, registered and launched: the app starts and stays up. Whether video
plays is Fela's to say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram