Skip to content

Chat performance improvements#649

Merged
feruzm merged 6 commits into
developfrom
mono
Feb 10, 2026
Merged

Chat performance improvements#649
feruzm merged 6 commits into
developfrom
mono

Conversation

@feruzm
Copy link
Copy Markdown
Member

@feruzm feruzm commented Feb 10, 2026

Summary by CodeRabbit

  • New Features

    • Virtualized message list for smoother scrolling
    • Admin moderation panel (ban, delete posts/accounts, nuke)
    • Keyboard shortcuts modal and mention tokens with quick DM
    • Deep-link focus for specific messages
    • Channel-scoped real-time WebSocket for typing and pending-post tracking
  • Improvements

    • Reduced re-renders via memoization
    • Better emoji/markdown/image rendering and fallback handling
    • Improved image upload accessibility and button behavior
    • Layout and header UI refinements

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

Adds many chat features: virtualized message list (react-virtuoso), admin/moderation UI and hook, multiple chat hooks (websocket, metadata, deep-linking, pending posts, message rendering, post actions, DM warning), several UI components (channel header, mention token, keyboard shortcuts modal), message-input and emoji/GIF picker updates, emoji-utils typings, Mattermost API/ws minor typing tweaks, and image-upload prop/accessibility updates.

Changes

