Split message-container into pieces you can change safely

The whole messages page lives in one component with 138 fields and 199 methods. This plan moves its state into stores and send jobs, shares the upload and attachment code with automated messages, and cuts the template into 16 components, in ten steps. Nothing a user sees or does changes. Bugs whose fix someone would notice are written down for a separate decision and left alone here.

Read from hotfix/release-0.0.23/messages-revamp at 61843a4e3b, 7 Oct 2026.

What "no UX change" means here

Every PR in this plan has to pass one test. A user who uses the page before and after the PR can't tell which build they're on. That rules out more than redesigns.

  • Same DOM classes and nesting, so _messages.scss, _theme.scss, _white-label-theme.scss and the white-label themes keep matching. A new component either uses an attribute selector on the element that's already there, like <div app-inbox-row>, or sets :host { display: contents }. An extra inline host element between a flex parent and its children is enough to move pixels.
  • The app-message-container selector stays, because _page.scss line 18 styles it. The route, the guard and the route-level AudioRecordingService stay as they are.
  • Same copy, byte for byte, even when it moves into messages.constant.ts, and even where today's wording looks wrong.
  • Same prompts and guards. canDeactivate blocks leaving in exactly the cases it blocks today, and a thread switch is refused in exactly the cases getUserMsgDetails refuses it today.
  • Same loaders, at the same moments. Same scroll position after sending, after loading older pages, and after switching threads.
  • Same composer contents at the same moments. On Send, the text and paid items clear right away. The price chip and the free-preview tray stay on screen until the paid uploads finish, because price and freePreviewList are only reset then (TS lines 2376 and 2468).
  • Same network traffic, as seen from the user's side. Same requests in the same order, same payment payloads, and a message that is delivered today is still delivered, including after the user leaves mid-upload.
  • Same state on return. Today every visit starts from an empty inbox and reloads. Text and files in the DM composer survive switching to the automated tab and back.

A bug fix fits this plan only if it changes nothing on that list. Each PR is checked against screenshots and network logs captured from today's build in step 1.

What one file holds today

File or measureCount
message-container.component.ts4,746 lines
message-container.component.html3,180 lines
message-container.component.scss1,228 lines
Class fields138
Methods and getters199
Entries in the standalone imports array40
Reads of chatLogsList[0|1|2] by index48
.spec.ts files in the whole repo0

Line count is the visible symptom. The thing that makes every change risky is that the open conversation has no single home. About a dozen loose fields describe it: sid, broadCastId, groupId, gsi4pk, email, username, currentUsername, profile, selectedType, selectedUserName, usersProfileList and groupIcon. Each way of opening a thread writes a different subset of them.

getUserMsgDetails sets eleven of them. requestParamsofBroadcast sets nine, and blanks sid while doing it. getUserChatMessage then writes the same group id into both sid and broadCastId. onBroadcast has four branches, and two of them differ only in where they read the user from. If a field gets missed on one path, the bug shows up two screens later, and nobody can tell which path left it stale.

The inbox has the same problem in a smaller form. Its three tabs sit in an array, and the code picks a tab by position, so chatLogsList[2] means "general" for creators and "everything" for fans, via getChatTypeIndex.

Code that already exists twice

The split shares the logic behind each copy. Where both copies render the same markup, one component replaces them. Where they render differently today, both templates stay, so the automated-message tray keeps its own markup and both price modals stay as they are.

CopyWhereHow close
Free media vs paid media inside a messageTemplate lines 895 to 1180 and 1335 to 1845286 and 511 lines. The paid copy adds the lock overlay and the unlock footer.
Free vs paid drag-and-drop trays in the composerTemplate lines 2018 to 2230 and 2301 to 2530213 and 230 lines, 81 lines differ
Upload queue, thumbnails and the "all uploaded?" checkmessage-container and automated-message-modal111 of the modal's 165 lines match the container
Media compressionmessage-container and image-audio-message60 of 68 lines match
Attachment tray, audio recording, drop rulesmessage-container and image-audio-messageSame method names and the same logic. image-audio-message is the automated-message composer.
Price modalmessage-price-model and message-price-model-v2The container opens v1 through BsModalService. image-audio-message embeds v2.
Payment payloadssendTip, payForMessage, payForMessageAndTipThree builders that differ in about eight keys. Each one pops the queue and navigates to dashboard/make-payment.

