Make messages maintainable

Split the page by responsibility. Give each piece one owner for its state, move transport and payment work out of the UI, and preserve the current inbox, history, and send behavior one extraction at a time.

TopFans web frontend · Source reviewed 7 October 2026 · Base revision 61843a4e3b, with the current working tree · Proposed refactor, application code unchanged

The container owns too much

4,746
TypeScript lines
3,180
HTML lines
1,228
SCSS lines

Those 9,154 lines belong to one component. It searches and pages the inbox, selects recipients, fetches history, merges live events, validates DMs, records audio, uploads media, chooses payment paths, manages broadcasts, edits automated messages, opens dialogs, and controls scroll position.

The size is a symptom. The real problem is that changing one job requires understanding all the others. sendMessageApiCall() alone mixes socket payloads, DM credits, tips, guaranteed replies, and payment navigation.

Existing code that defines the extraction boundaries
ResponsibilityCurrent locationWhy it needs its own owner
Inbox and selectionTS 723-857, 3865-3962, 4029-4050
HTML 28-533
Search, cursor, tab counts, recipient selection, and row actions share mutable lists.
Thread and live messagesTS 2810-3072, 3359-3545
HTML 734-1872
History replacement must retain live events. Older pages must preserve the reader's scroll position.
Draft, attachments, and sendingTS 1104-1141, 1535-1780, 2018-2717
HTML 1874-3024
Draft fields and send jobs share arrays, recipient fields, upload progress, and payment state.
Automated messagesTS 634-660, 4326-4360
HTML 3046-3117
A separate feature tab still lives inside the chat container. Its existing media editor repeats composer logic.
Share content and dialogsTS 4383-4647
HTML 3105-3180
Share URL creation, item deduplication, preview state, and dialog references pull unrelated imports into the shell.

Line references are relative to components/message-container/message-container.component.* under src/app/modules/messages/. They identify today's code, not permanent interfaces.

Keep the recent behavior

The current source already has cursor pagination, cancellation on tab or thread changes, an open-thread presence poll, and history responses that can include DM validation. An older plan in notes recommends separate history and permission requests. That recommendation no longer matches this checkout.

  • Keep the existing lazy MESSAGES_ROUTES entry, access guards, and canDeactivateGuard. The route already uses a standalone container. A new NgModule would add work without fixing ownership.
  • Keep ApiCacheService for inbox and presence GETs. History is a POST through ApiCallService, even though it reads data. Preserve groupId, cursor, from, and includeValidation.
  • Keep mergeHistoryWithLiveMessages() and its pending-event buffer when moving history. A socket event can arrive before the first history page returns.
  • Keep server counts as the authority when the socket provides them. Preserve the fallback count updates when counts are absent.
  • Keep message actions disabled while DM validation loads or fails. Readable history and permission status need separate fields even though one request returns both.
  • Keep tenant recipient defaults, owner self-chat checks, delegate permissions, age restrictions, and mobile back navigation. These decisions belong in typed page policy, not repeated template conditions.

The component split

Keep MessageContainerComponent as the route shell. It chooses the active page tab, applies route inputs, handles the mobile layout, and delegates the navigation guard. It should have no upload pipeline, socket-event parsing, or payment branch.

  • MessageContainerComponentExisting route entry. Page layout, route context, access policy, navigation guard.
    • MessagesInboxComponentSearch, Priority / General / Bulk tabs, cursor loading, empty and error states.
      • ConversationRowComponentAvatar, badges, preview, unread count, selection and row action intents. Direct and mailing-list row variants.
    • MessageThreadComponentSelected conversation layout and coordination. It passes view models to its children.
      • ThreadHeaderComponentParticipant or group title, presence, badges, block/report, mobile back.
      • MessageTimelineComponentScroll viewport, older-page trigger, date groups, history loading and errors.
        • MessageBubbleComponentSender, time, delivery state, text, tip/dispute notices, and message actions.
        • MessageMediaComponentImage, video, audio, file, locked-media, and shared-item variants. Reuse existing player and preview components.
      • MessageComposerComponentText entry, send intent, access notices, and local draft controls.
        • MessageAttachmentEditorComponentPaid attachments, free previews, ordering, limits, removal, and price intent.
        • MessageRecorderComponentRecord, stop, cancel, preview, and waveform. Browser-only resources.
        • MessageTipPickerComponentPreset and custom amount UI. Payment decisions stay outside it.
    • AutomatedMessagesPanelComponentList, create/edit/delete, loading, and retry. Reuse the existing automated-message modal.

