Telegram github commits and releases
4.54K subscribers
601 files
20.3K links
Broadcast from the most important Telegram clients' repositories
Download Telegram
UnigramDev/Unigram/develop068f4741 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/developea510bd2 files, +0/-41
Removed unused code

UnigramDev/Unigram/develop8de24ae15 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-datesb6cc86e3 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-dates7b39d502 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-datesecaf4407 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-dates62ce6092 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-dates1eb61cb2 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-datesf35945b2 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-dates22a1d7a2 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-datesf07fe692 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-datesfcde9552 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-datesbdd64312 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-datesde739242 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-datesb92d90f3 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-datesfa1db162 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-datesf3fd2c01 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-dates76a63571 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-datese7e25f02 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-datesa22e3b41 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-datesf51bf972 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-dates42d021b1 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-dates090790a2 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-dates98d56403 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
UnigramDev/Unigram/record-sessiona597f509 files, +1363/-1157
Give a recording an owner

The record button held four things: the gesture, the state of the recording, the
capture engine, and the decision of what to do with the result. The engine was a
thread-static singleton every loaded button subscribed to, so a recording started
in one chat drove the state of every other chat and story composer on the thread,
and QuantumProcessed was a single field that whichever button loaded last took
over and the first to unload set back to null.

ChatRecordSession now owns the state and the engine, one per button, which is one
per chat. What is left in the button is the press-and-hold gesture and its own
visuals.

Seven booleans and an integer interface state become three named states. The
fourth one - locked, then stopped - was only reachable through StopRecording(false),
which nothing ever called, so it went out along with the tri-state stop that
neither sent nor discarded what it recorded. Cancel and Complete say which they
are.

Elapsed comes off a monotonic clock rather than DateTime.Now, freezes while
paused, and starts when the capture device is actually open rather than when the
button was pressed. The pause state is reset when a recording ends instead of
leaking into the next one, and the bar detaches from the button on unload.

Permissions go through MediaDevicePermissions, which is what calls already use,
so granting access no longer costs the first press: the old check asked
DeviceAccessInformation and then returned false regardless, leaving the prompt as
the only thing that press did.

MediaCapture.Failed ends the recording instead of only logging it, and a press
too short to record now says so.

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

#unigram
UnigramDev/Unigram/swipe-to-go-backc4edf3c1 files, +13/-0
Record that the series landed and what is still open

Tested in the app against the message set the script sends, including the
relative date and spoiler cases that were visibly wrong beforehand. Three items
stay open and each one says under its own entry what it is waiting on.

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

UnigramDev/Unigram/swipe-to-go-back630b88310 files, +2/-2
Move the working notes into notes/

The reviews, to-dos and audits had grown to a dozen files sharing the root with
README, CHANGELOG and the rest. Only the two references that name a path needed
updating; the ones that merely name a file still read fine.

webrtc-uwp-review.md stays at the root for now - it has uncommitted edits that
are not mine to commit - as do the untracked notes.

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

UnigramDev/Unigram/swipe-to-go-back27791be2 files, +453/-3
Catch the collection marshalling errors at compile time

Every AOT failure so far but two has been a collection crossing the ABI with
no CCW vtable for its concrete type, and none of them warn: set_ItemsSource
returns E_INVALIDARG, which on a DispatcherQueue callback is a fail-fast, so
the log's tail is gone too. Two analyzer rules cover the class.

TG1001 is the boxed case - object, or a bindable interface with no element
type. TG1002 is an array of a value type that is not a WinRT fundamental,
which has to go through IReferenceArray. The line between them is whether the
compiler can see the conversion: a parameter typed IEnumerable<T> is one, and
CsWinRT generates the marshaller for it, which is why an array of a
runtimeclass reaches ConfigurePositionXInertiaModifiers intact.

Both are silent on .NET Native, where the attribute they suggest does not
exist and neither does the problem.

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

UnigramDev/Unigram/swipe-to-go-back1e20ebf3 files, +54/-6
Register what the analyzer found, and fix the array it found

The ItemsSource block was a grep sweep of mine, and two of its entries named
the wrong type: what those popups assign is the DiffObservableCollection, and
the List beside it is only the backing store. Replaced wholesale by TG1001's
output, so it can be regenerated rather than extended by hand.

StealthModeFeature has to be internal to be named in the attribute.

