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-modalautomated-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
| Component | Takes from the template today | Leaves behind |
|---|---|---|
app-message-container | Route params, the Messages / Automated tabset, white-label visibility, selected groupId | Fetching, sorting, rendering rows, sending |
app-inbox | Left column, search, empty state, mobile list | Per-row menus |
app-inbox-row | One 1:1 row: avatar, preview, unread, time | Bulk lists |
app-broadcast-row | One mailing-list row and its actions | 1:1 rows |
app-thread | Right column, including the mobile back state | How a single bubble is drawn |
app-thread-header | messages-righthead: name, badges, block, report, participants | The message list |
app-message-list | Scrollbar, day chip, "in progress", history loader | Media markup |
app-message-item | Incoming or outgoing alignment, avatar, timestamp | The body switch |
app-message-body | Text, image, video, audio, file, tip, locked attachment, share cards | The composer preview |
app-composer | Free-DM banner, the form, send disabled until permissions arrive | The drag lists |
app-attachment-tray | Paid items, optional free preview, the drag rules | Emoji and the record button |
app-composer-tools | Attach, record, emoji host, share, price | When those chunks download. Emoji and the audio UI are @defer here |
app-automated-messages | Second tab: button, app-table, empty state | The 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
- Critical path, current file. Delete the timer in
getUserEmail(). Give history and validation their own cancel streams, keyed bygroupId. SplitmsgrightLoaderinto 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 withafterNextRender. RemovengAfterViewChecked(). Sort each inbox tab once, and track rows bygroupIdand messages bysk. Debounce search. Measure on a production build before the next PR. - Inbox. Move the left column, the 1:1 row, and the broadcast row. No loading change.
- Thread body. Move the header, the list, the item, and the body. Collapse the duplicated media markup in this PR. No loading change.
- Composer, and the deferred chunks. Move the composer, the attachment tray, and the tools. This is also phase 2 of the speed note:
@deferthe 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 withng build --stats-jsonthat those chunks stay out of the first load. - 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. - Memory and live updates. Add
MessagesStore(providedIn: 'root'). Start the inbox request when navigation to/messagesstarts. 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.
WLSAllowDMsstill controls the list. - Default conversation. Creators still open Priority, then General, then Bulk. A
groupIdin history state still opens Bulk. - The header unread badge,
coreService.messageCount, once the full inbox refetch goes away. - Optimistic sends. They match on
uinqueIdtoday. 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-backid. - Both TopFans and one generated white-label project, as fan, creator, and delegate.
How you know a PR is done
- PR 1 adds
performance.markat 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-jsonwith 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-validationis the slow call, the composer stays disabled longer. That is acceptable. History should not wait for it. - ngx-bootstrap
tabsetstays 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 whethergetpreviousMessagesFromHttpalready 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-usersis at p95. If a typical inbox is 30 rows, the virtual-scroll idea in PR 6 can be dropped without a debate.