The tree describes ownership. Do not create an empty component for every line in it on day one. Extract the inbox and automated panel as complete units. Split a media variant into another component only when its player, inputs, or lifecycle justify that file.

Keep these components inside src/app/modules/messages/components/. Common buttons, tabs, icons, tables, and skeletons come from src/app/components/. Feature components should not become global shared components just because two message screens use them.

Give state one owner

Moving all 4,746 lines into a MessagesService would leave the same problem. Use small owners with typed state and commands. A page coordinator connects their results. It does not hold another copy of their arrays.

Proposed ownerOwnsLifetime and limits
MessagesPageCoordinatorSelected conversation, route intent, page tab, tenant/delegate context. Connects inbox, thread, and send results.Provide at the container. No raw HTTP, media files, or payment implementation.
MessagesInboxStoreRows, search, active inbox tab, cursor, counts, loading/error states, and immutable row updates.Container-scoped. Replace cancels the old query. Append is guarded by the current tab, search, and request generation.
MessageThreadStoreHistory, cursor, validation, buffered live events, presence subscription, message identity, and merge results.Container-scoped. Reset on selection. Late history and validation results must match the current selection generation.
MessageDraftStoreText, attachments, free previews, price, selected share content, and tip amount.Provide at each composer/editor owner. The automated editor gets a separate instance. Preserve current reset behavior across selection.
MessageSendCoordinatorImmutable send jobs, upload progress, eligibility decision, credit consumption, payment handoff, and optimistic send result.Container-scoped to match the page queue and guard. Jobs retain their original recipient when the user opens another thread.
AutomatedMessagesStoreAutomated-message list, CRUD status, selected edit item, and refresh after save/delete.Panel-scoped. No dependency on live thread history.
MessagesApiServiceExisting endpoints, request builders, response typing, and cache choice.Stateless root singleton. GETs use ApiCacheService, mutations and history POST use ApiCallService.
MessageMediaServiceFile classification, compression, thumbnail creation, upload operations, and share URL requests.Stateless root singleton. Progress and cancellation belong to the owning draft or send job, never singleton fields.

Keep the existing root ChatService as transport. The shared header creates its connection, and the post-payment page also sends through it. The messages page owns its event subscription. It must not disconnect the global socket when a child component closes.

A single page-level socket adapter should decode incoming events and pass typed events to the stores. Move broadcast status handling into that adapter too. Today's sliceMessageFromMessageQueue() adds another socket subscription after a broadcast send.

Retain the current route-provided AudioRecordingService initially. Put recording subscription and stop/cancel ownership in the recorder, and make sure opening automated messages cannot deliver a recorded blob to the direct-message draft.

Define contracts before moving state

Reuse Angular's existing input() and output() style. Children receive read-only view models and emit user intents. Do not pass the parent component, a bag of callbacks, or shared mutable arrays into them.

BoundaryInputOutput or command
InboxInboxViewModel with rows, counts, tab, query and load statesSelect a ConversationRef, change tab/query, load next page, create/edit/delete list intent
HeaderThreadHeaderViewModel with participants, presence and allowed actionsBack, open profile, block, report, edit participants
TimelineThreadViewModel with grouped messages, page state and stable IDsLoad older page, retry history, preview media, request unlock
ComposerDraftViewModel, ComposerPolicy and current job stateDraft changes, submit snapshot, retry validation, price/share/tip intent
Attachment editorOrdered attachments, free previews, price, limits and allowed media kindsAdd/remove/reorder/move item, change price. No socket or DM-credit calls.
Automated panelAccount policy and its own list stateCreate/edit/delete and refresh. Its editor returns a saved result.
Send jobSendJob with client ID, conversation, recipient, tenant, draft snapshot and payment contextProgress, payment handoff, submitted result or failure, matched by job ID