Bugs found along the way

I found these while tracing where each piece would go. Each one gets one of two treatments. If fixing it changes nothing a user can see, it's fixed in its own small PR, never inside a move. If a user could notice the fix, this plan keeps today's behavior on purpose and the bug goes on the follow-up list at the end.

Fixed in this plan, because nobody can see the difference

  • Broadcast sends leak subscriptions. sliceMessageFromMessageQueue subscribes to chatService.receiveMessage on every broadcast or followers send. After five sends, each incoming event refetches the inbox five times. Each refetch cancels the one before it through inboxCancel$, so the screen shows one load either way. One subscription, installed on the first such send, gives the same screen with one request.
  • The queue lookup matches by accident. messsageQueue.findIndex(x => x.id === queue.id) compares two undefined values, because queue entries have no id, so it always returns 0. Giving entries an id picks the same entry in every case that occurs today.
  • Change detection runs on every check. ngAfterViewChecked calls detectChanges(), and there are ten more manual calls. Thread messages also get view flags like profileStatus and messageDay mutated in place, which is why OnPush can't be switched on today. Done right, the screen is identical.
  • Dead abort code. ngOnDestroy calls this.awsRequest?.abort?.bind(this.awsRequest), which creates a bound function and throws it away. Delete the line. Don't make it work. The first item in the next list explains why that would be a visible change.
  • The template hardcodes strings like "Messages", "+ New Message List", "Block User", "Custom Amount" and "In progress", against the repo's constants rule. Moving them changes nothing on screen as long as the text stays identical.

Left as they are, because a fix would be visible

  • The shared destroy$ dies on the first socket event. userIdleState() calls destroy$.next() and complete() right after it subscribes. After that, every takeUntil(this.destroy$) in the file is a no-op, and video and audio uploads go through an S3 XHR with no cancel at all. The result is that a user who leaves mid-upload still gets the message delivered. Wiring teardown up properly would quietly drop those messages, so in-flight sends have to keep running after the page closes, exactly as today. There's one narrow exception. If the user leaves before any socket event has arrived, destroy$ is still alive, and leaving does cancel image uploads and the credit call. The send queue copies that rule too.
  • A media send can go to the thread that's open when the upload finishes. uploadPriceItemsGeneric removes the queue entry before the upload starts, one splice per media item. Once the queue is empty, getUserMsgDetails allows opening another thread and canDeactivate allows leaving. When the upload finishes, sendMessageApiCall reads this.sid, this.gsi4pk and this.selectedType from whichever thread is open, and getResItems flips the loader on the last bubble of the current list. I traced this in the code but haven't reproduced it in a browser. Fixing it changes where a message lands and adds a leave prompt, so it needs a product decision.
  • The presence timers pile up. Every socket event after the first one starts another two-minute timer that runs until the page closes. The inner interval restarts on every outer tick, so I can't say whether setUserOnline() fires on schedule. Changing it could change when other people see this user as online, so it moves into presence.service intact, with its own subject that completes at the same moment destroy$ does today.
  • Two rules decide whether messages are duplicates. Older pages dedupe on sk || timeStamp. The live merge uses sk || message_id || uinqueId || id. Unifying them could show or hide a message, so both rules move into utils/ unchanged.

Target layout

State lives in stores, and stores use signals

The inbox, thread, socket and presence stores are plain @Injectable() classes listed in the providers of MessageContainerComponent. They're created and destroyed with the page, exactly like today's fields. Don't put them in the route's providers. This app doesn't enable withExperimentalAutoCleanupInjectors, so route injectors outlive navigation, and a route-provided store would show the previous visit's inbox when the user comes back.

