Skip to content

General enhancements, optimizations, bug fixes#90

Merged
ValwareIRC merged 20 commits into
mainfrom
fix/general-bug-fixes-and-improvements
Oct 17, 2025
Merged

General enhancements, optimizations, bug fixes#90
ValwareIRC merged 20 commits into
mainfrom
fix/general-bug-fixes-and-improvements

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

@ValwareIRC ValwareIRC commented Oct 16, 2025

This PR fixes the scroll position issue in chat windows where new messages would always scroll to the bottom, even when users had scrolled up to read previous messages.

Changes

  • Added to track scroll position before new messages arrive
  • Modified auto-scroll logic to only scroll when user was already at bottom
  • Preserves scroll position when reading chat history
  • Maintains auto-scroll behavior for active conversation following

Testing

  • All existing tests pass
  • Manual testing confirms proper scroll behavior
  • No linting issues

Fixes the issue where scrolling up in a channel would be interrupted by new messages automatically scrolling to bottom.

Summary by CodeRabbit

  • New Features

    • Buffered channel listing with per-server advanced filters, lazy-loading and user-count sorting; UnrealIRCd detection for improved listing behavior.
    • Collapsible messages with "Show more"/"Show less".
    • Rich Markdown: syntax highlighting, language labels, copy buttons, improved URL/link handling and safer external-link behavior.
  • Bug Fixes

    • Channel list now commits only after listing completes.
    • Chat auto-scroll respects whether you were at the bottom.
    • Removed stale typing-done notifications.
  • UI

    • Operator credential controls in Account/Settings and enhanced channel list modal interactions.
  • Tests

    • Updated mocks and tests for listings, saved servers, operator fields, and Markdown/rendering.

- Preserve scroll position when user scrolls up to read history
- Auto-scroll only when user is already at bottom
- Add wasAtBottomRef to track scroll state before new messages arrive
- Fix timing issue where scroll position was checked after DOM updates
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 16, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Adds per-server buffered LIST handling with ELIST/filters and lazy loading, detects UnrealIRCd via RPL_YOURHOST, extends IRC client and store APIs, refactors message rendering with collapsible messages and enhanced markdown (hljs, copy UI), refines link security, adds OPER credentials in settings, and adjusts ChatArea auto-scroll behavior.

Changes