Add models under interfaces/ for conversation references, message records, drafts, socket events, view models, and send jobs. Parse JSON and normalize legacy payloads at the API/socket boundary. Map the existing misspelled uinqueId field there; keep the backend payload compatible.

Put pure identity, sorting, day grouping, inbox-count, and merge functions under helpers/. The current older-page merge uses sk || timeStamp, while the live merge uses sk || message_id || uinqueId || id. Characterize both first, then agree on one identity rule. A timestamp alone can collide.

Do not introduce a second global state library for this feature. Use typed local state and the project's existing RxJS patterns for request cancellation. Adopt OnPush in an extracted component after its owner replaces arrays and objects rather than mutating them.

Treat each send as a job

The send path is the highest-risk extraction. A job must capture its conversation and recipient before upload begins. Reading this.sid, this.gsi4pk, or the currently selected tip amount after an asynchronous step can mix two conversations.

  1. Validate the draft and current composer policy. Preserve whitespace checks, media limits, age/delegate restrictions, guaranteed-reply prompts, and minimum-tip rules.
  2. Capture a typed draft snapshot, recipient, tenant owner ID, client message ID, and the relevant validation context.
  3. Upload that job's media and thumbnails. Keep free previews and paid attachments in their recorded order.
  4. Choose the current credit, paid-DM, tip, combined-payment, guaranteed-reply, or broadcast path. Keep the decision in the send coordinator, using a pure eligibility helper where possible.
  5. Apply the result to the original thread and inbox. Match optimistic items by client/server IDs. Clear only the draft that produced the job; a later edit must survive.
  6. Settle progress and guard state on completion, failure, cancellation, or payment handoff according to current behavior.

The code currently marks some socket sends complete immediately after chatService.send(). Preserve that meaning during extraction. Do not rename it "delivered" or invent a server acknowledgement. Payment retry and socket acknowledgement rules need backend confirmation before behavior changes.

Start by moving existing branches intact and testing their results. Refactor payment selection after the job boundary is stable. Do not bundle billing changes with the component split.

Move styles and dialogs with their owners

Angular style encapsulation makes this part easy to miss. Container selectors such as .messages-left .messages-item will stop reaching markup moved into a child. Move those selectors into the child's SCSS in the same change. Check src/assets/scss/_messages.scss too, since global styles also reach the page.

  • Keep only page columns, page tabs, and responsive shell layout in the container SCSS. Inbox rows, bubbles, composer controls, and recorders own their respective styles.
  • Use the six configured theme variables from .guides/color-guide.md in the Angular UI. Use --theme-interactive-link for link text, since the theme color can be a gradient. The palette on this document is only for notes.
  • Create message-specific modal components around AlertModalComponent for inline delete-list, tip-required, and guaranteed-reply dialogs. Keep existing shared dialogs reusable and leave their other consumers intact.
  • Give each dialog typed input data and a result event. The store or coordinator performs the operation after confirmation. Avoid a new universal dialog manager that imports every modal.
  • Group labels, validation messages, media kinds, socket actions, and dialog copy into related const objects. Extract existing hardcoded copy when moving each block. Use as const, not new enums.
  • Use the dynamic form builder when it supports every required field type. Custom attachment ordering, recording, and rich composer behavior can remain dedicated components.
  • Put inbox and thread skeletons under src/app/components/skeleton/. Match their final geometry and theme colors. Reuse the available listing/table skeletons when they fit.

Text input should use Angular bindings. Focus, selection, canvas rendering, and scroll measurement may need a small ViewChild boundary. Remove document-wide queries, manual textarea value writes, and dropdown class manipulation as their UI moves.

Migration order

Each step should leave the application usable. Move one responsibility, remove its old implementation, and verify its behavior before starting the next step. Keep the existing selector and route so callers do not need to change.

1. Capture behavior and establish the types