MessageForwardHeader is TG1002, and it is the forward-header crash the notes
record as fixed - GetParts returns a List now, but this one builds its own
array.

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

UnigramDev/Unigram/swipe-to-go-back3985a941 files, +4/-1
Log the exception, not where it was caught

Logger.Exception passed Environment.StackTrace, which only says where the
catch is - and the caller attribution already gives that. The exception's own
message and stack went nowhere.

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

#unigram
UnigramDev/Unigram/swipe-to-go-backb21e5bc3 files, +38/-11
Switch the modern projects between the two identities

ModernIdentity=Original carries the identity Telegram.csproj and
Telegram.Msix ship, which is what a beta for testers needs; Alternative, the
default, installs beside them. The property is in the generated manifest's
path as well as in the conditions, because the target is incremental on the
source manifest and flipping the property alone would reuse the old one.

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

UnigramDev/Unigram/swipe-to-go-backc825e6b2 files, +7/-0
Keep the ProgressBar cast the trimmer cannot see

Nothing in the app constructs a ProgressBar - both uses are
GetTemplateChild(...) as ProgressBar - so the metadata the cast needs is
trimmed and the field comes back null rather than throwing. VideoContent then
dereferenced it on every position change, once per frame inside a catch, and
again in Play_Click; PollOptionContent has the same shape on its vote bars.

CsWinRT1034 named both sites. I had concluded the cast-only set was empty and
was wrong: of the 117 types the warning covers, 67 are never constructed in
C#, and the ones reached only through GetTemplateChild are all in this
position.

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

UnigramDev/Unigram/swipe-to-go-back72303c110 files, +212/-57
Root every projected cast from the casts themselves

The RCW metadata a cast needs is only reachable through the cast, so trimming
takes it and `as` yields null instead of throwing. That is a null template
child and a feature that silently does nothing: ProgressBar cost a day of an
exception per frame, and ItemsPresenter the chat list's folder swipe, both
flagged by CsWinRT1034 all along among 1800 others.

CsWinRT gathers every [DynamicWindowsRuntimeCast] into one module initializer
of DynamicDependency attributes, so the attribute's placement never mattered -
only the type. Emitting those attributes from a generator is no use, since
generators all see the original compilation; this emits the initializer
itself, from the as/is/cast expressions. 222 types against the 116 CsWinRT
warns about, because it also covers patterns and casts it can prove nothing
about.

Nothing declares this by hand any more: the six annotated methods and the
.NET Native dummy attribute are gone. The generator emits nothing when
ModuleInitializer or DynamicDependency do not resolve, which is that build.

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

#unigram
UnigramDev/Unigram/swipe-to-go-back16a26bd7 files, +408/-9
Pull a back chip in from the edge of the detail

A left-to-right swipe over the detail pane brings in a circular back chip and,
past 72px, navigates back. Only the chip moves: revealing the master would mean
measuring the whole chat list at the moment the finger starts moving, since it is
collapsed while a chat is open, and a Frame navigation cannot be scrubbed anyway.

MessageSelector already owns a horizontal tracker over every bubble, and it wins
the manipulation: its source claims the contact first and chaining is off. So the
MasterDetailView does not compete for it. It owns the chip and binds it, by
expression, to whichever tracker is driving - MessageSelector's over a bubble, its
own everywhere else. Expressions may reference a tracker from anywhere in the
compositor, so nothing runs per frame on the UI thread.

The bubble already stays still under such a swipe: its offset expression reads
CanShare, which is false whenever back has the direction. Only MinPosition was
holding it at zero.

Back takes the direction Share is not using, so a user who wants it everywhere
turns Share off, and no setting is ever on while doing nothing.

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

UnigramDev/Unigram/swipe-to-go-back5c8d8da4 files, +85/-17
Make the back chip harder to miss

Fela asked for something catchier. It is 40px rather than 30 and travels the
full width of its own diameter, it swings upright from -30 as it comes in, and
past the threshold it latches into an armed state - scale pops to 1.15 and
opacity goes to full - so releasing feels committed rather than guessed at.

The tracker is clamped at the threshold, so progress rests at exactly 1 for as
long as the gesture is past it. Testing that rather than a distance means the
armed state latches on and stays on, instead of flickering at the boundary.

Still one expression per property, so none of this costs a frame.

The fill colour is now refreshed per gesture. The chip is built once and lives
for the window, so it kept whatever accent it was born under across a theme
change.

