Development notes

Messages Phase 1: stop waiting

Opening a thread no longer sits on a 1s timer and then chat-user-validation before you can read anything. History and permissions run together. The composer is visible with send locked until the server says you may send. Sending another DM to a conversation already in the list patches that row and skips get-chatGroups-and-users.

dc5f52683a hotfix/release-0.0.23/messages-phase-1 into hotfix/release-0.0.23/messages-revamp PR 10885

What shipped

Phase 1 from the opus-5-5 notes, minus two items we skipped on purpose, plus the send and tab fixes that landed on the same branch. Almost all of it is message-container. PR 10885 review covers the last commit in more detail.

PointCommitWhat you should see
1. Kill the 1s timer. Start history and validation together e92434dddd, then 10fb9557c5 Receiver comes from the inbox row gsi4pk. Validation starts with the history POST, not after it.
2 + 4. Separate cancel streams. Stop validating from the inbox list 70fc2cc615 A list refetch or a thread switch cancels only its own request. A late reply for thread A cannot write into thread B.
3. Split the right-pane loader 10fb9557c5 historyLoading hides only the timeline. The composer stays up. Send, tip, and unlock stay disabled until permissions arrive.
5, scroll half. afterNextRender instead of a 500ms timer e92434dddd Scroll runs in the write phase after the list paints. The inbox skeleton from this point was skipped.
7. Partition, then sort once. Track by ids c4e7a4ccee Each tab is sorted once. Conversations track groupId. Messages track sk or uinqueId.
8. Search stream bc6edfba1d Still waits 500ms. Unchanged text is ignored. switchMap drops the old query.
Compile leftovers ac1526e2e5 Unused isMailingList and initial args renamed so noUnusedParameters passes. Call sites did not change.
Search listener after login, not in the constructor 2945620591 listenForInboxSearch() starts after fetchInitialData. The empty-string GET no longer races the first inbox load.
Tab heading is a signal. Prefetch emoji CSS ce0dc226ae changeTab reads event.heading(). Hover or click on the emoji button loads the mart CSS. No @defer. This runtime's Promise is named $ and has no allSettled.
Do not refetch the inbox on another DM. Fail closed if permissions never arrive dc5f52683a Existing conversation: patch preview text, timeStamp, and updated_at. New conversation still fetches. Send stays locked if group create fails or the receiver has no id. Mailing list send no longer reads dmValidations.minTipKeys when that object is null.

What we left alone

Skipped Point 6, ngAfterViewChecked still calls detectChanges(). Taking that out without a pass over missed updates is how you get a silent blank pane. Do it in its own change.

Skipped The inbox list skeleton from point 5. The first inbox GET still uses the existing spinner. Later refetches only turn that spinner on when every tab is empty.

History is still POST api/getpreviousMessagesFromHttp. No combined thread bootstrap. A new incoming thread still refetches the inbox. Sending another DM to a row you already have does not. Phase 2 click-time imports and video CSS were left alone. @defer was tried and dropped.

The wait this removes

On a creator open of an existing thread, after the route chunk and profile were already in memory, the old path was:

  1. Inbox GET
  2. History POST
  3. 1000ms of setTimeout that guessed the other user from messageList[0]
  4. Validation POST
  5. msgrightLoader finally false, so both the timeline and the composer appear
  6. Another 500ms before scroll

History and validation only need the selected groupId and the other user's gsi4pk. The inbox row already has both. The shared userValidationCancel$ was the other problem. Starting either call cancelled the other, so you could not run them together, and an inbox refetch in the middle of a thread switch cancelled history.

1. Kill the 1s timer. Start history and validation together

getUserChatMessage used to POST history, then call getUserEmail from the subscribe. getUserEmail waited a second, then started validation. Now, on a fresh thread open, it sets both loaders, starts validation, then fires the history POST. Pagination does not restart permissions and does not hide the list.

if (!onSrcollItemUp) {
  this.historyLoading = true;
  this.permissionsLoading = true;
  this.permissionsFailed = false;
  this.dmValidations = null;
}
this.historyCancel$.next();
const requestedGroupId = email;
if (!onSrcollItemUp) {
  this.getUserEmail(selectedUser, showMainLoader);
}
const sub = this.apiCallService
  .post(API_URLS.previousMessages, messageObj, showMainLoader)
  .pipe(takeUntil(this.historyCancel$))

The 1s guess is gone. The other user is selectedUser?.gsi4pk || this.gsi4pk, which is the inbox users[0], not the logged-in account. Broadcast and followers skip validation and unlock the composer immediately.