Two services are the exception. SendQueueService is providedIn: 'root', because sends finish after the user leaves today and must keep doing so. ComposerStore is provided by composer and separately by the automated-message editor, so each gets its own instance. Today the DM composer keeps its message in fields on the container, and the automated editor keeps its own in image-audio-message. One shared store would let a file or price from one show up in the other. Nothing in the store is saved anywhere. It holds what the user is writing until Send, and it's gone on reload or leave, as today.

The recorder is the one piece of state the two composers share, and it stays shared. messages.routes.ts lists AudioRecordingService in the route's providers, so the messages route has one instance holding the mic stream, the recorder, the timer and the subject that emits the finished clip. Only one recording can run at a time, because startRecording() returns early if a recorder exists. Both composers subscribe to getRecordedBlob() and get every clip. The container ignores it on the automated tab (TS line 483), and image-audio-message ignores it when its editor is closed (its line 124). Once a composer accepts a clip, the clip goes into that composer's own store. Both checks move as they are.

Panels inject stores. Leaf components take inputs and emit outputs.

I'd skip a separate page coordinator. The page component already sits where inbox, thread and send meet, and it's small once the stores exist. Another layer would just pass calls through.

One ActiveThread object replaces twelve fields

It looks like { kind, groupId, peer?, recipients?, listName?, listIcon? }, where kind is one of dm, broadcast, followers or mailingList from a const map. Each entry point builds a whole new object and calls thread.open(next). One catch: today some paths leave old values in place, and if the header or a link shows one of them, that stale value is part of today's UX. The baseline captures decide. Wherever a stale value is visible, the builder for that path copies it forward on purpose and the case goes on the follow-up list.

The inbox is keyed by tab name

rows: Record<InboxTab, InboxRow[]> replaces the 48 index reads. The fan case becomes one line in the store instead of a getChatTypeIndex call at every site.

Free and paid are one component with an input

media-grid takes access: 'free' | 'paid' plus the lock state, and renders exactly the markup each copy renders today, lock overlay and unlock footer included. attachment-tray does the same for the DM composer's two trays. image-audio-message has different classes and structure, such as media-scroll-container, free-upload-icon and its own audio row. It keeps its template and its app-image-audio-message selector, which _white-label-theme.scss styles, and shares only the logic: ComposerStore, drop rules and compression. Both price modals stay.

Don't split past the point where a piece owns something

A component earns its own folder when it owns state, appears twice, or has a template longer than one screen. Sixteen components with twenty inputs each would be worse than what's there now. The tree above is the ceiling, not the floor. If thread-header ends up as forty lines, fold it back into thread-panel.

What each component gets and emits

Write these types in interfaces/ before moving any markup. Children never receive the parent component, a callback bag, or an array they can mutate.

ComponentGetsEmits
inbox-panelInjects InboxStoreopen(thread), newMessage, editList, deleteList
inbox-row, broadcast-rowRow, selected flag, online flagselect, menu action
thread-headerActiveThread, presence, allowed actionsback, block, report, editParticipants
message-listDecorated messages, hasMore, loading stateloadOlder, previewMedia, unlock
message-bubbleOne message, isMine, tenant flagpreviewMedia, unlock, playVideo, openShare
media-gridItems, access, lock state, pricepreview, unlock, download
composerProvides its own ComposerStore, reads dmValidations from ThreadStoresubmit, which hands the store to SendQueueService
attachment-trayOrdered items, access, limits, allowed media kindsadd, remove, move, priceChange
tip-popoverMinimum tip, presets, currencytipChange
dm-gatePermission state, age, tenant rulesretry

Treat each send as a job

This is the riskiest part of the page. Today every step after an async hop reads the component's current fields, so the job has to reproduce those reads exactly, including the ones that look wrong.