Carries the generated Resources.cs and .resw for the SwipeGoBack string added
in the previous commit.

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

#unigram
UnigramDev/Unigram/swipe-to-go-backc4e3c8c2 files, +84/-31
Carry the gesture inside the page's scrolling host

On settings pages the chip only appeared in the title strip and nowhere in the
page itself. A vertical ScrollViewer takes the touchpad pan before any ancestor
sees it, and the source lives on DetailRoot, which is an ancestor of the page -
so the header, sitting outside the scroller, was the only place left.

The chat history was never evidence to the contrary: what works there is
MessageSelector's source, which is a descendant of the scroller and so wins the
contact. This does the same thing deliberately, putting a second source on the
scrolling host's content element and adding it to the same tracker. Rails and an
X-only source mode leave vertical panning to the ScrollViewer, as they already
do in the chat history.

Only ScrollViewer hosts. A ListViewBase one would need its ItemsPanelRoot, which
does not exist until the list realises, and the list that matters is the chat
history.

The old source is dropped on the way in rather than on the way out, because
OnNavigating is never subscribed.

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

UnigramDev/Unigram/swipe-to-go-back17d603c2 files, +68/-5
Reach the gesture to the foot of a short page, and slide back from the left

Moving the source inside the scrolling host fixed only the long pages. Privacy
and Security worked; Power Saving and Advanced did not, and those are the two
shortest settings pages in the app. A ScrollViewer arranges content shorter than
the viewport at its desired height, so the source reached the rows and stopped -
everything below the last one is bare scroller, which takes the pan.

The source has to sit inside the scrolled content, since that is the only thing
that beats the scroller to the contact. So the content is what has to cover the
viewport: its MinHeight now comes from the host's ViewportHeight, kept current on
SizeChanged. Nothing else moves, because the extent still matches the viewport
and a short page stays unscrollable. ViewportHeight is 0 at OnNavigated, so
SizeChanged is where the height first lands rather than only where it is kept.

Committing the gesture now navigates with a FromLeft slide, mirroring the
FromRight that TLNavigationService uses going forward - the default entrance
transition reads as unrelated to the finger that asked for it. Gated on the power
saving policy, which FrameFacade applies to Navigate but not to GoBack, so
nothing else would have stopped it animating.

Drops a comment that claimed this route lets an INavigablePage answer for
itself. It does not: NavigationService.GoBack goes straight to the frame and
never reaches OnBackRequested. That matches the visible back button, which takes
the same route.

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

#unigram
UnigramDev/Unigram/swipe-to-go-back2cd789f2 files, +54/-17
Give a short settings page an area that hit-tests

SettingsStickersPage works, and its ScrollingHost is a TableListView, so none of
the scrolling-host code runs for it at all. An ancestor source does get the pan
there, which means the scroller was never the whole story.

The discriminator is hit-testing. SettingsPanel is a bare Panel, and a Panel with
a null Background does not hit-test its own area. Its rows hit-test themselves,
which is why the gesture always worked on a page long enough to be all rows and
nowhere on the empty part of a short one - and why Stickers works from the other
direction, its ListViewItems being real content. With no hit there is no element
to walk up from and no source is ever found.

So the height added in the last commit bought nothing on its own. It needs a
Background to go with it, and Transparent paints the same as null.

Also flips the transition to FromRight. The frame inverts the effect on a back
navigation, so FromRight is what slides the uncovered page in from the left, and
it is the same value TLNavigationService passes going forward.

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

UnigramDev/Unigram/swipe-to-go-back73c6a752 files, +46/-96
Take back the two fixes for a cause that was not the cause

Advanced and Power Saving fail over text and buttons, not only over empty space,
so neither the content falling short of the viewport nor a bare Panel declining
to hit-test its own area can be what is stopping them. Both were guesses, both
mutated pages this control does not own, and neither moved the symptom.

The scroller theory that started the sequence is doubtful too: SettingsStickers
works, and its host is a TableListView that ConfigureBackGestureContent skips
altogether, so an ancestor source is evidently enough somewhere. The source it
adds is left in place but is not to be treated as load-bearing.

What is left is a page whose XAML matches, element for element, pages that work.
The notes record what has been ruled out so the next attempt starts from
evidence rather than from another reading of the markup.

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

UnigramDev/Unigram/swipe-to-go-back3bc5ef72 files, +47/-16
Keep the scroller manipulable so the pan stays a manipulation