- setTimeout(() => {
-   const data = this.messageList[0];
-   this.fetchedUser = data?.from === this.loggedInUser ? data?.to : data?.from;
-   this.checkNewUserValidation(this.fetchedUser, ...);
- }, 1000);
+ const receiver = selectedUser?.gsi4pk || this.gsi4pk;
+ this.checkNewUserValidation(receiver, false, showMainLoader);

2 + 4. Separate cancel streams. Stop validating from the inbox list

The inbox handler used to call checkNewUserValidation on every list response. That fired userValidationCancel$, which history also listened to. A websocket refresh or a search result could kill the thread you just opened.

- private userValidationCancel$ = new Subject<void>();
+ private historyCancel$ = new Subject<void>();
+ private validationCancel$ = new Subject<void>();
+ private searchChatLogs$ = new Subject<string>();

Each request stores the groupId it was started for. If this.sid has moved on, the reply is dropped. Destroy next()s both cancel subjects so in-flight work dies with the page.

this.historyCancel$.next();
const requestedGroupId = email;
// ...
.subscribe({
  next: (res) => {
    if (this.sid !== requestedGroupId) {
      return;
    }
    // write messageList, then historyLoading = false
  }
});

The inbox complete handler used to flip msgrightLoader false while history could still be in flight. That is gone. Inbox completion does not touch the right pane.

3. Split the right-pane loader

msgrightLoader gated both panes. Ten places set it. Split:

- msgrightLoader = false;
+ historyLoading = false;
+ permissionsLoading = false;
+ permissionsFailed = false;

Timeline:

- @if (msgrightLoader) { <app-loader> }
- @if (messageList?.length && !msgrightLoader) { ... }
+ @if (historyLoading) { <app-loader> }
+ @if (messageList?.length && !historyLoading) { ... }

Composer. While permissions are in flight we still show it, even if the last thread had is_hidden. After a success we honor is_hidden again. Send, tip, unlock, the textarea, and the footer icons use areMessageActionsLocked, which is loading or failed. The methods themselves return early too. That matters. Paid DM must not look free for a frame.

- @if (!msgrightLoader && (cs.isWhiteLabelWeb || !dmValidations?.is_hidden) && ...)
+ @if ((permissionsLoading || cs.isWhiteLabelWeb || !dmValidations?.is_hidden) && ...)

get areMessageActionsLocked(): boolean {
  return this.permissionsLoading || this.permissionsFailed;
}

If validation returns status: false or errors, the thread stays. A Retry button calls retryThreadPermissions(). Copy lives in messages.constant.ts: PERMISSIONS_UNAVAILABLE and RETRY.

5, scroll half. afterNextRender instead of a 500ms timer

scrollToBottom used a timer so the scrollbar existed. That is what afterNextRender write is for. A bare callback is mixedReadWrite and Angular nags. The write phase is the one that moves the scroller.

afterNextRender(
  {
    write: () => {
      const chatListDiv = this.chatListDiv();
      if (chatListDiv) {
        chatListDiv.scrollTo({ bottom: 0, duration: 0 });
      }
    }
  },
  { injector: this.injector }
);

7. Partition, then sort once. Track by ids

searchResults used to push then sort on every row. A 200-conversation inbox paid for hundreds of sorts. It now fills three arrays, sorts each once, then assigns.

const broadcast = [];
const featured = [];
const general = [];
usersList.forEach((item) => { /* partition into those three */ });
broadcast.sort((a, b) => b.updated_at - a.updated_at);
featured.sort((a, b) => b.last_message.timeStamp - a.last_message.timeStamp);
general.sort((a, b) => b.last_message.timeStamp - a.last_message.timeStamp);

Fans still land everything in tab index 2. Featured and general are concatenated and sorted by timestamp, which is what the old shared-array sorts ended up doing.

Identity track rebuilt every row on refetch. Optimistic sends have uinqueId and sometimes an empty sk, so the message track is sk || uinqueId.

- track data;
+ track data.groupId;

- track trackItem(idx, log);
+ track log.groupId;

- track message;
+ track message.sk || message.uinqueId;

8. Search stream

There was already a 500ms setTimeout. A slow first query could land after a later one and put the old list back. The input now pushes into a subject. switchMap unsubscribes the previous GET.

