Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2c4c6c7
Fix scroll position preservation in chat
ValwareIRC Oct 16, 2025
ce6d8da
lint
ValwareIRC Oct 16, 2025
db7539b
UI sidebar width fix in User Settings modal mobile view
ValwareIRC Oct 17, 2025
0c881d7
Fix a test
ValwareIRC Oct 17, 2025
88fe28c
Fix tests some more
ValwareIRC Oct 17, 2025
1074d6d
Fix tests some more
ValwareIRC Oct 17, 2025
7d77dab
feat: enhance channel list modal with user count sorting and improved UX
ValwareIRC Oct 17, 2025
cb594e0
/LIST improvements
ValwareIRC Oct 17, 2025
053fda4
Implement lazy-loading on channels lists in chunks of 50, dynamic adv…
ValwareIRC Oct 17, 2025
8876f8d
Make markdown rendering more beautiful and practical
ValwareIRC Oct 17, 2025
c0ee726
Collapsible/Expandable multiline messages
ValwareIRC Oct 17, 2025
0d947ff
Center see more button
ValwareIRC Oct 17, 2025
17d805f
add a hs divider around the "show more button" to be clearer about th…
ValwareIRC Oct 17, 2025
ec1d37a
Improvements on package.json and regarding ELIST possibility to not e…
ValwareIRC Oct 17, 2025
353718e
deduplicate +typing=done tags
ValwareIRC Oct 17, 2025
71b8b58
Show display-names in PM windows in the ChatHeader
ValwareIRC Oct 17, 2025
62a1460
Fix metadata sync issue, bug added by me earlier
ValwareIRC Oct 17, 2025
95c6891
Fix > and < rendering in markdown codeblocks
ValwareIRC Oct 17, 2025
81f9d3c
Fix channel listing headers, be clear about channel name and users pr…
ValwareIRC Oct 17, 2025
659d067
Be more specific that the number in the channels list is users
ValwareIRC Oct 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"emoji-picker-react": "^4.13.3",
"exifr": "^7.1.3",
"gh-pages": "^6.3.0",
"highlight.js": "^11.11.1",
"html-react-parser": "^5.2.7",
"marked": "^16.4.0",
"react": "^18.3.1",
Expand Down
9 changes: 7 additions & 2 deletions src/components/layout/ChannelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1074,8 +1074,12 @@ export const ChannelList: React.FC<{
privateChat.realname || user?.realname;
if (realname) {
// Parse IRC colors/formatting in realname
secondPart =
processMarkdownInText(realname);
secondPart = processMarkdownInText(
realname,
true,
false,
`privatechat-${privateChat.id}-realname`,
);
}
}

Expand Down Expand Up @@ -1337,6 +1341,7 @@ export const ChannelList: React.FC<{
<button
className="hover:text-white"
data-testid="user-settings-button"
onClick={() => toggleUserProfileModal(true)}
>
<FaCog className="mr-2" />
</button>
Expand Down
18 changes: 9 additions & 9 deletions src/components/layout/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const ChatArea: React.FC<{
FormattingType[]
>([]);
const [isScrolledUp, setIsScrolledUp] = useState(false);
const wasAtBottomRef = useRef(true); // Track if user was at bottom before new messages
const [isFormattingInitialized, setIsFormattingInitialized] = useState(false);
const [cursorPosition, setCursorPosition] = useState(0);
const [showAutocomplete, setShowAutocomplete] = useState(false);
Expand Down Expand Up @@ -477,16 +478,20 @@ export const ChatArea: React.FC<{
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
// Reset visible message count and search when changing channels
// Reset scroll state and visible message count when changing channels
setIsScrolledUp(false);
wasAtBottomRef.current = true;
setVisibleMessageCount(100);
setSearchQuery("");
}, [selectedServerId, selectedChannelId]);

// Auto scroll to bottom on new messages
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want to scroll when messages change, not when isScrolledUp changes
useEffect(() => {
if (isScrolledUp) return;
scrollDown();
// Only auto-scroll if user was at the bottom before new messages arrived
if (wasAtBottomRef.current) {
scrollDown();
}
}, [displayedMessages]);

// Check if scrolled away from bottom
Expand All @@ -499,6 +504,7 @@ export const ChatArea: React.FC<{
container.scrollHeight - container.scrollTop - container.clientHeight <
30;
setIsScrolledUp(!atBottom);
wasAtBottomRef.current = atBottom;
};

container.addEventListener("scroll", checkIfScrolledToBottom);
Expand Down Expand Up @@ -544,12 +550,6 @@ export const ChatArea: React.FC<{
}
}, 0);
}