Advanced and Power Saving take the gesture once the window is small enough for
their content to overflow, and refuse it at any size where it fits. That is the
whole of it.

ScrollMode.Auto turns the manipulation off when there is nothing to scroll, and a
touchpad pan over an element that cannot be manipulated is never classified as
one - so no interaction source is consulted anywhere up the tree and the gesture
is absent rather than misrouted, which is why no chip ever appeared.

Every settings page declares VerticalScrollMode="Auto". The difference between
the ones that worked and the ones that did not was never the markup, which is
identical, but whether the content happened to overflow at the size the window
was. Enabled holds the manipulation open regardless of extent, and costs nothing
here: there is still nothing to scroll, so nothing becomes scrollable that was
not already.

The declared value is restored when the page is left.

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

#unigram
UnigramDev/Unigram/swipe-to-go-back661bff21 files, +6/-2
Record that the gesture is working on a build

Confirmed across the chat history, profile and settings, including the pages that
refused it while their content fitted the window.

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

UnigramDev/Unigram/swipe-to-go-backa57c7722 files, +100/-45
Draw the back indicator out of the edge instead of sliding it in

A circle arriving over the page was the chat list's vocabulary borrowed wholesale,
and it read as stock even after it was made larger and given an armed state.

It is a pill now, and the edge is where it comes from rather than somewhere it
passes. It starts as an 18px sliver whose right edge sits exactly on x=0, so
there is nothing to see until the finger moves, and it lengthens as it is pulled.
Height grows with it, 40 to 48, so the shape reads as elastic rather than as
something arriving at a fixed size. The corner radius stays at half the height,
so it is a true capsule the whole way and lands on a circle exactly when width
and height meet at the threshold - which is also the moment its left edge clears
the boundary and it detaches from what it grew out of. That detach is the armed
state, with a scale pop and full opacity behind it.

The arrow waits until the pill is long enough to hold it, then fades in and
swings upright.

All of it is one expression per property, the geometry's Size and CornerRadius
included, so nothing here costs a frame on the UI thread.

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

UnigramDev/Unigram/swipe-to-go-back26fd71b5 files, +122/-136
Put the chat list's indicator back, and frost all three

The pill was worse than what it replaced. This is ChatListListView's indicator on
its own numbers - in from the edge over 55px, growing 0.8 to 1, fading the whole
way - mirrored on X, since ArrowLeft.png is drawn for the right-hand edge and the
chat list flips it the same way for the one it brings in from the left.

The fill is the one thing that is new. The circle is a SpriteVisual painted with
SolidGaussianBrush, so it is frosted over whatever sits behind it rather than a
flat 40%-alpha grey, and the same goes for the ChatListListView and
MessageSelector indicators, which are the same 30px circle.

A shape fill cannot carry an effect brush - CompositionSpriteShape takes colour
and gradient brushes only - so the circle becomes a mask over the effect instead,
drawn once through a CompositionVisualSurface. That keeps the antialiasing a
geometric clip would have thrown away. The mask is applied either way, so where
materials are switched off the fallback is still a circle and not a square.

SolidGaussianBrush keeps its XAML behaviour and now builds its graph through a
static factory the three call sites share.

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

#unigram
UnigramDev/Unigram/develop4daed162 files, +19/-6
Fix layout cycle

UnigramDev/Unigram/developa380fa41 files, +0/-7
Remove dead method

UnigramDev/Unigram/developfd5f90c6 files, +929/-141
Encode voice messages while they are recorded

A voice message was captured twice and encoded three times: a MediaFrameReader
already held every sample in memory to drive the blob, while LowLagMediaRecording
wrote the same audio to a WAV that was read back and re-encoded to Opus when the
message was sent.

OpusOutput.WriteFrame has been there the whole time and had no caller. The reader
now feeds it directly, so the file that lands on disk is already the file that
gets uploaded, and the duration is counted from the samples rather than taken
from MediaCaptureStopResult and truncated to a whole second.

The encoder is 48kHz mono and a frame source hands over whatever the endpoint
runs at, so this only applies when the format fits, or can be set to fit. It
falls back to the old path otherwise, because playing back at the wrong speed is
worse than transcoding.

Waveforms are sent now. They were computed and then dropped for Array.Empty,
which hid a bug in the accumulator: the bucket peak was never reset, so every
bucket held the running maximum of the whole recording and the waveform could
only climb.