A job is a message the user pressed Send on that hasn't been delivered yet. Today there are two kinds. A text message sent while an earlier one is still going out waits, because getResItems only sends when the queue has exactly one entry (TS line 2268), and afterSendChatLogic sends the rest once the earlier one finishes. A media message uploads every file, builds thumbnails, uploads the free previews after the paid items, maybe goes through the guaranteed-reply tip step, and only then goes out on the socket. That second kind is slow, and it's the one that keeps running after the user leaves.

Reordering the inbox isn't a job. It's a step that runs after a job is delivered, in sliceMessageFromMessageQueue. For a DM it moves the thread to the top of the tabs. For a broadcast it reloads the inbox. Then it clears the textarea. In the plan, the queue reports that the job is done, and InboxStore does the reorder if the page is still open.

  • A SendJob holds a client id and what today's code copies at the moment of Send: the text, the paid items, and the payload sendMessage builds, which already carries the price as amount and the international prices. It also holds the dmValidations it was checked against.
  • The job does not copy the free previews. Today checkAllMediaFiles reads the live freePreviewList when the paid uploads finish, so the job reads them from the composer's ComposerStore at that same moment. The composer clears the text and paid items on Send, and clears the price and free previews only when the paid uploads finish, matching TS lines 2240, 2376 and 2468. Copying everything at Send time is on the follow-up list.
  • Everything that today reads the current thread after an await goes through one function, resolveDeliveryTarget(job). In this plan it returns the thread that is open at that moment, as the code does now. The spinner keeps flipping on the last bubble of the current list. If product signs off on the wrong-thread fix, that function changes to return a snapshot taken at send time, and nothing else has to move.
  • The queue entry is removed at the moment it's removed today, when the upload starts. So canDeactivate and the thread-switch check behave exactly as they do now. The job's own bookkeeping lives in a separate list that no guard reads.
  • SendQueueService lives at root, so a job still finishes after the user leaves the page. The steps after delivery that only touch the page, reordering the inbox and clearing the textarea, are skipped when the page is gone. Nothing is lost, because the inbox loads fresh when the user comes back. Today those steps throw on a null #editable after delivery, and nobody sees it.
  • decideSendPath(job) picks the path. The three payment paths hand a payload to cs.subscriptionPaymentData and navigate to dashboard/make-payment, and post-payment.component.ts sends the socket message after payment. That payload is a contract with another module. Keep its keys identical and diff them against the step 1 baseline.
  • ChatService stays a root singleton. The header opens the connection, and the messages page subscribes but never disconnects.
  • Today a socket send counts as done right after chatService.send(). Keep that meaning. There's no server acknowledgement to wait for, and inventing one is a product change.
  • Move the existing branches intact. The three payment builders merge into one only if the merged builder produces the same keys and values for every flow in the baseline.

Styles and dialogs move with their markup

  • The container's SCSS uses emulated encapsulation, so a selector like .messages-left .messages-item stops matching as soon as that markup moves into a child. Move the selectors in the same PR as the markup.
  • Global files style this page too. src/assets/scss/_messages.scss is 1,932 lines, and _theme.scss matches chains like .messages-container .messages-left .singleImage. Keep class names and nesting the same, or update those files in the same PR.
  • Moving a rule from the container's SCSS into a child's SCSS changes its specificity, because Angular adds a different attribute selector. A rule that used to beat a global one can start losing. Compare screenshots at desktop and phone widths, on the main theme and one white-label theme, before merging.
  • _white-label-theme.scss targets the app-image-audio-message element, so that component keeps its selector.
  • The inline delete-list, tip-required and reply-fulfilled dialogs are already app-alert-modal. They move into small wrapper components with the same inputs and copy, so they look identical. Each one takes typed data and emits a result, and the store does the work after confirmation.
  • There's a fourth inline dialog, #deleteAutoMessage at template lines 3102 to 3117, which confirms deleting an automated message. It moves into automated-messages-tab with the same markup, together with the app-automated-message-modal host above it, because that tab owns both actions.
  • AudioRecordingService also hosts generateCommonGroupId, which has nothing to do with audio. The thread store calls it where it is. Moving it is a cleanup for later, not part of this plan.