// Send typing done notification
const target = selectedChannel?.name ?? selectedPrivateChat?.username;
if (target) {
typingNotification.notifyTypingDone(target);
}
};

const handleImageUpload = async (file: File) => {
Expand Down
158 changes: 152 additions & 6 deletions src/components/layout/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import {
FaBell,
FaBellSlash,
FaCheckCircle,
FaChevronLeft,
FaChevronRight,
FaEdit,
Expand Down Expand Up @@ -115,6 +116,106 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
return null;
}, [selectedPrivateChat, selectedServerId, servers]);

// 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;
};
Comment on lines +119 to +145
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.


// 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;
};
Comment on lines +147 to +163
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.


// 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>
)}
</>
);
};
Comment on lines +165 to +217
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.


const privateChatAvatar = privateChatUserMetadata?.avatar?.value;

// Check if current user is operator
Expand Down Expand Up @@ -345,13 +446,58 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
{/* Username and status */}
<div className="flex flex-col">
<h2 className="font-bold text-white">
{selectedPrivateChat.username}
{(() => {
const userMetadata = getUserMetadata(
selectedPrivateChat.username,
);
const displayName = userMetadata?.["display-name"]?.value;
const user = getUserFromChannels(
selectedPrivateChat.username,
);
return (
<>
{displayName || selectedPrivateChat.username}
{/* Only show verified badge if NO display-name (showing username directly) */}
{renderUserBadges(
selectedPrivateChat.username,
selectedPrivateChat,
user,
!displayName,
)}
</>
);
})()}
</h2>
{privateChatUserMetadata?.status?.value && (
<span className="text-xs text-discord-text-muted">
{privateChatUserMetadata.status.value}
</span>
)}
{(() => {
const userMetadata = getUserMetadata(
selectedPrivateChat.username,
);
const displayName = userMetadata?.["display-name"]?.value;
const user = getUserFromChannels(selectedPrivateChat.username);

// Show username in badge if display-name exists
if (displayName) {
return (
<div className="flex items-center gap-1.5 text-xs truncate mt-0.5">
<span className="bg-gray-300 text-black px-1 py-0 rounded font-bold whitespace-nowrap text-[10px]">
{selectedPrivateChat.username}
{renderUserBadges(
selectedPrivateChat.username,
selectedPrivateChat,
user,
)}
</span>
</div>
);
}

// Show status if no display-name (status was already shown above when display-name exists)
return privateChatUserMetadata?.status?.value ? (
<span className="text-xs text-discord-text-muted">
{privateChatUserMetadata.status.value}
</span>
) : null;
})()}
</div>
{/* Pin/Unpin button */}
{selectedServerId && (
Expand Down
14 changes: 12 additions & 2 deletions src/components/layout/MemberList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,25 @@ const UserItem: React.FC<{
)}
{user.realname && (
<span className="truncate text-discord-text-muted">
{processMarkdownInText(user.realname)}
{processMarkdownInText(
user.realname,
true,
false,
`member-${user.id}-realname`,
)}
</span>
)}
{user.realname && metadataStatus && (
<span className="text-discord-text-muted opacity-50">•</span>
)}
{metadataStatus && (
<span className="truncate text-discord-text-muted">
{processMarkdownInText(metadataStatus)}
{processMarkdownInText(
metadataStatus,
true,
false,
`member-${user.id}-status`,
)}
</span>
)}
{(user.realname || metadataStatus) && website && (
Expand Down
Loading