The reader also had to stop dropping frames. Realtime acquisition and a 64ms
gate that returned before touching the buffer were fine for a blob and would
have cut holes in a recording, so it is Buffered now and only the level
notification is rate-limited.

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

UnigramDev/Unigram/developdba8a191 files, +11/-6
Never renegotiate the capture format for a video message

The blob is animated for voice and video alike, so the frame reader runs in both
modes, and the format negotiation added with the sink was running in both too.

A video message records through MediaCapture. Changing the format of the audio
source it is about to record from, to suit an encoder that isn't involved, would
change what lands in the mp4 for the sake of a level meter.

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

#unigram
🫡1
UnigramDev/Unigram/record-session8c055846 files, +929/-141
Encode voice messages while they are recorded

A voice message was captured twice and encoded three times: a MediaFrameReader
already held every sample in memory to drive the blob, while LowLagMediaRecording
wrote the same audio to a WAV that was read back and re-encoded to Opus when the
message was sent.

OpusOutput.WriteFrame has been there the whole time and had no caller. The reader
now feeds it directly, so the file that lands on disk is already the file that
gets uploaded, and the duration is counted from the samples rather than taken
from MediaCaptureStopResult and truncated to a whole second.

The encoder is 48kHz mono and a frame source hands over whatever the endpoint
runs at, so this only applies when the format fits, or can be set to fit. It
falls back to the old path otherwise, because playing back at the wrong speed is
worse than transcoding.

Waveforms are sent now. They were computed and then dropped for Array.Empty,
which hid a bug in the accumulator: the bucket peak was never reset, so every
bucket held the running maximum of the whole recording and the waveform could
only climb.

The reader also had to stop dropping frames. Realtime acquisition and a 64ms
gate that returned before touching the buffer were fine for a blob and would
have cut holes in a recording, so it is Buffered now and only the level
notification is rate-limited.

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

UnigramDev/Unigram/record-sessionb62a4411 files, +11/-6
Never renegotiate the capture format for a video message

The blob is animated for voice and video alike, so the frame reader runs in both
modes, and the format negotiation added with the sink was running in both too.

A video message records through MediaCapture. Changing the format of the audio
source it is about to record from, to suit an encoder that isn't involved, would
change what lands in the mp4 for the sake of a level meter.

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

#unigram
UnigramDev/Unigram/record-session16ec88e9 files, +1363/-1157
Give a recording an owner

The record button held four things: the gesture, the state of the recording, the
capture engine, and the decision of what to do with the result. The engine was a
thread-static singleton every loaded button subscribed to, so a recording started
in one chat drove the state of every other chat and story composer on the thread,
and QuantumProcessed was a single field that whichever button loaded last took
over and the first to unload set back to null.

ChatRecordSession now owns the state and the engine, one per button, which is one
per chat. What is left in the button is the press-and-hold gesture and its own
visuals.

Seven booleans and an integer interface state become three named states. The
fourth one - locked, then stopped - was only reachable through StopRecording(false),
which nothing ever called, so it went out along with the tri-state stop that
neither sent nor discarded what it recorded. Cancel and Complete say which they
are.

Elapsed comes off a monotonic clock rather than DateTime.Now, freezes while
paused, and starts when the capture device is actually open rather than when the
button was pressed. The pause state is reset when a recording ends instead of
leaking into the next one, and the bar detaches from the button on unload.

Permissions go through MediaDevicePermissions, which is what calls already use,
so granting access no longer costs the first press: the old check asked
DeviceAccessInformation and then returned false regardless, leaving the prompt as
the only thing that press did.

MediaCapture.Failed ends the recording instead of only logging it, and a press
too short to record now says so.

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

#unigram
🫡1
UnigramDev/Unigram/swipe-to-go-back092e30f7 files, +408/-9
Pull a back chip in from the edge of the detail

A left-to-right swipe over the detail pane brings in a circular back chip and,
past 72px, navigates back. Only the chip moves: revealing the master would mean
measuring the whole chat list at the moment the finger starts moving, since it is
collapsed while a chat is open, and a Frame navigation cannot be scrubbed anyway.

MessageSelector already owns a horizontal tracker over every bubble, and it wins
the manipulation: its source claims the contact first and chaining is off. So the
MasterDetailView does not compete for it. It owns the chip and binds it, by
expression, to whichever tracker is driving - MessageSelector's over a bubble, its
own everywhere else. Expressions may reference a tracker from anywhere in the
compositor, so nothing runs per frame on the UI thread.

