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
UnigramDev/Unigram/develop • 35b04e1 • 1 files, +26/-2
Write down where the calls window stands
Everything else Fela tried works: stickers, animations, WebView2, VLC and
secondary windows. Calls do not - no video, no lottie - and the exceptions behind
it are caught, so ErrorReports stays empty.
Records what the blob waves rule out. They animate, and they run off the same
per-view CompositionTarget.Rendering that AnimatedImage uses, so the frame driver
is not the problem and the surface path is.
Also adds the Unigram* -> Telegram* property rename to the cleanups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 1bc5aaf • 1 files, +6/-0
Log the exceptions the XAML handler swallows
OnUnhandledException marks almost everything handled and returns, so a handled
exception leaves no trace at all unless Diagnostics.ShowMemoryUsage happens to be
on, in which case it goes to a popup. That is how the calls window fails on .NET
10: something in AnimatedImage throws, the app carries on, and neither the log nor
ErrorReports has anything to say about it.
Logger.Error rather than TrackError: the app survived these, and a crash report
would claim otherwise. At the default packaged verbosity it reaches tdlib_log.txt,
which is readable after the fact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • adcdff1 • 1 files, +19/-1
Register rendering on the presenter's own loader
AnimatedImageLoader.Current is [ThreadStatic], so RegisterRendering was resolving
whichever loader belonged to the calling thread, while every other call in the
presenter goes through its own _loader - Load, Remove, Activated, PopupActivated.
When the two differ, the presenter joins the wrong view's rendering list and its
WriteableBitmap is invalidated from that view's thread:
COMException 0x8001010E (RPC_E_WRONG_THREAD)
at WriteableBitmap.Invalidate()
at AnimatedImagePresenter.DrawFrame() AnimatedImage.cs:1499
at AnimatedImageLoader.OnRendering() AnimatedImage.cs:1827
which is what leaves the call window without video or lottie. The call was always
illegal; .NET Native called through the raw pointer and never checked, CsWinRT
does.
The off-thread path also logs, because it should not happen: something is calling
in from another thread, and the log will name the case rather than leaving it to
inference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 870308e • 1 files, +20/-0
The file checks moved, and the parse shows it
Same page, next startup: file handling 0.086s to 0.007s, 34% of the parse to
4%. The checks themselves cost what they always did - 1,080 of them, 0.094s,
87us each - they are just not on the TDLib thread any more. What is left is the
dictionary, the publish and the enqueue, 0.6us per update.
Also notes what the Reader/Pointer A/B will not show: both modes start timing
after the copy and the terminator scan, which the pointer path deleted, so it
compares parser against parser and understates the change to Receive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Write down where the calls window stands
Everything else Fela tried works: stickers, animations, WebView2, VLC and
secondary windows. Calls do not - no video, no lottie - and the exceptions behind
it are caught, so ErrorReports stays empty.
Records what the blob waves rule out. They animate, and they run off the same
per-view CompositionTarget.Rendering that AnimatedImage uses, so the frame driver
is not the problem and the surface path is.
Also adds the Unigram* -> Telegram* property rename to the cleanups.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 1bc5aaf • 1 files, +6/-0
Log the exceptions the XAML handler swallows
OnUnhandledException marks almost everything handled and returns, so a handled
exception leaves no trace at all unless Diagnostics.ShowMemoryUsage happens to be
on, in which case it goes to a popup. That is how the calls window fails on .NET
10: something in AnimatedImage throws, the app carries on, and neither the log nor
ErrorReports has anything to say about it.
Logger.Error rather than TrackError: the app survived these, and a crash report
would claim otherwise. At the default packaged verbosity it reaches tdlib_log.txt,
which is readable after the fact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • adcdff1 • 1 files, +19/-1
Register rendering on the presenter's own loader
AnimatedImageLoader.Current is [ThreadStatic], so RegisterRendering was resolving
whichever loader belonged to the calling thread, while every other call in the
presenter goes through its own _loader - Load, Remove, Activated, PopupActivated.
When the two differ, the presenter joins the wrong view's rendering list and its
WriteableBitmap is invalidated from that view's thread:
COMException 0x8001010E (RPC_E_WRONG_THREAD)
at WriteableBitmap.Invalidate()
at AnimatedImagePresenter.DrawFrame() AnimatedImage.cs:1499
at AnimatedImageLoader.OnRendering() AnimatedImage.cs:1827
which is what leaves the call window without video or lottie. The call was always
illegal; .NET Native called through the raw pointer and never checked, CsWinRT
does.
The off-thread path also logs, because it should not happen: something is calling
in from another thread, and the log will name the case rather than leaving it to
inference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 870308e • 1 files, +20/-0
The file checks moved, and the parse shows it
Same page, next startup: file handling 0.086s to 0.007s, 34% of the parse to
4%. The checks themselves cost what they always did - 1,080 of them, 0.094s,
87us each - they are just not on the TDLib thread any more. What is left is the
dictionary, the publish and the enqueue, 0.6us per update.
Also notes what the Reader/Pointer A/B will not show: both modes start timing
after the copy and the terminator scan, which the pointer path deleted, so it
compares parser against parser and understates the change to Receive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • b162d28 • 1 files, +0/-3
Drop the packaging project's pre-build event
It only ever ran inside Visual Studio. Under MSBuild ProjectDir and
ConfigurationName are empty, so it expanded to
UpdateManifest.ps1 -path "\" -config "" -mode "SideloadOnly"
which PowerShell read as one escaped argument, Resolve-Path rejected, and git
answered "cannot change to 'rev-list'". The script's TryParse guard then exited
without touching anything - silently, and with the build reporting success. The
package inherited whatever version the manifest already carried, which is how
two different builds ended up stamped 12.10.0.13894 and one overwrote the other.
Build.ps1 already calls UpdateManifest.ps1 itself before invoking msbuild, which
is the call that works, so this was a duplicate of it that could only fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 6d23cf4 • 1 files, +39/-3
Pin down why the call window does not animate
It is the Windows SDK XAML projection, not the app. A CompositionTarget.Rendering
handler subscribed on a secondary view runs on the main view's thread: measured in
a throwaway app (subscribed on 5, ran on 4) and matched by the app's own log, which
pairs a presenter whose bitmaps were built on thread 23 with a DrawFrame on thread
4.
Microsoft.Windows.UI.Xaml.dll caches the statics in a plain static field and
carries no ThreadStatic or ThreadLocal anywhere, so the first view to touch
CompositionTarget owns it for the process and everyone else's add_Rendering
registers against that view's core. No CsWinRT property covers it.
That also explains what looked contradictory: the call's blob waves animate because
Composition objects are agile, while lottie and video die on WriteableBitmap, which
is not - RPC_E_WRONG_THREAD, swallowed by the XAML handler.
Three options written down, smallest first. Nothing changed in the app yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 87a294a • 2 files, +34/-12
1.56x in the app, and the Utf8JsonReader set stops being built
Two startups differing only in TdParsers: 87.4 MB/s through Utf8JsonReader
against 136.4 through TdJsonReader. The corpus says 2.4x for the same two
parsers on the same toolchain, so the app sees about two thirds of it.
The gap is everything the change does not touch. Solving for it puts the
reader-dependent work at 2.9 ms/MB and the shared part at 4.4 - 60% - which is
building the object graph and, mostly, a GC that promotes objects the app goes
on to hold, where a benchmark loop sweeps them at gen0. A corpus ratio is an
upper bound on what a parser change buys in situ.
Worth ~105 ms of TDLib-thread time on a startup that size, on top of the ~85 ms
the file-check deferral took off it.
So TdParsers goes to Pointer: 44,508 generated lines and ~500 hand-written ones
stop being compiled, and the app carries one parser. Reader walks it back in
one word - nothing was deleted, and Both is still there for the benchmark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Drop the packaging project's pre-build event
It only ever ran inside Visual Studio. Under MSBuild ProjectDir and
ConfigurationName are empty, so it expanded to
UpdateManifest.ps1 -path "\" -config "" -mode "SideloadOnly"
which PowerShell read as one escaped argument, Resolve-Path rejected, and git
answered "cannot change to 'rev-list'". The script's TryParse guard then exited
without touching anything - silently, and with the build reporting success. The
package inherited whatever version the manifest already carried, which is how
two different builds ended up stamped 12.10.0.13894 and one overwrote the other.
Build.ps1 already calls UpdateManifest.ps1 itself before invoking msbuild, which
is the call that works, so this was a duplicate of it that could only fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 6d23cf4 • 1 files, +39/-3
Pin down why the call window does not animate
It is the Windows SDK XAML projection, not the app. A CompositionTarget.Rendering
handler subscribed on a secondary view runs on the main view's thread: measured in
a throwaway app (subscribed on 5, ran on 4) and matched by the app's own log, which
pairs a presenter whose bitmaps were built on thread 23 with a DrawFrame on thread
4.
Microsoft.Windows.UI.Xaml.dll caches the statics in a plain static field and
carries no ThreadStatic or ThreadLocal anywhere, so the first view to touch
CompositionTarget owns it for the process and everyone else's add_Rendering
registers against that view's core. No CsWinRT property covers it.
That also explains what looked contradictory: the call's blob waves animate because
Composition objects are agile, while lottie and video die on WriteableBitmap, which
is not - RPC_E_WRONG_THREAD, swallowed by the XAML handler.
Three options written down, smallest first. Nothing changed in the app yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 87a294a • 2 files, +34/-12
1.56x in the app, and the Utf8JsonReader set stops being built
Two startups differing only in TdParsers: 87.4 MB/s through Utf8JsonReader
against 136.4 through TdJsonReader. The corpus says 2.4x for the same two
parsers on the same toolchain, so the app sees about two thirds of it.
The gap is everything the change does not touch. Solving for it puts the
reader-dependent work at 2.9 ms/MB and the shared part at 4.4 - 60% - which is
building the object graph and, mostly, a GC that promotes objects the app goes
on to hold, where a benchmark loop sweeps them at gen0. A corpus ratio is an
upper bound on what a parser change buys in situ.
Worth ~105 ms of TDLib-thread time on a startup that size, on top of the ~85 ms
the file-check deferral took off it.
So TdParsers goes to Pointer: 44,508 generated lines and ~500 hand-written ones
stop being compiled, and the app carries one parser. Reader walks it back in
one word - nothing was deleted, and Both is still there for the benchmark.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • b2ec787 • 1 files, +6/-0
Carrying both parsers costs compile time, not binary size
Three x64 bundles built the same way: Reader 77,228,975 bytes, Pointer
77,754,662, Both 77,749,472. Both and Pointer are within 5 KB, because ILC
strips the set nothing calls. The 526 KB between Reader and Pointer is the
pointer parsers being larger native code, the same 40% the line counts show.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 85ee4c2 • 3 files, +131/-19
Register CompositionTarget.Rendering per view
Verified by Fela: video and lottie both work in the call window again.
CsWinRT keeps one event source per statics object for the whole process, so a
second view's CompositionTarget.Rendering += never reaches add_Rendering - the
delegate is appended to the registration the first view made, and the handler runs
on that view's thread. Every secondary view's frame callback lands on the main
view's thread. Composition objects do not mind, being agile, which is why the call
blobs animated throughout; WriteableBitmap does mind, and DrawFrame died on
RPC_E_WRONG_THREAD.
CompositionTargetRendering registers through the ABI instead, so each view gets a
registration of its own. The statics object is a process-wide agile singleton -
measured, along with its IAgileObject - so the call runs on the calling thread and
registers there. Proved in a throwaway app first: in the same run, the projected
subscription fired on view 1's thread and the ABI one on view 2's.
Reported as https://github.com/microsoft/CsWinRT/issues/2524.
Also drops the marshalling branch added to RegisterRendering earlier, which was
aimed at the wrong cause and never once fired; the presenter's own _loader stays.
Left alone, and noted in the todo: CompositionVSync, VisualUtilities,
PremiumProgressBar, PremiumSlider and GiftCraftPopup still subscribe through the
projection. Same latent bug, invisible so far because they only touch Composition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 388e015 • 8 files, +114/-66
Alias CompositionTarget rather than #if every call site
Fela's idea, and a better one: CsWinRT.cs already keeps the global usings, so the
stand-in goes in there and the twenty call sites stay written the way they were.
#if NET9_0_OR_GREATER
global using CompositionTarget = Telegram.Common.CompositionTargetImpl;
#else
global using CompositionTarget = Windows.UI.Xaml.Media.CompositionTarget;
#endif
Aliasing in both directions pays for itself twice: .NET Native binds to the real
type as before, and the alias settles the ambiguity with
Windows.UI.Composition.CompositionTarget, which is the only reason those call sites
spelled the namespace out. They lose the prefix and a comment explaining it, so the
net change is shorter code than before the port.
That also carries the fix to everything else that draws per frame -
CompositionVSync, VisualUtilities, PremiumProgressBar, PremiumSlider,
GiftCraftPopup - which until now was running on the first view's thread in any
secondary window and getting away with it only because Composition objects are
agile.
Rendered goes through the ABI too, via ICompositionTargetStatics3. Forwarding it to
the projection, as it was a commit ago, left one honest half and one broken half in
a type that exists to fix exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Carrying both parsers costs compile time, not binary size
Three x64 bundles built the same way: Reader 77,228,975 bytes, Pointer
77,754,662, Both 77,749,472. Both and Pointer are within 5 KB, because ILC
strips the set nothing calls. The 526 KB between Reader and Pointer is the
pointer parsers being larger native code, the same 40% the line counts show.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 85ee4c2 • 3 files, +131/-19
Register CompositionTarget.Rendering per view
Verified by Fela: video and lottie both work in the call window again.
CsWinRT keeps one event source per statics object for the whole process, so a
second view's CompositionTarget.Rendering += never reaches add_Rendering - the
delegate is appended to the registration the first view made, and the handler runs
on that view's thread. Every secondary view's frame callback lands on the main
view's thread. Composition objects do not mind, being agile, which is why the call
blobs animated throughout; WriteableBitmap does mind, and DrawFrame died on
RPC_E_WRONG_THREAD.
CompositionTargetRendering registers through the ABI instead, so each view gets a
registration of its own. The statics object is a process-wide agile singleton -
measured, along with its IAgileObject - so the call runs on the calling thread and
registers there. Proved in a throwaway app first: in the same run, the projected
subscription fired on view 1's thread and the ABI one on view 2's.
Reported as https://github.com/microsoft/CsWinRT/issues/2524.
Also drops the marshalling branch added to RegisterRendering earlier, which was
aimed at the wrong cause and never once fired; the presenter's own _loader stays.
Left alone, and noted in the todo: CompositionVSync, VisualUtilities,
PremiumProgressBar, PremiumSlider and GiftCraftPopup still subscribe through the
projection. Same latent bug, invisible so far because they only touch Composition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • 388e015 • 8 files, +114/-66
Alias CompositionTarget rather than #if every call site
Fela's idea, and a better one: CsWinRT.cs already keeps the global usings, so the
stand-in goes in there and the twenty call sites stay written the way they were.
#if NET9_0_OR_GREATER
global using CompositionTarget = Telegram.Common.CompositionTargetImpl;
#else
global using CompositionTarget = Windows.UI.Xaml.Media.CompositionTarget;
#endif
Aliasing in both directions pays for itself twice: .NET Native binds to the real
type as before, and the alias settles the ambiguity with
Windows.UI.Composition.CompositionTarget, which is the only reason those call sites
spelled the namespace out. They lose the prefix and a comment explaining it, so the
net change is shorter code than before the port.
That also carries the fix to everything else that draws per frame -
CompositionVSync, VisualUtilities, PremiumProgressBar, PremiumSlider,
GiftCraftPopup - which until now was running on the first view's thread in any
secondary window and getting away with it only because Composition objects are
agile.
Rendered goes through the ABI too, via ICompositionTargetStatics3. Forwarding it to
the projection, as it was a commit ago, left one honest half and one broken half in
a type that exists to fix exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/develop • 068f474 • 1 files, +9/-4
Note that the alias covers every frame callback
The todo still said CompositionVSync and VisualUtilities were left on the broken
path; the alias in 6a2466fb3 took them with it, along with Rendered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • ea510bd • 2 files, +0/-41
Removed unused code
UnigramDev/Unigram/develop • 8de24ae • 15 files, +33/-281
Make the app AOT-clean
The whole trim surface of the app was five warnings in two files, and both were
dead weight.
TypeCrosserGenerator is deleted: nothing called it. Its one call site was
[Conditional("DEBUG")], so Release stripped the call but still compiled the body,
and ILC saw Assembly.GetTypes() over the TDLib types. Note that Conditional still
binds the name, so #if DEBUG around the class does not compile - the reference has
to go with it, and the Compile entry in Telegram.csproj too, which is not globbed.
TypeContainerGenerator has no call sites at all, so #if DEBUG is enough for it, and
there is now a todo to rewrite it as a source generator. Its GetConstructors and
GetProperties are the last reflection left in the app.
Fourteen classes that cross the WinRT ABI are marked partial, which is CsWinRT1028:
without it the marshalling code cannot be generated at build time and falls back to
reflection, which is what AOT trims away. Two of them are LottieGen output, so
regenerating those icons will drop the keyword again.
PublishAot moves into Telegram.Modern.csproj. Passed on the command line it is a
global property, so it flows into the ProjectReference and Telegram.Generators -
netstandard2.0 - fails with NETSDK1207.
Compile verified on the modern project; the ILC run to confirm the warning count is
still to come. .NET Native untouched by all of this bar the two deletions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
Note that the alias covers every frame callback
The todo still said CompositionVSync and VisualUtilities were left on the broken
path; the alias in 6a2466fb3 took them with it, along with Rendered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UnigramDev/Unigram/develop • ea510bd • 2 files, +0/-41
Removed unused code
UnigramDev/Unigram/develop • 8de24ae • 15 files, +33/-281
Make the app AOT-clean
The whole trim surface of the app was five warnings in two files, and both were
dead weight.
TypeCrosserGenerator is deleted: nothing called it. Its one call site was
[Conditional("DEBUG")], so Release stripped the call but still compiled the body,
and ILC saw Assembly.GetTypes() over the TDLib types. Note that Conditional still
binds the name, so #if DEBUG around the class does not compile - the reference has
to go with it, and the Compile entry in Telegram.csproj too, which is not globbed.
TypeContainerGenerator has no call sites at all, so #if DEBUG is enough for it, and
there is now a todo to rewrite it as a source generator. Its GetConstructors and
GetProperties are the last reflection left in the app.
Fourteen classes that cross the WinRT ABI are marked partial, which is CsWinRT1028:
without it the marshalling code cannot be generated at build time and falls back to
reflection, which is what AOT trims away. Two of them are LottieGen output, so
regenerating those icons will drop the keyword again.
PublishAot moves into Telegram.Modern.csproj. Passed on the command line it is a
global property, so it flows into the ProjectReference and Telegram.Generators -
netstandard2.0 - fails with NETSDK1207.
Compile verified on the modern project; the ILC run to confirm the warning count is
still to come. .NET Native untouched by all of this bar the two deletions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#unigram
UnigramDev/Unigram/formatted-text-block-dates • b6cc86e • 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-dates • 7b39d50 • 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-dates • ecaf440 • 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-dates • 7b39d50 • 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-dates • ecaf440 • 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-dates • 62ce609 • 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-dates • 1eb61cb • 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-dates • f35945b • 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-dates • 22a1d7a • 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-dates • 1eb61cb • 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-dates • f35945b • 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-dates • 22a1d7a • 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-dates • f07fe69 • 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-dates • fcde955 • 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-dates • bdd6431 • 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-dates • de73924 • 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-dates • fcde955 • 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-dates • bdd6431 • 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-dates • de73924 • 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-dates • b92d90f • 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-dates • fa1db16 • 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-dates • f3fd2c0 • 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>
UnigramDev/Unigram/formatted-text-block-dates • 76a6357 • 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
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-dates • fa1db16 • 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-dates • f3fd2c0 • 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>
UnigramDev/Unigram/formatted-text-block-dates • 76a6357 • 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
UnigramDev/Unigram/formatted-text-block-dates • e7e25f0 • 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 • a22e3b4 • 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>
UnigramDev/Unigram/formatted-text-block-dates • f51bf97 • 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>
#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 • a22e3b4 • 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>
UnigramDev/Unigram/formatted-text-block-dates • f51bf97 • 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>
#unigram
UnigramDev/Unigram/formatted-text-block-dates • 42d021b • 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>
UnigramDev/Unigram/formatted-text-block-dates • 090790a • 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>
UnigramDev/Unigram/formatted-text-block-dates • 98d5640 • 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
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>
UnigramDev/Unigram/formatted-text-block-dates • 090790a • 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>
UnigramDev/Unigram/formatted-text-block-dates • 98d5640 • 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