Record creator, fan, delegate, broadcast, and tenant flows. Add the boundary models and characterize history/live merge, count updates, and send eligibility. Extract pure helpers without changing their behavior. Record the route's loaded chunks as a baseline.

Ready when replayable fixtures explain selection, pagination, counts, validation, and payment branches. Identify the current draft-reset behavior before assigning a new owner.

2. Extract automated messages

Move the table, loading state, list fetch, CRUD callbacks, and delete confirmation into AutomatedMessagesPanelComponent and its store. Keep AutomatedMessageModalComponent. Convert its GET to ApiCacheService with an explicit refresh after writes. Leave its current media editor in place until the shared editor step.

Ready when create, edit, delete, retry, and reopening the panel work without live-thread fields or container callbacks. This is the first implementation slice I would take.

3. Extract inbox and selection

Move the left column and rows, then move cursor/search/count ownership into MessagesInboxStore. Connect selected conversation intents through the page coordinator. Preserve default tabs, deep links, mailing lists, participant edits, and white-label owner selection.

Ready when rapid tab/search changes cannot apply stale results and each row action works on its original conversation. The container no longer owns inbox arrays.

4. Extract thread, history, and live events

Move header and timeline templates with their SCSS. Move history, validation, cancellation, merge helpers, and presence into the thread store. Keep scrolling in the timeline component. Replace the multiple page socket subscriptions with one typed event adapter.

Ready when an A-to-B-to-A switch rejects old responses, a live event survives first-page loading, overlapping pages do not duplicate messages, and prepending history holds the reading position.

5. Extract composer, media, and send jobs

Give the composer its draft store and explicit snapshot submit contract. Move recording and attachment editing, then move the upload/payment/socket flow into the send coordinator. Replace the automated modal's old ImageAudioMessageComponent with the same attachment editor and a separate draft instance. Remove the duplicate logic once both consumers pass.

Ready when switching threads during an upload cannot change its recipient, a newer draft survives an earlier send result, payment paths match existing payloads, and automated-message files never enter the chat draft.

6. Remove the remaining coupling

Move inline dialogs into their owners. Replace broad DOM mutations and scattered subscriptions with owned teardown. Remove obsolete state, imports, duplicated media code, and migrated styles. Enable OnPush only after immutable updates work. Remove the unconditional ngAfterViewChecked() detection loop once the page no longer depends on it.

Ready when the shell handles layout, route intent, policy and navigation only. Optional code can then load on demand if bundle inspection shows a benefit.

Fix teardown at the extraction boundary

Today's container has two destroy subjects and a subscription array. ngOnDestroy() emits destroyV2$, but does not emit destroy$, which several upload/payment/share subscriptions use. It also binds awsRequest.abort without invoking the bound function. Those are concrete cleanup gaps.

Use takeUntilDestroyed() tied to the component-scoped owner's DestroyRef for lifetime cleanup. Keep separate cancellation for replaceable inbox queries, selected threads, presence polls, and individual upload jobs. Component destruction and thread replacement are different events.

  • Cancel the prior presence poll on selection and page exit. Keep browser-only work out of SSR.
  • Stop the microphone, remove listeners, revoke owned object URLs, and call the actual upload abort operation when its owner cancels.
  • Protect writes with a selection generation or request identity. Checking only the conversation ID is insufficient for A-to-B-to-A navigation.
  • Keep the shell's navigation guard connected to active send jobs. The current guard checks the queue, not unsent text. Adding a draft warning would be a separate product change.

Verification that catches real regressions

Test the data transitions that span requests and UI owners. Snapshot tests for extracted markup will not catch a message sent to the wrong recipient.