The bubble already stays still under such a swipe: its offset expression reads
CanShare, which is false whenever back has the direction. Only MinPosition was
holding it at zero.

Back takes the direction Share is not using, so a user who wants it everywhere
turns Share off, and no setting is ever on while doing nothing.

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

UnigramDev/Unigram/swipe-to-go-backa5773c04 files, +85/-17
Make the back chip harder to miss

Fela asked for something catchier. It is 40px rather than 30 and travels the
full width of its own diameter, it swings upright from -30 as it comes in, and
past the threshold it latches into an armed state - scale pops to 1.15 and
opacity goes to full - so releasing feels committed rather than guessed at.

The tracker is clamped at the threshold, so progress rests at exactly 1 for as
long as the gesture is past it. Testing that rather than a distance means the
armed state latches on and stays on, instead of flickering at the boundary.

Still one expression per property, so none of this costs a frame.

The fill colour is now refreshed per gesture. The chip is built once and lives
for the window, so it kept whatever accent it was born under across a theme
change.

Carries the generated Resources.cs and .resw for the SwipeGoBack string added
in the previous commit.

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

#unigram
🫡1
UnigramDev/Unigram/swipe-to-go-back87983ca2 files, +84/-31
Carry the gesture inside the page's scrolling host

On settings pages the chip only appeared in the title strip and nowhere in the
page itself. A vertical ScrollViewer takes the touchpad pan before any ancestor
sees it, and the source lives on DetailRoot, which is an ancestor of the page -
so the header, sitting outside the scroller, was the only place left.

The chat history was never evidence to the contrary: what works there is
MessageSelector's source, which is a descendant of the scroller and so wins the
contact. This does the same thing deliberately, putting a second source on the
scrolling host's content element and adding it to the same tracker. Rails and an
X-only source mode leave vertical panning to the ScrollViewer, as they already
do in the chat history.

Only ScrollViewer hosts. A ListViewBase one would need its ItemsPanelRoot, which
does not exist until the list realises, and the list that matters is the chat
history.

The old source is dropped on the way in rather than on the way out, because
OnNavigating is never subscribed.

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

UnigramDev/Unigram/swipe-to-go-backe55bf3d2 files, +68/-5
Reach the gesture to the foot of a short page, and slide back from the left

Moving the source inside the scrolling host fixed only the long pages. Privacy
and Security worked; Power Saving and Advanced did not, and those are the two
shortest settings pages in the app. A ScrollViewer arranges content shorter than
the viewport at its desired height, so the source reached the rows and stopped -
everything below the last one is bare scroller, which takes the pan.

The source has to sit inside the scrolled content, since that is the only thing
that beats the scroller to the contact. So the content is what has to cover the
viewport: its MinHeight now comes from the host's ViewportHeight, kept current on
SizeChanged. Nothing else moves, because the extent still matches the viewport
and a short page stays unscrollable. ViewportHeight is 0 at OnNavigated, so
SizeChanged is where the height first lands rather than only where it is kept.

Committing the gesture now navigates with a FromLeft slide, mirroring the
FromRight that TLNavigationService uses going forward - the default entrance
transition reads as unrelated to the finger that asked for it. Gated on the power
saving policy, which FrameFacade applies to Navigate but not to GoBack, so
nothing else would have stopped it animating.

Drops a comment that claimed this route lets an INavigablePage answer for
itself. It does not: NavigationService.GoBack goes straight to the frame and
never reaches OnBackRequested. That matches the visible back button, which takes
the same route.

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

#unigram
UnigramDev/Unigram/swipe-to-go-backc177d662 files, +54/-17
Give a short settings page an area that hit-tests

SettingsStickersPage works, and its ScrollingHost is a TableListView, so none of
the scrolling-host code runs for it at all. An ancestor source does get the pan
there, which means the scroller was never the whole story.

The discriminator is hit-testing. SettingsPanel is a bare Panel, and a Panel with
a null Background does not hit-test its own area. Its rows hit-test themselves,
which is why the gesture always worked on a page long enough to be all rows and
nowhere on the empty part of a short one - and why Stickers works from the other
direction, its ListViewItems being real content. With no hit there is no element
to walk up from and no source is ever found.

So the height added in the last commit bought nothing on its own. It needs a
Background to go with it, and Transparent paints the same as null.