Cohort / File(s) Summary
Dependencies
apps/web/package.json
Bumped package and added react-virtuoso ^4.18.1.
Virtualized list & message components
apps/web/src/features/chat/components/virtualized-message-list.tsx, apps/web/src/features/chat/components/message-list.tsx, apps/web/src/features/chat/components/message-item.tsx
New VirtualizedMessageList (and INITIAL_INDEX); MessageList/MessageItem converted to memoized exports with refined prop typings, optional member.user_id, and custom equality/memoization.
Admin UI & hook
apps/web/src/features/chat/components/admin-panel.tsx, apps/web/src/features/chat/hooks/use-channel-admin.ts
Added AdminPanel component and useChannelAdmin hook exposing ban, delete posts/DMs, delete account, and nuke operations with confirmations and mutation wiring.
Channel UI & small components
apps/web/src/features/chat/components/channel-header.tsx, apps/web/src/features/chat/components/keyboard-shortcuts-modal.tsx, apps/web/src/features/chat/components/mention-token.tsx
Added ChannelHeader (title, pinned & online modals), KeyboardShortcutsModal, and MentionToken with dropdown actions (view profile / start DM).
Message composition & pickers
apps/web/src/features/chat/components/message-input.tsx
Refined refs to MutableRefObject, introduced GifPickerStyle type, conditional EmojiPicker render with onSelect, relaxed channelData.member.user_id typing, and explicit MouseEvent handlers.
Hooks — websocket, pending, rendering, actions, metadata, deep-link, dm warning
apps/web/src/features/chat/hooks/*
Multiple new hooks: useChannelWebSocket (typing, posted-event->pending matching), usePendingPosts, useMessageRendering, usePostActions, useChannelMetadata, useDeepLinking, useDmWarning — each exposes public APIs for consuming components.
Mattermost API & websocket
apps/web/src/features/chat/mattermost-api.ts, apps/web/src/features/chat/mattermost-websocket.ts
Added optional member.user_id and post.pending_post_id; token-refresh uses matched user; small iteration style change in websocket typing cleanup.
Emoji utilities
apps/web/src/features/chat/emoji-utils.ts
Internal typing adjustments, introduced EMOJI_UNICODE_REGEX constant and safer iteration typings.
Shared image upload
apps/web/src/features/shared/image-upload-button.tsx, apps/web/src/features/shared/image-upload-button/index.tsx
Added aria-label, title, and disabled props; forwarded accessibility props to Button; added try/catch around upload and respect external disabled.
Layout / channel list
apps/web/src/app/chats/_components/chats-client.tsx, apps/web/src/app/chats/layout.tsx
Removed channel timestamp rendering, added thread_unread to unread map, and adjusted container padding/overflow rules (overflow-x/hidden, overflow-hidden).

Sequence Diagram(s)

sequenceDiagram
  participant User as Client (UI)
  participant Input as MessageInput
  participant Mutation as SendMutation
  participant Server as Mattermost API
  participant WS as useChannelWebSocket
  participant UIList as VirtualizedMessageList

  User->>Input: submit message
  Input->>Mutation: trigger send (optimistic pending id)
  Mutation-->>UIList: render pending post (uses pending id)
  Mutation->>Server: POST message
  Server-->>WS: broadcast "posted" event
  WS->>WS: attempt match by pending_post_id or fallback (message/root/timestamp)
  alt matched
    WS->>UIList: onPostedConfirm (replace pending with confirmed post)
  else not matched
    WS->>UIList: add incoming post normally
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hop through threads with joyful cheer,

New lists, admin tools, and emojis near,
Websockets hum as pending posts take flight,
Pins and mentions sparkle in the night,
A tiny rabbit celebrates this chat so bright! 🎉

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Chat performance improvements' is generic and vague, using broad language that doesn't specify which performance improvements or what aspects of chat were changed. Consider a more specific title that highlights the main performance improvement, such as 'Add virtualization to chat message list' or 'Memoize message components for performance'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch mono

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
apps/web/src/app/chats/_components/chats-client.tsx (2)

534-537: Minor indentation inconsistency after timestamp removal.

Line 536's </div> closes the outer flex container (Line 513) but sits at the same indentation as Line 535's </div> (which closes Line 514). This is cosmetic — the JSX nesting is structurally correct.


1042-1046: Correct migration to isPending, but note the shared mutation state applies to all user rows simultaneously — clicking "Start DM" for one user will show the pending label on every user button in the list. Consider tracking the pending target username to scope the label.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/src/app/chats/_components/chats-client.tsx (2)

1043-1045: ⚠️ Potential issue | 🟡 Minor

isLoading should be isPending for mutation status check.

All other mutations in this file use .isPending (e.g., markChannelViewedMutation.isPending, joinChannelMutation.isPending), but directChannelMutation uses .isLoading on line 1043. In React Query v5, mutations expose isPending instead of isLoading.

Proposed fix
-                        {directChannelMutation.isLoading
+                        {directChannelMutation.isPending

117-128: ⚠️ Potential issue | 🟡 Minor

Type annotation on unreadByChannelId is narrower than the actual value.

Line 118 declares the fallback as Map<string, { mention_count: number; message_count: number }> (missing thread_unread), but the reducer on line 124 populates thread_unread and its generic includes it. This means callers going through the fallback type path won't see thread_unread. The two types should be consistent.

Proposed fix
-    if (!unreadSummary?.channels) return new Map<string, { mention_count: number; message_count: number }>();
+    if (!unreadSummary?.channels) return new Map<string, { mention_count: number; message_count: number; thread_unread: number }>();
apps/web/src/features/shared/image-upload-button/index.tsx (1)

40-52: ⚠️ Potential issue | 🔴 Critical

Remove duplicate: The modern ImageUploadButton implementation is unused and should be consolidated.

Two implementations exist in the codebase:

  • apps/web/src/features/shared/image-upload-button.tsx — modernized version using useImageUpload hook (react-query based)
  • apps/web/src/features/shared/image-upload-button/index.tsx — legacy version using manual uploadImage, getAccessToken, and useState

When both are present, module resolution prefers the directory structure, so all consumers import the legacy version. The modern implementation is completely unused dead code and should be removed. Consider either deleting the unused modern version or refactoring consumers to use it instead, then removing the legacy code. Also note that the export appears twice in apps/web/src/features/shared/index.ts (lines 20 and 52).

🤖 Fix all issues with AI agents
In `@apps/web/src/features/chat/hooks/use-message-rendering.tsx`:
- Around line 134-141: The code currently calls marked.setOptions() which
mutates global marked state; change to create a dedicated Marked instance and
use it in the markdownParser so you don't mutate globals. Import the Marked
class (or factory) from the marked package, then inside the hook create a new
instance (e.g., new Marked(...)) or call the Marked constructor and set options
on that instance (not marked.setOptions). Update markdownParser to call
instance.parse(content) and return that string, keeping the creation local to
the hook (e.g., inside useMemo) so no global marked state is modified.
🧹 Nitpick comments (11)
apps/web/src/app/chats/_components/chats-client.tsx (1)

460-1006: Heavy duplication of channel card rendering across all four sections.

The Favorites, Direct Messages, Regular Channels, and Search Results sections each repeat nearly identical channel card markup (avatar, title, unread badge, dropdown actions). Consider extracting a shared ChannelListItem component to reduce duplication and simplify future changes.

apps/web/src/features/chat/hooks/use-deep-linking.ts (1)

10-14: aroundQuery.data is typed as any — consider using the actual response type.

Since MattermostPostsResponse is already imported, the data field on aroundQuery could be typed as MattermostPostsResponse | undefined (or whatever the around-query actually returns) to preserve type safety across callers.

♻️ Suggested typing
   aroundQuery: {
-    data: any;
+    data: MattermostPostsResponse | undefined;
     isLoading: boolean;
     error: Error | null;
   };
apps/web/src/features/shared/image-upload-button/index.tsx (1)

22-22: Nit: Destructure aria-label and title directly instead of using restProps.

Since aria-label and title are the only remaining props in restProps and are explicitly declared in the interface, destructuring them directly would be clearer and avoid the indirection.

Proposed simplification

At line 22:

-export function ImageUploadButton({ onBegin, onEnd, size = "sm", appearance, className, disabled, ...restProps }: UploadButtonProps) {
+export function ImageUploadButton({ onBegin, onEnd, size = "sm", appearance, className, disabled, "aria-label": ariaLabel, title }: UploadButtonProps) {

At lines 70-71:

-        aria-label={restProps["aria-label"]}
-        title={restProps.title}
+        aria-label={ariaLabel}
+        title={title}

Also applies to: 70-71

apps/web/src/features/chat/hooks/use-post-actions.ts (1)

93-115: handlePinToggle has inconsistent return types: string | null | undefined.

The function returns a string (line 97) when pin limit is hit, null (line 114) on success, and undefined implicitly (line 94) when !canPin. Callers must handle all three, which is error-prone. Consider normalizing to always return string | null.

♻️ Suggested fix
   const handlePinToggle = useCallback((postId: string, isPinned: boolean) => {
-    if (!canPin) return;
+    if (!canPin) return null;

     if (!isPinned && (pinnedPostsQuery.data?.posts.length ?? 0) >= 5) {
       return "Cannot pin more than 5 messages per channel";
     }
apps/web/src/features/chat/components/admin-panel.tsx (1)

53-53: Remove debug console.log from production code.

Line 53 logs the full admin permissions object to the console. Consider removing this or gating it behind a development-only check.

apps/web/src/features/chat/components/virtualized-message-list.tsx (2)

7-7: Consider exporting INITIAL_INDEX as a named constant with documentation.

INITIAL_INDEX = 100000 is a Virtuoso pattern for reverse-infinite scrolling. A brief comment explaining why this value exists would help future maintainers.


265-268: followOutput return type cast is unnecessary.

The as "smooth" | false cast can be avoided by typing the return directly, since TypeScript can infer the union from the ternary.

♻️ Suggested simplification
   const followOutput = useCallback(
-    (isAtBottom: boolean) => (isAtBottom ? "smooth" : false) as "smooth" | false,
+    (isAtBottom: boolean): "smooth" | false => (isAtBottom ? "smooth" : false),
     []
   );
apps/web/src/features/chat/hooks/use-channel-websocket.ts (1)

12-49: Inline type intersection is harder to maintain than extending the interface.

The extra params (setIsWebSocketActive, setReconnectAttempt, setReconnectDelay, isWebSocketActive) are added via an intersection type at the function signature (line 45) instead of in UseChannelWebSocketParams. This splits the contract across two locations.

♻️ Suggested consolidation
 interface UseChannelWebSocketParams {
   channelId: string;
   queryClient: QueryClient;
   getCurrentUserId: () => string | undefined;
   onPostedConfirm: (payload: OnPostedConfirmPayload) => void;
   pendingPostRefs: { /* ... */ };
   usersById: Record<string, MattermostUser>;