ScenarioRequired result
Inbox search, tab change, scrollEach query starts with its own cursor. Late responses cannot replace a newer result. Append does not reset existing rows or count badges.
Thread A → B → A with delayed historyOnly the latest selection generation can write history, validation, presence, or loading states.
Socket event during first-page fetchThe event remains after history replacement. A server ID or client ID present in both sources produces one message.
Older history with overlapRepeated messages do not duplicate. Date groups remain correct. The viewport retains its reading position.
Missing or failed DM validationHistory stays readable when available. Send/unlock/payment actions stay disabled. Retry uses the current thread.
Inbox socket countsServer counts take precedence. Local fallback changes do not double-count outgoing events or active-thread reads.
Send/payment combinationsFree or unlimited credits, exhausted credits, tip, DM plus tip, guaranteed reply, broadcast and followers keep the same destination and payload. Failed operations release loading state.
Upload followed by a thread switchThe job retains the original recipient and tenant. Its result updates that conversation without clearing the new draft. Queue guard remains active.
Paid and free mediaLimits, drag/drop order, thumbnails, locked-media unlock, price, previews, downloads, audio and share links still work.
Automated-message editingCreate/edit/delete, media patching, prices, form validation and refresh work. Recording and attachments stay isolated from the direct-message composer.
Page leave and returnNo accumulated socket handlers, polls, recorder subscriptions, object URLs, or abandoned uploads. Global chat transport remains available to other pages.
Main site and tenantCreator, fan, delegate, age restriction, owner default chat, self-chat exclusion, disabled DMs, configured links and mobile layout behave as before.

Use unit tests for merge/identity/count helpers and eligibility rules. Use controlled API/socket streams for cancellation and send-job integration tests. Use browser checks for scroll anchoring, modals, media playback, responsive layout, keyboard focus, and tenant themes.

Run npm run build-dev with the project's 10 GB memory setting. Repeat a representative generated white-label build using its actual generated project name and configuration. This checkout currently lists only TopFans in angular.json, so tenant build setup must come from the existing automation. Do not edit generated environments.

Check browser and SSR compilation, lazy route access, AOT template checking, and loaded chunks. The outer messages route is already lazy. Extracting a statically imported child does not automatically reduce the route bundle. Defer automated UI, players, or pickers only in a later measured change, with a loading state and no eager references defeating the split.

What completion looks like

  • The shell has no API calls, raw socket parsing, media upload loops, payment branches, or direct access to child draft fields.
  • Inbox, thread, draft, send queue, and automated messages each have one authoritative state owner. Their public contracts use named types instead of any.
  • A row or bubble consumes a view model and emits intents. It cannot mutate the parent's arrays.
  • Direct and automated messages use the same attachment editor with independent drafts. Legacy duplicate media code is removed.
  • New modal components use AlertModalComponent. Migrated copy uses grouped constants. Every extracted component has its own SCSS and verified tenant theme.
  • The regression cases above pass on the main project and a generated tenant. No unconditional view-check detection loop or document-wide UI mutations remain in the messages feature.

I would start with automated messages, then inbox ownership. Those changes establish the component and state pattern before touching the upload and payment path. File count is not the success measure. Being able to change inbox search without reading send-credit logic is.

Source notes

  • src/app/modules/messages/components/message-container/message-container.component.ts, .html, and .scss. Primary source for file sizes, responsibilities, send paths, state and cleanup.
  • src/app/modules/messages/messages.routes.ts and src/app/app-routing.module.ts:150. Existing lazy routing, access guards and route-scoped recording provider.
  • src/app/modules/messages/components/automated-message-modal/ and components/image-audo-message/. Existing modal and duplicated attachment/recording editor. The folder spelling is current source.
  • src/app/services/chat.service.ts, src/app/modules/shared/components/header/header.component.ts:726 and src/app/modules/dashboard/components/post-payment/post-payment.component.ts:2862. Shared socket ownership and another send consumer.
  • src/app/modules/messages/constants/messages.constant.ts, interfaces/messages.interface.ts, src/assets/scss/_messages.scss and the four .guides/ files. Existing payload builders, types, styles and repository conventions.
  • Colors copied from the Angular upgrade reference. Background #131A24, panel #1A2331, text #E8EDF4, muted #8593A6, rules #2A3546, blue #8FB4FF, green #5FCF8F, red #F0776F.

This document records source inspection and proposed implementation checkpoints. It does not claim application runtime tests, measured speed gains, or an implemented refactor.