UnigramDev/Unigram/develop • 7496686 • 1 files, +121/-5
Stop system error messages from fragmenting crash groups (#3357)
* Translate more localised system error messages
Windows returns system error text in the user's language, so one fault
arrives at the crash backend as a different message per locale and splits
into a separate group for each. TranslateText maps those back onto the
canonical English wording before the report is sent, and it only ever
covers the languages someone has already looked at.
This adds the languages seen in recent reports for the faults it already
handles, and the faults it did not handle at all: file sharing violations,
disk full, paging file too small, insufficient system resources, service
disabled, data area too small, invalid operation identifier, out of range
access, interface not registered, invalid registry value and missing
Unicode mapping.
Every string maps onto the wording the same fault produces on an English
system, or onto the documented Windows text. The handful that could not
be established that way are left untranslated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Normalize the ASTA "application is busy" message
RPC_E_SERVERCALL_RETRYLATER comes with a detail sentence naming the ASTA
thread that was timed out. The id is different on every hang, and the
sentence is localized, so one fault reports under a new group each time
and splits again per language.
Rewrite it to a canonical English sentence keeping only the IID and the
method index, which are the parts that say which call hung. The GUID and
the number after it are matched structurally, since the labels around
them are translated. Anything the pattern does not match is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Match the HRESULT suffix instead of the first parenthesis
TranslateMessage cut each line at the first '(' so that the HRESULT .NET
appends to a COM message would not stop the sentence in front of it matching.
The sentence can contain parentheses of its own, though, and across the
recorded reports that boundary lands mid-sentence far more often than it lands
on the suffix - in TDLib assert text, in EETypeRva casts, in captured stack
traces. It only ever worked because a head that had been truncated matched
nothing and was concatenated back unchanged.
Match the suffix itself, anchored at the end. Nothing that previously reached
TranslateText stops reaching it, and the Portuguese RPC_E_WRONG_THREAD message
can now be mapped: its text reads "empacotada (marshall) para outro módulo",
so the first parenthesis was inside the sentence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Map ERROR_NOT_ENOUGH_MEMORY to its own English text
"Zur Verarbeitung dieses Befehls..." is 0x80070008, whose English ends "to
process this command"; it was returning 0x8007000E's "to complete this
operation" and so merged two distinct codes into one group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Stop system error messages from fragmenting crash groups (#3357)
* Translate more localised system error messages
Windows returns system error text in the user's language, so one fault
arrives at the crash backend as a different message per locale and splits
into a separate group for each. TranslateText maps those back onto the
canonical English wording before the report is sent, and it only ever
covers the languages someone has already looked at.
This adds the languages seen in recent reports for the faults it already
handles, and the faults it did not handle at all: file sharing violations,
disk full, paging file too small, insufficient system resources, service
disabled, data area too small, invalid operation identifier, out of range
access, interface not registered, invalid registry value and missing
Unicode mapping.
Every string maps onto the wording the same fault produces on an English
system, or onto the documented Windows text. The handful that could not
be established that way are left untranslated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Normalize the ASTA "application is busy" message
RPC_E_SERVERCALL_RETRYLATER comes with a detail sentence naming the ASTA
thread that was timed out. The id is different on every hang, and the
sentence is localized, so one fault reports under a new group each time
and splits again per language.
Rewrite it to a canonical English sentence keeping only the IID and the
method index, which are the parts that say which call hung. The GUID and
the number after it are matched structurally, since the labels around
them are translated. Anything the pattern does not match is left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Match the HRESULT suffix instead of the first parenthesis
TranslateMessage cut each line at the first '(' so that the HRESULT .NET
appends to a COM message would not stop the sentence in front of it matching.
The sentence can contain parentheses of its own, though, and across the
recorded reports that boundary lands mid-sentence far more often than it lands
on the suffix - in TDLib assert text, in EETypeRva casts, in captured stack
traces. It only ever worked because a head that had been truncated matched
nothing and was concatenated back unchanged.
Match the suffix itself, anchored at the end. Nothing that previously reached
TranslateText stops reaching it, and the Portuguese RPC_E_WRONG_THREAD message
can now be mapped: its text reads "empacotada (marshall) para outro módulo",
so the first parenthesis was inside the sentence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Map ERROR_NOT_ENOUGH_MEMORY to its own English text
"Zur Verarbeitung dieses Befehls..." is 0x80070008, whose English ends "to
process this command"; it was returning 0x8007000E's "to complete this
operation" and so merged two distinct codes into one group.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 7d92ca7 • 1 files, +133/-9
Key localised error messages on the HRESULT, not on the sentence (#3360)
Windows returns system error text in the user's language, so a single fault
lands in a different crash group in every locale. Matching those sentences one
by one only ever covers the languages somebody has already noticed, and only
after the group has fragmented. The HRESULT behind them is the same number
everywhere.
ProcessException now passes the exception's type name and HResult into
TranslateMessage, and the first line is rebuilt from a code lookup when the
type is Exception or COMException. The first line is the only one that can be
system text - a WinRT error puts its originating description on a line of its
own after it - and it is also the only part the group hash reads.
Any other type keeps the sentence match. Managed exceptions carry generic
codes: NullReferenceException is E_POINTER, ArgumentException is E_INVALIDARG,
InvalidCastException is E_NOINTERFACE. Rewriting those from the code would
replace .NET's own text with system wording and merge unrelated groups.
FatalError has no HResult of its own, so there the code is read back out of the
"(Exception from HRESULT: 0x...)" suffix when .NET appended one, and otherwise
nothing changes.
The sentence table stays. It is the fallback for every code the lookup does not
list, and it is the only cover for the DirectWrite and Direct2D families:
CoreApplication.UnhandledErrorDetected flattens those to E_FAIL while the
message keeps the original wording, so the code cannot be taken at its word.
E_FAIL and the COR_E_* codes are left out of the lookup for that reason.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Key localised error messages on the HRESULT, not on the sentence (#3360)
Windows returns system error text in the user's language, so a single fault
lands in a different crash group in every locale. Matching those sentences one
by one only ever covers the languages somebody has already noticed, and only
after the group has fragmented. The HRESULT behind them is the same number
everywhere.
ProcessException now passes the exception's type name and HResult into
TranslateMessage, and the first line is rebuilt from a code lookup when the
type is Exception or COMException. The first line is the only one that can be
system text - a WinRT error puts its originating description on a line of its
own after it - and it is also the only part the group hash reads.
Any other type keeps the sentence match. Managed exceptions carry generic
codes: NullReferenceException is E_POINTER, ArgumentException is E_INVALIDARG,
InvalidCastException is E_NOINTERFACE. Rewriting those from the code would
replace .NET's own text with system wording and merge unrelated groups.
FatalError has no HResult of its own, so there the code is read back out of the
"(Exception from HRESULT: 0x...)" suffix when .NET appended one, and otherwise
nothing changes.
The sentence table stays. It is the fallback for every code the lookup does not
list, and it is the only cover for the DirectWrite and Direct2D families:
CoreApplication.UnhandledErrorDetected flattens those to E_FAIL while the
message keeps the original wording, so the code cannot be taken at its word.
E_FAIL and the COR_E_* codes are left out of the lookup for that reason.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/collection-adapters-review • f17fd30 • 1 files, +162/-0
Write up the collection adapters
SearchCollection and IncrementalCollectionView are two implementations of the
same adapter, differing by one real feature - the search query driver - and
duplicating everything else. Reading them after the crash fixed in #3361 turned
up a list of defects in both, plus a few in MediaCollection, none of which is
worth losing.
Records what the adapters are for, who uses which, what is wrong with each, and
a direction: converge on IncrementalCollectionView's per-index morph and leave
SearchCollection owning only the query. Open decisions are listed as open, and
the one performance claim that has not been measured says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Write up the collection adapters
SearchCollection and IncrementalCollectionView are two implementations of the
same adapter, differing by one real feature - the search query driver - and
duplicating everything else. Reading them after the crash fixed in #3361 turned
up a list of defects in both, plus a few in MediaCollection, none of which is
worth losing.
Records what the adapters are for, who uses which, what is wrong with each, and
a direction: converge on IncrementalCollectionView's per-index morph and leave
SearchCollection owning only the query. Open decisions are listed as open, and
the one performance claim that has not been measured says so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
morethanwords/tweb/master • 267f539 • 3 files, +212/-4
Fix false pin limit from archived pins leaking into the main folder
Folder-scoped messages.getDialogs / messages.getPinnedDialogs answers come
without dialog.folder_id, so archived dialogs were filed into folder 0 and their
pins piled up in pinnedOrders[0]. Nothing ever pruned them and the order is
persisted, so toggleDialogPin reported the limit as reached with only a few
chats actually pinned.
Pass the requested folder to saveDialog again (as an argument, so a known dialog
keeps its cached folder), thread it through applyDialogs, and drop foreign
folder entries from pinnedOrders on load to repair existing state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 79b177c • 3 files, +71/-1
Don't count folded Community members against the pin limit
A member chat of a folded Community is hidden from the real folders, so its pin
holds a slot the user can neither see nor unpin there — the limit popup fires
with fewer rows on screen than the limit. tdesktop drops such a history from the
folder's local pinned list when the Community folds, so its limit ignores them
too. Count only the visible pins, leaving pinnedOrders as server truth so the
pins survive unfolding; the server still answers the actual request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 4184843 • 7 files, +230/-267
Fix inline bluff spoilers in Safari
morethanwords/tweb/master • 08e3a63 • 3 files, +5/-5
Rename getPinnedOrdersCount to getVisiblePinnedCount
Sitting next to getPinnedOrders, it read as that array's length — the one thing
it is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 2a06488 • 300 files, +789/-785
Build
#webk
Fix false pin limit from archived pins leaking into the main folder
Folder-scoped messages.getDialogs / messages.getPinnedDialogs answers come
without dialog.folder_id, so archived dialogs were filed into folder 0 and their
pins piled up in pinnedOrders[0]. Nothing ever pruned them and the order is
persisted, so toggleDialogPin reported the limit as reached with only a few
chats actually pinned.
Pass the requested folder to saveDialog again (as an argument, so a known dialog
keeps its cached folder), thread it through applyDialogs, and drop foreign
folder entries from pinnedOrders on load to repair existing state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 79b177c • 3 files, +71/-1
Don't count folded Community members against the pin limit
A member chat of a folded Community is hidden from the real folders, so its pin
holds a slot the user can neither see nor unpin there — the limit popup fires
with fewer rows on screen than the limit. tdesktop drops such a history from the
folder's local pinned list when the Community folds, so its limit ignores them
too. Count only the visible pins, leaving pinnedOrders as server truth so the
pins survive unfolding; the server still answers the actual request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 4184843 • 7 files, +230/-267
Fix inline bluff spoilers in Safari
morethanwords/tweb/master • 08e3a63 • 3 files, +5/-5
Rename getPinnedOrdersCount to getVisiblePinnedCount
Sitting next to getPinnedOrders, it read as that array's length — the one thing
it is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
morethanwords/tweb/master • 2a06488 • 300 files, +789/-785
Build
#webk
🫡3
telegramdesktop/tdesktop/nightly • 50be7b2 • 1 files, +8/-0
[iv-editor] Fixed caret loss after undo of structural change.
telegramdesktop/tdesktop/nightly • 3b5470c • 1 files, +2/-2
[iv-editor] Fixed last line skip for vertical caret move in field.
telegramdesktop/tdesktop/nightly • 18a5e80 • 1 files, +11/-6
[iv-editor] Replaced item start with last line for caret moving up.
telegramdesktop/tdesktop/nightly • f429af8 • 1 files, +2/-1
[iv-editor] Fixed missing redraw for ordered list turned into bullet.
telegramdesktop/tdesktop/nightly • c28679b • 3 files, +18/-18
[iv-editor] Added subscript, superscript and marked to selected text.
telegramdesktop/tdesktop/nightly • d0fca68 • 2 files, +19/-4
[iv-editor] Added mutual exclusion for subscript and superscript.
telegramdesktop/tdesktop/nightly • b7a0967 • 1 files, +4/-7
[iv-editor] Replaced next cell with cell below on Enter inside table.
telegramdesktop/tdesktop/nightly • 994d209 • 2 files, +73/-5
[iv-editor] Fixed loss of list items wrapped into quote.
telegramdesktop/tdesktop/nightly • ff2d8ca • 3 files, +11/-1
[iv-editor] Fixed Enter with emoji suggestion having no selection.
telegramdesktop/tdesktop/nightly • ba48762 • 1 files, +2/-1
Added discard of article draft without confirmation with pressed Ctrl.
telegramdesktop/tdesktop/nightly • 8ee340a • 2 files, +7/-0
Added discard of rich draft without confirmation with pressed Ctrl.
telegramdesktop/tdesktop/nightly • 4ce8b54 • 1 files, +28/-0
Fixed checkbox colors of rich text messages for dark chat themes.
telegramdesktop/tdesktop/nightly • 9408ac9 • 2 files, +63/-4
Added ability to replace of rich message media from clipboard.
#tdesktop
[iv-editor] Fixed caret loss after undo of structural change.
telegramdesktop/tdesktop/nightly • 3b5470c • 1 files, +2/-2
[iv-editor] Fixed last line skip for vertical caret move in field.
telegramdesktop/tdesktop/nightly • 18a5e80 • 1 files, +11/-6
[iv-editor] Replaced item start with last line for caret moving up.
telegramdesktop/tdesktop/nightly • f429af8 • 1 files, +2/-1
[iv-editor] Fixed missing redraw for ordered list turned into bullet.
telegramdesktop/tdesktop/nightly • c28679b • 3 files, +18/-18
[iv-editor] Added subscript, superscript and marked to selected text.
telegramdesktop/tdesktop/nightly • d0fca68 • 2 files, +19/-4
[iv-editor] Added mutual exclusion for subscript and superscript.
telegramdesktop/tdesktop/nightly • b7a0967 • 1 files, +4/-7
[iv-editor] Replaced next cell with cell below on Enter inside table.
telegramdesktop/tdesktop/nightly • 994d209 • 2 files, +73/-5
[iv-editor] Fixed loss of list items wrapped into quote.
telegramdesktop/tdesktop/nightly • ff2d8ca • 3 files, +11/-1
[iv-editor] Fixed Enter with emoji suggestion having no selection.
telegramdesktop/tdesktop/nightly • ba48762 • 1 files, +2/-1
Added discard of article draft without confirmation with pressed Ctrl.
telegramdesktop/tdesktop/nightly • 8ee340a • 2 files, +7/-0
Added discard of rich draft without confirmation with pressed Ctrl.
telegramdesktop/tdesktop/nightly • 4ce8b54 • 1 files, +28/-0
Fixed checkbox colors of rich text messages for dark chat themes.
telegramdesktop/tdesktop/nightly • 9408ac9 • 2 files, +63/-4
Added ability to replace of rich message media from clipboard.
#tdesktop
🫡2
UnigramDev/Unigram/formatted-text-block-review • 1fa885f • 3 files, +347/-6
Anchor rendered indices to the paragraph a block starts at
The fast path left _indexMap null on the grounds that a plain single run maps
1:1 onto StyledText offsets. That only holds for a block whose first paragraph
is the message's first: MessageTextBlock gives every plain paragraph sandwiched
between typed ones its own block, and those render a slice of a shared
StyledText starting somewhere in the middle.
With no map, the converters returned the rendered index unchanged, so copying a
selection from such a block returned text from the start of the message, word
and paragraph expansion resolved against the wrong paragraph, and the search
highlight landed past the end of the block.
_origin is set beside _first/_last so it is correct before every early return
and on both paths - the slow path can also end up with an empty map when every
entity is dropped by the length guard, and that takes the same fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 505777e • 2 files, +24/-12
Give the code block tokenization an identity that does not restart
_templateExecuted was doing two jobs: the "text has been applied" flag OnLoaded
tests, and the token that ProcessCodeBlock compares against after its await.
OnUnloaded resets it, so the second job was an ABA - execution 1 is handed out
again after every unload, and a tokenization started before it resumes, matches,
and calls ClearCollection on inlines whose Paragraph is by then back in the
shared pool, wiping whichever block dequeued it.
The async guard now uses its own never-reset counter. That leaves the flag as a
flag, so it is a bool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • d33e082 • 7 files, +34/-22
Build the monospace font chain once, in Theme
Nine sites spelled out "Cascadia Mono, Consolas" and built a FontFamily from it.
FormattedTextBlock meant to memoize its own, but the local it returned was never
assigned, so it allocated one per monospace entity - and ProcessCodeBlock, being
recursive, one per node of the token tree. Six of the others sit inside a loop
over entities, so they allocated one per code span they rendered.
Theme already owns XamlAutoFontFamily and rebuilds the families that embed it in
UpdateEmojiSet, which is the only place the chain can change, so the family
belongs there and everything reads Theme.Current.MonospaceFontFamily.
Those six spelled the chain without the fallback tail, and now inherit it: a
character the monospace faces don't cover renders through the text and emoji
fonts instead of the system default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Anchor rendered indices to the paragraph a block starts at
The fast path left _indexMap null on the grounds that a plain single run maps
1:1 onto StyledText offsets. That only holds for a block whose first paragraph
is the message's first: MessageTextBlock gives every plain paragraph sandwiched
between typed ones its own block, and those render a slice of a shared
StyledText starting somewhere in the middle.
With no map, the converters returned the rendered index unchanged, so copying a
selection from such a block returned text from the start of the message, word
and paragraph expansion resolved against the wrong paragraph, and the search
highlight landed past the end of the block.
_origin is set beside _first/_last so it is correct before every early return
and on both paths - the slow path can also end up with an empty map when every
entity is dropped by the length guard, and that takes the same fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 505777e • 2 files, +24/-12
Give the code block tokenization an identity that does not restart
_templateExecuted was doing two jobs: the "text has been applied" flag OnLoaded
tests, and the token that ProcessCodeBlock compares against after its await.
OnUnloaded resets it, so the second job was an ABA - execution 1 is handed out
again after every unload, and a tokenization started before it resumes, matches,
and calls ClearCollection on inlines whose Paragraph is by then back in the
shared pool, wiping whichever block dequeued it.
The async guard now uses its own never-reset counter. That leaves the flag as a
flag, so it is a bool.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • d33e082 • 7 files, +34/-22
Build the monospace font chain once, in Theme
Nine sites spelled out "Cascadia Mono, Consolas" and built a FontFamily from it.
FormattedTextBlock meant to memoize its own, but the local it returned was never
assigned, so it allocated one per monospace entity - and ProcessCodeBlock, being
recursive, one per node of the token tree. Six of the others sit inside a loop
over entities, so they allocated one per code span they rendered.
Theme already owns XamlAutoFontFamily and rebuilds the families that embed it in
UpdateEmojiSet, which is the only place the chain can change, so the family
belongs there and everything reads Theme.Current.MonospaceFontFamily.
Those six spelled the chain without the fallback tail, and now inherit it: a
character the monospace faces don't cover renders through the text and emoji
fonts instead of the system default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-review • 70384c9 • 2 files, +25/-6
Stop building the syntax colour tables per text block
_light and _dark held 28 entries each and were instance fields, so every
FormattedTextBlock in the app carried both - the chat list, replies, web page
previews, all of them - to serve the rare one that renders a code block. Color
is a value type and the tables are never written to, so they are static.
The brushes built from them can't be shared the same way, since a
DependencyObject belongs to the thread that created it and the app runs a XAML
thread per view, but they can wait until a code block asks for one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 44cb66d • 2 files, +24/-7
Set the I-beam on the way in, not on every pointer move
_textSelectionIBeam was assigned and never read, so an Extended block set
CoreWindow.PointerCursor - a marshalled call, plus a CoreCursor allocation - for
every pointer sample over its text.
Testing it is not enough on its own: a Hyperlink puts its own Hand cursor up, so
once the pointer crosses one, ours is no longer what is on screen. The flag is
cleared there, or moving back onto text would skip the I-beam and leave the Hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 1c80658 • 2 files, +31/-12
Measure an expandable quote once per width, not once per pass
MaxLines is a native text layout over the whole quote, and it ran on every
measure - so on every window resize and every InvalidateMeasure the bubble
takes - to answer a question whose inputs had not moved.
The text is compared by reference rather than by value, which is exact here:
GetParts returns the same instance until a relative date rewrites the paragraph,
and that is the one thing that can change the answer at a fixed width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 464a06f • 2 files, +42/-22
Allocate the entity collections only for text that has entities
Seven collections were built in the field initializers of a control that the
chat list, replies, web page previews and every message bubble instantiate.
Links, dates and spoilers are rare enough that those three are now null until
something is added; the active-element lists are not, because every rendered
block fills them.
All four active collections become List<T>: nothing can be added twice - each
element is newly constructed or dequeued from the pool, Recycle clears the
lists, and the one mid-render hand-back removes before it enqueues - so the
hashing bought nothing but the cost of a Contains that runs once per code block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Stop building the syntax colour tables per text block
_light and _dark held 28 entries each and were instance fields, so every
FormattedTextBlock in the app carried both - the chat list, replies, web page
previews, all of them - to serve the rare one that renders a code block. Color
is a value type and the tables are never written to, so they are static.
The brushes built from them can't be shared the same way, since a
DependencyObject belongs to the thread that created it and the app runs a XAML
thread per view, but they can wait until a code block asks for one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 44cb66d • 2 files, +24/-7
Set the I-beam on the way in, not on every pointer move
_textSelectionIBeam was assigned and never read, so an Extended block set
CoreWindow.PointerCursor - a marshalled call, plus a CoreCursor allocation - for
every pointer sample over its text.
Testing it is not enough on its own: a Hyperlink puts its own Hand cursor up, so
once the pointer crosses one, ours is no longer what is on screen. The flag is
cleared there, or moving back onto text would skip the I-beam and leave the Hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 1c80658 • 2 files, +31/-12
Measure an expandable quote once per width, not once per pass
MaxLines is a native text layout over the whole quote, and it ran on every
measure - so on every window resize and every InvalidateMeasure the bubble
takes - to answer a question whose inputs had not moved.
The text is compared by reference rather than by value, which is exact here:
GetParts returns the same instance until a relative date rewrites the paragraph,
and that is the one thing that can change the answer at a fixed width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 464a06f • 2 files, +42/-22
Allocate the entity collections only for text that has entities
Seven collections were built in the field initializers of a control that the
chat list, replies, web page previews and every message bubble instantiate.
Links, dates and spoilers are rare enough that those three are now null until
something is added; the active-element lists are not, because every rendered
block fills them.
All four active collections become List<T>: nothing can be added twice - each
element is newly constructed or dequeued from the pool, Recycle clears the
lists, and the one mid-render hand-back removes before it enqueues - so the
hashing bought nothing but the cost of a Contains that runs once per code block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-review • 63c66d9 • 2 files, +39/-11
Keep the relative date timer from arming itself 68 years out
An item that was not due yet contributed its remaining time truncated to whole
seconds, so anything under a second contributed nothing. That is the normal
state of the one-second bucket - the timer can fire a hair early - and when it
was the only item, the minimum stayed at int.MaxValue and every relative
timestamp on the thread stopped updating until something else subscribed.
Rounds up now, and the return clamps, so an empty or fully-skipped set falls
back to a second rather than to the sentinel.
TextDate also stops being a record: .NET Native has no records, and as a
dictionary value keyed by its own Element it never needed value equality. The
EntityType member went with it, since only the constructor ever read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 93bf4d4 • 2 files, +25/-3
Retry the highlighters on load instead of losing them
ApplyHighlighters returns without doing anything when the inner RichTextBlock is
not loaded, and OnApplyTemplate calls SetText before that is true. Nothing put
them back: OnLoaded returns early on the very flag SetText had just set. If the
guard ever holds - and it is there because it once did - the spoiler cover for a
first render is simply never applied.
A flag is the answer that is correct whichever way the ordering actually falls:
no change if the block is already loaded, and the highlighters arrive a moment
late rather than never if it isn't.
Revealing a spoiler no longer calls SetQuery(string.Empty) either. SetText
reapplies the highlighters on its own now, so all that line still did was forget
the search term the user is looking at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 633e958 • 2 files, +87/-132
Write the run property reset once
The two GetOrCreateRun overloads carried the same sixty lines of "set this or
clear it" for a pooled Run, which is the part that has to stay in step with what
NativeUtils does for a new one.
They are not merged into one: on the path that builds a Run, the range overload
hands the offset and length to NativeUtils and never materializes the substring,
so forwarding one to the other would have cost exactly the thing it exists for.
Each keeps its Run_Text line and its native call, and shares the rest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 3bc3ea2 • 2 files, +26/-4
Make Clear reset the highlighters it claims to reset
Clear nulls the query and the spoiler, but the highlighters themselves live on
the inner RichTextBlock and it never touched that collection - and it ignored
the cached, marked and selection ones entirely.
It runs from MessageService.Recycle, on the Text block of every service message,
under a contract spelled out at the call site: whatever isn't reset is inherited
by the next message in that container. So a recycled service message could come
back wearing the previous one's spoiler cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Keep the relative date timer from arming itself 68 years out
An item that was not due yet contributed its remaining time truncated to whole
seconds, so anything under a second contributed nothing. That is the normal
state of the one-second bucket - the timer can fire a hair early - and when it
was the only item, the minimum stayed at int.MaxValue and every relative
timestamp on the thread stopped updating until something else subscribed.
Rounds up now, and the return clamps, so an empty or fully-skipped set falls
back to a second rather than to the sentinel.
TextDate also stops being a record: .NET Native has no records, and as a
dictionary value keyed by its own Element it never needed value equality. The
EntityType member went with it, since only the constructor ever read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 93bf4d4 • 2 files, +25/-3
Retry the highlighters on load instead of losing them
ApplyHighlighters returns without doing anything when the inner RichTextBlock is
not loaded, and OnApplyTemplate calls SetText before that is true. Nothing put
them back: OnLoaded returns early on the very flag SetText had just set. If the
guard ever holds - and it is there because it once did - the spoiler cover for a
first render is simply never applied.
A flag is the answer that is correct whichever way the ordering actually falls:
no change if the block is already loaded, and the highlighters arrive a moment
late rather than never if it isn't.
Revealing a spoiler no longer calls SetQuery(string.Empty) either. SetText
reapplies the highlighters on its own now, so all that line still did was forget
the search term the user is looking at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 633e958 • 2 files, +87/-132
Write the run property reset once
The two GetOrCreateRun overloads carried the same sixty lines of "set this or
clear it" for a pooled Run, which is the part that has to stay in step with what
NativeUtils does for a new one.
They are not merged into one: on the path that builds a Run, the range overload
hands the offset and length to NativeUtils and never materializes the substring,
so forwarding one to the other would have cost exactly the thing it exists for.
Each keeps its Run_Text line and its native call, and shares the rest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 3bc3ea2 • 2 files, +26/-4
Make Clear reset the highlighters it claims to reset
Clear nulls the query and the spoiler, but the highlighters themselves live on
the inner RichTextBlock and it never touched that collection - and it ignored
the cached, marked and selection ones entirely.
It runs from MessageService.Recycle, on the Text block of every service message,
under a contract spelled out at the call site: whatever isn't reset is inherited
by the next message in that container. So a recycled service message could come
back wearing the previous one's spoiler cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-review • 18269b1 • 3 files, +92/-55
Say what these actually are
Spoilers build a Span and called it hyperlink, twice; UpdateSpoilers iterated
TextStyleSpoiler structs as hyperlink; RelativeDateService took a TextStyleRun
called Yolo through three signatures.
The two foreground property handlers were the same five lines under different
names - neither of which described what they do, which is recolour the links
still carrying the outgoing brush.
OnApplyTemplate drops _blocks once it has handed the XAML-declared Paragraphs to
the RichTextBlock, so re-templating cannot try to re-parent them, and skips a
non-Paragraph entry instead of adding null.
The Selectable header claimed an inline object counts as one unit in highlighter
space. It counts zero - the ZWNJ emitted next to it is the unit - which is what
WalkInlines does and what the index map assumes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • efffd55 • 2 files, +16/-2
Offset the query highlight by the host content in inline mode
An inline-mode block renders into a Span the host owns, behind whatever it put
in front - a sender name in a chat cell - while TextHighlighter indices count
from the start of the RichTextBlock. The spoiler ranges correct for that; the
query highlight did not, so it would have landed short by the length of the
prefix.
Would have, because nothing gives an inline-mode block a query today: the only
live query is MessageBubble's, and that goes to MessageTextBlock. This is right
by symmetry with the spoiler branch rather than by observation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 6d3a3e5 • 1 files, +48/-2
Say why the five open items are open
Each of the remaining items is a decision rather than a patch, so the entry now
carries what I found while working through the rest: SetFontSize runs into the
undecided quote sizing question the TODO at :1127 already flags, the non-pooled
teardown has two fixes that each cost something real, and the last two want a
profiler and the layout cycle audit respectively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Say what these actually are
Spoilers build a Span and called it hyperlink, twice; UpdateSpoilers iterated
TextStyleSpoiler structs as hyperlink; RelativeDateService took a TextStyleRun
called Yolo through three signatures.
The two foreground property handlers were the same five lines under different
names - neither of which described what they do, which is recolour the links
still carrying the outgoing brush.
OnApplyTemplate drops _blocks once it has handed the XAML-declared Paragraphs to
the RichTextBlock, so re-templating cannot try to re-parent them, and skips a
non-Paragraph entry instead of adding null.
The Selectable header claimed an inline object counts as one unit in highlighter
space. It counts zero - the ZWNJ emitted next to it is the unit - which is what
WalkInlines does and what the index map assumes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • efffd55 • 2 files, +16/-2
Offset the query highlight by the host content in inline mode
An inline-mode block renders into a Span the host owns, behind whatever it put
in front - a sender name in a chat cell - while TextHighlighter indices count
from the start of the RichTextBlock. The spoiler ranges correct for that; the
query highlight did not, so it would have landed short by the length of the
prefix.
Would have, because nothing gives an inline-mode block a query today: the only
live query is MessageBubble's, and that goes to MessageTextBlock. This is right
by symmetry with the spoiler branch rather than by observation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-review • 6d3a3e5 • 1 files, +48/-2
Say why the five open items are open
Each of the remaining items is a decision rather than a patch, so the entry now
carries what I found while working through the rest: SetFontSize runs into the
undecided quote sizing question the TODO at :1127 already flags, the non-pooled
teardown has two fixes that each cost something real, and the last two want a
profiler and the layout cycle audit respectively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-review • dfc92d0 • 1 files, +11/-21
Close SetFontSize as by design
Its only caller is UpdateMockup, for the fake conversation in appearance
settings where the font size slider has to take effect without going back
through SetText. Those are one plain paragraph each, so the first paragraph is
the whole text - and no quote ever reaches it, which takes the caption size
question off this method entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Close SetFontSize as by design
Its only caller is UpdateMockup, for the fake conversation in appearance
settings where the font size slider has to take effect without going back
through SetText. Those are one plain paragraph each, so the first paragraph is
the whole text - and no quote ever reaches it, which takes the caption size
question off this method entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-dates • 1580221 • 2 files, +191/-55
Keep spoilers and copy offsets in step with relative dates
A relative date rewrites itself in place, and three things downstream are
expressed in coordinates that move when it does: the spoiler geometry, the
highlighter ranges that hide the text, and the index map the selection layer
reads. Only the first was maintained, from OriginalOffset plus a delta measured
against the source length - which is the total growth since the first render, so
applying it on every tick, and once per date, compounded. The ranges were a
commented-out block, and nobody had noticed the index map at all.
Geometry is now derived rather than stored: TextStyleSpoiler holds source
offsets and DisplayedRange works out where the spoiler currently sits from the
paragraph's runs, so any number of dates in any order land in the same place.
Rendered space is shifted instead, by the change since the last tick, from the
map segment each date now remembers - which is also what fixes copy in any
message with a relative date, spoiler or not.
Two more from the same root: a spoiler's range took the source length, so a
spoiler wrapping a date was short from the first render - it is now measured
from what was emitted. And the two halves of UpdateSpoilers disagreed about
whether the offset was date-expanded, which deriving per branch settles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-dates • 2e1c099 • 1 files, +226/-0
Add a test plan for both branches
Markdown covers most of it, since ParseMarkdown runs on send. Dates do not have
a markdown syntax, so those come from the composer's formatted date item, and
part C starts by confirming the entity survives the round trip at all - if it
does not, nothing downstream of it is testable that way.
Each item says what wrong looks like, because most of these fail by a couple of
characters rather than by crashing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Keep spoilers and copy offsets in step with relative dates
A relative date rewrites itself in place, and three things downstream are
expressed in coordinates that move when it does: the spoiler geometry, the
highlighter ranges that hide the text, and the index map the selection layer
reads. Only the first was maintained, from OriginalOffset plus a delta measured
against the source length - which is the total growth since the first render, so
applying it on every tick, and once per date, compounded. The ranges were a
commented-out block, and nobody had noticed the index map at all.
Geometry is now derived rather than stored: TextStyleSpoiler holds source
offsets and DisplayedRange works out where the spoiler currently sits from the
paragraph's runs, so any number of dates in any order land in the same place.
Rendered space is shifted instead, by the change since the last tick, from the
map segment each date now remembers - which is also what fixes copy in any
message with a relative date, spoiler or not.
Two more from the same root: a spoiler's range took the source length, so a
spoiler wrapping a date was short from the first render - it is now measured
from what was emitted. And the two halves of UpdateSpoilers disagreed about
whether the offset was date-expanded, which deriving per branch settles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-dates • 2e1c099 • 1 files, +226/-0
Add a test plan for both branches
Markdown covers most of it, since ParseMarkdown runs on send. Dates do not have
a markdown syntax, so those come from the composer's formatted date item, and
part C starts by confirming the entity survives the round trip at all - if it
does not, nothing downstream of it is testable that way.
Each item says what wrong looks like, because most of these fail by a couple of
characters rather than by crashing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-dates • ec95648 • 2 files, +247/-30
Send the test messages from a script, and correct two cases
The Bot API does carry the date entity, as date_time with unix_time and
date_time_format. Worth knowing that unknown extra fields are accepted and
ignored, so a wrong field name leaves the format empty and the date renders as
plain text - it looks like the feature is broken rather than the request.
Two cases in the plan could not exist. A spoiler may not overlap a date: the
server splits the spoiler around it rather than rejecting the message. So a date
inside a spoiler is not a state the app can be given, and the stretch branch of
ShiftRanges is unreachable for dates - it stays live only for the marked and
cached highlighters, which the app builds itself.
The split is the better test anyway. T10 now sends a spoiler spanning a date and
gets two covers back, one either side, which have to do different things across
the same tick: the one in front stays, the one behind moves.
The script takes the token and chat as arguments and carries neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-dates • 62519ad • 1 files, +9/-0
Note that a spoiler cannot contain a date
Found while building the test set: the server splits a spoiler around a date
rather than letting them overlap. So the branch that stretches a spoiler around
a growing date is unreachable for dates, and stays live only for the marked and
cached highlighters, which the app builds itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Send the test messages from a script, and correct two cases
The Bot API does carry the date entity, as date_time with unix_time and
date_time_format. Worth knowing that unknown extra fields are accepted and
ignored, so a wrong field name leaves the format empty and the date renders as
plain text - it looks like the feature is broken rather than the request.
Two cases in the plan could not exist. A spoiler may not overlap a date: the
server splits the spoiler around it rather than rejecting the message. So a date
inside a spoiler is not a state the app can be given, and the stretch branch of
ShiftRanges is unreachable for dates - it stays live only for the marked and
cached highlighters, which the app builds itself.
The split is the better test anyway. T10 now sends a spoiler spanning a date and
gets two covers back, one either side, which have to do different things across
the same tick: the one in front stays, the one behind moves.
The script takes the token and chat as arguments and carries neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/formatted-text-block-dates • 62519ad • 1 files, +9/-0
Note that a spoiler cannot contain a date
Found while building the test set: the server splits a spoiler around a date
rather than letting them overlap. So the branch that stretches a spoiler around
a growing date is unreachable for dates, and stays live only for the marked and
cached highlighters, which the app builds itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-dates • 7481285 • 2 files, +45/-13
Anchor the dates in seconds, since the formatter counts seconds
I had read RelativeDate, which falls back to "just now", rather than
RelativeDateAgo, which is the one InAgo calls and which ends in
Declension(SecondsAgo, value). So the text does tick per second, and the width
changes at 2s, 10s, 60s, 2min, 10min and an hour.
Anchoring at eight seconds gives three width changes inside two minutes instead
of one, with the counter visibly running in between.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Anchor the dates in seconds, since the formatter counts seconds
I had read RelativeDate, which falls back to "just now", rather than
RelativeDateAgo, which is the one InAgo calls and which ends in
Declension(SecondsAgo, value). So the text does tick per second, and the width
changes at 2s, 10s, 60s, 2min, 10min and an hour.
Anchoring at eight seconds gives three width changes inside two minutes instead
of one, with the counter visibly running in between.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
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