+  setIsWebSocketActive: (active: boolean) => void;
+  setReconnectAttempt: (attempt: number | null) => void;
+  setReconnectDelay: (delay: number | null) => void;
+  isWebSocketActive: boolean;
 }
apps/web/src/features/chat/components/message-item.tsx (1)

104-156: Custom equality function is thorough but reactMutationPending, canPin, and pinMutationPending trigger re-renders for all items.

Lines 146-148 compare global mutation/state flags that change simultaneously for every item, causing all memoized items to re-render when any reaction or pin mutation is in flight. This reduces the effectiveness of the custom comparator for those state transitions.

Consider moving these checks into per-item derived booleans (e.g., only pass reactMutationPending when the item's reaction picker is open, or handle the disabled state at the parent level) to avoid invalidating every item's memo.

apps/web/src/features/chat/hooks/use-message-rendering.tsx (2)

148-153: RegExp re-created on every renderTextWithMentions call.

renderTextWithMentions is invoked many times per message (for each text segment, trailing text, etc.), and each call allocates a new RegExp on Line 150. Since the pattern is static, hoist it outside the function.

Proposed fix
   const renderMessageContent = useMemo(() => {
+    const mentionMatcher = new RegExp(USER_MENTION_PURE_REGEX.source, "i");
+
     const renderTextWithMentions = (content: string, keyPrefix = "mention") => {
-      const mentionMatcher = new RegExp(USER_MENTION_PURE_REGEX.source, "i");
       const parts = content.split(

270-310: Redundant conditions in isPlainImageLink detection.

Lines 282-283: childText === href vs childText === href.trim() — since URLs shouldn't contain leading/trailing whitespace and childText is already trimmed, these are effectively identical. Consider simplifying to reduce cognitive load.

Simplified check
                 const isPlainImageLink = !containsImage && isImageUrl(href) && (
                   childText === href ||
-                  childText === href.trim() ||
                   !childText ||
                   childText === href.replace(/^https?:\/\//, '').trim() ||
                   isImageUrl(childText)
                 );

Comment thread apps/web/src/features/chat/hooks/use-message-rendering.tsx
@feruzm feruzm merged commit a084038 into develop Feb 10, 2026
1 check passed
@feruzm feruzm deleted the mono branch February 10, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant