notes/sendfiles-popup-todo.md
Findings from a read-through of Telegram/Views/Popups/SendFilesPopup.xaml{,.cs}, the five
Telegram/Entities/Storage*.cs entities, both album-grouping implementations, and the entry
points that reach the popup (ComposeViewModel.SendFileExecute, DialogViewModel.HandlePackageAsync,
SendMessagesView, Extensions.PickSingleMediaAsync).
Tasks are ordered so each one can land and be reviewed on its own. Check an item off in the same commit as its fix.
1.1 StorageMedia._preview is assigned in four places and released in none. Nothing
ever sets it back to null, so a decoded thumbnail lives as long as the model does — and the
model outlives the popup, because ComposeViewModel.SendFileExecute holds the items list
across the whole send loop.
Per item, never reclaimed:
BitmapImage at DecodePixelWidth = 300 logical, so ~450px at 150% scale ≈ 1 MBWriteableBitmap at 600 min-side ≈ 2 MB, held as a live pixel bufferSoftwareBitmapSource at 60040 dropped videos ≈ 75 MB retained past the popup.
The only consumer is the ImageBrush in MediaItemTemplate (SendFilesPopup.xaml), reached
only through StorageAlbumPanel — so the move is self-contained.
1.2 Thundering herd: Refresh() is async void fired from the Preview getter with no
in-flight guard. _preview stays null across the await, so every getter hit before it completes
starts another decode of the same file. StorageAlbumPanel.UpdateMessage clears and rebuilds
every button on each UpdatePanel(), re-evaluating every x:Bind Preview.
1.3 No cancellation. Closing the popup mid-decode leaves every outstanding decode running.
How it landed. Preview/Refresh/RefreshAsync are gone from StorageMedia, along with the
StorageVideo.Refresh override — the LoadPreview() it re-ran after a crop only feeds the
compression fields that Task 5 shows are dead, and the constructor still runs it. The decode moved
verbatim into StorageThumbnailCache at the foot of SendFilesPopup.xaml.cs, which keys two
dictionaries off the model: the decoded source, and the in-flight Task that coalesces concurrent
requests (1.2). The ImageBrush in MediaItemTemplate no longer binds; UpdateTemplate pushes into
it, and LoadThumbnail re-checks root.DataContext before applying a late result.
Eviction has two triggers: OnContainerContentChanging with InRecycleQueue nulls the brushes and
drops the album's entries, and OnUnloaded clears everything. A decode that completes after either
one is dropped rather than cached — DecodeAsync only writes to the cache if its own _inflight
entry is still there, which is also what stops a pre-crop image from landing after Invalidate.
Caveat on 1.3: no decode already under way is stopped, so only the retention is fixed, not the CPU. See the cancellation survey below for what it would take — the first estimate here was too pessimistic.
Two levers already exist and neither is used.
.AsTask(token) covers every WinRT stage: StorageFile.OpenReadAsync,
BitmapImage.SetSourceAsync, BitmapDecoder.CreateAsync, GetSoftwareBitmapAsync,
SoftwareBitmapSource.SetBitmapAsync. That is the whole photo path and the whole cropped-photo
path. The repo calls AsTask() in three places and never with a token.
VideoAnimation.Stop() is projected to C# (VideoAnimation.idl) and the stopped flag it sets
is already checked in all three hot spots — readCallback, seekCallback, and the
while (!stopped && triesCount > 0) loop in RenderSync (VideoAnimation.cpp). Nothing in the app
has ever called it. ImageHelper's two video branches build the animation inside a Task.Run and
never expose it, so reaching Stop() is a C#-side restructure, not a native one.
The one genuinely uncancellable stage is the probe. VideoAnimation::LoadFromFile constructs the
instance and only then runs avformat_open_input and avformat_find_stream_info, so the caller has
no handle while the expensive part runs. The fix is fmt_ctx->interrupt_callback pointed at the same
stopped flag before avformat_open_input; ffmpeg polls it through open, probe, read and seek.
That one needs a Telegram.Native rebuild.
Two latent bugs in the existing Stop() path, never exercised because nothing calls it:
stopped is a plain bool, written by Stop() without taking m_lock and read from
the decode thread. Should be std::atomic<bool>. Now std::atomic<bool>; every use is a plain
load or store, so only the declaration changed. Stop() still does not take m_lock, and that is
the point — taking it would block until the decode it is trying to interrupt had finished.readCallback returns 0 when stopped rather than AVERROR_EOF. ffmpeg reads 0 as
"no bytes this call" and can spin instead of aborting. Fixed, and seekCallback had the same
shape: returning 0 there reports a successful seek to offset 0, sending the demuxer back to the
start rather than letting it fail out.Not built. Telegram.Native needs the vcpkg ffmpeg setup, so these three edits are unverified
by a compiler. std::atomic<bool> needs <atomic>, which is now included.
One thing left alone next door: in seekCallback's non-stopped path, return moved ? offset : 0;
reports a successful seek to 0 when SetFilePointerEx fails. Same class of bug, but on the live
path rather than the abort path, so changing it belongs with someone who can run a video.
Cost × rate says this does not pay for thumbnails alone — closing the popup mid-decode is rare. It
pays as part of Task 2, whose cancel affordance needs the same plumbing, and whose probe pipeline is
what justifies the native interrupt_callback work.
2.1 StorageMedia.CreateAsync(IEnumerable) is a serial foreach, and every iteration is
expensive: a GetBasicPropertiesAsync RPC per file, plus BitmapDecoder.CreateAsync for photos
and a full VideoAnimation.LoadFromFile (ffmpeg open + probe) for video and audio. All of it is
awaited before new SendFilesPopup is reached, with no feedback and no cancel.
2.2 The whole loop sits in one try/catch, so a single throwing file silently discards every remaining file in the drop. Needs to be per-file.
2.3 Each media item is opened and probed twice: StoragePhoto.CreateAsync decodes for
dimensions and the preview opens the file again; StorageVideo.CreateAsync builds a
VideoAnimation for dimensions and GetPreviewBitmapAsync builds a second one for one frame.
True, and closed as won't-fix: every way of collapsing it undoes Task 1.
The two opens are not the same work at the same time. The probe answers what is this and how
big, and it has to answer before the item is published, because the mosaic cannot lay out
without dimensions and the type decides whether the item is a photo at all — StoragePhoto.CreateAsync
returning null is how a mis-named file becomes a StorageDocument. The thumbnail answers what
does it look like, on realization, and must stay releasable.
Fusing them means one of:
VideoAnimation open between the two — a file handle, a codec context and a 64 KB
IO buffer per video, for the life of the popup;It is also cheaper than "twice" suggests, in both cases, because the probe is already the light
half. VideoAnimation.LoadFromFile takes preview and probe flags that exist for exactly this:
preview sets AVFMT_FLAG_NOBUFFER so nothing is buffered, and probe skips the frame and packet
allocation and the "no video stream" bail. StorageVideo.CreateAsync and StorageAudio.CreateAsync
both ask for (preview: true, probe: true) — headers only. The photo probe is the same shape:
BitmapDecoder.CreateAsync reads a header, it does not decode.
So what runs twice is a header read plus a real decode, not two decodes — which is about as good as a lazy, releasable thumbnail gets.
If it ever needs revisiting: render the frame once during the probe and keep it encoded (a 600px JPEG is ~50 KB against ~2 MB decoded), letting the thumbnail cache decode from that blob without touching ffmpeg again. It is not a clear win: it renders a frame for every video including the ones never scrolled to, so it is worse exactly where the current design is best — a large drop where most items are never realized. Needs a measurement, not a refactor.
ImageHelper asking for preview: false while passing preview: true to RenderSync is not an
oversight: unbuffered often fails to grab the first frame, which is the whole point there. The
flag is right for a header-only probe and wrong for a frame grab. Now commented at all three
call sites, since it is exactly the kind of thing someone "tidies up" later.
2.4 Pipeline the probes with bounded concurrency.
2.5 Open the popup first and append items as they resolve. No placeholder rows — Fela's call: in the vast majority of cases probing is instant, so a row that exists only to be replaced buys nothing and costs the popup having to tolerate a not-yet-typed item everywhere.
2.6 Add_Click and the popup's own HandlePackageAsync still call the blocking
CreateAsync, so dropping onto an already-open popup stalls exactly the way the initial drop used
to. They need the same pipeline, but Probe is single-shot and owns _probeCount, so it has to
be generalised for a second batch first.
Generalised by splitting the two jobs the one counter was doing. _allocated is the next free
slot in the index space and only grows, so a batch appended later lands behind everything already
picked and the contiguous-run logic keeps working across batches. _expected is what the title
claims while items are in flight, and drops to zero when nothing is. Batches can overlap — files
dropped while the first batch is still typing — so _isLoading became a count.
LoadAsync takes one initial flag rather than a pile of behaviour switches: only the batch the
popup opened for runs the caller's guard and only that one closes the popup when it comes back
empty. An appended batch is deliberately no more checked than it was before it streamed, because
rejecting one late arrival would otherwise close a popup with a composed caption in it — see 6.6.
The editing branch of HandlePackageAsync still types its one file inline: it replaces a single
message, so there is nothing to stream.
2.8 The batch CreateAsync(IEnumerable) overload was still the serial loop, and
SendMessagesView — the share target — had become its only remaining caller, so sharing many
files probed them one at a time before anything was sent. It is now an ordered wrapper over
ProbeAsync: concurrent, original order preserved, failures omitted. Same contract, no call site
changed.
Streaming does not apply there and was not attempted: the view is an AnimatedImage and a
ProgressBar swapped in after the chats are picked, so there is no list to append into. Order
also has to survive, since the caption is attached positionally and GetItemsView needs the whole
set before it can group.
Its second stage is untouched: Initialize still builds each InputMessageContent serially
through MessageFactory, which for a large share is plausibly the bigger delay. Parallelising
that touches TDLib generation, ordering and resource pressure at once, so it wants to be its own
change.
How it landed. StorageMedia.ProbeAsync types files concurrently — capped at
Math.Clamp(Environment.ProcessorCount, 2, 8) so a large drop cannot open hundreds of decoders —
and reports each result through a callback as it lands, with the file's original index. Callbacks
arrive on the UI thread, since every await in the chain captures the caller's context.
ComposeViewModel.SendFileExecute no longer probes. Both overloads now funnel into SendFilesAsync,
which takes either already-typed items or raw files; with files it builds the popup empty and starts
Probe from the Loaded handler. That hook matters: OpenAsync queues behind any other dialog and
only creates its closing task once it reaches the front, so a Hide from a probe result that
resolved earlier would have had nothing to close.
Results are buffered and flushed on a low-priority dispatch, so everything resolving within one UI
turn is appended by a single AddRange — one CollectionChanged, one UpdateView, one
UpdatePanel. Each batch is sorted by original index, so the picked order survives whenever
probing is fast enough to land in one flush; across batches items appear as they resolve.
The guard moved rather than disappeared. SendFilesAsync keeps one Validating function holding
the original messages: run as a loop up front for already-typed items, handed to the popup as a
callback for probed ones. The first failure cancels probing and closes the popup, and the error is
raised after OpenAsync returns — which is also where the caption is already restored.
Three smaller consequences:
TitleText counts Math.Max(Items.Count, _probeCount) while probing, so it shows the drop's real
size instead of ticking up, and stays on the Files declension until types are known.UpdateView settles it
as the first items arrive — until the user picks a mode themselves, which wins from then on.Accept returns early, since sending half a drop would
silently discard the rest.Known edge: if every file fails to probe, the popup appears briefly and then closes itself, where before it never appeared. The alternative is waiting for the first result, which is the stall this task removed.
The first cut left the popup with a three-step construction protocol — build with an empty list, set
Validating, call Probe from the caller's Loaded — driven by a SendFilesAsync(items, files, …)
null-pair. Miss a step and you get a silently empty popup or an unguarded send, with nothing
enforcing it. The two construction sites differ enough that this was a real trap: the edit path
passes exactly one already-typed item, has no guard at all, and reads popup.Items[0] back the
moment the popup closes.
StorageMediaSource (in StorageMedia.cs, so no .csproj entry) is now the single
thing the popup is given. FromMedia exposes everything through Ready; FromFiles leaves
Ready empty and delivers through LoadAsync. Count is known up front either way.The constructor seeds Items from source.Ready — so the edit path still has its item before the
popup opens and Items[0] cannot throw — and the popup calls LoadAsync from its own OnLoaded,
which is a no-op for a complete source. Callers can no longer forget it. _expectedCount comes from
source.Count at construction rather than being patched in later, so the title is right from the
first frame.
The guard is a constructor argument instead of a settable property, and the up-front loop in
SendFilesAsync runs over source.Ready — empty for files, so the last flavour check disappeared
rather than moving. The edit path passes null, which is honest: that item's permissions were
checked when it was first sent.
Pulled forward from Task 3, because incremental arrival made it much worse: a drop of 20 photos fills album 0 a batch at a time, then album 1, and each batch rebuilt the album from nothing.
CompareItems compared album contents, so an album that gained a photo was a
different item and the diff removed and re-inserted it. ChatDiffHandler shows the intended
contract — CompareItems is identity, UpdateItem carries content over. StorageAlbum now has
an Ordinal (its position among the albums of a view) and that is what is compared.UpdateItem now moves the new media onto the retained album instance and refreshes
the realized panel, rather than only invalidating layout — which had left the panel showing the
old contents whenever it did fire.StorageAlbumPanel.UpdateMessage reuses its children instead of Children.Clear()
plus a fresh Button per item. Growth now only appends, and a surviving item keeps the thumbnail
it already had. (This was Task 3.4.)Remove_Click invalidates the removed item's thumbnail, since a container recycling
underneath it no longer does.Also restored: the constructor's Logger.Info line names each item's width and height, which is
what album layout bugs get diagnosed from. On the drop path nothing is typed yet, so it logs the
pending count there and logs the dimensions again once everything has landed.
2b.6 Publish in album-sized chunks, so an album appears complete instead of reflowing as each photo lands.
This also fixed an ordering bug. Flush sorted within a batch but appended batches in arrival
order, so a slot that resolved late landed after everything behind it — the picked order was
wrong across batches, and album membership with it. Results are now buffered by source index and
only the longest settled run is published, which is required anyway: album membership is
positional, so a slot that has not settled could still turn out to be a document and split the
album behind it.
While loading, and only when the caller asked for media mode, the run is truncated to a multiple
of StorageAlbum.MAX_ITEMS — every complete album available, not one per pass, so a drop that
types quickly still lands in a single flush. Flush(final: true) at the end publishes the tail
including a part-filled last album.
ProbeAsync now reports every index, passing null for a file it could not type. Without that a
failed file would be a permanent hole and the run would never get past it.
Trade-off: a slow file holds back everything behind it, where before those items appeared (out of
order) without it. The popup and its title still appear immediately, which was the actual
complaint; and a drop with media: false is not chunked at all, since file rows do not reflow.
UpdatePanel() is called from roughly a dozen places — mute, TTL, crop, spoiler toggle, item
add/remove, even SendFilesAlbumPanel_Loading.
UpdateCollection() rebuilds the whole view list and, in files-mode, allocates a
fresh StorageDocument wrapper for every item. New instances every time, so CompareItems falls
back to path comparison and the diff churns.await ScrollingHost.UpdateLayoutAsync() forces a synchronous layout pass per call.LayoutUpdated once, completes a TaskCompletionSource, and
unsubscribes — it waits for the next layout pass rather than forcing one, and the container walk
genuinely has to happen after layout. The real cost is that concurrent calls each walk everything
after the same layout pass, and there are several: each album panel raises Loading, and every
arriving batch raises it again. A call that finds a walk already waiting now returns instead of
queuing another — the walk reads live state, so it already covers whatever the callers behind it
changed. The flag clears before the walk, so a call arriving during one still gets its own.GaussianBlurEffect, effect factory,
CompositionEffectBrush, backdrop brush and SpriteVisual per media item per call. Effect
factories are expensive and meant to be created once and shared. Same for new ParticlesImageSource().
The factory is now built once per popup (per instance, not static — the compositor belongs to the
window), and both the particles and the backdrop are only touched when the state they represent
actually flipped, instead of being reassigned on every pass.StorageAlbumPanel.UpdateMessage does Children.Clear() and a new Button with a
fresh Click subscription per media, on every container realization. Done as 2b.4.SendFilesPopup.UpdateCollection and ComposeViewModel.GetItemsView both group into
StorageAlbums and disagree. GetItemsView splits on muted video, splits .webp documents (the
server-side workaround), and tracks albumType so media/audio/documents never mix; UpdateCollection
does none of that. The grouping previewed is not the grouping sent — and Send_ContextRequested
already calls GetItemsView, comparing against a different grouping than the one on screen.GetItemsView is the source of truth and is now the only place a StorageAlbum is constructed.
UpdateCollection calls it and adapts the result for display rather than grouping again.
Three things the adaptation has to do, because grouping for sending and grouping for display are not quite the same question:
StorageDocument for the glyph, as before.GetItemsView leaves a muted video bare
because it is sent as its own message; rendering that literally would drop it out of the mosaic
and into a file row the moment the user hits mute. Sending cannot tell the difference — a
one-item album and a bare item take the same path in SendFilesAsync.GetItemsView silently drops an item whose
type is not permitted, which is right when sending and wrong when displaying. Everything in
Items already cleared the guard in SendFilesAsync, and the edit path has no guard at all, so
filtering here could only blank out an item the popup exists to show.StorageAlbum carries its StorageAlbumType now, so the popup can tell a mosaic from a row without
re-deriving it.
Send_ContextRequested predicted the send with a hardcoded albumAllowed: true, forceDocuments: false, so in files mode it decided whether to offer "Send without grouping" from
a grouping that was neither on screen nor the one that would be sent. It passes the real
IsAlbum/IsFilesSelected now, the same pair SendFilesAsync uses.Mute_Click never refreshed anything. That was harmless while the popup did its own
grouping and ignored IsMuted; sharing the grouping makes muting move the video out of its
album, and nothing binds IsMuted, so it has to ask. Everything else GetItemsView reads is
either immutable (type, extension, IsAnimated) or already refreshes: Items through
OnCollectionChanged, IsFilesSelected through ToggleIsFilesSelected and MakeContentPaid,
IsAlbum only from SendWithoutGrouping, which hides the popup.Compression, MaxCompression, CanCompress, GetEncodingAsync, ToString(),
UpdateWidthHeightBitrateForCompression and the original*/videoDuration/rotationValue fields
have no consumer outside the file — only GetGeneration() is live. They cannot work either: every
original* field is assigned only in commented-out lines, so they are permanently 0,
MaxCompression is always 1, CanCompress always false, and ToString() divides by zero.299 lines out, one in. What is left is Width/Height, TotalSeconds, Duration, IsMuted and
GetGeneration, which is the whole of what the app ever asked a StorageVideo for.
Two knock-ons worth knowing:
IsMuted's setter was resetting Compression and raising CanCompress; it is now just the set.LoadPreview() went with it. It was called from the constructor and did nothing but compute the
dead compression values — which is why removing the Refresh override back in Task 1 was safe.The commented-out blocks in the constructor went too: they assign the original* fields, so
leaving them would have described members that no longer exist. Anyone reviving video compression
starts from the Android implementation rather than from this, which never worked.
Three of these did not survive a second look. They were written from reading, before measuring, and are corrected here rather than acted on — churning code for a cost that is not there is how a file gets worse, not better.
FileItem_PointerEntered: storage is assigned and never used, and content is the
template root rather than the container, so ItemFromContainer returns null anyway. Removed. Its
glyph.Glyph was also unguarded where the matching PointerExited uses ?.; now both do.OnContainerContentChanging: the root is AspectView branch is unreachable. The
selector only returns FileItemTemplate or AlbumTemplate, both Grid-rooted; MediaItemTemplate
is only ever a Button.ContentTemplate inside StorageAlbumPanel. Removed.root.FindName(...) namescope walks per container realization, plus two
Substring allocations to split the filename extension.FindName on a template root is a namescope table lookup, not a tree walk, and the two
substrings are a few dozen bytes on a path that runs once per row realization — tens of times per
popup, not per frame. What was actually worth fixing there was duplication, not cost: the glyph
expression appeared three times and is now GlyphFor.string.Format per item and repeated
StringBuilder.Prepend (quadratic in characters) before the popup shows.Extensions.Prepend is misnamed: it appends, writing the
separator first when the builder is non-empty. So it is linear, and the log is in natural order,
not reversed. That leaves one string.Format per item, once per popup.HandlePackageAsync, Add_Click and StorageMedia.CreateAsync all catch { } silently.
The first two now log. So does the per-file catch in ProbeAsync, where reaching the handler is a
genuine surprise — the per-type factories already return null for the expected "this is not a
photo" case. The factories' own catch { return null; } is left alone deliberately: that is their
documented contract, and logging it would flood a 200-entry ring buffer that ships with crash
reports the moment someone shares a folder of unsupported files.Add_Click and drop-into-popup bypass the permission and file-size checks that
SendFileExecute applies to the initial set, and do not dedupe against files already listed.
Still open, and now deliberate: 2.6 routes appended files through the same pipeline, so _validating
could be applied to them, but the guard is all-or-nothing and would close a popup that already
has a caption in it. Doing this properly means dropping the offending item and saying why, which
is a behaviour decision rather than a wiring one.StorageMedia.CreateAsync uses OfType<StorageFile>(), so dropping a folder does
nothing with no feedback. StorageMedia.GetFilesAsync now carries the same limitation for the
package paths, and is the one place to fix it.IsMediaAllowed runs up to three LINQ passes and TitleText up to three more over
Items, on every UpdateView().UpdateView runs on the order of ten times per popup over at most a few hundred items. One pass
instead of three saves a few thousand type checks across the whole life of the popup. The
single-pass rewrite is not clearer either, so there is nothing left to buy.Still open, both because they are decisions rather than wiring:
8.1 SendMessagesView exists only to send what was shared. It still types the whole set
first — CreateAsync(files) — and only then builds and sends message after message. So nothing
reaches the network until the slowest file has been probed, which for a share of large videos is
the probe delaying the upload, not just the UI. Uploading the first file can start while the
last is still being read.
This is a better fit than it was for the popup: there, streaming fed a list a user looks at; here it feeds a send queue, and the win is wall-clock on the transfer.
The shape. ChooseChatsViewModel.SendWithChat resolves options and topic synchronously and
does not look at the content at all, so it can be called once per chat up front and the results
kept. ProbeAsync then reports files as they land, and each message goes out to every captured
chat as soon as its InputMessageContent is built.
Four things that need deciding, not just wiring:
Flush, minus the display half.SendFilesAsync already faces this and sends the caption as its own message when there
is more than one item; matching that is probably right, and files.Count is known up front so the
single-item case can still attach it.UpdateFiles divides by the sum over _trackedFiles, so a
denominator that grows as files arrive makes the bar drop. It needs to be weighted by the file
count known at the start, or to accept the jump.Cancel() already deletes tracked messages, which still works,
but it would also have to cancel the probe — the token from 1.3's survey, finally with a caller
that wants it.7.0 Split the row model out of the send model, as Task 7's precursor.
StorageAlbum was answering two questions at once. It means one outgoing message, which is
what GetItemsView produces — but the template selector was reading it as a row that draws a
mosaic, and those disagree in both directions. So UpdateCollection had to lie three times: a
standalone muted video got wrapped in a fake one-item album with a negative ordinal to keep
its thumbnail; a documents album got expanded and its grouping thrown away; and a files-mode row
got its item wrapped in a StorageDocument purely for the glyph — which ItemsView_CollectionChanged
then had to unwrap again with Original ?? document.
The list now holds StorageRow: MosaicRow (media drawn as a mosaic, 1..N) or FileRow (one
item, plus the AsDocument flag that used to be a wrapper object). The selector asks the question
it means. StorageAlbum goes back to being purely a send grouping — Ordinal, Update, the
mosaic layout and the display constants all moved to MosaicRow, and StorageAlbumPanel became
MosaicPanel so the naming stops contradicting itself.
It fixed a live bug on the way. OnContainerContentChanging set the delete button's Tag to
the displayed item, and Remove_Click looks that up in Items — but in files mode the
displayed item was a StorageDocument wrapper that was never in Items, so Items.Remove found
nothing. Deleting a photo or video row in "send as files" mode silently did nothing. FileRow.Media
is the item itself, so the lookup succeeds.
StorageDocument's wrapping constructor and its Original property went with it: the popup was
their only consumer.
7.1 The ListView already has CanReorderItems/CanDragItems, so the rows can be
dragged — an album as a whole, or a file row. There is no way to reorder the media within an
album, or to move one from one album to another, and order is what decides both the mosaic layout
and which ten photos end up in which message.
Notes for whoever picks this up, now that 7.0 has landed:
Buttons parented by MosaicPanel, a bare Grid that arranges them
from MosaicRow.GetPositionsForWidth. There is no items control involved, so none of the ListView
drag machinery applies — it needs its own pointer handling, hit-tested against the rectangles the
panel already computes in MeasureOverride.MosaicRow: a display object that owns an ordered media list and knows its
rects. Reordering mutates MosaicRow.Media and ItemsView_CollectionChanged flattens it back
into Items — the collection that actually gets sent — with no pretending a message changed.GetItemsView counting to StorageAlbum.MAX_ITEMS.