Also flips the transition to FromRight. The frame inverts the effect on a back
navigation, so FromRight is what slides the uncovered page in from the left, and
it is the same value TLNavigationService passes going forward.

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

UnigramDev/Unigram/swipe-to-go-backa58dc5d2 files, +46/-96
Take back the two fixes for a cause that was not the cause

Advanced and Power Saving fail over text and buttons, not only over empty space,
so neither the content falling short of the viewport nor a bare Panel declining
to hit-test its own area can be what is stopping them. Both were guesses, both
mutated pages this control does not own, and neither moved the symptom.

The scroller theory that started the sequence is doubtful too: SettingsStickers
works, and its host is a TableListView that ConfigureBackGestureContent skips
altogether, so an ancestor source is evidently enough somewhere. The source it
adds is left in place but is not to be treated as load-bearing.

What is left is a page whose XAML matches, element for element, pages that work.
The notes record what has been ruled out so the next attempt starts from
evidence rather than from another reading of the markup.

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

UnigramDev/Unigram/swipe-to-go-backc4792232 files, +47/-16
Keep the scroller manipulable so the pan stays a manipulation

Advanced and Power Saving take the gesture once the window is small enough for
their content to overflow, and refuse it at any size where it fits. That is the
whole of it.

ScrollMode.Auto turns the manipulation off when there is nothing to scroll, and a
touchpad pan over an element that cannot be manipulated is never classified as
one - so no interaction source is consulted anywhere up the tree and the gesture
is absent rather than misrouted, which is why no chip ever appeared.

Every settings page declares VerticalScrollMode="Auto". The difference between
the ones that worked and the ones that did not was never the markup, which is
identical, but whether the content happened to overflow at the size the window
was. Enabled holds the manipulation open regardless of extent, and costs nothing
here: there is still nothing to scroll, so nothing becomes scrollable that was
not already.

The declared value is restored when the page is left.

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

#unigram
🫡1
UnigramDev/Unigram/swipe-to-go-back71e3f2e1 files, +6/-2
Record that the gesture is working on a build

Confirmed across the chat history, profile and settings, including the pages that
refused it while their content fitted the window.

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

UnigramDev/Unigram/swipe-to-go-backe1c3efe2 files, +100/-45
Draw the back indicator out of the edge instead of sliding it in

A circle arriving over the page was the chat list's vocabulary borrowed wholesale,
and it read as stock even after it was made larger and given an armed state.

It is a pill now, and the edge is where it comes from rather than somewhere it
passes. It starts as an 18px sliver whose right edge sits exactly on x=0, so
there is nothing to see until the finger moves, and it lengthens as it is pulled.
Height grows with it, 40 to 48, so the shape reads as elastic rather than as
something arriving at a fixed size. The corner radius stays at half the height,
so it is a true capsule the whole way and lands on a circle exactly when width
and height meet at the threshold - which is also the moment its left edge clears
the boundary and it detaches from what it grew out of. That detach is the armed
state, with a scale pop and full opacity behind it.

The arrow waits until the pill is long enough to hold it, then fades in and
swings upright.

All of it is one expression per property, the geometry's Size and CornerRadius
included, so nothing here costs a frame on the UI thread.

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

UnigramDev/Unigram/swipe-to-go-back804db3b5 files, +122/-136
Put the chat list's indicator back, and frost all three

The pill was worse than what it replaced. This is ChatListListView's indicator on
its own numbers - in from the edge over 55px, growing 0.8 to 1, fading the whole
way - mirrored on X, since ArrowLeft.png is drawn for the right-hand edge and the
chat list flips it the same way for the one it brings in from the left.

The fill is the one thing that is new. The circle is a SpriteVisual painted with
SolidGaussianBrush, so it is frosted over whatever sits behind it rather than a
flat 40%-alpha grey, and the same goes for the ChatListListView and
MessageSelector indicators, which are the same 30px circle.

A shape fill cannot carry an effect brush - CompositionSpriteShape takes colour
and gradient brushes only - so the circle becomes a mask over the effect instead,
drawn once through a CompositionVisualSurface. That keeps the antialiasing a
geometric clip would have thrown away. The mask is applied either way, so where
materials are switched off the fallback is still a circle and not a square.

SolidGaussianBrush keeps its XAML behaviour and now builds its graph through a
static factory the three call sites share.

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

#unigram