this.searchChatLogs$
  .pipe(
    debounceTime(Application.FIVE_HUNDRED),
    distinctUntilChanged(),
    tap(() => { this.isLoadingChatLogList = true; }),
    switchMap((value) =>
      this.apiCallService
        .get(API_URLS.chatLogsUsersList + `?search_key=${value}`, false)
        .pipe(catchError((err) => {
          this.cs.showErrorMessage(err);
          this.isLoadingChatLogList = false;
          return EMPTY;
        }))
    ),
    takeUntil(this.destroyV2$)
  )
  .subscribe((res) => { /* updateCount + searchResults */ });

Initial inbox load still goes through getUserChatLogs. Only the search box uses the stream. That is intentional. Block-user refetch and group-create refetch stay on the old function.

2945620591 moved this subscription out of the constructor. It now starts in listenForInboxSearch() after the first login fetch. Same operators. The page-lifetime listener no longer fires an empty search while the inbox is still loading.

Tab heading is a signal. No @defer

ngx-bootstrap TabDirective.heading is an InputSignal. changeTab used to assign event.heading to this.active, which is Application. The tab never matched 'Messages', so the automated-messages tab looked selected and the messages pane stayed up. Call heading().

- this.active = event.heading;
+ this.active = event.heading();

Phase 2 tried @defer on the automated tab and on the emoji picker. This app's Promise is a custom $ without allSettled, so the deferred view never swapped, then threw on hover. Both @defer blocks are gone. The emoji button prefetches the mart CSS on mouseenter and click instead.

Do not refetch the inbox on another DM

Send used to call get-chatGroups-and-users?search_key= because updateUserList looked for this.sid only in the tab it was sorting. Creators sort Featured and General. Sean lives in Priority. General's !foundObj was true, so it refetched. The open-thread receiveMessage branch also fetched on the send echo.

The list already has the row. Patch it. Refetch only if that groupId is in no tab.

data[i].last_message.text = selectedObj.text;
data[i].last_message.timeStamp = Date.now();
data[i].updated_at = Date.now();
// ...
const foundInInbox = this.chatLogsList.some((tab) =>
  tab?.data?.some((el) => el.groupId === this.sid)
);
if (!foundInInbox) {
  this.getUserChatLogs(false, '');
}

The time label is getTimeDiff(updated_at || last_message.timeStamp). Updating only timeStamp left "5 Minutes Ago" on screen. Set both.

getUserChatLogs used to flip isLoadingChatLogList on every call, which is why the inbox spinner appeared after send. The spinner now turns on only when the list has no rows. A first message to someone new still fetches. An incoming message from a thread that is not in the list still fetches. A mailing-list send still refetches when the send socket reports status.

Fail closed when permissions never arrive

Opening a mailing list after a custom tip on a DM left dmValidations null and the tip amount still set. sendMessage read this.dmValidations.minTipKeys and threw. The tip read is optional now. Broadcast open calls closeTipPopup().

const minTip = Number(
  this.dmValidations?.minTipKeys?.min_tip_guaranteed_reply ||
    this.minimumTip
);

If generateGroudId rejects, or the response has no group id, or the receiver has no email, permissionsFailed stays true and dmValidations is null. Send, tip, unlock, and emoji stay disabled until Retry gets status: true.

How I checked it

Logged in on local :4200 as the Perfect Crew creator. Vite overlay from a 500 on wls-config.json is unrelated and was dismissed. The later send and tab rows were checked on the same account after dc5f52683a.

CheckResult
Open /messagesInbox tabs, default thread Sean Andrew, history visible, composer visible, textarea enabled, send disabled while empty.
Switch to Newton JamesHeader and 126 messages for Newton. Composer still enabled. No permissions error.
Search TripleAfter the debounce, only Triple Spur Band in the list. Sean Andrew thread stayed open.
Type ok in the composerSend enabled. Cleared, not sent.
Click a display nameStill navigates to that profile. Existing. Click the last-message preview to stay on Messages.
Send time-check-176 to Sean after the inbox-refetch changeRow preview became that text. Time flipped to Now. No get-chatGroups-and-users.
Automated Messages tabAfter heading(), the tab and pane match. No @defer skeleton stuck on that tab.
Production buildNot run. This is PR 10885 into messages-revamp.
Fan, paid DM, guaranteed reply, ?user=, tip-backNot run in this pass.

Verification checklist

Creator on a white-label site for most of this. A fan account for the last group. DevTools Network open, disable cache. Watch getpreviousMessagesFromHttp and chat-user-validation. They should start in the same window, not 1s apart. Grouped by what this PR can actually break.

0 of 0 checked

High First paint and the two loaders

High Thread switch and stale replies

High Paid DM, tip, unlock

Medium Search, sort, tracking

Medium Entry paths

Low Fan, white-label, leftovers