Where today's code goes

New homeTakes overFrom today
inbox.storePaged inbox load, search debounce, tab switching, counts and badges, picking the first thread on load, the reorder after a send is deliveredTS 712 to 1100, 3895 to 3962, 4029 to 4050, 4668 to 4678
thread.storeHistory paging, the live-message stash and merge, guaranteed-reply pending state, opening a threadTS 2914 to 3053, 3174 to 3205, 3326 to 3560, 3865 to 3893
chat-events.serviceSocket subscription, envelope parsing, routing to the inbox or the open threadTS 2722 to 2742, 2810 to 2912
presence.serviceOnline poll for the open thread, the members list, the heartbeat if it staysTS 1479 to 1534, 2744 to 2808
composer.store and attachment-trayFile pick, drop rules, removal, price, audio capture, first-name insert, emojiTS 531 to 559, 1104 to 1141, 1535 to 1765, 1856 to 1892, 4144 to 4249. Template 1945 to 2545.
send-queue.serviceQueue, upload fan-out, the four-way payment decision, socket send, the "job done" signal the inbox reorders onTS 2068 to 2717, 3692 to 3863
tip-popoverPredefined amounts, custom amount, min and max checksTS 3620 to 3690. Template 2573 to 2720.
share-urls and the share modals' handlersStore, portfolio and event-ticket share linksTS 4383 to 4666
inbox-panel, inbox-row, broadcast-rowSearch box, tab pills, infinite scroll, both row types and the row menuTemplate 28 to 533
thread-headerAvatar, name, recipients, badges, block and report menu, mobile closeTemplate 540 to 745
message-list, message-bubble, media-grid, share-cardDay separators, upward paging, every bubble type, both media copiesTemplate 746 to 1872
dm-gatePermission retry, price-for-DM notice, under-18, white-label exclusive, tip requiredTemplate 1885 to 1944, 3010 to 3040
automated-messages-tabThe table and its row actions, the add button, the editor modal host, the delete confirmationTS 376, 390 to 404, 634 to 660, 4318 to 4392. Template 3050 to 3117.

Order of work

