notes/message-collection-plan.md
Telegram/ViewModels/Dialogs/MessageCollection.cs, 641 lines. Two jobs in one class:
DialogViewModel.Items and the chat ListView;ctor:72) that a history response is decanted into so that
attach state and separators are computed off to the side, then copied over by
AppendSlice / PrependSlice / ReplaceSlice.On top of Collection<T> it maintains three things: an id -> item map (_messages, so that
Handle(...) can find a message by id), the date and forum-topic separator rows, and the
IsFirst/IsLast grouping flags of the neighbours of every mutation, which it reports back
to the view through AttachChanged.
Worth pinning down before optimising anything:
DialogViewModel.cs:938);Constants.HistoryLimit);So the per-item CPU inside this class is nowhere near the cost of realising the bubble that each insert causes. This is not a CPU bottleneck. What is worth fixing is (1) the garbage it produces per message, (2) the fact that the insert logic exists in three near-identical copies, and (3) a handful of traps and dead ends in the API.
Line numbers below are against the tree as it was before phases 1-2, i.e. on top of the staged
change that rebases the class from RangeObservableCollection onto SuppressObservableCollection.
InsertItem is the same algorithm written three times — 206:390_suppressNext (225), _suppressPrev (265) and the fall-through (305) are one algorithm with
two independent halves:
UpdateSeparatorOnInsert(prev, item), UpdateForumTopicSeparatorOnInsert(prev, item),
UpdateAttach, insert the separators before item, note prev's attach hash;item, note next's hash._suppressNext runs the prev half, _suppressPrev runs the next half, the fall-through runs
both. That is bool doPrev = !_suppressPrev; bool doNext = !_suppressNext; and two ifs —
roughly 185 lines collapse to ~70, with one place to get the insertion order right instead of
three. Every other finding below is easier to land once this is done.
ObservableCollection.InsertItem builds a NotifyCollectionChangedEventArgs (plus its
single-item list wrapper) before it calls the virtual OnCollectionChanged where
SuppressObservableCollection drops it. So both suppressed paths still allocate per item:
the slice ctor (87, nobody is subscribed at all) and ReplaceSlice (193-199, inside
SuppressEvents). Opening a chat on a 100-message slice throws away ~400 of these.
RangeObservableCollection.AddArrangeCore already avoids this by writing into Items
directly, and both of these paths can do the same.AttachChanged is Action<IEnumerable<MessageViewModel>> (61), so each notified insert
or remove allocates a 1- or 2-element array (262, 302, 383, 433) and then an array
enumerator in ChatView.OnAttachChanged's foreach. It is never more than two items and
they are always (prev, next), so Action<MessageViewModel, MessageViewModel> makes it
free and also drops the message == null filtering at ChatView.xaml.cs:1088.Neither is a bottleneck; both are free to remove and this is a UWP AOT app where GC pressure on the chat-open path is the thing we do care about.
Fixed by an InsertCore/RemoveCore pair that writes straight into Items when
EventsAreSuppressed, with the slice ctor now wrapping its fill in SuppressEvents() so it
takes that path too. ReplaceSlice emits Count and Item[] once at the end, which the
per-item Add used to emit N times.
AppendSlice:145 sets _suppressOperations = i > 0 and PrependSlice:174 sets it to
i < source.Count - 1. The intent is "the first item I actually insert joins onto the existing
list, so it needs the full treatment; the rest already have their attach state from the slice
buffer". But i counts candidates, not insertions — if the item at the seam index is dropped
by the filter above it, the next one is inserted raw and the seam gets no attach recomputation
and no date separator.
I could not construct a live repro: the slice ctor's exclude list (77) already removes the
ids the _messages.ContainsKey filter would catch, and the Id < lastId / Id > firstId
filter does not fire for the offsets LoadNextSliceAsync uses. So this is a trap rather than a
bug — but it is one line to make it honest (bool first = true; flipped after a real insert).
PrependSlice's index parameter is ignored — 156, 177The body always does Insert(0, message). Both callers pass 0. Drop the parameter.
RawRemoveAt — 392 — has no callersIsEndReached — 64 — only means anything on the slice instanceIt is assigned solely by the slice ctor (90) and read solely off a slice
(DialogViewModel.cs:949,954). On the live collection it is permanently false. It is the
clearest sign that the two roles want to be two types.
Message, written four times447 and 465 here, DialogViewModel.cs:1325,1327,1338. All positional, all constructing a
header/service row. A schema change silently shifts arguments across all five. One
CreateSyntheticMessage(MessageViewModel template, MessageContent content, int date) factory
removes that.
AreOnTheSameDay costs two Formatter.ToLocalTime calls (a DateTime.AddSeconds plus a
timezone conversion each) and runs about twice per insert. That is sub-microsecond against a
24-item batch — not worth caching a local-day on MessageViewModel, which would then need
invalidating in both Replace overloads.
Worth noting while passing: Formatter.cs:408 discards the result of
DateTime.SpecifyKind, so dtDateTime.Kind stays Unspecified. It happens to be correct
because ToLocalTime assumes UTC for Unspecified, but the line reads as though it does
something.AreTogether:585 reads message1.ClientService.Options.VerificationCodesBotChatId through
two interface hops on every call for a value that is fixed for the whole collection. Hoist it
to a field in the ctor — a clarity fix that happens to be cheaper.Three bool fields cleared by assignment at the end of each range method. If Add/Insert
throws (CheckReentrancy), the collection is stuck in raw mode for the rest of its life and
silently stops maintaining separators. A readonly struct scope like the existing
SuppressEventsDisposable, or a single enum field set in a try/finally, closes that.
ReplaceSliceIt runs entirely with _suppressOperations = true, so it assumes the source already carries
correct attach state and separators. True today for every caller (they are all slice
collections, or a collection built through Add), but nothing says so.
ProcessEvents reverses nothingDialogEventLogViewModel:367 ended with result.Reverse();. result is a MessageCollection,
which has no in-place Reverse - neither ObservableCollection<T> nor Collection<T> declares
one - so it bound to Enumerable.Reverse and the sequence it returns was discarded. The line
has never done anything.
Deleting it is the fix, not making it work: the result.Insert(0, message) loop above already
turns TDLib's newest-first events into the ascending order the chat list wants, so an in-place
reverse would render the event log upside down.
Surfaced by typing ReplaceSlice's parameter, which forces ProcessEvents to declare that it
returns a MessageCollection rather than an IList<MessageViewModel>.
LoadScheduledSliceAsync never built a sliceDialogViewModel:1793 builds replied with .Select(CreateMessage).ToList() - a plain
List<MessageViewModel> that never went through MessageCollection's insert path. So no
message in it has attach state (they all keep the IsFirst = IsLast = true defaults, i.e. every
scheduled message renders as its own ungrouped bubble) and there are no date separators beyond
the single one hand-inserted at index 0.
ReplaceSlice(MessageCollection) makes that call impossible to write, which is the point. Now
built through the slice ctor, with OrderBy(x => x.Id) flipped to OrderByDescending because
the ctor inserts each item at the top and so wants TDLib's newest-first order. Going through the
ctor also switches CreateMessage to its forLanguageStatistics overload, which is a no-op
here: UpdateLanguageStatistics returns early on outgoing messages and every scheduled message
is outgoing.
ProcessMessages / ProcessAlbums mutate a slice by indexBoth take IList<MessageViewModel> and are handed a MessageCollection at four call sites, then
walk it with an index while mutating it:
slice.RemoveAt(i); i--; (DialogViewModel:1916,1930 and ProcessAlbums) assumes exactly one
item disappears at i. MessageCollection.RemoveItem also drops the date or topic separator
the removal orphans, and that one can sit at index - 1 - so the loop can skip the next item.
The same trap is already spelled out in a comment at DialogViewModel.Handle.cs:1566.slice[i] = group in ProcessAlbums goes through SetItem, which MessageCollection does
not override. _messages keeps mapping the replaced message's id to the old item and never
learns the album's id or its children's.Neither reaches the live Items today - ProcessMessages is only ever given a slice or a plain
List - so the stale map is thrown away before it matters. Latent, but it is the third thing the
tightened signature points at.
Fixed by deferring instead of walking backwards. Both loops now collect the items to drop and
remove them by identity once the walk is over, so no index survives a removal and nothing has to
reason about how many items RemoveItem took. The List is only allocated when something is
actually dropped, which is the rare case. Deferral also makes ProcessAlbums' slice[i] = group
trivially valid, since the slice no longer shrinks underneath it.
The removals were placed immediately after the loop and before the groups block, which reads
first.IsFirst and album.Messages[^1].IsLast: removing there replays the same UpdateAttach
sequence the interleaved removals produced, so that block still sees what it saw.
SetItem is overridden and maintains the id map only. Its one caller swaps an album root in over
the child that seeded it, so neither the day nor the neighbours change and there is no attach
state to recompute - worth knowing before a second caller appears.
OnAttachChanged gave up after the first neighbourChatView.OnAttachChanged looped over the reported items, and the if (ViewModel.IsSavedMessagesTab) guard two thirds of the way down was a return, not a
continue. So on the Saved Messages tab the second of the two neighbours never reached
bubble.UpdateAttach, and its grouping stayed stale until something else redrew it.
Splitting the body into a per-message overload - which the two-argument AttachChanged wanted
anyway - makes the return mean what the continue meant. The diff looks large because the
body dedents by one level; git diff -w shows the eight real lines.
NextIndexOf in DialogViewModel.Handle.cs read Items.Count, Items[i] and
Items.ContainsKey and nothing else - a MessageCollection method in the wrong class. Because
it was outside, InsertMessageInOrder had to hand an index back across the boundary, and 4425c24
had to patch the case where the index went stale: RemoveItem can take the orphaned separator
too, so the collection shrinks by two and the adjusted index lands past the end
(ArgumentOutOfRangeException, reached from PendingMessage_Completed).
Moved in behind one public InsertInOrder that owns the whole move, so no index crosses the
API boundary any more and MoveMessageInOrder is gone.
The cost matters here and 4425c24 got it wrong: the code before it did one backward pass and an O(1) insert; the fix replaced the insert with a second full pass. Neither reproducing that nor splitting the pass in two is acceptable, so:
RemoveItem can take. Walking back from that index settles within those few rows instead of
rescanning the list. Clamping the start of the walk to Count is also what stops the
ArgumentOutOfRangeException - not a blind clamp of the insert, which is what the commit
message rightly rejected.force came out as Reinsert: it was never an ordering concern, it exists so the list builds a
fresh container when a content template changes (expired media), and
InsertMessageInOrder(message, 0, true) said none of that.
Three behaviours preserved on purpose, each with a comment:
ContainsKey and is then found nowhere by the
scan. Rewriting the scan as a map lookup - which looks like a clean simplification - would
start dragging album roots around by their children.NextIndexOf returning Count when nothing sorts below the message. That reads like an
off-by-one but it is what puts sponsored messages at the bottom: they carry a sponsor
identifier smaller than every real message id, so nothing ever matches.InsertMessageInOrder as the entry point. ChatHistoryView calls it for sponsored
messages, so it stays as a one-line forward rather than making the view reach into Items.Uncommitted, in the working tree on top of the staged base-class change.
InsertItem is one path: 185 lines to 112 including the id-map maintenance and comments.
joinPrev/joinNext gate the two halves, which are otherwise the code that was there.bool flags became one AttachMode enum (Both / Previous / Next / None)
set inside try/finally, so a throw can no longer strand the collection in raw mode.AppendSlice / PrependSlice pick the seam off empty - "have I inserted anything yet" -
instead of the loop index, which counted candidates. That is finding C.PrependSlice lost its ignored index parameter; both callers updated.RawRemoveAt deleted.ReplaceSlice documents that it recomputes nothing.MessageCollection rather than a loose IList/IEnumerable, and
ProcessEvents declares the MessageCollection it was already returning. That is
findings K, L and M.ProcessEvents' no-op result.Reverse() deleted (K).Behaviour is meant to be identical throughout, with one deliberate exception: the two
single-sided paths used to notify AttachChanged with a one-element array and now send the
same two-element (prev, next) array as the both-sided path, with the unchanged side null.
ChatView.OnAttachChanged already skips nulls, and phase 3 removes the array outright.
Worth a look while testing: date separators and avatar grouping at the seam of a loaded slice - scroll up to pull in older messages and check the join between the batch and what was already there, in a chat where the batch crosses midnight, and in a forum topic.
Ordered so that each phase stands on its own and can be committed and tested separately.
1. DONE. Collapse InsertItem to one path. Finding A. Pure refactor, no behaviour change —
worth landing alone so the diff is reviewable against the three existing copies.
2. DONE. Correctness and API cleanup. Findings C, D, E, I, J. Small, and each is a one-liner
once A is in: seam flag, drop index, delete RawRemoveAt, scope the flags, comment the
ReplaceSlice invariant.
3. DONE. Stop index-walking a slice while mutating it. Finding M. Both loops defer their
removals and remove by identity; SetItem is overridden to maintain the id map. Walking
backwards was the other candidate and was rejected: it revisits the item below a removal that
also took a separator, and ProcessAlbums accumulates album.Messages in encounter order, so
reversing it would reverse the album.
4. DONE. Stop allocating per message. Findings B and N. AttachChanged is
Action<MessageViewModel, MessageViewModel>, and the suppressed paths write into Items
directly.
Extra, done 2026-08-27: move the ordering into the collection. Finding O, Fela's call after spotting that 4425c24 patched the symptom.
5. Factor the synthetic-message construction. Finding G, plus hoisting the verification-
codes chat id (H). Touches DialogViewModel as well.
6. Optional, larger: split the two roles. Finding F. A MessageSlice that is a plain
List<MessageViewModel> carrying IsEndReached, built by the same separator/attach engine,
and a MessageCollection that is only the live list. This is what makes phase 4's
"write into Items directly" unnecessary rather than a special case, and it removes the
ObservableCollection machinery from the slice path entirely. Only worth doing if phases 1-5
land first.
Explicitly not planned: caching dates or attach state on MessageViewModel (H), and
replacing the per-item CollectionChanged notifications with a batched one — the ListView
needs one notification per item.