Cohort / File(s) Summary
Chat scroll / auto-scroll
src/components/layout/ChatArea.tsx
Track prior-bottom with wasAtBottomRef; reset on channel change; update on scroll; auto-scroll only when user was previously at bottom; removed sending some typing-done notifications.
IRC client & replies
src/lib/ircClient.ts
Add RPL_YOURHOST event parsing (numeric 002) and event emission; extend listChannels(serverId, elist?, filters?) to build LIST parameters.
Store: channel list buffering & server flags
src/store/index.ts, src/types/index.ts
Add channelListBuffer, channelListFilters, metadataChangeCounter; buffer LIST_CHANNEL entries and commit on LIST_END; listChannels accepts filters; add updateChannelListFilters; add isUnrealIRCd? and elist? on Server.
Channel list UI & modal filtering
src/components/ui/ChannelListModal.tsx, src/components/layout/ChannelList.tsx
Add ELIST-aware advanced filters, sync filters to store, lazy-loading/pagination, IntersectionObserver metadata fetch, listing loading state, and UI/backdrop/close behavior; minor ChannelList rendering changes.
User settings / OPER fields
src/components/ui/UserSettings.tsx, tests
Add operName/operPassword/operOnConnect UI and persistence to serverConfig; add forget-credentials UI and OPER send integration; tests updated to include oper fields in mocks.
Markdown/rendering & copy UI
src/lib/ircUtils.tsx, src/index.css, package.json
Integrate highlight.js dependency; add inline and block code rendering with syntax highlighting, language labels and copy buttons; tilde-fence handling; URL processing/truncation and external-link-security class; export isUnrealIRCd helper; extensive CSS for markdown and code UI.
Link handling refinement
src/components/ui/LinkWrapper.tsx
Walk up DOM ancestors to detect external-link-security class on click targets and set pendingUrl from matched element.
ISUPPORT ELIST handling
src/protocol/isupport.ts
Handle ELIST ISUPPORT key to store elist on server and return early.
Message UI: collapsible messages & MessageItem refactor
src/components/message/CollapsibleMessage.tsx, src/components/message/MessageItem.tsx, src/components/message/index.ts
Add CollapsibleMessage component and export it; wrap message bodies with collapsible view; refactor MessageItem to use metadata helpers, multiline aggregation, and new rendering flow.
Header / member list metadata rendering
src/components/layout/ChatHeader.tsx, src/components/layout/MemberList.tsx
Add metadata lookup helpers, display-names and badges (bot/verified/op); update processMarkdownInText calls with extra args (showExternalContent, enableMarkdown, key).
Message subcomponents markdown changes
src/components/message/MessageReply.tsx, src/components/message/SystemMessage.tsx, src/components/message/WhisperMessage.tsx, src/components/ui/StandardReplyNotification.tsx
Update processMarkdownInText invocations to pass additional flags and unique keys/ids for rendered content.
Tests — mock store, saved servers & formatter tests
tests/App.test.tsx, tests/components/*.test.tsx, tests/lib/messageFormatter.test.ts
Replace store wiring with a selector-based mock store and loadSavedServers mock; add channelListBuffer/channelListFilters mocks; extend formatter tests for tilde fences, code highlighting and copy UI; update modal assertions to use store toggles.
Styling
src/index.css
Add styles for collapsible messages, code block headers and copy UI, markdown spacing, tables and dark hljs theme, and arrow animations.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ScrollHandler
    participant ChatArea
    participant MessageService

    User->>ScrollHandler: scroll
    ScrollHandler->>ChatArea: set wasAtBottomRef(atBottom)

    MessageService->>ChatArea: deliver new messages
    ChatArea->>ChatArea: read wasAtBottomRef
    alt wasAtBottomRef == true
        ChatArea->>ChatArea: scrollToBottom()
    else
        ChatArea->>ChatArea: append messages (no auto-scroll)
    end

    Note right of ChatArea: switching channels resets wasAtBottomRef -> true
Loading
sequenceDiagram
    participant User
    participant ChannelListModal
    participant Store
    participant IRCClient

    User->>ChannelListModal: open modal(server)
    ChannelListModal->>Store: read channelListFilters[server]
    User->>ChannelListModal: apply filters
    ChannelListModal->>Store: updateChannelListFilters(server, filters)
    ChannelListModal->>IRCClient: listChannels(server, elist, filters)
    IRCClient->>Store: emit LIST_CHANNEL (buffered)
    IRCClient->>Store: emit LIST_END
    Store->>Store: commit channelListBuffer -> channelList
    Store->>ChannelListModal: channelList updated
    ChannelListModal->>User: render filtered/lazy-loaded channel rows
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • matheusfillipe

Poem

🐰 I hop where messages tumble and wait,

I watch the bottom so new scrolls behave straight,
I buffer channel lists until LIST says end,
I color-code codeblocks and give each snippet a friend,
I whisper OPER secrets and press copy with a grin.

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title Check ❓ Inconclusive The PR title "General enhancements, optimizations, bug fixes" is vague and uses non-descriptive terms that fail to convey meaningful information about the changeset. While the PR does contain real changes including bug fixes, enhancements to markdown rendering, new features like collapsible messages and IRC operator support, and channel listing improvements, the title does not clearly summarize the main change. According to the PR objectives, the primary fix addresses a chat window scroll-position issue where new messages would auto-scroll even when users had scrolled up to read history; however, this critical change is not reflected in the generic title. A teammate scanning the PR history would not understand what the changeset is actually about. Revise the title to specifically highlight the primary change. For example: "Fix chat scroll position retention when new messages arrive" or "Preserve scroll position when reading channel history" would clearly communicate the main bug fix. Alternatively, if multiple changes are intended to be equally prominent, consider breaking this into multiple focused PRs or restructuring the title to specifically name the most impactful change rather than using generic terms.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 81f9d3c and 659d067.

📒 Files selected for processing (1)
  • src/components/ui/ChannelListModal.tsx (5 hunks)

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.

@github-actions
Copy link
Copy Markdown

Pages Preview
Preview URL: https://fix-general-bug-fixes-and-im.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

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

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ce6d8da and 1074d6d.

📒 Files selected for processing (4)
  • src/components/layout/ChannelList.tsx (1 hunks)
  • src/components/ui/UserSettings.tsx (14 hunks)
  • tests/App.test.tsx (6 hunks)
  • tests/components/UserSettings.test.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
tests/App.test.tsx (1)
src/lib/ircClient.ts (1)
  • ircClient (2643-2643)
src/components/ui/UserSettings.tsx (3)
src/store/index.ts (1)
  • loadSavedServers (108-110)
src/lib/ircClient.ts (1)
  • sendRaw (763-776)
src/types/index.ts (1)
  • ServerConfig (48-64)

Comment on lines +932 to +947
// Save oper settings to server config if changed
if (currentServer) {
const serverConfigUpdates: Partial<ServerConfig> = {};
if (operName !== originalValues.operName) {
serverConfigUpdates.operUsername = operName || undefined;
}
if (operPassword !== originalValues.operPassword) {
serverConfigUpdates.operPassword = operPassword || undefined;
}
if (operOnConnect !== originalValues.operOnConnect) {
serverConfigUpdates.operOnConnect = operOnConnect;
}
if (Object.keys(serverConfigUpdates).length > 0) {
updateServer(currentServer.id, serverConfigUpdates);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

“Forget Credentials” leaves the oper password behind

Because operPassword is initialized to an empty string and originalValues.operPassword is captured as the same empty string, the comparison in handleSaveAll never detects a change when we wipe the field (either manually or via Forget Credentials). As a result, updateServer never receives operPassword: undefined, so the previously stored value remains in localStorage. Reproduce: save an oper password once, reopen settings, hit “Forget Credentials”, save—inspect the saved config and the password is still there. We need to mark the field as dirty (or track the stored value separately) and always propagate the clear operation so the credential actually gets removed.

-  if (operPassword !== originalValues.operPassword) {
-    serverConfigUpdates.operPassword = operPassword || undefined;
-  }
+  if (operPasswordDirty) {
+    serverConfigUpdates.operPassword = operPassword || undefined;
+  }

(Remember to add an operPasswordDirty flag that flips on any input change or when “Forget Credentials” is pressed, and include it in hasUnsavedChanges.)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/components/ui/UserSettings.tsx around lines 932-947, the oper password
clear action is not propagated because operPassword is initialized to empty
string and the save logic only compares values; add an operPasswordDirty boolean
state initialized false, set operPasswordDirty = true whenever the oper password
input changes or when “Forget Credentials” is pressed, include operPasswordDirty
in hasUnsavedChanges, and change the save logic to treat operPassword as updated
when operPasswordDirty is true (i.e., set serverConfigUpdates.operPassword =
operPassword || undefined when operPasswordDirty or when operPassword !==
originalValues.operPassword), then pass that to updateServer so clearing the
field writes operPassword: undefined to storage.

- Default sort channels by user count (highest first)
- Display user count as purple badge matching selected server tab color
- Click overlay to close modal
- Make channels list scrollable while keeping filter/sort options fixed
- Fix modal layout structure for proper scrolling behavior
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: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1074d6d and 7d77dab.

📒 Files selected for processing (1)
  • src/components/ui/ChannelListModal.tsx (5 hunks)
🧰 Additional context used
🪛 GitHub Actions: Lint and Tests
src/components/ui/ChannelListModal.tsx

[error] 165-165: File content differs from formatting output. ChannelListModal.tsx needs formatting. Run the formatter to fix.

🔇 Additional comments (1)
src/components/ui/ChannelListModal.tsx (1)

207-208: LGTM! Proper flexbox scroll implementation.

The combination of flex-1, min-h-0, and overflow-y-auto correctly enables scrolling within the flex container. The min-h-0 is essential to allow the flex child to shrink below its content size, preventing layout issues when the content overflows.

Comment thread src/components/ui/ChannelListModal.tsx Outdated
Comment thread src/components/ui/ChannelListModal.tsx Outdated
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

♻️ Duplicate comments (2)
src/components/ui/ChannelListModal.tsx (2)

204-208: Trailing whitespace issue resolved.

The earlier pipeline failure is fixed here.


204-211: Add role/aria and ESC-to-close for keyboard accessibility.

Modal supports click-to-close, but lacks ESC handling and dialog semantics.

Apply this diff for ARIA; then add the keydown handler shown below:

-      <div
-        className="bg-discord-dark-200 rounded-lg w-full max-w-2xl p-5 max-h-[90vh] flex flex-col"
-        onClick={(e) => e.stopPropagation()}
-      >
+      <div
+        className="bg-discord-dark-200 rounded-lg w-full max-w-2xl p-5 max-h-[90vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="channel-list-title"
+        tabIndex={-1}
+      >

Add this effect in the component to close on Escape:

useEffect(() => {
  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") {
      e.stopPropagation();
      toggleChannelListModal(false);
    }
  };
  window.addEventListener("keydown", onKeyDown);
  return () => window.removeEventListener("keydown", onKeyDown);
}, [toggleChannelListModal]);
🧹 Nitpick comments (9)
src/protocol/isupport.ts (1)

77-88: Normalize ELIST value when storing

Store ELIST as uppercase to simplify later checks and avoid case drift.

Apply:

-    if (key === "ELIST") {
-      useStore.setState((state) => {
+    if (key === "ELIST") {
+      const elist = (value || "").toUpperCase();
+      useStore.setState((state) => {
         const updatedServers = state.servers.map((server: Server) => {
           if (server.id === serverId) {
-            return { ...server, elist: value };
+            return { ...server, elist };
           }
           return server;
         });
         return { servers: updatedServers };
       });
       return;
     }
tests/components/ChannelListModal.test.tsx (1)

119-120: Consider asserting close interaction

Optional: verify toggleChannelListModal (or equivalent) is called when clicking the Close control to improve coverage.

src/lib/ircUtils.tsx (1)

515-522: Minor simplification possible

Return a boolean cast instead of strict equality for clarity.

-export function isUnrealIRCd(server: Server): boolean {
-  return server.isUnrealIRCd === true;
-}
+export function isUnrealIRCd(server: Server): boolean {
+  return !!server.isUnrealIRCd;
+}
src/lib/ircClient.ts (1)

1665-1679: Make RPL_YOURHOST parsing more robust

Relax wording/case to avoid missing variants (e.g., “running vX”, capitalization differences).

-      } else if (command === "002") {
+      } else if (command === "002") {
         // RPL_YOURHOST - Your host is <servername>, running version <version>
-        const message = parv.slice(1).join(" ");
-        // Parse the message: "Your host is <servername>, running version <version>"
-        const match = message.match(
-          /Your host is ([^,]+), running version (.+)/,
-        );
+        const message = parv.slice(1).join(" ");
+        // Be tolerant of variations and case
+        const re = /your host is ([^,]+), running (?:version\s*)?(.+)/i;
+        const match = re.exec(message);
         if (match) {
           const serverName = match[1];
           const version = match[2];
           this.triggerEvent("RPL_YOURHOST", {
             serverId,
             serverName,
             version,
           });
         }
       }
src/components/ui/ChannelListModal.tsx (1)

192-201: Ensure new list items are observed after filter/sort changes.

Currently observed once; new DOM nodes after re-render may not be observed, so metadata fetches can be missed.

Apply this diff to observe/unobserve in the ref callback:

   const setChannelRef = (
     channelName: string,
     element: HTMLDivElement | null,
   ) => {
-    if (element) {
-      channelRefs.current.set(channelName, element);
-    } else {
-      channelRefs.current.delete(channelName);
-    }
+    if (element) {
+      channelRefs.current.set(channelName, element);
+      observerRef.current?.observe(element);
+    } else {
+      const prev = channelRefs.current.get(channelName);
+      if (prev) observerRef.current?.unobserve(prev);
+      channelRefs.current.delete(channelName);
+    }
   };
src/store/index.ts (4)

595-607: Avoid clearing existing list while (re)listing to reduce flicker.

You wipe channelList before LIST completes, causing brief empty state. Keep current list, just set listingInProgress and clear the buffer; replace on LIST_END.

Apply this diff:

-    // Clear the channel list and buffer before starting a new list
-    set((state) => ({
-      channelList: {
-        ...state.channelList,
-        [serverId]: [],
-      },
-      channelListBuffer: {
+    // Prepare for new list: clear buffer, keep current list until LIST_END
+    set((state) => ({
+      channelListBuffer: {
         ...state.channelListBuffer,
         [serverId]: [],
       },
       listingInProgress: {
         ...state.listingInProgress,
         [serverId]: true,
       },
     }));

Also applies to: 1262-1291


1293-1300: Persist filters optionally.

Consider persisting channelListFilters to localStorage per server to survive reloads.


1319-1327: Make duplicate check timestamp-safe.

Strict equality on timestamps can miss dupes (Date vs string). Compare epoch millis.

-          (existingMessage.content === message.content &&
-            existingMessage.timestamp === message.timestamp &&
-            existingMessage.userId === message.userId)
+          (existingMessage.content === message.content &&
+            new Date(existingMessage.timestamp).getTime() ===
+              new Date(message.timestamp).getTime() &&
+            existingMessage.userId === message.userId)

5759-5765: LIST buffering is correct; consider chunked commits for huge lists.

For servers with tens of thousands of channels, a single giant array push and final swap can stutter. Optionally commit in batches (e.g., every N entries or via rAF throttle).

Also applies to: 5771-5781

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d77dab and cb594e0.

📒 Files selected for processing (9)
  • src/components/ui/ChannelListModal.tsx (5 hunks)
  • src/lib/ircClient.ts (4 hunks)
  • src/lib/ircUtils.tsx (2 hunks)
  • src/protocol/isupport.ts (1 hunks)
  • src/store/index.ts (8 hunks)
  • src/types/index.ts (1 hunks)
  • tests/components/ChannelListModal.test.tsx (4 hunks)
  • tests/components/ChannelSettingsModal.test.tsx (1 hunks)
  • tests/components/LinkSecurityWarningModal.test.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/protocol/isupport.ts (1)
src/types/index.ts (1)
  • Server (25-48)
src/components/ui/ChannelListModal.tsx (2)
src/lib/ircClient.ts (1)
  • listChannels (1018-1085)
src/lib/ircUtils.tsx (2)
  • getChannelAvatarUrl (454-469)
  • getChannelDisplayName (439-449)
src/store/index.ts (2)
src/lib/ircClient.ts (1)
  • ircClient (2728-2728)
src/lib/ircUtils.tsx (1)
  • isUnrealIRCd (520-522)
src/lib/ircClient.ts (1)
src/types/index.ts (1)
  • BaseIRCEvent (206-208)
src/lib/ircUtils.tsx (1)
src/types/index.ts (1)
  • Server (25-48)
🔇 Additional comments (13)
tests/components/ChannelSettingsModal.test.tsx (1)

76-78: State shape alignment looks good

Adding channelListBuffer and channelListFilters keeps tests in sync with store. No issues.

src/types/index.ts (1)

46-47: Type additions are appropriate

Server gains isUnrealIRCd and elist; consistent with protocol/util changes.

tests/components/LinkSecurityWarningModal.test.tsx (1)

119-121: Mock state updated correctly

channelListBuffer and channelListFilters additions keep tests compatible with store shape.

tests/components/ChannelListModal.test.tsx (2)

28-34: Store mock updates align with new filtering/buffering flow

channelListBuffer/channelListFilters and updateChannelListFilters mock are appropriate for the new flow.

Also applies to: 43-44


65-67: Updated expectations for numeric counts make sense

Asserting raw numbers (“10”, “20”, “5”) matches the new UI.

src/lib/ircUtils.tsx (1)

4-4: Import change is fine

Type import extended to include Server; consistent with new helper usage.

src/lib/ircClient.ts (3)

195-198: Event map extension LGTM

RPL_YOURHOST event definition is clear and consistent.


1826-1831: Timestamp selection for multiline batches LGTM

Taking the earliest timestamp (or batch time) is sensible for ordering.


1018-1085: ELIST parameter building: syntax verified as correct

The syntax you've implemented matches the IRC standard across common daemons (UnrealIRCd, DALnet, Bahamut, ISUPPORT ELIST draft): user-count filters as >n/<n (U), creation/topic time as C>n/C<n and T>n/T<n (C, T), and masks as mask/!mask (M, N). No changes needed.

Regarding allowing 0 values: if meaningful for your servers (e.g., channels modified 0 minutes ago, or exactly 0 users), consider relaxing the > 0 checks to >= 0. Otherwise, the current guards are fine.

src/components/ui/ChannelListModal.tsx (3)

421-426: Loading state gating looks good.

Correctly hides “No channels found” while listing is in progress.


152-166: State sync with store filters works.

Values hydrate from per‑server filters as expected.


167-183: Filters application flow LGTM.

Commits to store and triggers list refresh with normalized values.

src/store/index.ts (1)

468-485: AppState additions for channelListBuffer and channelListFilters look solid.

Clear per‑server modeling; aligns with LIST buffering and UI filters.

Comment thread src/store/index.ts
Comment on lines +5062 to +5073
ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => {
// Check if the server is running UnrealIRCd
const isUnrealIRCd = version.includes("UnrealIRCd");

// Update the server with the UnrealIRCd information
useStore.setState((state) => ({
servers: state.servers.map((server) =>
server.id === serverId ? { ...server, isUnrealIRCd } : server,
),
}));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard version, avoid name shadowing, and reuse util if available.

version may be undefined; also local const name matches util fn name.

-ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => {
-  // Check if the server is running UnrealIRCd
-  const isUnrealIRCd = version.includes("UnrealIRCd");
+ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => {
+  // Detect UnrealIRCd
+  const isUnreal = typeof version === "string" && version.includes("UnrealIRCd");
   // Update the server with the UnrealIRCd information
   useStore.setState((state) => ({
     servers: state.servers.map((server) =>
-      server.id === serverId ? { ...server, isUnrealIRCd } : server,
+      server.id === serverId ? { ...server, isUnrealIRCd: isUnreal } : server,
     ),
   }));
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => {
// Check if the server is running UnrealIRCd
const isUnrealIRCd = version.includes("UnrealIRCd");
// Update the server with the UnrealIRCd information
useStore.setState((state) => ({
servers: state.servers.map((server) =>
server.id === serverId ? { ...server, isUnrealIRCd } : server,
),
}));
});
ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => {
// Detect UnrealIRCd
const isUnreal = typeof version === "string" && version.includes("UnrealIRCd");
// Update the server with the UnrealIRCd information
useStore.setState((state) => ({
servers: state.servers.map((server) =>
server.id === serverId ? { ...server, isUnrealIRCd: isUnreal } : server,
),
}));
});
🤖 Prompt for AI Agents
In src/store/index.ts around lines 5062 to 5073, the handler uses version
without guarding against undefined and shadows a util name; change the callback
to first obtain the version safely (e.g., const ver = version ?? ""), or better,
call an existing util isUnrealIRCd(ver) if available, and rename the local
boolean to avoid name shadowing (e.g., const isUnreal = typeof ver === "string"
&& ver.includes("UnrealIRCd") or const isUnreal = isUnrealIRCd(ver)); then
update useStore.setState exactly as before but using the new local name
(isUnreal) so includes is only called on a string and the util is
reused/variable name collision is avoided.

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 (1)
src/components/ui/ChannelListModal.tsx (1)

158-172: IntersectionObserver never observes new items; metadata prefetch won’t run.

The “Observe channel elements” effect runs once with an empty map. New items aren’t observed. Observe/unobserve in setChannelRef and set IO root to the scroll container.

Apply this diff:

@@
-  // Setup IntersectionObserver
-  useEffect(() => {
-    observerRef.current = new IntersectionObserver(
+  // Setup IntersectionObserver (use scroll container as root when available)
+  useEffect(() => {
+    const root = scrollContainerRef.current ?? null;
+    observerRef.current = new IntersectionObserver(
       (entries) => {
         const visibleChannels = entries
           .filter((entry) => entry.isIntersecting)
           .map((entry) => entry.target.getAttribute("data-channel"))
           .filter((ch): ch is string => ch !== null);
 
         if (visibleChannels.length > 0) {
           fetchMetadataForChannels(visibleChannels);
         }
       },
       {
-        root: null,
+        root,
         rootMargin: "100px", // Start loading slightly before they come into view
         threshold: 0.1,
       },
     );
 
     return () => {
       if (observerRef.current) {
         observerRef.current.disconnect();
       }
     };
-  }, [fetchMetadataForChannels]);
+  }, [fetchMetadataForChannels]);
@@
-  // Observe channel elements
-  useEffect(() => {
-    if (!observerRef.current) return;
-
-    channelRefs.current.forEach((element) => {
-      observerRef.current?.observe(element);
-    });
-
-    return () => {
-      if (observerRef.current) {
-        channelRefs.current.forEach((element) => {
-          observerRef.current?.unobserve(element);
-        });
-      }
-    };
-  }, []);
+  // (Removed) Observe-once effect; observation handled in setChannelRef
@@
   const setChannelRef = (
     channelName: string,
     element: HTMLDivElement | null,
   ) => {
-    if (element) {
-      channelRefs.current.set(channelName, element);
-    } else {
-      channelRefs.current.delete(channelName);
-    }
+    if (element) {
+      channelRefs.current.set(channelName, element);
+      observerRef.current?.observe(element);
+    } else {
+      const prev = channelRefs.current.get(channelName);
+      if (prev) observerRef.current?.unobserve(prev);
+      channelRefs.current.delete(channelName);
+    }
   };

Also applies to: 259-268, 130-155

♻️ Duplicate comments (1)
src/components/ui/ChannelListModal.tsx (1)

270-299: Add ESC-to-close for keyboard accessibility (previously requested).

Modal lacks ESC handling. Add a keydown listener to close on Escape.

Apply this diff:

@@
   const {
@@
   } = useStore();
@@
+  useEffect(() => {
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") {
+        toggleChannelListModal(false);
+      }
+    };
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, [toggleChannelListModal]);
@@
-    <div
+    <div
+      role="dialog"
+      aria-modal="true"
+      aria-label="Channel list"
       className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50"
       onClick={() => toggleChannelListModal(false)}
     >

As per previous review.

🧹 Nitpick comments (2)
src/components/ui/ChannelListModal.tsx (2)

542-553: Lazy-load avatars to reduce network/paint cost.

Add loading="lazy" and decoding="async" to the img.

Apply this diff:

-                          <img
+                          <img
+                            loading="lazy"
+                            decoding="async"
                             src={avatarUrl}
                             alt={channel.channel}
                             className="w-8 h-8 rounded-full object-cover"

195-224: Optional: clear timeout on unmount to avoid stray state updates.

Minor polish: store the setTimeout id and clear it in the effect cleanup.

-        setTimeout(() => {
+        const id = window.setTimeout(() => {
           setDisplayedChannelsCount((prev) =>
             Math.min(prev + 50, filteredChannels.length),
           );
           setLoadingMore(false);
         }, 200); // Small delay for smooth UX
+        return () => window.clearTimeout(id);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cb594e0 and 053fda4.

📒 Files selected for processing (1)
  • src/components/ui/ChannelListModal.tsx (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/components/ui/ChannelListModal.tsx (2)
src/lib/ircClient.ts (1)
  • listChannels (1018-1085)
src/lib/ircUtils.tsx (2)
  • getChannelAvatarUrl (454-469)
  • getChannelDisplayName (439-449)
🔇 Additional comments (1)
src/components/ui/ChannelListModal.tsx (1)

248-250: No signature mismatch—review comment is incorrect.

The component calls listChannels from the store wrapper (not directly from ircClient). The store's signature is (serverId, filters?), which matches your calls at lines 176 and 249. The store internally extracts elist from the server configuration and correctly forwards (serverId, elist, filterSettings) to ircClient.listChannels, so parameter order is correct and no mis-routing occurs.

Likely an incorrect or invalid review comment.

Comment thread src/components/ui/ChannelListModal.tsx Outdated
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

🧹 Nitpick comments (2)
src/lib/ircUtils.tsx (2)

264-309: Consider adding language validation for security.

The code block renderer properly handles hljs integration with fallbacks, but consider validating the lang parameter to prevent potential injection if it's user-controlled. While hljs.getLanguage() provides some validation, an explicit allowlist of supported languages would add defense-in-depth.

Example implementation:

// At module level
const SUPPORTED_LANGUAGES = new Set([
  'javascript', 'typescript', 'python', 'java', 'cpp', 'c',
  'go', 'rust', 'ruby', 'php', 'swift', 'kotlin', 'scala',
  'html', 'css', 'json', 'xml', 'yaml', 'markdown', 'sql'
  // Add other commonly used languages
]);

// In renderer.code
renderer.code = ({ text, lang, escaped }) => {
  const trimmedText = text.trimEnd();
  let highlightedCode = trimmedText;
  let language = lang;

  // Only attempt highlighting if language is in the allowlist
  if (lang && SUPPORTED_LANGUAGES.has(lang) && hljs.getLanguage(lang)) {
    // ... rest of highlighting logic
  }
  // ...
};

346-358: Consider using crypto.randomUUID() for IDs.

The current ID generation using Math.random().toString(36).substr(2, 9) works but has potential collision risks. Consider using crypto.randomUUID() for cryptographically secure unique IDs, or at least crypto.getRandomValues() for better entropy.

Example:

// For code block IDs
const codeId = `code-${crypto.randomUUID()}`;

// For inline code IDs (in codespan renderer)
const codeId = `inline-code-${crypto.randomUUID()}`;

This eliminates collision risks and provides better uniqueness guarantees, especially if multiple code blocks are rendered simultaneously.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 053fda4 and 8876f8d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • package.json (1 hunks)
  • src/components/ui/LinkWrapper.tsx (1 hunks)
  • src/index.css (8 hunks)
  • src/lib/ircUtils.tsx (6 hunks)
  • tests/components/ChannelListModal.test.tsx (5 hunks)
  • tests/lib/messageFormatter.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
tests/lib/messageFormatter.test.ts (1)
src/lib/ircUtils.tsx (1)
  • renderMarkdown (194-415)
src/lib/ircUtils.tsx (1)
src/types/index.ts (1)
  • Server (25-48)
🪛 ast-grep (0.39.6)
src/lib/ircUtils.tsx

[warning] 364-364: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🔇 Additional comments (13)
tests/components/ChannelListModal.test.tsx (4)

28-43: LGTM! Mock store properly extended for buffering and filtering.

The test mock correctly includes the new per-server channelListBuffer and channelListFilters state, plus the updateChannelListFilters action. These additions align with the store changes for buffered LIST results handling.


56-56: LGTM! UI text updated to match component changes.

The header text expectation correctly reflects the new channel list modal header format.


65-67: LGTM! User count display expectations updated.

Test expectations correctly updated to match the simplified user count display format (numeric values only).


119-119: LGTM! Close button selector improved.

Using getByLabelText("Close") is more accessible and semantic than the previous generic role-based selector.

tests/lib/messageFormatter.test.ts (1)

343-390: LGTM! Comprehensive test coverage for new markdown features.

These test cases effectively validate:

  • Strikethrough rendering with ~~text~~ syntax
  • Prevention of single-line tilde syntax from being treated as code blocks
  • Multi-line tilde-fenced code block rendering
  • Syntax highlighting integration via highlight.js
  • Copy button functionality in code blocks

The tests align with the markdown rendering enhancements in src/lib/ircUtils.tsx and provide good coverage of the new features.

src/index.css (3)

5-10: LGTM! Proper highlight.js stylesheet integration.

The GitHub theme import with transparent background override ensures syntax highlighting works correctly without interfering with the custom code block styling. This aligns with the highlight.js integration in src/lib/ircUtils.tsx.


249-318: LGTM! Well-designed copy button UI.

The code block copy button implementation includes:

  • Proper positioning and styling for both regular and inline code blocks
  • Smooth hover transitions with opacity changes
  • Comprehensive dark mode support
  • Consistent sizing and spacing

The inline code copy button's hover-triggered appearance (line 353-355) is a nice UX touch that keeps the UI clean when not needed.


488-579: LGTM! Comprehensive dark mode syntax highlighting.

The dark mode hljs class mappings cover all major token types (keywords, strings, comments, operators, etc.) with appropriate colors from the GitHub dark theme palette. This ensures consistent and readable syntax highlighting in dark mode.

src/lib/ircUtils.tsx (4)

252-261: LGTM! Inline code copy button implementation.

The custom codespan renderer adds copy functionality to inline code blocks with a unique ID and hidden-until-hover button. The implementation is clean and follows the same pattern as the block-level copy buttons.


318-325: LGTM! Single-line tilde syntax properly escaped.

The regex correctly identifies and escapes single-line tilde-fenced code (e.g., ~~~lang code~~~) to prevent it from being rendered as a code block. This aligns with the test case in tests/lib/messageFormatter.test.ts (lines 351-357).


366-412: LGTM! Robust copy button click handler.

The click handler implementation includes:

  • Proper event delegation for both block and inline copy buttons
  • Success and error feedback with visual indicators (checkmark/X)
  • Automatic reset after 2 seconds
  • Dynamic sizing based on button type
  • Error logging for debugging

The implementation is production-ready and provides good UX.


669-676: LGTM! Simple and clear server type check.

The isUnrealIRCd utility function provides a clean API for checking server type. The implementation correctly checks the optional isUnrealIRCd boolean property with strict equality.

src/components/ui/LinkWrapper.tsx (1)

29-41: LGTM! Proper DOM traversal for external link detection.

The updated click handler correctly traverses up the DOM tree to detect the external-link-security class on the target element or any of its ancestors. This is necessary because click events may originate from child elements (e.g., images, SVG icons, or text nodes) within the link.

The implementation:

  • Uses a while loop with proper null checking
  • Breaks early once a matching element is found (efficient)
  • Correctly reads the href from the matched element rather than the click target

This aligns with the markdown rendering changes in src/lib/ircUtils.tsx where external links are assigned the external-link-security class.

Comment thread package.json Outdated
@ValwareIRC ValwareIRC changed the title Fix scroll position preservation in chat General enhancements, optimizations, bug fixes Oct 17, 2025
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: 2

🧹 Nitpick comments (4)
src/index.css (2)

175-177: Consider the impact of negative margins and !important overrides.

The negative margins (-0.0625rem, -0.125rem) and multiple !important declarations tighten spacing for a compact chat UI but may cause:

  • Content overlap with certain markdown patterns
  • Difficulty overriding styles in specific contexts
  • Maintenance challenges when adjusting spacing in the future

Ensure these spacing adjustments have been tested with various markdown patterns (nested lists, complex structures, etc.) to avoid visual glitches.

Also applies to: 415-457


342-394: Inline code copy button may misalign with dynamic font sizes.

The inline code container adjusts padding on hover (line 353: padding-right: 2.125rem), but the copy button uses absolute positioning with fixed right: 0.25rem. This could cause misalignment with:

  • Different font sizes/zoom levels
  • Custom font families with varying metrics
  • RTL layouts

Consider testing at different zoom levels and with various fonts to ensure the copy button remains properly positioned.

src/components/message/MessageItem.tsx (1)

562-564: Consider memoizing collapsibleContent to avoid recreation on every render.

The collapsibleContent element is recreated on every render of MessageItem. Since htmlContent is already computed and MessageItem is wrapped in React.memo, consider using useMemo to avoid unnecessary CollapsibleMessage component recreation.

-  // Create collapsible content wrapper
-  const collapsibleContent = <CollapsibleMessage content={htmlContent} />;
+  // Create collapsible content wrapper
+  const collapsibleContent = useMemo(
+    () => <CollapsibleMessage content={htmlContent} />,
+    [htmlContent]
+  );

Note: You'll need to import useMemo from React (it's not currently imported, though useCallback is on line 3).

src/components/message/CollapsibleMessage.tsx (1)

63-80: Add ARIA attributes for better accessibility.

The toggle button lacks aria-expanded and aria-controls attributes, which are important for screen reader users to understand the collapsible state.

Apply this diff:

           <button
             onClick={toggleExpanded}
+            aria-expanded={isExpanded}
+            aria-label={isExpanded ? "Show less content" : "Show more content"}
             className="ml-1 text-blue-500 hover:text-blue-600 text-xs font-medium cursor-pointer border border-blue-500 rounded-full py-0 px-1"
             style={{ textDecoration: "none" }}
           >

This improves accessibility by explicitly communicating the button's purpose and state to assistive technologies.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8876f8d and 0d947ff.

📒 Files selected for processing (4)
  • src/components/message/CollapsibleMessage.tsx (1 hunks)
  • src/components/message/MessageItem.tsx (3 hunks)
  • src/components/message/index.ts (1 hunks)
  • src/index.css (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/components/message/MessageItem.tsx (2)
src/components/message/CollapsibleMessage.tsx (1)
  • CollapsibleMessage (9-84)
src/components/message/index.ts (1)
  • CollapsibleMessage (4-4)
src/components/message/CollapsibleMessage.tsx (1)
src/components/message/index.ts (1)
  • CollapsibleMessage (4-4)
🔇 Additional comments (9)
src/index.css (3)

5-13: LGTM: Collapsible message styles with good fallback.

The line-clamp implementation correctly uses both webkit-prefixed and standard properties for broader compatibility, with a max-height fallback for unsupported browsers.


271-340: LGTM: Code block copy button implementation.

The copy button styling is well-structured with proper hover states and dark mode support. The position: static for .copy-button is appropriate for the header layout context.


510-601: LGTM: Comprehensive dark mode syntax highlighting.

The syntax highlighting color mappings are thorough and cover all major token types with appropriate semantic colors.

src/components/message/index.ts (1)

4-4: LGTM: Clean export addition.

The CollapsibleMessage export is properly added and maintains alphabetical ordering.

src/components/message/MessageItem.tsx (2)

16-16: LGTM: Clean import of CollapsibleMessage.


876-886: No virtualization currently implemented; performance concern is valid but may be overstated.

The codebase has no virtualization (react-window, react-virtualized) or message windowing in place. Every text message instantiates CollapsibleMessage, which adds 5 state variables per instance and runs useLayoutEffect to measure content height on mount. While the layout effect dependency array [maxLines] means repeated measurements are avoided, having 1000+ message nodes in the DOM with per-message state could still impact memory usage and performance.

However, the impact is primarily from DOM size and state overhead rather than repeated layout thrashing. The component already uses React.memo and optimization patterns (useCallback, useMemo).

Suggested approach:

  • Benchmark with realistic message volumes before optimizing
  • If performance degrades, consider message list virtualization over selective wrapping (virtualization is simpler than detecting "likely long" messages)
  • IntersectionObserver is already used in ChannelListModal; similar pattern could apply to messages if needed
src/components/message/CollapsibleMessage.tsx (3)

4-12: LGTM: Well-defined component interface.

The props interface is clean and the default maxLines of 3 is appropriate for chat messages.


13-18: LGTM: State initialization is appropriate.


46-83: LGTM: Render logic is well-structured with appropriate conditional styling.

The maxHeight transitions and arrow animation are correctly implemented. The combination of CSS classes and inline styles provides smooth expand/collapse animations.

Comment on lines +20 to +35
useLayoutEffect(() => {
if (!contentRef.current) return;

// Measure the actual rendered content height
const element = contentRef.current;
const computedStyle = window.getComputedStyle(element);
const lineHeight = Number.parseFloat(computedStyle.lineHeight) || 16; // fallback to 16px
const maxHeight = lineHeight * maxLines;

// Get the full content height
const fullHeight = element.scrollHeight;
setContentHeight(fullHeight);

// Check if content overflows the max height
setNeedsCollapsing(fullHeight > maxHeight);
}, [maxLines]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add content to the dependency array to handle dynamic content updates.

The useLayoutEffect only depends on maxLines, but if the content prop changes (e.g., message editing, dynamic updates), the height measurement and needsCollapsing flag won't update. This could cause:

  • Stale collapse state when content changes
  • Incorrect "Show more" button visibility
  • Wrong expanded height

Apply this diff:

-  }, [maxLines]);
+  }, [maxLines, content]);

This ensures the component remeasures when content changes. Note that content is a ReactNode which may not have stable reference equality, so consider the performance implications if parent components frequently recreate the content prop.

🤖 Prompt for AI Agents
In src/components/message/CollapsibleMessage.tsx around lines 20 to 35, the
useLayoutEffect currently only depends on maxLines so it won't remeasure when
the message content changes; update the effect dependency array to include the
content prop (i.e., [maxLines, content]) so the effect remeasures and updates
contentHeight and needsCollapsing whenever content changes, and ensure any lint
warnings are addressed (if content is a ReactNode with unstable identity,
consider memoizing the content upstream or stableizing the prop to avoid
unnecessary re-renders).

Comment thread src/components/message/CollapsibleMessage.tsx
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: 0

Caution

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

⚠️ Outside diff range comments (1)
src/index.css (1)

426-440: Resolve conflicting and over-specified padding rules in lists.

Lines 426–440 define list styling with conflicting rules: line 431 sets padding: 0 !important, but line 439 adds padding-left: 0.5rem without !important. The second rule will not override the first due to specificity. Additionally, the extensive !important flags suggest potential CSS conflicts; these should be resolved through proper specificity rather than broad !important usage.

Consolidate list padding into a single rule without !important:

 .markdown-content ul,
 .markdown-content ol {
   list-style-type: disc;
   list-style-position: inside;
-  margin: 0 !important;
-  padding: 0 !important;
+  margin: 0;
+  padding: 0 0 0 0.5rem;
   margin-bottom: 0.125rem !important;
   margin-top: -0.125rem !important;
   line-height: 1.0 !important;
   display: block;
   box-sizing: border-box;
   height: auto !important;
   min-height: auto !important;
-  padding-left: 0.5rem;
 }
🧹 Nitpick comments (1)
src/index.css (1)

267-267: Dark mode inline code background is overly harsh.

Line 267 sets the dark mode code background to pure black, which creates high contrast and appears harsher than the surrounding pre block background (rgb(31 41 55) on line 280). For visual consistency and readability, consider using a softer color like rgb(31 41 55) or rgb(20 24 32).

Apply this adjustment for softer dark mode appearance:

-.dark .markdown-content code {
-  background-color: black;
-}
+.dark .markdown-content code {
+  background-color: rgb(31 41 55);
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0d947ff and 17d805f.

📒 Files selected for processing (2)
  • src/components/message/CollapsibleMessage.tsx (1 hunks)
  • src/index.css (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/message/CollapsibleMessage.tsx
🔇 Additional comments (4)
src/index.css (4)

321-321: Verify copy button positioning strategy.

Line 321 marks .copy-button with position: static, which prevents absolute positioning within a relative container. For UI elements that need precise positioning (like copy buttons), this typically should be position: relative or position: absolute depending on the layout context.

Verify the intended behavior: Is position: static correct, or should this be position: relative or position: absolute to allow proper positioning relative to its container?


260-260: Clarify non-standard code padding value.

Line 260 uses padding: 0.25rem 0.45rem, where the horizontal padding of 0.45rem is unusual compared to standard Tailwind/CSS increment patterns (0.25, 0.5, 0.75, 1rem). Was this intentional, or should it be 0.5rem for consistency?


192-194: Clarify intent of negative margin on markdown content.

Line 193 applies a very small negative margin (margin-bottom: -0.0625rem) to markdown content elements. This unusual value is not immediately clear in purpose—is it compensating for an upstream style, or is there a specific design intent? Document the rationale or consider removing if it's a legacy adjustment.

Confirm whether the -0.0625rem margin is intentional and, if so, add an inline comment explaining the spacing adjustment.


527-618: Dark mode syntax highlighting additions look well‑considered.

The comprehensive GitHub-themed syntax highlighting colors (lines 527–618) are well-structured and use appropriate semantic color assignments for different syntax elements. The color palette is consistent and readable.

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: 4

Caution

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

⚠️ Outside diff range comments (1)
src/components/ui/ChannelListModal.tsx (1)

159-173: Observer won't track new channel elements due to empty dependency array.

The effect only runs once on mount because of the empty [] dependency array. When displayedChannelsCount increases or filteredChannels changes, new channel elements are rendered but never observed for metadata fetching. Visible channels beyond the initial 50 won't trigger metadata requests.

Apply this diff to re-run observation when the displayed channels change:

   }, [fetchMetadataForChannels]);
 
   // Observe channel elements
   useEffect(() => {
     if (!observerRef.current) return;
 
     channelRefs.current.forEach((element) => {
       observerRef.current?.observe(element);
     });
 
     return () => {
       if (observerRef.current) {
         channelRefs.current.forEach((element) => {
           observerRef.current?.unobserve(element);
         });
       }
     };
-  }, []);
+  }, [displayedChannelsCount, filteredChannels.length]);
♻️ Duplicate comments (1)
src/components/message/CollapsibleMessage.tsx (1)

31-46: Still missing content in the dependency array.

The previous review comment about adding content to the dependency array has not been addressed. When the content prop changes, the component won't remeasure the height, potentially causing incorrect collapse state and wrong "Show more" button visibility.

Apply this diff:

-  }, [maxLines]);
+  }, [maxLines, content]);

Note: Since content is a ReactNode, consider the performance implications if the parent frequently recreates this prop.

🧹 Nitpick comments (4)
src/components/message/CollapsibleMessage.tsx (1)

66-107: Consider defensive handling for null contentHeight.

In the inline style at lines 72-76, when isExpanded is true, the code uses ${contentHeight}px. If contentHeight is null, this would render invalid CSS ("nullpx").

While the logical flow suggests this shouldn't happen (button only renders when needsCollapsing is true, which implies contentHeight is set), defensive coding would be safer.

Apply this diff for more defensive rendering:

         maxHeight: isExpanded
-          ? `${contentHeight}px`
+          ? contentHeight ? `${contentHeight}px` : "none"
           : needsCollapsing
             ? "4.5em"
             : "none",
src/index.css (1)

426-491: Heavy use of !important flags indicates potential specificity issues.

The list styling rules (lines 426-491) use many !important declarations (7 in the ul/ol rule alone). This typically indicates:

  • Specificity conflicts with other styles
  • Difficulty in maintaining or overriding these styles later
  • Possible architectural issues in CSS organization

Consider refactoring to reduce !important usage by:

  • Increasing selector specificity naturally (e.g., .markdown-content ul.markdown-list)
  • Reviewing conflicting styles and resolving at their source
  • Using CSS cascade order more effectively
src/components/ui/ChannelListModal.tsx (2)

52-71: Consider simplifying the dual state-and-ref pattern.

The custom setters that update both state and refs work correctly but add complexity. Modern React patterns typically prefer:

  • Using useCallback with proper dependencies and accepting re-creation of listeners, or
  • Using functional state updates in event handlers to access latest state without closures

This pattern is functional but may confuse future maintainers.


540-561: Prefer state-based image fallback over direct DOM manipulation.

The onError handler directly manipulates the DOM by hiding the <img> and showing the next sibling. This is fragile and non-idiomatic for React. Use a state variable to track image load failures instead.

Consider this pattern:

const [imageLoadError, setImageLoadError] = useState(false);

// In render:
{avatarUrl && !imageLoadError ? (
  <img
    src={avatarUrl}
    alt={channel.channel}
    className="w-8 h-8 rounded-full object-cover"
    onError={() => setImageLoadError(true)}
  />
) : (
  <span className="text-gray-400 text-xl font-bold">#</span>
)}

Note: You'll need to track image errors per channel, perhaps in a Map or as part of the metadata cache.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 17d805f and ec1d37a.

📒 Files selected for processing (4)
  • package.json (1 hunks)
  • src/components/message/CollapsibleMessage.tsx (1 hunks)
  • src/components/ui/ChannelListModal.tsx (4 hunks)
  • src/index.css (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json
🧰 Additional context used
🧬 Code graph analysis (2)
src/components/message/CollapsibleMessage.tsx (1)
src/components/message/index.ts (1)
  • CollapsibleMessage (4-4)
src/components/ui/ChannelListModal.tsx (2)
src/lib/ircClient.ts (1)
  • listChannels (1018-1085)
src/lib/ircUtils.tsx (2)
  • getChannelAvatarUrl (608-623)
  • getChannelDisplayName (593-603)
🔇 Additional comments (7)
src/components/message/CollapsibleMessage.tsx (2)

22-29: Good addition: timeout cleanup on unmount.

This properly addresses the previous review feedback about cleaning up the animation timeout when the component unmounts.


48-64: Timeout management properly implemented.

The timeout is now correctly stored in animationTimeoutRef, cleared before setting new timeouts, and the ref is properly cleaned up on unmount. This addresses the previous review feedback.

src/index.css (3)

192-194: Verify the negative margin doesn't cause layout issues.

The negative margin-bottom: -0.0625rem could cause overlapping elements or unexpected spacing behavior. While this might be intentional to tighten spacing between markdown blocks, it's fragile and could interact poorly with other spacing rules.

Test with various markdown content (headings, paragraphs, lists, code blocks) to ensure the negative margin doesn't create visual artifacts or overlapping elements.


288-410: Well-structured code block and copy button styling.

The code block container, header, and copy button styles are comprehensive and include proper hover states, dark mode variants, and transition effects. The inline code copy button pattern with opacity transitions on hover is a good UX choice.


511-617: Comprehensive table and syntax highlighting styles.

The table styling is clean and includes dark mode variants. The syntax highlighting color scheme for hljs is comprehensive and follows a GitHub-style dark theme with proper color assignments for all token types.

src/components/ui/ChannelListModal.tsx (2)

22-23: Good fix for the ELIST safety issue.

Normalizing elist to a safe string upfront prevents the runtime errors from unsafe optional chaining that were flagged in previous reviews.


196-224: Scroll listener logic is complex but appears correct.

The disabled lint warning and ref-based state access pattern correctly avoid recreating the scroll listener on every render while preventing stale closures. The listener recreates only when filteredChannels.length changes, which is the intended behavior.

Comment on lines +235 to +251
const applyFilters = () => {
if (!selectedServerId) return;

const filters = {
minUsers: minUsers > 0 ? minUsers : undefined,
maxUsers: maxUsers > 0 ? maxUsers : undefined,
minCreationTime: minCreationTime > 0 ? minCreationTime : undefined,
maxCreationTime: maxCreationTime > 0 ? maxCreationTime : undefined,
minTopicTime: minTopicTime > 0 ? minTopicTime : undefined,
maxTopicTime: maxTopicTime > 0 ? maxTopicTime : undefined,
mask: mask.trim() || undefined,
notMask: notMask.trim() || undefined,
};

updateChannelListFilters(selectedServerId, filters);
listChannels(selectedServerId, filters);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Missing elist parameter in listChannels call.

The listChannels function signature expects (serverId, elist?, filters?) but Line 250 calls listChannels(selectedServerId, filters), omitting the elist parameter. This causes the filters object to be misinterpreted as the elist string, breaking server-side filtering.

Apply this diff to pass the correct parameters:

     updateChannelListFilters(selectedServerId, filters);
-    listChannels(selectedServerId, filters);
+    listChannels(selectedServerId, elist, filters);
   };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/components/ui/ChannelListModal.tsx around lines 235-251, the call
listChannels(selectedServerId, filters) omits the required elist parameter so
the filters object is being passed as elist; change the call to pass the elist
as the second argument and filters as the third (e.g.
listChannels(selectedServerId, elist, filters)), ensuring you reference the
existing elist state/variable (or pass undefined explicitly if none) so
server-side filtering receives the correct arguments.

Comment on lines 166 to +279
return (
<div className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50">
<div className="bg-discord-dark-200 rounded-lg w-full max-w-2xl p-5 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<div
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50"
onClick={() => toggleChannelListModal(false)}
>
<div
className="bg-discord-dark-200 rounded-lg w-full max-w-2xl p-5 max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Oct 17, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add ESC key handler for keyboard accessibility.

This was flagged in previous reviews but not yet addressed. The modal only supports mouse-based dismissal (overlay click and close button). As per the past review comment, other modals in the codebase (UserContextMenu, LinkSecurityWarningModal, etc.) implement Escape key handling for accessibility.

Add this effect after the other useEffect hooks (around line 233):

// ESC key handler to close modal
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") {
      toggleChannelListModal(false);
    }
  };

  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleChannelListModal]);
🤖 Prompt for AI Agents
In src/components/ui/ChannelListModal.tsx around lines 271-279 (add after the
other useEffect hooks near line 233), the modal lacks an Escape key handler for
keyboard accessibility; add a useEffect that registers a window keydown listener
which calls toggleChannelListModal(false) when e.key === "Escape", ensure you
remove the listener in the cleanup return, and include toggleChannelListModal in
the effect dependency array so the handler updates correctly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Indeed esc key not working for me in any modal @ValwareIRC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment thread src/index.css

.dark .markdown-content code {
background-color: rgb(55 65 81);
background-color: rgb(31 41 55);;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix CSS syntax error: double semicolon.

Line 267 has a double semicolon at the end of the property value.

Apply this diff:

-.dark .markdown-content code {
-  background-color: rgb(31 41 55);;
-}
+.dark .markdown-content code {
+  background-color: rgb(31 41 55);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
background-color: rgb(31 41 55);;
background-color: rgb(31 41 55);
🤖 Prompt for AI Agents
In src/index.css around line 267, there's a CSS syntax error: the
background-color declaration ends with a double semicolon ("background-color:
rgb(31 41 55);;"). Remove the extra semicolon so the property ends with a single
semicolon ("background-color: rgb(31 41 55);") to fix the syntax.

Comment thread src/index.css

.dark .markdown-content pre {
background-color: rgb(55 65 81);
background-color: rgb(31 41 55);;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix CSS syntax error: double semicolon.

Line 280 has a double semicolon at the end of the property value.

Apply this diff:

-.dark .markdown-content pre {
-  background-color: rgb(31 41 55);;
-}
+.dark .markdown-content pre {
+  background-color: rgb(31 41 55);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
background-color: rgb(31 41 55);;
.dark .markdown-content pre {
background-color: rgb(31 41 55);
}
🤖 Prompt for AI Agents
In src/index.css around line 280, there's a CSS syntax error: the property
"background-color: rgb(31 41 55);;" contains a double semicolon; remove the
extra semicolon so the declaration ends with a single semicolon
("background-color: rgb(31 41 55);") to fix the syntax.

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: 3

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 353718e and 71b8b58.

📒 Files selected for processing (1)
  • src/components/layout/ChatHeader.tsx (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/components/layout/ChatHeader.tsx (2)
src/store/index.ts (1)
  • loadSavedMetadata (113-115)
src/types/index.ts (2)
  • PrivateChat (90-106)
  • User (1-17)
🔇 Additional comments (2)
src/components/layout/ChatHeader.tsx (2)

7-7: LGTM!

The FaCheckCircle import is correctly added and used for rendering verification badges.


449-500: The badge rendering asymmetry is intentional and correct.

The search results confirm this is deliberate UX design, consistently implemented across the codebase:

  • The test case MetadataDisplay.test.tsx expects Alice Wonderland to display with username badge "alice" below it
  • Comments in ChatHeader.tsx, ChannelList.tsx, and MemberList.tsx all document the pattern: "Only show verified badge if NO display-name"
  • When a display-name exists, the verified badge moves from the title to the username badge context to reduce visual clutter
  • When no display-name exists, the verified badge appears next to the username in the title

This asymmetry is not a bug—it's consistent badge placement strategy across all components that avoids cluttering the primary display when a display-name is available.

Comment on lines +119 to +145
// Helper function to get user metadata
const getUserMetadata = (username: string) => {
if (!selectedServerId) return null;

// First check localStorage for saved metadata
const savedMetadata = loadSavedMetadata();
const serverMetadata = savedMetadata[selectedServerId];
if (serverMetadata?.[username]) {
return serverMetadata[username];
}

// If not in localStorage, check if user is in any shared channels
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;

// Search through all channels for this user
for (const channel of server.channels) {
const user = channel.users.find(
(u) => u.username.toLowerCase() === username.toLowerCase(),
);
if (user?.metadata && Object.keys(user.metadata).length > 0) {
return user.metadata;
}
}

return null;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Refactor to eliminate code duplication and improve performance.

This function duplicates the logic in the privateChatUserMetadata useMemo (lines 90-117). Additionally, since it's called during render (lines 450, 472) without memoization, it recalculates on every render.

Consider refactoring to a single memoized implementation:

+  // Get user metadata - memoized lookup for any username
+  const getUserMetadata = useMemo(() => {
+    return (username: string) => {
+      if (!selectedServerId) return null;
+
+      // First check localStorage for saved metadata
+      const savedMetadata = loadSavedMetadata();
+      const serverMetadata = savedMetadata[selectedServerId];
+      if (serverMetadata?.[username]) {
+        return serverMetadata[username];
+      }
+
+      // If not in localStorage, check if user is in any shared channels
+      const server = servers.find((s) => s.id === selectedServerId);
+      if (!server) return null;
+
+      // Search through all channels for this user
+      for (const channel of server.channels) {
+        const user = channel.users.find(
+          (u) => u.username.toLowerCase() === username.toLowerCase(),
+        );
+        if (user?.metadata && Object.keys(user.metadata).length > 0) {
+          return user.metadata;
+        }
+      }
+
+      return null;
+    };
+  }, [selectedServerId, servers]);
+
-  // Get private chat user metadata - first check localStorage, then check shared channels
-  const privateChatUserMetadata = useMemo(() => {
-    if (!selectedPrivateChat || !selectedServerId) return null;
-
-    // First check localStorage for saved metadata
-    const savedMetadata = loadSavedMetadata();
-    const serverMetadata = savedMetadata[selectedServerId];
-    if (serverMetadata?.[selectedPrivateChat.username]) {
-      return serverMetadata[selectedPrivateChat.username];
-    }
-
-    // If not in localStorage, check if user is in any shared channels
-    const server = servers.find((s) => s.id === selectedServerId);
-    if (!server) return null;
-
-    // Search through all channels for this user
-    for (const channel of server.channels) {
-      const user = channel.users.find(
-        (u) =>
-          u.username.toLowerCase() ===
-          selectedPrivateChat.username.toLowerCase(),
-      );
-      if (user?.metadata && Object.keys(user.metadata).length > 0) {
-        return user.metadata;
-      }
-    }
-
-    return null;
-  }, [selectedPrivateChat, selectedServerId, servers]);
+  const privateChatUserMetadata = selectedPrivateChat 
+    ? getUserMetadata(selectedPrivateChat.username)
+    : null;
-
-  // Helper function to get user metadata
-  const getUserMetadata = (username: string) => {
-    if (!selectedServerId) return null;
-
-    // First check localStorage for saved metadata
-    const savedMetadata = loadSavedMetadata();
-    const serverMetadata = savedMetadata[selectedServerId];
-    if (serverMetadata?.[username]) {
-      return serverMetadata[username];
-    }
-
-    // If not in localStorage, check if user is in any shared channels
-    const server = servers.find((s) => s.id === selectedServerId);
-    if (!server) return null;
-
-    // Search through all channels for this user
-    for (const channel of server.channels) {
-      const user = channel.users.find(
-        (u) => u.username.toLowerCase() === username.toLowerCase(),
-      );
-      if (user?.metadata && Object.keys(user.metadata).length > 0) {
-        return user.metadata;
-      }
-    }
-
-    return null;
-  };

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +147 to +163
// Helper function to get full user object from shared channels
const getUserFromChannels = (username: string) => {
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;

// Search through all channels for this user
for (const channel of server.channels) {
const user = channel.users.find(
(u) => u.username.toLowerCase() === username.toLowerCase(),
);
if (user) {
return user;
}
}

return null;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Memoize to avoid repeated channel scans on every render.

This function is called during render (lines 454, 476) and performs an O(n×m) search through all channels and users. Without memoization, it recalculates on every render.

Apply this diff to memoize the function:

-  // Helper function to get full user object from shared channels
-  const getUserFromChannels = (username: string) => {
-    const server = servers.find((s) => s.id === selectedServerId);
-    if (!server) return null;
-
-    // Search through all channels for this user
-    for (const channel of server.channels) {
-      const user = channel.users.find(
-        (u) => u.username.toLowerCase() === username.toLowerCase(),
-      );
-      if (user) {
-        return user;
-      }
-    }
-
-    return null;
-  };
+  // Helper function to get full user object from shared channels
+  const getUserFromChannels = useMemo(() => {
+    return (username: string) => {
+      const server = servers.find((s) => s.id === selectedServerId);
+      if (!server) return null;
+
+      // Search through all channels for this user
+      for (const channel of server.channels) {
+        const user = channel.users.find(
+          (u) => u.username.toLowerCase() === username.toLowerCase(),
+        );
+        if (user) {
+          return user;
+        }
+      }
+
+      return null;
+    };
+  }, [selectedServerId, servers]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Helper function to get full user object from shared channels
const getUserFromChannels = (username: string) => {
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
// Search through all channels for this user
for (const channel of server.channels) {
const user = channel.users.find(
(u) => u.username.toLowerCase() === username.toLowerCase(),
);
if (user) {
return user;
}
}
return null;
};
// Helper function to get full user object from shared channels
const getUserFromChannels = useMemo(() => {
return (username: string) => {
const server = servers.find((s) => s.id === selectedServerId);
if (!server) return null;
// Search through all channels for this user
for (const channel of server.channels) {
const user = channel.users.find(
(u) => u.username.toLowerCase() === username.toLowerCase(),
);
if (user) {
return user;
}
}
return null;
};
}, [selectedServerId, servers]);
🤖 Prompt for AI Agents
In src/components/layout/ChatHeader.tsx around lines 147 to 163, the
getUserFromChannels helper does an O(n×m) scan on every render; replace it with
a memoized lookup: use React.useMemo to build a Map (key =
username.toLowerCase(), value = user) for the currently selected server
(dependencies: servers and selectedServerId) and then expose getUserFromChannels
as a small wrapper that does a constant-time map.get(username.toLowerCase());
ensure useMemo is imported from React and update dependencies so the map is
rebuilt only when servers or selectedServerId change.

Comment on lines +165 to +217
// Helper function to render verification and bot badges
const renderUserBadges = (
username: string,
privateChat: PrivateChat | undefined,
user: User | null,
showVerified = true,
) => {
// Get account and bot info from privateChat first, fall back to channel user
const account = privateChat?.account || user?.account;
const isBot =
privateChat?.isBot ||
user?.isBot ||
user?.metadata?.bot?.value === "true";
const isIrcOp = user?.isIrcOp || false;

const isVerified =
showVerified &&
account &&
account !== "0" &&
username.toLowerCase() === account.toLowerCase();

if (!isVerified && !isBot && !isIrcOp) return null;

return (
<>
{isVerified && (
<FaCheckCircle
className="inline ml-0.5 text-green-500"
style={{ fontSize: "0.75em", verticalAlign: "baseline" }}
title="Verified account"
/>
)}
{isBot && (
<span
className="inline ml-0.5"
style={{ fontSize: "0.9em" }}
title="Bot"
>
🤖
</span>
)}
{isIrcOp && (
<span
className="inline ml-0.5"
style={{ fontSize: "0.9em" }}
title="IRC Operator"
>
🔑
</span>
)}
</>
);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent handling of IRC operator status.

Line 178 only checks user?.isIrcOp, unlike account (line 173) and isBot (lines 174-177) which check both privateChat and user. The PrivateChat interface includes an isIrcOp field, so it should be checked first.

Apply this diff to maintain consistency:

-    const isIrcOp = user?.isIrcOp || false;
+    const isIrcOp = privateChat?.isIrcOp || user?.isIrcOp || false;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Helper function to render verification and bot badges
const renderUserBadges = (
username: string,
privateChat: PrivateChat | undefined,
user: User | null,
showVerified = true,
) => {
// Get account and bot info from privateChat first, fall back to channel user
const account = privateChat?.account || user?.account;
const isBot =
privateChat?.isBot ||
user?.isBot ||
user?.metadata?.bot?.value === "true";
const isIrcOp = user?.isIrcOp || false;
const isVerified =
showVerified &&
account &&
account !== "0" &&
username.toLowerCase() === account.toLowerCase();
if (!isVerified && !isBot && !isIrcOp) return null;
return (
<>
{isVerified && (
<FaCheckCircle
className="inline ml-0.5 text-green-500"
style={{ fontSize: "0.75em", verticalAlign: "baseline" }}
title="Verified account"
/>
)}
{isBot && (
<span
className="inline ml-0.5"
style={{ fontSize: "0.9em" }}
title="Bot"
>
🤖
</span>
)}
{isIrcOp && (
<span
className="inline ml-0.5"
style={{ fontSize: "0.9em" }}
title="IRC Operator"
>
🔑
</span>
)}
</>
);
};
// Helper function to render verification and bot badges
const renderUserBadges = (
username: string,
privateChat: PrivateChat | undefined,
user: User | null,
showVerified = true,
) => {
// Get account and bot info from privateChat first, fall back to channel user
const account = privateChat?.account || user?.account;
const isBot =
privateChat?.isBot ||
user?.isBot ||
user?.metadata?.bot?.value === "true";
const isIrcOp = privateChat?.isIrcOp || user?.isIrcOp || false;
const isVerified =
showVerified &&
account &&
account !== "0" &&
username.toLowerCase() === account.toLowerCase();
if (!isVerified && !isBot && !isIrcOp) return null;
return (
<>
{isVerified && (
<FaCheckCircle
className="inline ml-0.5 text-green-500"
style={{ fontSize: "0.75em", verticalAlign: "baseline" }}
title="Verified account"
/>
)}
{isBot && (
<span
className="inline ml-0.5"
style={{ fontSize: "0.9em" }}
title="Bot"
>
🤖
</span>
)}
{isIrcOp && (
<span
className="inline ml-0.5"
style={{ fontSize: "0.9em" }}
title="IRC Operator"
>
🔑
</span>
)}
</>
);
};
🤖 Prompt for AI Agents
In src/components/layout/ChatHeader.tsx around lines 165 to 217, the isIrcOp
check currently only reads user?.isIrcOp; change it to read privateChat?.isIrcOp
|| user?.isIrcOp (consistent with account and isBot lookups) so IRC operator
status is derived from the privateChat first and falls back to the user; update
the isIrcOp variable assignment accordingly.

@matheusfillipe
Copy link
Copy Markdown
Contributor

image I think it is hard to understand that this signifies numbers of users, I thought was mentions or unread messages (the number in the right). Maybe could be a number over an icon?

@matheusfillipe
Copy link
Copy Markdown
Contributor

matheusfillipe commented Oct 17, 2025

regression on URL highlights not working anymore
image

@matheusfillipe
Copy link
Copy Markdown
Contributor

The scrolling keeping doesn't work for me in chrome and I dont see the arrow down button that used to happen, but this is gone since long ago. Also in anonymous mode so pretty weird.

@matheusfillipe
Copy link
Copy Markdown
Contributor

matheusfillipe commented Oct 17, 2025

classic highlight.js issues with symbols, or maybe is just the tag escapting idk

>
<
!
image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

neat that you tested!

Comment thread src/types/index.ts
}, [filteredChannels.length, setDisplayedChannelsCount, setLoadingMore]); // Only depend on filteredChannels.length to avoid recreating listener too often

// Reset displayed count when filtered channels change
// eslint-disable-next-line react-hooks/exhaustive-deps
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think you can update react and use useEffectEvent for this now. https://react.dev/reference/react/useEffectEvent

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: 0

Caution

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

⚠️ Outside diff range comments (2)
src/lib/ircUtils.tsx (1)

236-250: Escape href before injecting into HTML.

href can be attacker-controlled (e.g. [label](http://example.com" onmouseover="alert(1))) and we interpolate it directly into the anchor attribute. Because we don’t HTML-escape quotes after sanitizing the protocol, an attacker can break out of the attribute and run script even though we removed javascript:. Please HTML-escape the value (including quotes) and guard against falsy href before calling .replace. (docs.devnetexperttraining.com)

-  renderer.link = ({ href, title, text }) => {
-    // Sanitize URL to prevent javascript: and other malicious protocols
-    const sanitizedHref = href.replace(/^(javascript|data|vbscript):/i, "#");
+  const escapeHtmlAttr = (value: string) =>
+    value
+      .replace(/&/g, "&amp;")
+      .replace(/"/g, "&quot;")
+      .replace(/'/g, "&#x27;");
+
+  renderer.link = ({ href, title, text }) => {
+    if (!href) {
+      return text ?? "";
+    }
+
+    // Sanitize URL to prevent javascript: and other malicious protocols
+    const sanitizedHref = href.replace(/^(javascript|data|vbscript):/i, "#");
+    const escapedHref = escapeHtmlAttr(sanitizedHref);
@@
-    return `<a href="${sanitizedHref}"${titleAttr} target="_blank" rel="noopener noreferrer" class="${linkClass}">${text}</a>`;
+    return `<a href="${escapedHref}"${titleAttr} target="_blank" rel="noopener noreferrer" class="${linkClass}">${text}</a>`;
   };
src/components/layout/ChannelList.tsx (1)

1279-1348: Verify nested click handler intent.

The settings button at line 1344 and its parent container at line 1279 both call toggleUserProfileModal(true). This creates nested click handlers with identical behavior. If intentional (providing both a large clickable area and an explicit button), this is fine. Otherwise, consider removing one handler to simplify the interaction model.

♻️ Duplicate comments (1)
src/store/index.ts (1)

5065-5073: Guard version before calling .includes().

This handler still calls version.includes(...) without confirming version is a string, so the same TypeError reported earlier will reappear if the server omits the field. Please add the guard you already discussed.

-  const isUnrealIRCd = version.includes("UnrealIRCd");
+  const ver = typeof version === "string" ? version : "";
+  const isUnrealIRCd = ver.includes("UnrealIRCd");
🧹 Nitpick comments (1)
src/components/message/MessageItem.tsx (1)

573-611: Consider consolidating duplicate multiline logic.

The multiline message handling logic appears twice: first in getMessageContent() (lines 574-589) to aggregate content, and again at lines 600-611 for early return. Consider extracting the "is first message in group" check into a helper function to reduce duplication and improve maintainability.

Example consolidation:

const isFirstInMultilineGroup = () => {
  if (!message.multilineMessageIds || message.multilineMessageIds.length === 0) {
    return true;
  }
  const multilineMessages = channelMessages.filter((m) =>
    message.multilineMessageIds?.includes(m.id),
  );
  multilineMessages.sort(
    (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
  );
  return multilineMessages[0]?.id === message.id;
};

const getMessageContent = () => {
  if (message.multilineMessageIds && message.multilineMessageIds.length > 0) {
    const multilineMessages = channelMessages.filter((m) =>
      message.multilineMessageIds?.includes(m.id),
    );
    multilineMessages.sort(
      (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
    );
    if (!isFirstInMultilineGroup()) {
      return "";
    }
    return multilineMessages.map((m) => m.content).join("\n");
  }
  return message.content;
};

// Early return simplified
if (!isFirstInMultilineGroup()) {
  return null;
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 71b8b58 and 62a1460.

📒 Files selected for processing (10)
  • src/components/layout/ChannelList.tsx (2 hunks)
  • src/components/layout/MemberList.tsx (1 hunks)
  • src/components/message/MessageItem.tsx (2 hunks)
  • src/components/message/MessageReply.tsx (1 hunks)
  • src/components/message/SystemMessage.tsx (1 hunks)
  • src/components/message/WhisperMessage.tsx (1 hunks)
  • src/components/ui/StandardReplyNotification.tsx (1 hunks)
  • src/lib/ircUtils.tsx (9 hunks)
  • src/store/index.ts (12 hunks)
  • tests/lib/messageFormatter.test.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/lib/messageFormatter.test.ts
🧰 Additional context used
🧬 Code graph analysis (9)
src/components/message/WhisperMessage.tsx (1)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
src/components/layout/MemberList.tsx (1)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
src/components/message/SystemMessage.tsx (1)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
src/components/message/MessageReply.tsx (1)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
src/components/message/MessageItem.tsx (5)
src/store/index.ts (1)
  • loadSavedMetadata (113-115)
src/types/index.ts (1)
  • User (1-17)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
src/components/message/CollapsibleMessage.tsx (1)
  • CollapsibleMessage (9-108)
src/components/message/MessageReply.tsx (1)
  • MessageReply (14-51)
src/components/ui/StandardReplyNotification.tsx (1)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
src/store/index.ts (2)
src/lib/ircClient.ts (1)
  • ircClient (2728-2728)
src/lib/ircUtils.tsx (1)
  • isUnrealIRCd (774-776)
src/lib/ircUtils.tsx (1)
src/types/index.ts (1)
  • Server (25-48)
src/components/layout/ChannelList.tsx (1)
src/lib/ircUtils.tsx (1)
  • processMarkdownInText (417-451)
🪛 ast-grep (0.39.6)
src/lib/ircUtils.tsx

[warning] 364-364: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🔇 Additional comments (10)
src/components/layout/ChannelList.tsx (1)

1077-1082: LGTM! Enhanced markdown processing with unique key.

The updated processMarkdownInText call correctly passes explicit parameters and a unique key derived from the private chat ID, ensuring stable rendering and proper React reconciliation.

src/components/message/SystemMessage.tsx (1)

22-27: LGTM! Consistent markdown processing with fallback.

The updated call includes a proper fallback chain for the key prefix (message.id || message.msgid || "system"), ensuring stable keys even when message identifiers are missing.

src/components/layout/MemberList.tsx (2)

271-276: LGTM! Unique key for realname rendering.

The key prefix member-${user.id}-realname ensures each user's realname has a unique, stable identifier for React rendering.


284-289: LGTM! Unique key for status rendering.

The key prefix member-${user.id}-status appropriately distinguishes status rendering from realname rendering while maintaining per-user uniqueness.

src/components/ui/StandardReplyNotification.tsx (1)

71-76: LGTM! Composite key ensures uniqueness.

The key prefix combines command, code, and timestamp to create a unique identifier for each standard reply notification. Since the timestamp is passed as a prop and derived from the message, it remains stable across re-renders.

src/components/message/MessageReply.tsx (1)

42-47: LGTM! Reply-specific key with fallback.

The key prefix reply-${replyMessage.id || replyMessage.msgid || "unknown"} appropriately namespaces reply content rendering and includes a fallback for edge cases.

src/components/message/WhisperMessage.tsx (1)

58-63: LGTM! Whisper-specific key with fallback.

The key derivation follows the established pattern with an appropriate "whisper" fallback for cases where message identifiers are unavailable.

src/components/message/MessageItem.tsx (3)

352-395: Helper functions for metadata retrieval.

The getUserMetadata and getUserFromChannels helpers use useStore.getState() for non-reactive state access. This is appropriate here since these helpers are called within reactive contexts where updates are already tracked (e.g., within selectors or effects).


397-520: Refactored user resolution with reactive selectors.

The refactoring introduces clear separation between user key derivation (lines 422-464) and user data resolution (lines 466-520), with proper reactive tracking via useStore selectors. The pmUserMetadata selector includes metadataChangeCounter to ensure reactivity for metadata updates.


614-622: LGTM! CollapsibleMessage integration.

The integration of CollapsibleMessage wrapping the processed markdown content is clean and follows a clear separation of concerns. The htmlContent processing and wrapping in CollapsibleMessage provides the collapsible behavior mentioned in the PR objectives.

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

🧹 Nitpick comments (4)
src/lib/ircUtils.tsx (4)

263-263: Use crypto.randomUUID() or a counter for code IDs.

Math.random().toString(36).substr(2, 9) can produce collisions (low probability but nonzero), which would break copy-to-clipboard targeting if two code blocks receive the same ID on a single page render.

Apply this diff to use a counter-based ID generator:

+let codeIdCounter = 0;
+
 renderer.codespan = ({ text }) => {
   // Decode HTML entities back to characters for inline code
   const decodedText = text
     .replace(/&lt;/g, "<")
     .replace(/&gt;/g, ">")
     .replace(/&amp;/g, "&")
     .replace(/&quot;/g, '"')
     .replace(/&#39;/g, "'")
     .replace(/&apos;/g, "'");

-  const codeId = `inline-code-${Math.random().toString(36).substr(2, 9)}`;
+  const codeId = `inline-code-${codeIdCounter++}`;
   return `<span class="inline-code-container"><code id="${codeId}" class="inline-code">${decodedText}</code><button class="inline-copy-button" data-code-id="${codeId}" title="Copy to clipboard">

272-322: Extract entity decoding into a shared helper.

The HTML entity decoding logic (lines 278-284) is duplicated from the codespan renderer (lines 255-261). Extract this into a helper function to follow the DRY principle and ensure consistency if the entity list needs updating.

Apply this diff:

+// Helper to decode HTML entities in code content
+function decodeCodeEntities(text: string): string {
+  return text
+    .replace(/&lt;/g, "<")
+    .replace(/&gt;/g, ">")
+    .replace(/&amp;/g, "&")
+    .replace(/&quot;/g, '"')
+    .replace(/&#39;/g, "'")
+    .replace(/&apos;/g, "'");
+}
+
 renderer.codespan = ({ text }) => {
-  const decodedText = text
-    .replace(/&lt;/g, "<")
-    .replace(/&gt;/g, ">")
-    .replace(/&amp;/g, "&")
-    .replace(/&quot;/g, '"')
-    .replace(/&#39;/g, "'")
-    .replace(/&apos;/g, "'");
+  const decodedText = decodeCodeEntities(text);
   // ... rest of codespan
 };

 renderer.code = ({ text, lang }) => {
   const trimmedText = text.trimEnd();
-  const decodedText = trimmedText
-    .replace(/&lt;/g, "<")
-    .replace(/&gt;/g, ">")
-    .replace(/&amp;/g, "&")
-    .replace(/&quot;/g, '"')
-    .replace(/&#39;/g, "'")
-    .replace(/&apos;/g, "'");
+  const decodedText = decodeCodeEntities(trimmedText);
   // ... rest of code
 };

379-425: Add feature detection for Clipboard API.

The Clipboard API (navigator.clipboard) is only available in secure contexts (HTTPS or localhost). If the application is accessed over HTTP, the copy functionality will silently fail with a console error. Consider adding feature detection and gracefully degrading to a fallback (e.g., showing the code text for manual copy, or a message that HTTPS is required).

Apply this diff to add a check:

 onClick={(e) => {
   const target = e.target as HTMLElement;
   const button =
     (target.closest(".copy-button") as HTMLButtonElement) ||
     (target.closest(".inline-copy-button") as HTMLButtonElement);
   if (button) {
     const codeId = button.getAttribute("data-code-id");
     if (codeId) {
       const codeElement = document.getElementById(codeId);
       if (codeElement) {
         const textToCopy = codeElement.textContent || "";
+        if (!navigator.clipboard) {
+          console.warn("Clipboard API not available. Copy feature disabled.");
+          return;
+        }
         navigator.clipboard
           .writeText(textToCopy)

782-789: Consider whether this helper is necessary.

The function simply returns server.isUnrealIRCd === true. While it provides documentation and a stable API, you could inline this check at call sites (if (server.isUnrealIRCd)) unless you anticipate more complex detection logic in the future.

If you prefer to keep a function for future extensibility (e.g., fallback detection via other server properties), this is fine as-is.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 62a1460 and 95c6891.

📒 Files selected for processing (1)
  • src/lib/ircUtils.tsx (9 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/lib/ircUtils.tsx (1)
src/types/index.ts (1)
  • Server (25-48)
🪛 ast-grep (0.39.6)
src/lib/ircUtils.tsx

[warning] 377-377: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🔇 Additional comments (6)
src/lib/ircUtils.tsx (6)

221-232: LGTM: Image renderer enhancement.

The clickable image wrapper and external-link-security class improve UX and enable consistent link security handling.


332-338: Clarify the single-line tilde fence escaping logic.

The regex /^~~~.*~~~$/gm escapes single-line tilde blocks (e.g., ~~~js code~~~) so they render as literal text. However, multi-line tilde fences (~~~\ncode\n~~~) are still parsed as code blocks (pattern on line 444). This creates inconsistent behavior:

  • Single-line: ~~~code~~~ → renders as text ~~~code~~~
  • Multi-line: ~~~\ncode\n~~~ → renders as code block

Is this intentional? If single-line tilde blocks should render as code, remove the escaping. If they should render as text, document why they differ from multi-line blocks.


430-464: LGTM: Markdown detection enhanced.

The addition of keyPrefix for unique React keys and the tilde fence patterns for code blocks and strikethrough improve the markdown detection logic.


1-1: No action required—highlight.js version is correctly specified.

The package.json correctly specifies ^11.11.1, which matches the latest stable version (as of Dec 2024). The dependency is up-to-date and properly configured.


194-428: Sanitization strategy is sound and comprehensive for dangerouslySetInnerHTML usage.

The approach is secure:

  • HTML tags are escaped before parsing (line 341: <&lt;, >&gt;)
  • URLs are sanitized to remove dangerous protocols (lines 215, 238)
  • marked is configured with safe options
  • Code content is decoded for display but remains within <code> elements where script execution is prevented

No additional changes required.


252-270: Now let me check if there's an interaction issue with highlight.js:

Based on my verification, I can now provide the rewritten review comment:


Fix HTML entity decoding order in code renderers to follow best practices.

The entity decoding order violates standard security practices. When escaping/unescaping HTML entities, the ampersand (&) must be decoded last. Currently, &amp; is decoded in the middle (after &lt; and &gt;), which is fragile and non-standard.

Additionally, the entity decoder handles only six entities (&lt;, &gt;, &amp;, &quot;, &#39;, &apos;). While this covers what marked produces by default, marked encodes quotes, ampersands, and other characters into HTML entities. Consider using the html-entities package (already in dependencies) instead of manual decoding.

Recommended fixes:

  • For inline code (line 255-261): Reorder decoding to handle &amp; last
  • For both renderers: Replace manual entity decoding with the html-entities library's decode() function to handle all entities correctly
  • Consider whether pre-escaping on line 334 is necessary if marked already handles escaping

Comment thread src/lib/ircUtils.tsx
Comment on lines +602 to 677
// Helper function to detect and render URLs in text
function processUrlsInText(
text: string,
style?: React.CSSProperties,
keyPrefix = "",
elementIndexRef?: { current: number },
): React.ReactNode[] {
// URL regex pattern - matches http://, https://, and www. URLs
const urlRegex =
/(https?:\/\/[^\s<>"{}|\\^`[\]]+|www\.[^\s<>"{}|\\^`[\]]+)/gi;

const parts: React.ReactNode[] = [];
let lastIndex = 0;
let elementIndex = elementIndexRef ? elementIndexRef.current : 0;
let match: RegExpExecArray | null = urlRegex.exec(text);

while (match !== null) {
// Add text before the URL
if (match.index > lastIndex) {
parts.push(
<span key={`${keyPrefix}text-${elementIndex++}`} style={style}>
{text.slice(lastIndex, match.index)}
</span>,
);
}

const url = match[0];
// Ensure URL has protocol
const fullUrl = url.startsWith("http") ? url : `https://${url}`;

// Truncate long URLs for display
const displayText = url.length > 50 ? `${url.slice(0, 47)}...` : url;

parts.push(
<a
key={`${keyPrefix}url-${elementIndex++}`}
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 underline"
style={style}
title={url}
>
{displayText}
</a>,
);

lastIndex = match.index + match[0].length;
match = urlRegex.exec(text);
}

// Add remaining text
if (lastIndex < text.length) {
parts.push(
<span key={`${keyPrefix}text-${elementIndex++}`} style={style}>
{text.slice(lastIndex)}
</span>,
);
}

// Update the shared elementIndex if provided
if (elementIndexRef) {
elementIndexRef.current = elementIndex;
}

return parts.length > 0
? parts
: [
<span
key={`${keyPrefix}text-${elementIndexRef ? elementIndexRef.current++ : 0}`}
style={style}
>
{text}
</span>,
];
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Verify the URL regex and address user-reported URL highlighting regression.

A user reported that "URL highlights/styling no longer work as expected" (PR comments). The URL regex on line 611 may have escaping issues:

/(https?:\/\/[^\s<>"{}|\\^`[\]]+|www\.[^\s<>"{}|\\^`[\]]+)/gi

The character class [^\s<>"{}|\\^[]]might not correctly escape the closing]`. Depending on the JavaScript regex parser, this could:

  1. Fail to match URLs with certain characters
  2. Include unintended characters in matches
  3. Cause unexpected truncation of URLs

Additionally, the regex doesn't handle trailing punctuation (e.g., "Check https://example.com." includes the period in the URL).

Run the following script to test the regex behavior:

#!/bin/bash
# Test URL regex matching behavior
node -e "
const urlRegex = /(https?:\/\/[^\s<>\"{}|\\\\^\`[\]]+|www\.[^\s<>\"{}|\\\\^\`[\]]+)/gi;
const tests = [
  'Check out https://example.com.',
  'Visit https://example.com/path?query=1',
  'See https://example.com/page[1]',
  'Link: www.example.com',
  'Multiple: https://a.com and https://b.com'
];
tests.forEach(test => {
  const matches = test.match(urlRegex);
  console.log('Input:', test);
  console.log('Matches:', matches);
  console.log('---');
});
"

Consider using a well-tested URL regex library or improving the pattern:

-  const urlRegex =
-    /(https?:\/\/[^\s<>"{}|\\^`[\]]+|www\.[^\s<>"{}|\\^`[\]]+)/gi;
+  // Improved regex that properly escapes brackets and handles trailing punctuation
+  const urlRegex =
+    /(https?:\/\/[^\s<>"{}|\\^`\[\]]+[\w/]|www\.[^\s<>"{}|\\^`\[\]]+[\w/])/gi;
🤖 Prompt for AI Agents
In src/lib/ircUtils.tsx around lines 602–677, the URL regex has
incorrect/insufficient escaping for the closing bracket/backtick/backslash class
and also permits trailing punctuation (e.g., trailing period/comma) to be
captured; update the regex so the character class correctly escapes the closing
square bracket and backtick (for example ensure the class is something like
[^\s<>"{}|\\^`\[\]] or equivalent valid JS escape order) and either add a
lookahead to exclude terminal punctuation or post-process each regex match to
strip trailing punctuation characters (.,:;!?)]), then re-run the provided tests
and add a unit test covering cases like URL followed by punctuation, bracketed
URLs, query strings, and multiple URLs in one string to verify highlighting is
restored.

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: 0

Caution

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

⚠️ Outside diff range comments (1)
src/components/ui/ChannelListModal.tsx (1)

271-619: Major: ESC key handler still missing despite previous fix.

Although a previous review (marked as addressed in commit ec1d37a) flagged the missing ESC key handler for keyboard accessibility, the current code shows no useEffect with a keydown listener. matheusfillipe confirms "Indeed esc key not working for me in any modal". Other modals in the codebase (UserContextMenu, LinkSecurityWarningModal, etc.) implement this pattern.

Add this effect after the existing useEffect hooks (around line 233):

// ESC key handler to close modal
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") {
      toggleChannelListModal(false);
    }
  };

  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleChannelListModal]);

Based on learnings

♻️ Duplicate comments (1)
src/components/ui/ChannelListModal.tsx (1)

235-251: Critical: Missing elist parameter breaks server-side filtering.

Line 250 calls listChannels(selectedServerId, filters), but the signature is listChannels(serverId, elist?, filters?). The filters object is being passed as the elist parameter, breaking server-side filtering. This issue was flagged in previous reviews but remains unaddressed.

Apply this diff:

     updateChannelListFilters(selectedServerId, filters);
-    listChannels(selectedServerId, filters);
+    listChannels(selectedServerId, elist, filters);
   };
🧹 Nitpick comments (1)
src/components/ui/ChannelListModal.tsx (1)

52-71: Consider migrating to useEffectEvent when stable.

The custom setter pattern (updating both state and ref) avoids stale closures in the scroll handler, but React's upcoming useEffectEvent hook would eliminate this workaround. As matheusfillipe noted at line 227, you can now use useEffectEvent if you've updated to a React version that supports it.

Based on learnings

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95c6891 and 81f9d3c.

📒 Files selected for processing (1)
  • src/components/ui/ChannelListModal.tsx (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/components/ui/ChannelListModal.tsx (2)
src/lib/ircClient.ts (1)
  • listChannels (1018-1085)
src/lib/ircUtils.tsx (2)
  • getChannelAvatarUrl (721-736)
  • getChannelDisplayName (706-716)
🔇 Additional comments (5)
src/components/ui/ChannelListModal.tsx (5)

14-17: LGTM: Store integration and safe elist handling.

The new store properties enable per-server filtering state, and line 23 safely normalizes elist to prevent runtime errors from undefined optional chaining (as flagged in previous reviews).

Also applies to: 23-23


196-224: LGTM: Lazy loading implementation.

The scroll-based lazy loading correctly uses refs to avoid recreating the event listener on every render. The eslint-disable is intentional and appropriate here. The 50-item incremental loading with a 200ms delay provides smooth UX.


320-495: LGTM: Advanced filters UI respects server capabilities.

The filter panel correctly conditionally renders based on ELIST capability flags (U, C, T, M, N) and provides a clear fallback message when the server doesn't support advanced filtering. Once the critical issue at line 250 is fixed, this will integrate properly with server-side filtering.


522-599: LGTM: Metadata-driven channel rendering.

The channel item rendering correctly integrates metadata-driven avatars and display names with appropriate fallbacks. The avatar error handling and conditional badge display (showing original channel name when display name differs) enhance the UX.


290-290: LGTM: Accessibility improvement.

The aria-label="Close" attribute enhances screen reader accessibility for the close button.

@ValwareIRC ValwareIRC merged commit d82b7e1 into main Oct 17, 2025
3 of 4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Feb 12, 2026
8 tasks
zocram4cc pushed a commit to zocram4cc/ObsidianIRC that referenced this pull request Feb 17, 2026
* Fix scroll position preservation in chat

- Preserve scroll position when user scrolls up to read history
- Auto-scroll only when user is already at bottom
- Add wasAtBottomRef to track scroll state before new messages arrive
- Fix timing issue where scroll position was checked after DOM updates

* lint

* UI sidebar width fix in User Settings modal mobile view

* Fix a test

* Fix tests some more

* Fix tests some more

* feat: enhance channel list modal with user count sorting and improved UX

- Default sort channels by user count (highest first)
- Display user count as purple badge matching selected server tab color
- Click overlay to close modal
- Make channels list scrollable while keeping filter/sort options fixed
- Fix modal layout structure for proper scrolling behavior

* /LIST improvements

* Implement lazy-loading on channels lists in chunks of 50, dynamic advanced filtering using ELIST ISUPPORT

* Make markdown rendering more beautiful and practical

* Collapsible/Expandable multiline messages

* Center see more button

* add a hs divider around the "show more button" to be clearer about the multiline cutoff point

* Improvements on package.json and regarding ELIST possibility to not exist

* deduplicate +typing=done tags

* Show display-names in PM windows in the ChatHeader

* Fix metadata sync issue, bug added by me earlier

* Fix > and < rendering in markdown codeblocks

* Fix channel listing headers, be clear about channel name and users property

* Be more specific that the number in the channels list is users
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.

2 participants