The order matters. Pure functions come first because they're the only thing I can test without the component. The automated tab is the first component because nothing else depends on it. Stores come before the rest of the template split, because a template split on top of today's fields just spreads the twelve-field problem across sixteen files. OnPush comes last because it only works once nothing mutates in place.

  1. Get a test runner working and pin today's behavior1 PR

    karma.conf.js and src/test.ts exist, but the repo has no specs, so nobody knows whether ng test still runs on Angular 21. Get it running, or switch the messages specs to the Vitest-based unit-test builder. Then write a short manual script for the flows in the test matrix below. Run it on the current build and save three things per flow: screenshots at desktop and phone widths on the main theme and one white-label theme, a HAR file of the network traffic including payment payloads, and a screen recording for anything with timing, like loaders and scroll position.

    Done when one trivial spec passes in CI and the baseline captures are saved where every later PR can compare against them.

  2. Pull the pure logic out into functions2 or 3 PRs

    Move sameConversationId, matchesInboxRow, messageBelongsToThread and getThreadMessageKey into conversation-id.ts. Move mergeHistoryWithLiveMessages into live-merge.ts, taking the stash as an argument. Turn getDayDateProfile and onsetreceive into one decorateThread(messages) that returns new objects with the same profileStatus, messageDay and updatedDate values the two methods produce today. Write decideSendPath(dmValidations, tip), which returns consumeCredits, payForMessage, payForMessageAndTip, tip or free. Add the three payment payload builders, buildStoreShareUrl, and resolveInitialThread, which folds route id, history.state.user_key, ?user=, history.state.groupId, showBulkMessagesOnLoad and the white-label owner default into one decision, with the same precedence the scattered checks have today.

    The component keeps calling these from the same places. The commits in the last two weeks, like keeping live messages across a history load and keeping an incoming guaranteed-reply tip across permission checks, each get a spec named after the bug.

    Done when each function has specs covering the cases those commits fixed, and the component diff is only call-site swaps.

  3. Fix only the invisible bugs3 small PRs

    Keep a single post-broadcast receiveMessage subscription instead of one per send. Delete the dead abort.bind line. Split today's destroy$ users into two groups without changing what either group does. Page-only streams, like history, inbox, the online poll and shorten-URL, move to takeUntilDestroyed, which cancels them on leave. Today they're either cancelled on leave already or their result lands on a destroyed component, so nobody sees a difference. Send streams, meaning uploads, the blurred image and the credit call, keep today's rule: they run to completion unless the user leaves before the first socket event. The heartbeat keeps its own copy of today's subject so its timing doesn't move.

    Done when five broadcast sends followed by one incoming message produce one inbox load on screen, as before, and the HAR from leaving mid-upload matches the baseline, with the message still delivered.

  4. Extract the automated messages tab1 PR

    It's the most self-contained part of the page: a table, an add button, a delete confirmation and a list fetch, with no thread or inbox state. That makes it the right first component. It sets the folder, constants, SCSS and dialog pattern before the risky moves. The #deleteAutoMessage dialog and the app-automated-message-modal host move with it. Keep AutomatedMessageModalComponent itself as it is for now.

    Done when the tab's screenshots match the baseline, create, edit and delete behave as before, and the container no longer imports TableComponent or AutomatedMessageModalComponent.

  5. Inbox store and one socket entry point1 or 2 PRs

    Create InboxStore with rows keyed by tab and ChatEventsService. The container reads inbox.rows() and the template binds to it. The ngAfterViewInit socket subscription moves out.

    Done when chatLogsList no longer exists and there are no index reads left.

  6. Thread store with ActiveThread2 PRs

    Move history, the stash, dmValidations and the guaranteed-reply flag into ThreadStore. Rewrite getUserMsgDetails, requestParamsofBroadcast, the four onBroadcast branches, matchWithEmail, setNewUser and onSendTipBack so each builds an ActiveThread and calls thread.open(). Opening a thread cancels that store's in-flight history and presence requests, as historyCancel$ does today. This step changes the most code, and it's the one that pays for the rest.

    Done when the twelve thread fields are gone from the component, and every entry point in the matrix opens the same thread with the same header, links and composer state as the baseline.

  7. Shared leaf components3 or 4 PRs

    Extract media-grid first, since it removes the biggest copy. Then do attachment-tray for the DM composer's free and paid trays, backed by ComposerStore. Point image-audio-message at its own ComposerStore instance and the same drop rules and compression code, and leave its template alone. After that come message-bubble, share-card, inbox-row and broadcast-row.

    Done when the DM template has one media block and one tray, the automated composer uses the shared logic, and every screenshot matches the baseline.

  8. Send jobs2 PRs

    Build MediaUploadService so each upload returns an observable of progress plus the final item, which drops the shared percent and resItems fields. Build SendQueueService around the SendJob described above. Move the existing branches across unchanged. Move automated-message-modal onto the same upload code in the second PR.

    Done when uploadPriceItemsGeneric exists in neither file, and the payment payloads match the baseline key for key in every money flow.

  9. Panels, page shell, dialogs and strings3 PRs

    Build inbox-panel, thread-panel with thread-header and message-list, and composer with tip-popover, audio-recorder and dm-gate. Turn the inline alert modals into the three dialog components. MessageContainerComponent keeps its name, selector, route and guard, and becomes the shell. Every hardcoded string moves into messages.constant.ts under grouped keys as its block moves, with identical text.

    Done when the shell is under 200 lines of TS, no template is over about 400 lines, screenshots and HARs match the baseline, and npm run build-dev shows the messages chunk no bigger than before. Moving statically imported children won't make it smaller. Lazy-loading anything would add loading states, so it's out of scope.

  10. OnPush and the end of ngAfterViewChecked1 PR

    Turn on ChangeDetectionStrategy.OnPush from the leaves up. Delete ngAfterViewChecked and the ten manual detectChanges() calls. Wherever something stops updating, a store is still mutating in place, so fix the store, not the component. Watch the timing-sensitive spots: the recording timer, upload percent, the tip popover's padding calculation and scroll after send.

    Done when no detectChanges is left in the messages module and the screen recordings match the baseline frame for frame at the moments that matter.

