Split the messages page after the first speed pass

message-container is 4,283 lines of TypeScript, 3,118 lines of template, and 1,218 lines of SCSS. A conversation still takes about 1.9 s to become readable, and 1.5 s of that is timers. I would delete those timers in the file that exists, measure the page, and only then cut it apart. New components get Angular 21 APIs. A source-wide Angular 21 conversion does not belong in this work.

1 Oct 2026, branch hotfix/release-0.0.23/messages-revamp. The app already depends on Angular 21.2.23. Speed numbers and the phase plan come from Make the messages page load fast. That note is a suggestion. Where this page disagrees, it says so.

The four answers

1. Speed first, and only the critical path

Do the first speed pass before the split. The wait is a few functions, and the speed note already points at them. getUserEmail() sleeps for a second to discover a participant the inbox row already has. History and permissions share one cancel subject, so they cannot run together. One loader hides both the thread and the composer. ngAfterViewChecked() runs change detection a second time on the whole template.

That pass is a behavior change. It should land, and get a production timing, while there is still one file to blame. Expected result from the note: history readable around 0.6 s after the component starts, with no backend change. If the measurement misses that, splitting the template will not explain why.

The rest of the speed note waits. Deferring the emoji picker, the audio player, and the share modals is easier once those are their own components. A MessagesStore, a memory cache, and patching one inbox row per websocket message want a boundary to sit behind. Building that store against the current class just moves the pile.

2. The tree

Twelve components. The shell keeps routing, white-label gates, and which conversation is open. Children receive data and emit intents. They do not call get-chatGroups-and-users.

app-message-container
  app-inbox
    app-inbox-row
    app-broadcast-row
  app-thread
    app-thread-header
    app-message-list
      app-message-item
        app-message-body
    app-composer
      app-attachment-tray
      app-composer-tools
  app-automated-messages

app-tab-view stays inside the inbox for Priority, General, and Bulk. The modals that already exist stay modals. The shell or the composer opens them with await import(...) on click, and they leave the imports array:

  • new-message-model, message-price-model, edit-participants-modal
  • automated-message-modal
  • merchandise, portfolio, and event share modals
  • image overlay, tip-required alert, guaranteed-reply alert

image-audio-message stays with the automated modal, which already uses it. Thread audio should be a native element, with the real player deferred until a message is on screen. Pulling that old component into the thread would drag Angular Material back in for one slider.

3. One pull request at a time

Same files, so two people editing message-container.component.ts will collide every day, and a bug will have two possible causes. Ship the speed pass, then each slice of the tree, and stop between them.

Two of those slices also do the second speed phase. Extracting the composer and then deferring the emoji picker in a later PR means wiring the same boundary twice. Extract it once, and defer it in that PR. That is the only overlap I would allow.

4. Angular 21 APIs in the new files, later for the rest

The framework upgrade is done. package.json is on 21.2.23, control flow is already in this template (@if, @for), and viewChild() is already in the class. What this component does not have is OnPush, signal(), input(), output(), or @defer.

Write those into components as you create them. Leave the shell on class fields until it is small. Converting 8,000 lines to signals and then cutting them up is two rewrites of code you are about to delete. Doing the conversion inside every extraction PR makes the diff impossible to review, and a timing bug hides inside a signals migration.

A repo-wide pass stays on its own track. Signal inputs already shipped separately. This task should not reopen that.

What each piece owns

ComponentTakes from the template todayLeaves behind
app-message-containerRoute params, the Messages / Automated tabset, white-label visibility, selected groupIdFetching, sorting, rendering rows, sending
app-inboxLeft column, search, empty state, mobile listPer-row menus
app-inbox-rowOne 1:1 row: avatar, preview, unread, timeBulk lists
app-broadcast-rowOne mailing-list row and its actions1:1 rows
app-threadRight column, including the mobile back stateHow a single bubble is drawn
app-thread-headermessages-righthead: name, badges, block, report, participantsThe message list
app-message-listScrollbar, day chip, "in progress", history loaderMedia markup
app-message-itemIncoming or outgoing alignment, avatar, timestampThe body switch
app-message-bodyText, image, video, audio, file, tip, locked attachment, share cardsThe composer preview
app-composerFree-DM banner, the form, send disabled until permissions arriveThe drag lists
app-attachment-trayPaid items, optional free preview, the drag rulesEmoji and the record button
app-composer-toolsAttach, record, emoji host, share, priceWhen those chunks download. Emoji and the audio UI are @defer here
app-automated-messagesSecond tab: button, app-table, empty stateThe modal, which stays lazy

The history template draws media twice, once for a locked attachment and once for a normal message. app-message-body replaces both, with a locked flag. The composer has its own previews of those same files. They look alike. Keep them apart. Send-state and history rendering get tangled the moment one component serves both.

Styles move with the component. A shared messages.scss that every child imports would leave the pile in one place with extra filenames.

If a new template crosses about 400 lines, it still has two jobs. app-message-body is the one most likely to hit that. Split a media child out then, after you can see the real size, and only if audio or video needs its own deferred chunk.

Pull request order

  1. Critical path, current file. Delete the timer in getUserEmail(). Give history and validation their own cancel streams, keyed by groupId. Split msgrightLoader into history and permissions. Show history when it arrives, and keep send, tip, and unlock disabled until permissions arrive. Stop calling validation from the inbox response. Replace the full-screen loader with a list skeleton. Scroll with afterNextRender. Remove ngAfterViewChecked(). Sort each inbox tab once, and track rows by groupId and messages by sk. Debounce search. Measure on a production build before the next PR.
  2. Inbox. Move the left column, the 1:1 row, and the broadcast row. No loading change.
  3. Thread body. Move the header, the list, the item, and the body. Collapse the duplicated media markup in this PR. No loading change.
  4. Composer, and the deferred chunks. Move the composer, the attachment tray, and the tools. This is also phase 2 of the speed note: @defer the emoji picker on interaction, defer the audio player until a message is in view, and open the new-message, price, participants, and share modals with a dynamic import. Confirm with ng build --stats-json that those chunks stay out of the first load.
  5. Automated tab. Move it, and defer it until that tab is selected. app-table, the dynamic form builder, and the datepicker should not load with a normal inbox.
  6. Memory and live updates. Add MessagesStore (providedIn: 'root'). Start the inbox request when navigation to /messages starts. Cache the inbox and recent threads, scoped to account and tenant, and clear them on logout. Patch one row when a websocket message arrives, once the event actually carries the row summary. Turn history pagination back on. Virtualize the inbox only if a profile shows lists past about 100 rows. Leave the message list alone. Variable-height media makes that scroll fragile, and the speed note says the same.

Backend pagination, a thread bootstrap endpoint, and an inline first thread are still worth doing. They are a separate contract, and PRs 1 to 5 do not wait on them. The one-round-trip path in the speed note needs that API. Until it exists, parallel history and validation is the win you can ship.

What has to keep working

  • Paid DM and tip-required stay locked while permissions load. A missing validation response is not a free send. The server still enforces this. The UI must not get ahead of it.
  • White label. Fans with the user list hidden still land in the owner's chat. WLSAllowDMs still controls the list.
  • Default conversation. Creators still open Priority, then General, then Bulk. A groupId in history state still opens Bulk.
  • The header unread badge, coreService.messageCount, once the full inbox refetch goes away.
  • Optimistic sends. They match on uinqueId today. Keep that spelling until a rename has its own PR. A drive-by fix will drop in-flight messages.
  • Direct entry: ?user=, history.state.user_key, and tip-back id.
  • Both TopFans and one generated white-label project, as fan, creator, and delegate.

How you know a PR is done

  • PR 1 adds performance.mark at route activation, inbox painted, history painted, and composer enabled. Log them from a production build. The dev server will lie about the timers you just removed, because it lies about everything else too.
  • PR 4 compares --stats-json with the build from before. Emoji, Material, the datepicker, and the share modals stay unloaded until used.
  • Every PR gets a pass on an empty inbox, a normal inbox, fast switching, an incoming message during load, a failed validation, and a direct link. Send, tip, and unlock stay disabled until permissions arrive, and the history on screen belongs to the conversation you clicked.

Still open

  • The speed note had no logged-in session, so the 1.9 s and 0.6 s figures assume 300 ms per call. PR 1 replaces them with a real trace. If chat-user-validation is the slow call, the composer stays disabled longer. That is acceptable. History should not wait for it.
  • ngx-bootstrap tabset stays for this work. Replacing it is a visual change with no speed payoff.
  • Old messages may be unreachable. onUp() is commented out, and the frontend never asks for another page. Confirm with the backend whether getpreviousMessagesFromHttp already returns the whole thread. Pagination in PR 6 is pointless if the API returns everything, and dangerous if we assume a cursor it does not send.
  • Someone needs to say how large get-chatGroups-and-users is at p95. If a typical inbox is 30 rows, the virtual-scroll idea in PR 6 can be dropped without a debate.