What to test on every PR

Every flow is compared against the baseline from step 1, not against what seems right. If today's build does something odd, the PR has to do the same odd thing. Run each flow as four users: a creator on the main site, a fan on the main site, a white-label owner, and a white-label fan. The white-label fan never sees the inbox and lands on the owner's thread. Run the upload flows once more inside the mobile app, where file picking goes through MobileBridgeFileInputDirective. Build one generated white-label project as well as TopFans, since its theme file reaches into these components by selector.

AreaFlows
Opening a threadCold load, tip-back route id, history.state.user_key from a profile, ?user=, bulk tab on load, New Message to an existing user and to a new user, a mailing list, followers
ReadingScroll up through older pages, a live message arriving while history is still loading, a live message for a thread that isn't open, unread counts and tab pills
SendingText, free media, paid media with blur, free and paid together, audio, store and portfolio and event shares, a broadcast, a followers send, a restricted image, a video upload while switching to another thread, which must land in the same thread it lands in today. On a paid send with free previews, check the price chip and the free-preview tray stay in the composer until the paid upload finishes, then clear.
MoneyTip, guaranteed reply requested and fulfilled, pay per message with no free credits, pay per message plus tip, unlock a paid item
InboxSearch, infinite scroll on each tab, edit list details, edit participants, delete a list, block, report
Automated messagesCreate with media and audio, edit, delete. Type a DM message and attach a file, switch to the automated tab and back, and check the text and file are still there, as they are today. Record audio on the automated tab and check it doesn't show up in the DM composer.
Leaving and coming backLeave mid-upload and check the message is still delivered. Come back and check the inbox starts empty and reloads, the composer is empty, and the socket still works on other pages.

Decide before starting

  • Can feature work on message-container pause during steps 5 to 9? The pagination and socket-envelope fixes are still landing on this branch. Any parallel edit to the container will conflict with a store move. My preference is a short freeze with each step merged within a day, rather than a long-lived refactor branch.
  • Karma or Vitest? If karma runs today, keep it and don't fight two battles at once. If it's broken, I'd move to Vitest instead of repairing karma.

Follow-ups that change UX and need a product decision

None of these are in the plan. Each one is a separate ticket after the refactor lands, and the refactor makes each one small.

  • Deliver a media send to the thread it was written in. Today it goes to whichever thread is open when the upload finishes, and the spinner flips on the wrong bubble. After the refactor, the fix is one change inside resolveDeliveryTarget. I'd do this first. A message going to the wrong person is worse than any layout bug.
  • Clear the whole composer on Send. Today the price chip and the free previews stay in the composer until the paid uploads finish, and the job reads the free previews only at that point. So a free preview the user adds during the upload goes out with the earlier message. I traced this in the code but haven't reproduced it in a browser. The fix is to copy the free previews into the job at Send and clear everything at once.
  • Warn before leaving or switching threads mid-upload. Today both are allowed once the upload has started.
  • Cancel uploads when the user leaves. Today the message is still delivered. Whether that's a bug or a feature is a product call.
  • The two-minute presence heartbeat. As written it doesn't run reliably. The backend team should say whether presence needs it and how often.
  • One rule for duplicate messages. The backend should confirm which of sk, message_id and client_msg_id every history item and every socket event carries.
  • One price modal and one tray look. v1 and v2 of the price modal, and the automated composer's own tray markup, could be unified. Users would see it.
  • Stale header values. These are any values that step 6 had to copy forward because today's UX shows them.
  • Lazy-loading the automated tab, players or emoji picker. It may shrink the route chunk, and it adds loading states.