From 1437dd0fed8d3d1b8bc215ef09de92456ab163cd Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 29 Sep 2025 20:03:36 +0100 Subject: [PATCH 01/47] feat: make messages wrap instead of widening the page - Add break-words class to message content containers - Ensures long messages and URLs wrap properly - Applied to regular messages, system messages, action messages, and reply messages --- src/components/layout/ChatArea.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 8a560c0a..6f859a9f 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -237,7 +237,7 @@ const MessageItem: React.FC<{ if (isSystem) { return (
-
+
{htmlContent} @@ -305,7 +305,7 @@ const MessageItem: React.FC<{ {formatTime(new Date(message.timestamp))}
- + {message.userId === "system" ? "System" : (displayName || message.userId.split("-")[0]) + @@ -423,7 +423,7 @@ const MessageItem: React.FC<{
{message.replyMessage && (
┌ Replying to{" "} @@ -451,9 +451,11 @@ const MessageItem: React.FC<{
)} - - {htmlContent} - +
+ + {htmlContent} + +
{/* Reactions positioned below message content */} {message.reactions && message.reactions.length > 0 && ( From a49bbf2f245f1d994484c782228012a9270daeb6 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 29 Sep 2025 20:51:44 +0100 Subject: [PATCH 02/47] fix: ensure long URLs wrap properly - Add break-words class to link and span elements in EnhancedLinkWrapper - Prevents long URLs from forcing page width expansion --- src/components/layout/ChatArea.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 6f859a9f..e6c3b849 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -107,7 +107,7 @@ const EnhancedLinkWrapper: React.FC<{ return parts.map((part, index) => { // Generate stable keys based on content and position const partKey = `text-${part}-${index}`; - const textPart = {part}; + const textPart = {part}; // If there's a matching link for this part, render it if (index < matches.length) { @@ -119,7 +119,7 @@ const EnhancedLinkWrapper: React.FC<{ href={matches[index]} target="_blank" rel="noopener noreferrer" - className="text-discord-text-link underline hover:text-blue-700" + className="text-discord-text-link underline hover:text-blue-700 break-words" onClick={(e) => { if ( (matches[index].startsWith("ircs://") || From c275d8e7c5d65149063e4c24e5d4f7be5db21693 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 29 Sep 2025 20:53:59 +0100 Subject: [PATCH 03/47] fix: truncate long URLs to 60 chars for display - URLs longer than 60 characters are now truncated with '...' - Full URL remains functional in the href attribute - Prevents page width issues while maintaining link usability --- src/components/layout/ChatArea.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index e6c3b849..90dac9ef 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -107,7 +107,11 @@ const EnhancedLinkWrapper: React.FC<{ return parts.map((part, index) => { // Generate stable keys based on content and position const partKey = `text-${part}-${index}`; - const textPart = {part}; + const textPart = ( + + {part} + + ); // If there's a matching link for this part, render it if (index < matches.length) { @@ -119,7 +123,7 @@ const EnhancedLinkWrapper: React.FC<{ href={matches[index]} target="_blank" rel="noopener noreferrer" - className="text-discord-text-link underline hover:text-blue-700 break-words" + className="text-discord-text-link underline hover:text-blue-700" onClick={(e) => { if ( (matches[index].startsWith("ircs://") || @@ -131,7 +135,9 @@ const EnhancedLinkWrapper: React.FC<{ } }} > - {matches[index]} + {matches[index].length > 60 + ? matches[index].substring(0, 60) + "..." + : matches[index]} ); From 08157933ada7a866202dda074f3b0f7640d61509 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Mon, 29 Sep 2025 20:54:07 +0100 Subject: [PATCH 04/47] style: use template literal for URL truncation --- src/components/layout/ChatArea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 90dac9ef..21ca795d 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -136,7 +136,7 @@ const EnhancedLinkWrapper: React.FC<{ }} > {matches[index].length > 60 - ? matches[index].substring(0, 60) + "..." + ? `${matches[index].substring(0, 60)}...` : matches[index]} From dbfa73c5287139248b07c2def1a0c517d417f086 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Tue, 30 Sep 2025 01:23:45 +0100 Subject: [PATCH 05/47] feat: Implement comprehensive IRCv3 client features - Add IRCv3 capability negotiation with SASL 3.2, message tags, metadata, and extended features - Implement channel listing modal with sorting (alphabetical/user count), filtering, and one-click joining - Add operator-only channel renaming functionality with modal - Move channel list button to top bar for better UX - Fix server duplication issues on reconnect - Prevent duplicate channels in listing with concurrency control - Enhance UI with proper modal management and operator permissions - Add SETNAME support for realname changes in user settings - Implement server-time message tags and echo-message handling - Add comprehensive IRCv3 capability support (cap-notify, account tracking, etc.) --- src/App.tsx | 6 +- src/components/layout/ChatArea.tsx | 23 +++ src/components/layout/ServerList.tsx | 6 +- src/components/ui/ChannelListModal.tsx | 120 +++++++++++++++ src/components/ui/ChannelRenameModal.tsx | 88 +++++++++++ src/components/ui/UserSettings.tsx | 37 ++++- src/lib/ircClient.ts | 105 ++++++++++++- src/store/index.ts | 179 ++++++++++++++++++++++- 8 files changed, 544 insertions(+), 20 deletions(-) create mode 100644 src/components/ui/ChannelListModal.tsx create mode 100644 src/components/ui/ChannelRenameModal.tsx diff --git a/src/App.tsx b/src/App.tsx index 84007382..bb9c095f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import type React from "react"; import { useEffect } from "react"; import AppLayout from "./components/layout/AppLayout"; import AddServerModal from "./components/ui/AddServerModal"; +import ChannelListModal from "./components/ui/ChannelListModal"; +import ChannelRenameModal from "./components/ui/ChannelRenameModal"; import UserSettings from "./components/ui/UserSettings"; import ircClient from "./lib/ircClient"; import useStore, { loadSavedServers } from "./store"; @@ -66,7 +68,7 @@ const initializeEnvSettings = ( const App: React.FC = () => { const { toggleAddServerModal, - ui: { isAddServerModalOpen, isUserProfileModalOpen }, + ui: { isAddServerModalOpen, isUserProfileModalOpen, isChannelListModalOpen, isChannelRenameModalOpen }, joinChannel, connectToSavedServers, } = useStore(); @@ -82,6 +84,8 @@ const App: React.FC = () => { {isAddServerModalOpen && } {isUserProfileModalOpen && } + {isChannelListModalOpen && } + {isChannelRenameModalOpen && }
); }; diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 21ca795d..2b9f0259 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -16,8 +16,10 @@ import { FaBell, FaChevronLeft, FaChevronRight, + FaEdit, FaGrinAlt, FaHashtag, + FaList, FaPenAlt, FaPlus, FaReply, @@ -1373,6 +1375,27 @@ export const ChatArea: React.FC<{ + + {selectedChannel && (() => { + const { currentUser } = useStore.getState(); + const channelUser = selectedChannel.users.find(u => u.username === currentUser?.username); + const isOperator = channelUser?.status?.includes('@') || channelUser?.status?.includes('~'); + return isOperator ? ( + + ) : null; + })()} {/* Only show member list toggle for channels, not private chats */} {selectedChannel && (
)} {selectedServerId === server.id && ( -
+
diff --git a/src/components/ui/ChannelListModal.tsx b/src/components/ui/ChannelListModal.tsx new file mode 100644 index 00000000..4dfe45a5 --- /dev/null +++ b/src/components/ui/ChannelListModal.tsx @@ -0,0 +1,120 @@ +import type React from "react"; +import { useEffect, useState } from "react"; +import { FaTimes } from "react-icons/fa"; +import useStore from "../../store"; + +const ChannelListModal: React.FC = () => { + const { + servers, + ui: { selectedServerId }, + channelList, + listChannels, + toggleChannelListModal, + joinChannel, + } = useStore(); + + const selectedServer = servers.find((s) => s.id === selectedServerId); + const rawChannels = selectedServerId + ? channelList[selectedServerId] || [] + : []; + + const [isLoading, setIsLoading] = useState(false); + const [sortBy, setSortBy] = useState<"alpha" | "users">("alpha"); + const [filter, setFilter] = useState(""); + + const filteredChannels = rawChannels + .filter((channel) => + channel.channel.toLowerCase().includes(filter.toLowerCase()), + ) + .sort((a, b) => { + if (sortBy === "alpha") { + return a.channel.localeCompare(b.channel); + } + return b.userCount - a.userCount; + }); + + useEffect(() => { + if (selectedServerId) { + setIsLoading(true); + listChannels(selectedServerId); + } + }, [selectedServerId, listChannels]); + + useEffect(() => { + if (rawChannels.length > 0) { + setIsLoading(false); + } + }, [rawChannels]); + + const handleJoinChannel = (channelName: string) => { + if (selectedServerId) { + joinChannel(selectedServerId, channelName); + toggleChannelListModal(false); // Optionally close modal after joining + } + }; + + return ( +
+
+
+

+ Channel List - {selectedServer?.name || "Unknown Server"} +

+ +
+ +
+ setFilter(e.target.value)} + className="flex-1 bg-discord-dark-300 text-white px-3 py-2 rounded" + /> + +
+ + {isLoading &&

Loading channels...

} + +
+ {filteredChannels.length === 0 && !isLoading && ( +

No channels found.

+ )} + {filteredChannels.map((channel) => ( +
handleJoinChannel(channel.channel)} + > +
+ + {channel.channel} + +

+ {channel.topic || "No topic"} +

+
+ + {channel.userCount} users + +
+ ))} +
+
+
+ ); +}; + +export default ChannelListModal; diff --git a/src/components/ui/ChannelRenameModal.tsx b/src/components/ui/ChannelRenameModal.tsx new file mode 100644 index 00000000..a0f90340 --- /dev/null +++ b/src/components/ui/ChannelRenameModal.tsx @@ -0,0 +1,88 @@ +import type React from "react"; +import { useState } from "react"; +import { FaTimes } from "react-icons/fa"; +import useStore from "../../store"; + +const ChannelRenameModal: React.FC = () => { + const { + servers, + ui: { selectedServerId, selectedChannelId }, + renameChannel, + toggleChannelRenameModal, + } = useStore(); + + const selectedServer = servers.find((s) => s.id === selectedServerId); + const selectedChannel = selectedServer?.channels.find((c) => c.id === selectedChannelId); + + const [newName, setNewName] = useState(selectedChannel?.name || ""); + const [reason, setReason] = useState(""); + + const handleRename = () => { + if (selectedServer && selectedChannel && newName.trim()) { + renameChannel(selectedServer.id, selectedChannel.name, newName.trim(), reason.trim() || undefined); + toggleChannelRenameModal(false); + } + }; + + if (!selectedChannel) return null; + + return ( +
+
+
+

Rename Channel

+ +
+ +
+
+ + +
+ +
+ + setNewName(e.target.value)} + className="w-full p-2 bg-discord-dark-300 text-white rounded" + placeholder="Enter new channel name" + /> +
+ +
+ + setReason(e.target.value)} + className="w-full p-2 bg-discord-dark-300 text-white rounded" + placeholder="Reason for renaming" + /> +
+ + +
+
+
+ ); +}; + +export default ChannelRenameModal; \ No newline at end of file diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx index cb8bc08a..e8c8e559 100644 --- a/src/components/ui/UserSettings.tsx +++ b/src/components/ui/UserSettings.tsx @@ -11,6 +11,7 @@ const UserSettings: React.FC = () => { ui, metadataSet, sendRaw, + setName, } = useStore(); const currentServer = servers.find((s) => s.id === ui.selectedServerId); const supportsMetadata = @@ -21,6 +22,7 @@ const UserSettings: React.FC = () => { // Metadata state const [avatar, setAvatar] = useState(""); const [displayName, setDisplayName] = useState(""); + const [realname, setRealname] = useState(""); const [homepage, setHomepage] = useState(""); const [status, setStatus] = useState(""); const [color, setColor] = useState("#800040"); @@ -28,13 +30,14 @@ const UserSettings: React.FC = () => { // Load existing metadata on mount useEffect(() => { - if (currentUser?.metadata) { - setAvatar(currentUser.metadata.avatar?.value || ""); - setDisplayName(currentUser.metadata["display-name"]?.value || ""); - setHomepage(currentUser.metadata.homepage?.value || ""); - setStatus(currentUser.metadata.status?.value || ""); - setColor(currentUser.metadata.color?.value || "#800040"); - setBot(currentUser.metadata.bot?.value || ""); + if (currentUser) { + setAvatar(currentUser.metadata?.avatar?.value || ""); + setDisplayName(currentUser.metadata?.["display-name"]?.value || ""); + setRealname(currentUser.displayName || ""); + setHomepage(currentUser.metadata?.homepage?.value || ""); + setStatus(currentUser.metadata?.status?.value || ""); + setColor(currentUser.metadata?.color?.value || "#800040"); + setBot(currentUser.metadata?.bot?.value || ""); } }, [currentUser]); @@ -87,6 +90,13 @@ const UserSettings: React.FC = () => { } }); } + + // Handle realname + try { + setName(currentServer.id, realname); + } catch (error) { + console.error("Failed to set realname:", error); + } } toggleUserProfileModal(false); }; @@ -204,6 +214,19 @@ const UserSettings: React.FC = () => { className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" />
+ +
+ + setRealname(e.target.value)} + placeholder="Your real name" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" + /> +
)} diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 183930b0..d9990afd 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -95,6 +95,10 @@ export interface EventMap { key?: string; retryAfter?: number; }; + LIST_CHANNEL: { serverId: string; channel: string; userCount: number; topic: string }; + LIST_END: { serverId: string }; + RENAME: { serverId: string; oldName: string; newName: string; reason: string; user: string }; + SETNAME: { serverId: string; user: string; realname: string }; } type EventKey = keyof EventMap; @@ -105,6 +109,7 @@ export class IRCClient { private servers: Map = new Map(); private nicks: Map = new Map(); private currentUser: User | null = null; + private saslMechanisms: Map = new Map(); private eventCallbacks: { [K in EventKey]?: EventCallback[]; @@ -249,6 +254,19 @@ export class IRCClient { this.sendRaw(serverId, `@+typing=${typingState} TAGMSG ${target}`); } + listChannels(serverId: string): void { + this.sendRaw(serverId, "LIST"); + } + + renameChannel(serverId: string, oldName: string, newName: string, reason?: string): void { + const command = reason ? `RENAME ${oldName} ${newName} :${reason}` : `RENAME ${oldName} ${newName}`; + this.sendRaw(serverId, command); + } + + setName(serverId: string, realname: string): void { + this.sendRaw(serverId, `SETNAME :${realname}`); + } + // Metadata commands metadataGet(serverId: string, target: string, keys: string[]): void { const keysStr = keys.join(" "); @@ -435,7 +453,7 @@ export class IRCClient { sender, channelName, message, - timestamp: new Date(), + timestamp: getTimestampFromTags(mtags), }); } else { this.triggerEvent("USERMSG", { @@ -443,7 +461,7 @@ export class IRCClient { mtags, sender, message, - timestamp: new Date(), + timestamp: getTimestampFromTags(mtags), }); } } else if (command === "TAGMSG") { @@ -454,7 +472,27 @@ export class IRCClient { mtags, sender, channelName: target, - timestamp: new Date(), + timestamp: getTimestampFromTags(mtags), + }); + } else if (command === "RENAME") { + const user = getNickFromNuh(source); + const oldName = parv[0]; + const newName = parv[1]; + const reason = parv.slice(2).join(" ").substring(1); // Remove leading : + this.triggerEvent("RENAME", { + serverId, + oldName, + newName, + reason, + user, + }); + } else if (command === "SETNAME") { + const user = getNickFromNuh(source); + const realname = parv.join(" ").substring(1); // Remove leading : + this.triggerEvent("SETNAME", { + serverId, + user, + realname, }); } else if (command === "353") { const channelName = parv[2]; @@ -525,6 +563,8 @@ export class IRCClient { if (subcommand === "LS") this.onCapLs(serverId, caps); else if (subcommand === "ACK") this.triggerEvent("CAP ACK", { serverId, cliCaps: caps }); + else if (subcommand === "NEW") this.onCapNew(serverId, caps); + else if (subcommand === "DEL") this.onCapDel(serverId, caps); } else if (command === "005") { const capabilities = parseIsupport(parv.join(" ")); console.log("ISUPPORT capabilities:", capabilities); @@ -655,6 +695,20 @@ export class IRCClient { retryAfter, }); } + } else if (command === "322") { + // RPL_LIST: : + const channelName = parv[1]; + const userCount = parv[2] ? parseInt(parv[2], 10) : 0; + const topic = parv.slice(3).join(" ").substring(1); // Remove leading : + this.triggerEvent("LIST_CHANNEL", { + serverId, + channel: channelName, + userCount, + topic, + }); + } else if (command === "323") { + // RPL_LISTEND + this.triggerEvent("LIST_END", { serverId }); } } } @@ -678,13 +732,24 @@ export class IRCClient { "draft/chathistory", "draft/extended-isupport", "sasl", + "cap-notify", + "draft/channel-rename", + "setname", + "account-notify", + "account-tag", + "extended-join", "draft/metadata-2", ]; const caps = cliCaps.split(" "); let toRequest = "CAP REQ :"; for (const c of caps) { - const cap = c.includes("=") ? c.split("=")[0] : c; + const [cap, value] = c.split("=", 2); + if (cap === "sasl" && value) { + const mechanisms = value.split(","); + this.saslMechanisms.set(serverId, mechanisms); + console.log(`Available SASL mechanisms for ${serverId}: ${mechanisms.join(", ")}`); + } if (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) { if (toRequest.length + cap.length + 1 > 400) { this.sendRaw(serverId, toRequest); @@ -702,6 +767,31 @@ export class IRCClient { console.log(`Server ${serverId} supports capabilities: ${cliCaps}`); } + onCapNew(serverId: string, cliCaps: string): void { + const caps = cliCaps.split(" "); + for (const c of caps) { + const [cap, value] = c.split("=", 2); + if (cap === "sasl" && value) { + const mechanisms = value.split(","); + this.saslMechanisms.set(serverId, mechanisms); + console.log(`SASL mechanisms updated for ${serverId}: ${mechanisms.join(", ")}`); + // If sasl becomes available, perhaps request it if not already + // But for now, just log + } + } + } + + onCapDel(serverId: string, cliCaps: string): void { + const caps = cliCaps.split(" "); + for (const c of caps) { + const [cap] = c.split("=", 2); + if (cap === "sasl") { + this.saslMechanisms.delete(serverId); + console.log(`SASL capability removed for ${serverId}`); + } + } + } + on(event: K, callback: EventCallback): void { if (!this.eventCallbacks[event]) { this.eventCallbacks[event] = []; @@ -756,6 +846,13 @@ function getNickFromNuh(nuh: string) { return nick.startsWith(":") ? nick.substring(1) : nick; } +function getTimestampFromTags(mtags: Record | undefined): Date { + if (mtags?.time) { + return new Date(mtags.time); + } + return new Date(); +} + export const ircClient = new IRCClient(); export default ircClient; diff --git a/src/store/index.ts b/src/store/index.ts index a96cab01..838733c3 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -131,6 +131,8 @@ interface UIState { isMobileMenuOpen: boolean; isMemberListVisible: boolean; isChannelListVisible: boolean; + isChannelListModalOpen: boolean; + isChannelRenameModalOpen: boolean; mobileViewActiveColumn: layoutColumn; isServerMenuOpen: boolean; contextMenu: { @@ -155,6 +157,8 @@ export interface AppState { connectionError: string | null; messages: Record; typingUsers: Record; + channelList: Record; // serverId -> channels + listingInProgress: Record; // serverId -> is listing // Metadata state metadataSubscriptions: Record; // serverId -> keys metadataBatches: Record< @@ -198,6 +202,9 @@ export interface AppState { username: string, reason: string, ) => void; + listChannels: (serverId: string) => void; + renameChannel: (serverId: string, oldName: string, newName: string, reason?: string) => void; + setName: (serverId: string, realname: string) => void; addMessage: (message: Message) => void; selectServer: (serverId: string | null) => void; selectChannel: (channelId: string | null) => void; @@ -219,6 +226,8 @@ export interface AppState { toggleMobileMenu: (isOpen?: boolean) => void; toggleMemberList: (isVisible?: boolean) => void; toggleChannelList: (isOpen?: boolean) => void; + toggleChannelListModal: (isOpen?: boolean) => void; + toggleChannelRenameModal: (isOpen?: boolean) => void; toggleServerMenu: (isOpen?: boolean) => void; showContextMenu: ( x: number, @@ -253,6 +262,8 @@ const useStore = create((set, get) => ({ connectionError: null, messages: {}, typingUsers: {}, + channelList: {}, + listingInProgress: {}, metadataSubscriptions: {}, metadataBatches: {}, selectedServerId: null, @@ -269,6 +280,8 @@ const useStore = create((set, get) => ({ isMobileMenuOpen: false, isMemberListVisible: true, isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, mobileViewActiveColumn: "serverList", // Default to server list in mobile mode on open isServerMenuOpen: false, contextMenu: { @@ -329,11 +342,22 @@ const useStore = create((set, get) => ({ }); saveServersToLocalStorage(updatedServers); - set((state) => ({ - servers: [...state.servers, server], - currentUser: ircClient.getCurrentUser(), - isConnecting: false, - })); + set((state) => { + const alreadyExists = state.servers.some( + (s) => s.host === host && s.port === port, + ); + if (alreadyExists) { + return { + currentUser: ircClient.getCurrentUser(), + isConnecting: false, + }; + } + return { + servers: [...state.servers, server], + currentUser: ircClient.getCurrentUser(), + isConnecting: false, + }; + }); return server; } catch (error) { @@ -468,6 +492,34 @@ const useStore = create((set, get) => ({ ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); }, + listChannels: (serverId) => { + const state = get(); + if (state.listingInProgress[serverId]) { + // Already listing, ignore + return; + } + // Clear the channel list before starting a new list + set((state) => ({ + channelList: { + ...state.channelList, + [serverId]: [], + }, + listingInProgress: { + ...state.listingInProgress, + [serverId]: true, + }, + })); + ircClient.listChannels(serverId); + }, + + renameChannel: (serverId, oldName, newName, reason) => { + ircClient.renameChannel(serverId, oldName, newName, reason); + }, + + setName: (serverId, realname) => { + ircClient.setName(serverId, realname); + }, + addMessage: (message) => { set((state) => { const channelKey = `${message.serverId}-${message.channelId}`; @@ -901,6 +953,26 @@ const useStore = create((set, get) => ({ }); }, + toggleChannelListModal: (isOpen) => { + set((state) => ({ + ui: { + ...state.ui, + isChannelListModalOpen: + isOpen !== undefined ? isOpen : !state.ui.isChannelListModalOpen, + }, + })); + }, + + toggleChannelRenameModal: (isOpen?: boolean) => { + set((state) => ({ + ui: { + ...state.ui, + isChannelRenameModalOpen: + isOpen !== undefined ? isOpen : !state.ui.isChannelRenameModalOpen, + }, + })); + }, + toggleServerMenu: (isOpen) => { set((state) => ({ ui: { @@ -1512,6 +1584,34 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { } }); +ircClient.on("LIST_CHANNEL", ({ serverId, channel, userCount, topic }) => { + useStore.setState((state) => { + if (!state.listingInProgress[serverId]) { + // Not currently listing, ignore + return {}; + } + const currentList = state.channelList[serverId] || []; + const updatedList = [...currentList, { channel, userCount, topic }]; + return { + channelList: { + ...state.channelList, + [serverId]: updatedList, + }, + }; + }); +}); + +ircClient.on("LIST_END", ({ serverId }) => { + // Set listing as complete + useStore.setState((state) => ({ + listingInProgress: { + ...state.listingInProgress, + [serverId]: false, + }, + })); + console.log(`Channel listing complete for server ${serverId}`); +}); + // CTCPs lol ircClient.on("CHANMSG", (response) => { const { channelName, message, timestamp } = response; @@ -2028,4 +2128,71 @@ if (__DEFAULT_IRC_SERVER__) { console.log("Default server found, connecting..."); } -export default useStore; +ircClient.on("RENAME", ({ serverId, oldName, newName, reason, user }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + const channel = server.channels.find((c) => c.name === oldName); + if (!channel) return {}; + + channel.name = newName; + + const renameMessage: Message = { + id: `rename-${Date.now()}`, + content: `${user} renamed from ${oldName} to ${newName}${reason ? ` (${reason})` : ""}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId, + type: "system", + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const channelKey = `${serverId}-${channel.id}`; + const currentMessages = state.messages[channelKey] || []; + return { + messages: { + ...state.messages, + [channelKey]: [...currentMessages, renameMessage], + }, + }; + }); +}); + +ircClient.on("SETNAME", ({ serverId, user, realname }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Update current user if it's us + if (user === state.currentUser?.username) { + return { + currentUser: { + ...state.currentUser, + displayName: realname, + }, + }; + } + + // Update in channels + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedChannels = s.channels.map((c) => ({ + ...c, + users: c.users.map((u) => + u.username === user ? { ...u, displayName: realname } : u + ), + })); + return { ...s, channels: updatedChannels }; + } + return s; + }); + + return { servers: updatedServers }; + }); +}); + +export default useStore; \ No newline at end of file From af30cd2983a1e8aeb88b3cb691b405a46ba4d9b5 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Tue, 30 Sep 2025 01:31:37 +0100 Subject: [PATCH 06/47] feat: Implement comprehensive IRCv3 client features and add complete test coverage - Add IRCv3 capability negotiation (CAP LS 302, CAP REQ/ACK) - Implement SASL 3.2 authentication (PLAIN mechanism) - Add message tags parsing (server-time, account, echo-message) - Support channel LIST command with sorting and filtering - Add RENAME command for channel operators - Implement SETNAME for realname changes - Add duplicate server/channel prevention mechanisms - Move channel list and rename buttons to top bar (operator-only) - Create ChannelListModal with auto-loading, sorting, filtering, joining - Create ChannelRenameModal with operator permissions and validation - Add comprehensive test suite: - IRC client tests for all new commands and responses - Component tests for ChannelListModal and ChannelRenameModal - Store tests for state management functionality - Fix UI layout issues and modal rendering problems - Ensure all tests pass before PR submission --- src/App.tsx | 7 +- src/components/layout/ChatArea.tsx | 35 ++-- src/components/ui/ChannelRenameModal.tsx | 13 +- src/lib/ircClient.ts | 36 ++++- src/store/index.ts | 16 +- tests/components/ChannelListModal.test.tsx | 148 +++++++++++++++++ tests/components/ChannelRenameModal.test.tsx | 161 +++++++++++++++++++ tests/lib/ircClient.test.ts | 79 +++++++-- tests/store/index.test.ts | 98 +++++++++++ 9 files changed, 553 insertions(+), 40 deletions(-) create mode 100644 tests/components/ChannelListModal.test.tsx create mode 100644 tests/components/ChannelRenameModal.test.tsx create mode 100644 tests/store/index.test.ts diff --git a/src/App.tsx b/src/App.tsx index bb9c095f..1319f520 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -68,7 +68,12 @@ const initializeEnvSettings = ( const App: React.FC = () => { const { toggleAddServerModal, - ui: { isAddServerModalOpen, isUserProfileModalOpen, isChannelListModalOpen, isChannelRenameModalOpen }, + ui: { + isAddServerModalOpen, + isUserProfileModalOpen, + isChannelListModalOpen, + isChannelRenameModalOpen, + }, joinChannel, connectToSavedServers, } = useStore(); diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 2b9f0259..8eb94323 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -1382,20 +1382,27 @@ export const ChatArea: React.FC<{ > - {selectedChannel && (() => { - const { currentUser } = useStore.getState(); - const channelUser = selectedChannel.users.find(u => u.username === currentUser?.username); - const isOperator = channelUser?.status?.includes('@') || channelUser?.status?.includes('~'); - return isOperator ? ( - - ) : null; - })()} + {selectedChannel && + (() => { + const { currentUser } = useStore.getState(); + const channelUser = selectedChannel.users.find( + (u) => u.username === currentUser?.username, + ); + const isOperator = + channelUser?.status?.includes("@") || + channelUser?.status?.includes("~"); + return isOperator ? ( + + ) : null; + })()} {/* Only show member list toggle for channels, not private chats */} {selectedChannel && ( + )}
diff --git a/src/components/message/index.ts b/src/components/message/index.ts index 1b86cd7f..5ca078c4 100644 --- a/src/components/message/index.ts +++ b/src/components/message/index.ts @@ -1,3 +1,4 @@ +export { StandardReplyNotification } from "../ui/StandardReplyNotification"; export { ActionMessage } from "./ActionMessage"; export { DateSeparator } from "./DateSeparator"; export { MessageActions } from "./MessageActions"; diff --git a/src/components/ui/AddServerModal.tsx b/src/components/ui/AddServerModal.tsx index 613b4dd3..263628b0 100644 --- a/src/components/ui/AddServerModal.tsx +++ b/src/components/ui/AddServerModal.tsx @@ -31,6 +31,8 @@ export const AddServerModal: React.FC = () => { const [showServerPassword, setShowServerPassword] = useState(false); const [showAccount, setShowAccount] = useState(false); const [registerAccount, setRegisterAccount] = useState(false); + const [registerEmail, setRegisterEmail] = useState(""); + const [registerPassword, setRegisterPassword] = useState(""); const [error, setError] = useState(""); @@ -67,6 +69,9 @@ export const AddServerModal: React.FC = () => { password, saslAccountName, saslPassword, + registerAccount, + registerEmail, + registerPassword, ); toggleAddServerModal(false); } catch (err) { @@ -234,51 +239,84 @@ export const AddServerModal: React.FC = () => { )} {showAccount && ( - <> -
-
- - setSaslAccountName(e.target.value)} - onFocus={(e) => { - e.target.select(); - }} - placeholder="SASL Account Name" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" - /> -
-
-
+
+
+ + setSaslAccountName(e.target.value)} + onFocus={(e) => { + e.target.select(); + }} + placeholder="SASL Account Name" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + />
-
+
+
+
+ )} + +
+ setRegisterAccount(!registerAccount)} + className="accent-discord-accent rounded" + /> + +
+ {registerAccount && ( + <> +
+ + setRegisterEmail(e.target.value)} + onFocus={(e) => { + e.target.select(); + }} + placeholder="your@email.com" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + /> +
+
+ + setRegisterPassword(e.target.value)} + onFocus={(e) => { + e.target.select(); + }} + placeholder="Choose a secure password" + className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-discord-primary" + />
)} diff --git a/src/components/ui/StandardReplyNotification.tsx b/src/components/ui/StandardReplyNotification.tsx new file mode 100644 index 00000000..6e740489 --- /dev/null +++ b/src/components/ui/StandardReplyNotification.tsx @@ -0,0 +1,96 @@ +import type React from "react"; +import { + FaExclamationTriangle, + FaInfoCircle, + FaTimesCircle, +} from "react-icons/fa"; +import { mircToHtml } from "../../lib/ircUtils"; +import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; + +interface StandardReplyNotificationProps { + type: "FAIL" | "WARN" | "NOTE"; + command: string; + code: string; + message: string; + target?: string; + timestamp: Date; + onIrcLinkClick?: (url: string) => void; +} + +export const StandardReplyNotification: React.FC< + StandardReplyNotificationProps +> = ({ type, command, code, message, target, timestamp, onIrcLinkClick }) => { + const formatTime = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + const getIcon = () => { + switch (type) { + case "FAIL": + return ; + case "WARN": + return ( + + ); + case "NOTE": + return ; + default: + return null; + } + }; + + const getBackgroundColor = () => { + switch (type) { + case "FAIL": + return "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800"; + case "WARN": + return "bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800"; + case "NOTE": + return "bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800"; + default: + return "bg-gray-50 dark:bg-gray-950/20 border-gray-200 dark:border-gray-800"; + } + }; + + const getTextColor = () => { + switch (type) { + case "FAIL": + return "text-red-800 dark:text-red-200"; + case "WARN": + return "text-yellow-800 dark:text-yellow-200"; + case "NOTE": + return "text-blue-800 dark:text-blue-200"; + default: + return "text-gray-800 dark:text-gray-200"; + } + }; + + const htmlContent = mircToHtml(message); + + return ( +
+
+
{getIcon()}
+
+
+ {type} {command} {code} + {target && • {target}} +
+
+ + {htmlContent} + +
+
+ {formatTime(timestamp)} +
+
+
+
+ ); +}; diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index b56a1a93..74e3e7c0 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -82,6 +82,42 @@ export interface EventMap { user: string; }; SETNAME: { serverId: string; user: string; realname: string }; + FAIL: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + WARN: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + NOTE: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + SUCCESS: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + REGISTER_SUCCESS: EventWithTags & { + account: string; + message: string; + }; + REGISTER_VERIFICATION_REQUIRED: EventWithTags & { + account: string; + message: string; + }; + VERIFY_SUCCESS: EventWithTags & { + account: string; + message: string; + }; WHO_REPLY: { serverId: string; channel: string; @@ -187,6 +223,7 @@ export class IRCClient { } socket.send("CAP LS 302"); + socket.send(`NICK ${nickname}`); // Update server to mark as connected server.isConnected = true; @@ -306,6 +343,31 @@ export class IRCClient { this.sendRaw(serverId, `@+typing=${typingState} TAGMSG ${target}`); } + sendRedact( + serverId: string, + target: string, + msgid: string, + reason?: string, + ): void { + const command = reason + ? `REDACT ${target} ${msgid} :${reason}` + : `REDACT ${target} ${msgid}`; + this.sendRaw(serverId, command); + } + + registerAccount( + serverId: string, + account: string, + email: string, + password: string, + ): void { + this.sendRaw(serverId, `REGISTER ${account} ${email} ${password}`); + } + + verifyAccount(serverId: string, account: string, code: string): void { + this.sendRaw(serverId, `VERIFY ${account} ${code}`); + } + listChannels(serverId: string): void { this.sendRaw(serverId, "LIST"); } @@ -386,13 +448,13 @@ export class IRCClient { capEnd(_serverId: string) {} - nickOnConnect(serverId: string) { + userOnConnect(serverId: string) { const nickname = this.nicks.get(serverId); if (!nickname) { console.error(`No nickname found for serverId ${serverId}`); return; } - this.sendRaw(serverId, `NICK ${nickname}`); + // NICK is already sent before CAP negotiation, only send USER now this.sendRaw(serverId, `USER ${nickname} 0 * :${nickname}`); } @@ -735,30 +797,29 @@ export class IRCClient { target, retryAfter, }); - } else if (command === "FAIL") { + } else if (command === "FAIL" && parv[0] === "METADATA") { + // ERR_METADATATOOMANY, ERR_METADATATARGETINVALID, ERR_METADATANOACCESS, ERR_METADATANOKEY, ERR_METADATARATELIMITED const subcommand = parv[0]; - if (subcommand === "METADATA") { - const code = parv[1]; - let target: string | undefined; - let key: string | undefined; - let retryAfter: number | undefined; - if (parv[2]) target = parv[2]; - if (parv[3]) key = parv[3]; - if (parv[4] && code === "RATE_LIMITED") { - retryAfter = Number.parseInt(parv[4], 10); - } - console.log( - `[IRC] Received METADATA FAIL: subcommand=${parv[1]}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}`, - ); - this.triggerEvent("METADATA_FAIL", { - serverId, - subcommand: parv[1], - code, - target, - key, - retryAfter, - }); + const code = parv[1]; + let target: string | undefined; + let key: string | undefined; + let retryAfter: number | undefined; + if (parv[2]) target = parv[2]; + if (parv[3]) key = parv[3]; + if (parv[4] && code === "RATE_LIMITED") { + retryAfter = Number.parseInt(parv[4], 10); } + console.log( + `[IRC] Received METADATA FAIL: subcommand=${parv[1]}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}`, + ); + this.triggerEvent("METADATA_FAIL", { + serverId, + subcommand: parv[1], + code, + target, + key, + retryAfter, + }); } else if (command === "322") { // RPL_LIST: : const channelName = parv[1]; @@ -804,6 +865,97 @@ export class IRCClient { const target = parv[1]; const message = parv.slice(2).join(" ").substring(1); this.triggerEvent("WHOIS_BOT", { serverId, nick, target, message }); + } else if (command === "FAIL") { + // Standard replies: FAIL : + const cmd = parv[0]; + const code = parv[1]; + const target = parv[2] || undefined; + const message = parv.slice(3).join(" ").substring(1); // Remove leading : + this.triggerEvent("FAIL", { + serverId, + mtags, + command: cmd, + code, + target, + message, + }); + } else if (command === "WARN") { + // Standard replies: WARN : + const cmd = parv[0]; + const code = parv[1]; + const target = parv[2] || undefined; + const message = parv.slice(3).join(" ").substring(1); // Remove leading : + this.triggerEvent("WARN", { + serverId, + mtags, + command: cmd, + code, + target, + message, + }); + } else if (command === "NOTE") { + // Standard replies: NOTE : + const cmd = parv[0]; + const code = parv[1]; + const target = parv[2] || undefined; + const message = parv.slice(3).join(" ").substring(1); // Remove leading : + this.triggerEvent("NOTE", { + serverId, + mtags, + command: cmd, + code, + target, + message, + }); + } else if (command === "SUCCESS") { + // Standard replies: SUCCESS : + const cmd = parv[0]; + const code = parv[1]; + const target = parv[2] || undefined; + const message = parv.slice(3).join(" ").substring(1); // Remove leading : + this.triggerEvent("SUCCESS", { + serverId, + mtags, + command: cmd, + code, + target, + message, + }); + } else if (command === "REGISTER") { + // Account registration responses + const subcommand = parv[0]; + if (subcommand === "SUCCESS") { + const account = parv[1]; + const message = parv.slice(2).join(" ").substring(1); + this.triggerEvent("REGISTER_SUCCESS", { + serverId, + mtags, + account, + message, + }); + } else if (subcommand === "VERIFICATION_REQUIRED") { + const account = parv[1]; + const message = parv.slice(2).join(" ").substring(1); + this.triggerEvent("REGISTER_VERIFICATION_REQUIRED", { + serverId, + mtags, + account, + message, + }); + } + } else if (command === "VERIFY") { + // Account verification responses + const subcommand = parv[0]; + if (subcommand === "SUCCESS") { + const account = parv[1]; + const message = parv.slice(2).join(" ").substring(1); + this.triggerEvent("VERIFY_SUCCESS", { + serverId, + mtags, + account, + message, + }); + } } } } @@ -834,6 +986,8 @@ export class IRCClient { "account-tag", "extended-join", "draft/metadata-2", + "draft/message-redaction", + "draft/account-registration", ]; const caps = cliCaps.split(" "); diff --git a/src/store/index.ts b/src/store/index.ts index 3b983bbe..95190b2d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -187,6 +187,13 @@ export interface AppState { }[]; } >; // batchId -> batch info + // Account registration state + pendingRegistration: { + serverId: string; + account: string; + email: string; + password: string; + } | null; // UI state ui: UIState; globalSettings: GlobalSettings; @@ -199,11 +206,27 @@ export interface AppState { password?: string, saslAccountName?: string, saslPassword?: string, + registerAccount?: boolean, + registerEmail?: string, + registerPassword?: string, ) => Promise; disconnect: (serverId: string) => void; joinChannel: (serverId: string, channelName: string) => void; leaveChannel: (serverId: string, channelName: string) => void; sendMessage: (serverId: string, channelId: string, content: string) => void; + redactMessage: ( + serverId: string, + target: string, + msgid: string, + reason?: string, + ) => void; + registerAccount: ( + serverId: string, + account: string, + email: string, + password: string, + ) => void; + verifyAccount: (serverId: string, account: string, code: string) => void; kickUser: ( serverId: string, channelName: string, @@ -286,6 +309,7 @@ const useStore = create((set, get) => ({ listingInProgress: {}, metadataSubscriptions: {}, metadataBatches: {}, + pendingRegistration: null, selectedServerId: null, // UI state @@ -326,6 +350,9 @@ const useStore = create((set, get) => ({ password, saslAccountName, saslPassword, + registerAccount, + registerEmail, + registerPassword, ) => { // Check if already connected to this server const state = get(); @@ -401,6 +428,18 @@ const useStore = create((set, get) => ({ // get().joinChannel(server.id, channelName); // } + // Set up pending account registration if requested + if (registerAccount && registerEmail && registerPassword) { + set({ + pendingRegistration: { + serverId: server.id, + account: nickname, // Use nickname as account name for now + email: registerEmail, + password: registerPassword, + }, + }); + } + return server; } catch (error) { set({ @@ -522,6 +561,28 @@ const useStore = create((set, get) => ({ const message = ircClient.sendMessage(serverId, channelId, content); }, + redactMessage: ( + serverId: string, + target: string, + msgid: string, + reason?: string, + ) => { + ircClient.sendRedact(serverId, target, msgid, reason); + }, + + registerAccount: ( + serverId: string, + account: string, + email: string, + password: string, + ) => { + ircClient.registerAccount(serverId, account, email, password); + }, + + verifyAccount: (serverId: string, account: string, code: string) => { + ircClient.verifyAccount(serverId, account, code); + }, + kickUser: (serverId, channelName, username, reason) => { ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); }, @@ -1688,7 +1749,7 @@ ircClient.on("AUTHENTICATE", ({ serverId, param }) => { `AUTHENTICATE ${btoa(`${user}\x00${user}\x00${pass}`)}`, ); ircClient.sendRaw(serverId, "CAP END"); - ircClient.nickOnConnect(serverId); + ircClient.userOnConnect(serverId); }); ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { @@ -1720,10 +1781,35 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { preventCapEnd = true; } } + + // Check if there's pending account registration + const state = useStore.getState(); + const pendingReg = state.pendingRegistration; + if (pendingReg && pendingReg.serverId === serverId) { + preventCapEnd = true; + // Check if server supports account registration + const server = state.servers.find((s) => s.id === serverId); + if (server?.capabilities?.includes("draft/account-registration")) { + console.log(`Triggering account registration for server ${serverId}`); + useStore + .getState() + .registerAccount(serverId, "*", pendingReg.email, pendingReg.password); + console.log(pendingReg); + // Clear the pending registration + useStore.setState({ pendingRegistration: null }); + } else { + console.log(`Server ${serverId} does not support account registration`); + // Clear the pending registration + useStore.setState({ pendingRegistration: null }); + // Send CAP END since registration is not possible + preventCapEnd = false; + } + } + if (!preventCapEnd) { console.log(`Sending CAP END for server ${serverId}`); ircClient.sendRaw(serverId, "CAP END"); - ircClient.nickOnConnect(serverId); + ircClient.userOnConnect(serverId); } else { console.log(`Preventing CAP END for server ${serverId}`); } @@ -1976,6 +2062,217 @@ ircClient.on("TAGMSG", (response) => { } }); +// Standard reply event handlers +ircClient.on("FAIL", ({ serverId, command, code, target, message }) => { + console.log(`[FAIL] ${command} ${code} ${target || ""}: ${message}`); + // Add notification to the current channel or show as system message + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // For now, we'll add it as a system message to the first available channel + // In a real implementation, you might want to show it in a dedicated notification area + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "standard-reply", + content: `FAIL ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + standardReplyType: "FAIL", + standardReplyCommand: command, + standardReplyCode: code, + standardReplyTarget: target, + standardReplyMessage: message, + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +ircClient.on("WARN", ({ serverId, command, code, target, message }) => { + console.log(`[WARN] ${command} ${code} ${target || ""}: ${message}`); + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "standard-reply", + content: `WARN ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + standardReplyType: "WARN", + standardReplyCommand: command, + standardReplyCode: code, + standardReplyTarget: target, + standardReplyMessage: message, + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +ircClient.on("NOTE", ({ serverId, command, code, target, message }) => { + console.log(`[NOTE] ${command} ${code} ${target || ""}: ${message}`); + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "standard-reply", + content: `NOTE ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + standardReplyType: "NOTE", + standardReplyCommand: command, + standardReplyCode: code, + standardReplyTarget: target, + standardReplyMessage: message, + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +// Account registration event handlers +ircClient.on("REGISTER_SUCCESS", ({ serverId, account, message }) => { + console.log(`[REGISTER_SUCCESS] Account ${account} registered: ${message}`); + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account registration successful for ${account}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +ircClient.on( + "REGISTER_VERIFICATION_REQUIRED", + ({ serverId, account, message }) => { + console.log( + `[REGISTER_VERIFICATION_REQUIRED] Account ${account} requires verification: ${message}`, + ); + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account registration for ${account} requires verification: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } + }, +); + +ircClient.on("VERIFY_SUCCESS", ({ serverId, account, message }) => { + console.log(`[VERIFY_SUCCESS] Account ${account} verified: ${message}`); + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account verification successful for ${account}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + // Metadata event handlers ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { console.log( diff --git a/src/types/index.ts b/src/types/index.ts index 58823af2..24fcbab2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -72,11 +72,24 @@ export interface Message { userId: string; channelId: string; serverId: string; - type: "message" | "system" | "error" | "join" | "leave" | "nick"; + type: + | "message" + | "system" + | "error" + | "join" + | "leave" + | "nick" + | "standard-reply"; reactions: Reaction[]; replyMessage: Message | null | undefined; mentioned: string[]; tags?: Record; + // Standard reply fields + standardReplyType?: "FAIL" | "WARN" | "NOTE"; + standardReplyCommand?: string; + standardReplyCode?: string; + standardReplyTarget?: string; + standardReplyMessage?: string; } // Alias for backwards compatibility diff --git a/tests/components/StandardReplyNotification.test.tsx b/tests/components/StandardReplyNotification.test.tsx new file mode 100644 index 00000000..fcf3ec5d --- /dev/null +++ b/tests/components/StandardReplyNotification.test.tsx @@ -0,0 +1,200 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { StandardReplyNotification } from "../../src/components/ui/StandardReplyNotification"; + +// Mock the IRC utilities +vi.mock("../../src/lib/ircUtils", () => ({ + mircToHtml: vi.fn((text: string) => `${text}`), +})); + +// Mock the LinkWrapper component +vi.mock("../../src/components/ui/LinkWrapper", () => ({ + EnhancedLinkWrapper: ({ + children, + onIrcLinkClick, + }: { + children: React.ReactNode; + onIrcLinkClick?: (url: string) => void; + }) => ( +
+ {children} +
+ ), +})); + +describe("StandardReplyNotification", () => { + const mockOnIrcLinkClick = vi.fn(); + const baseProps = { + command: "AUTHENTICATE", + code: "INVALID_CREDENTIALS", + message: "Authentication failed", + timestamp: new Date("2023-01-01T12:00:00Z"), + }; + + test("renders FAIL notification with correct styling and icon", () => { + render( + , + ); + + // Check that the notification is rendered + expect( + screen.getByText("FAIL AUTHENTICATE INVALID_CREDENTIALS"), + ).toBeInTheDocument(); + + // Check FAIL-specific styling (red colors) - find the main container by class + const mainContainer = document.querySelector(".bg-red-50"); + expect(mainContainer).toHaveClass("bg-red-50", "border-red-200"); + expect(mainContainer).toHaveClass( + "dark:bg-red-950/20", + "dark:border-red-800", + ); + + // Check that the red icon is present (FaTimesCircle) + const iconContainer = screen.getByText( + "FAIL AUTHENTICATE INVALID_CREDENTIALS", + ).parentElement?.previousElementSibling; + expect(iconContainer).toBeInTheDocument(); + }); + + test("renders WARN notification with correct styling and icon", () => { + render( + , + ); + + expect( + screen.getByText("WARN AUTHENTICATE INVALID_CREDENTIALS"), + ).toBeInTheDocument(); + + // Check WARN-specific styling (yellow colors) + const mainContainer = document.querySelector(".bg-yellow-50"); + expect(mainContainer).toHaveClass("bg-yellow-50", "border-yellow-200"); + expect(mainContainer).toHaveClass( + "dark:bg-yellow-950/20", + "dark:border-yellow-800", + ); + }); + + test("renders NOTE notification with correct styling and icon", () => { + render( + , + ); + + expect( + screen.getByText("NOTE AUTHENTICATE INVALID_CREDENTIALS"), + ).toBeInTheDocument(); + + // Check NOTE-specific styling (blue colors) + const mainContainer = document.querySelector(".bg-blue-50"); + expect(mainContainer).toHaveClass("bg-blue-50", "border-blue-200"); + expect(mainContainer).toHaveClass( + "dark:bg-blue-950/20", + "dark:border-blue-800", + ); + }); + + test("displays target when provided", () => { + render( + , + ); + + expect(screen.getByText(/FAIL/)).toBeInTheDocument(); + expect(screen.getByText(/AUTHENTICATE/)).toBeInTheDocument(); + expect(screen.getByText(/INVALID_CREDENTIALS/)).toBeInTheDocument(); + expect(screen.getByText(/user123/)).toBeInTheDocument(); + }); + + test("does not display target when not provided", () => { + render(); + + expect( + screen.getByText("FAIL AUTHENTICATE INVALID_CREDENTIALS"), + ).toBeInTheDocument(); + expect(screen.queryByText("•")).not.toBeInTheDocument(); + }); + + test("displays formatted timestamp", () => { + render( + , + ); + + // Should display time in 12-hour format with 2 digits + expect(screen.getByText("03:30 PM")).toBeInTheDocument(); + }); + + test("renders message content through EnhancedLinkWrapper", () => { + render( + , + ); + + // Check that the message is wrapped in EnhancedLinkWrapper + const linkWrapper = screen.getByTestId("enhanced-link-wrapper"); + expect(linkWrapper).toBeInTheDocument(); + expect(linkWrapper).toHaveAttribute("data-onclick", "true"); + + // Check that mircToHtml was called and the result is displayed + expect(linkWrapper).toHaveTextContent("Warning: Invalid command"); + }); + + test("handles onIrcLinkClick callback", () => { + render( + , + ); + + const linkWrapper = screen.getByTestId("enhanced-link-wrapper"); + expect(linkWrapper).toHaveAttribute("data-onclick", "true"); + }); + + test("works without onIrcLinkClick callback", () => { + render(); + + const linkWrapper = screen.getByTestId("enhanced-link-wrapper"); + expect(linkWrapper).toHaveAttribute("data-onclick", "false"); + }); + + test("applies correct text colors for each type", () => { + const { rerender } = render( + , + ); + + // FAIL should have red text + let header = screen.getByText("FAIL AUTHENTICATE INVALID_CREDENTIALS"); + expect(header).toHaveClass("text-red-800", "dark:text-red-200"); + + // Re-render with WARN + rerender(); + + header = screen.getByText("WARN AUTHENTICATE INVALID_CREDENTIALS"); + expect(header).toHaveClass("text-yellow-800", "dark:text-yellow-200"); + + // Re-render with NOTE + rerender(); + + header = screen.getByText("NOTE AUTHENTICATE INVALID_CREDENTIALS"); + expect(header).toHaveClass("text-blue-800", "dark:text-blue-200"); + }); +}); From bbebb23ed27a52854200d305de246b58954c59a1 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Tue, 30 Sep 2025 07:07:13 +0100 Subject: [PATCH 16/47] Properly deal with CAPs --- src/components/layout/AppLayout.tsx | 2 + src/components/ui/GlobalNotifications.tsx | 109 +++++++++++++++++++ src/lib/ircClient.ts | 61 ++++++++--- src/store/index.ts | 123 ++++++++++++++-------- 4 files changed, 236 insertions(+), 59 deletions(-) create mode 100644 src/components/ui/GlobalNotifications.tsx diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 232fbbb1..146249cd 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -3,6 +3,7 @@ import type React from "react"; import { useEffect } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import useStore from "../../store"; +import { GlobalNotifications } from "../ui/GlobalNotifications"; import { ChannelList } from "./ChannelList"; import { ChatArea } from "./ChatArea"; import { MemberList } from "./MemberList"; @@ -193,6 +194,7 @@ export const AppLayout: React.FC = () => { {getLayoutColumn("serverList")} {getLayoutColumn("chatView")} {selectedServerId && getLayoutColumn("memberList")} +
); }; diff --git a/src/components/ui/GlobalNotifications.tsx b/src/components/ui/GlobalNotifications.tsx new file mode 100644 index 00000000..af66b13f --- /dev/null +++ b/src/components/ui/GlobalNotifications.tsx @@ -0,0 +1,109 @@ +import type React from "react"; +import { FaExclamationTriangle, FaTimesCircle } from "react-icons/fa"; +import { mircToHtml } from "../../lib/ircUtils"; +import useStore from "../../store"; + +export const GlobalNotifications: React.FC = () => { + const { globalNotifications, removeGlobalNotification } = useStore(); + + if (globalNotifications.length === 0) { + return null; + } + + const getIcon = (type: "fail" | "warn" | "note") => { + switch (type) { + case "fail": + return ; + case "warn": + return ( + + ); + case "note": + return null; // Notes don't need global notification treatment + default: + return null; + } + }; + + const getBackgroundColor = (type: "fail" | "warn" | "note") => { + switch (type) { + case "fail": + return "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800"; + case "warn": + return "bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800"; + case "note": + return "bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800"; + default: + return "bg-gray-50 dark:bg-gray-950/20 border-gray-200 dark:border-gray-800"; + } + }; + + const formatTime = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + // Only show FAIL and WARN notifications globally + const visibleNotifications = globalNotifications.filter( + (n: { type: "fail" | "warn" | "note" }) => + n.type === "fail" || n.type === "warn", + ); + + if (visibleNotifications.length === 0) { + return null; + } + + return ( +
+ {visibleNotifications.map( + (notification: { + id: string; + type: "fail" | "warn" | "note"; + command: string; + code: string; + message: string; + target?: string; + serverId: string; + timestamp: Date; + }) => ( +
+
+ {getIcon(notification.type)} +
+
+
+ {notification.type.toUpperCase()} {notification.command}{" "} + {notification.code} + {notification.target && ` ${notification.target}`} +
+ +
+
+
+ {formatTime(notification.timestamp)} +
+
+
+
+ ), + )} +
+ ); +}; diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 74e3e7c0..1dbdbc90 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -147,6 +147,7 @@ export class IRCClient { private nicks: Map = new Map(); private currentUser: User | null = null; private saslMechanisms: Map = new Map(); + private capLsAccumulated: Map> = new Map(); private pendingConnections: Map> = new Map(); private eventCallbacks: { @@ -678,7 +679,18 @@ export class IRCClient { let i = 0; let caps = ""; if (parv[i] === "*") i++; - const subcommand = parv[i++]; + let subcommand = parv[i++]; + // Handle CAP ACK which has nickname before subcommand + if ( + subcommand !== "LS" && + subcommand !== "NEW" && + subcommand !== "DEL" && + subcommand !== "NAK" + ) { + // This is likely a nickname, skip it and get the real subcommand + subcommand = parv[i++]; + } + const isFinal = subcommand === "LS" && parv[i] !== "*"; if (parv[i] === "*") i++; parv[i] = parv[i].substring(1); // trim the ":" lol while (parv[i]) { @@ -686,7 +698,7 @@ export class IRCClient { if (parv[i]) caps += " "; } - if (subcommand === "LS") this.onCapLs(serverId, caps); + if (subcommand === "LS") this.onCapLs(serverId, caps, isFinal); else if (subcommand === "ACK") this.triggerEvent("CAP ACK", { serverId, cliCaps: caps }); else if (subcommand === "NEW") this.onCapNew(serverId, caps); @@ -968,7 +980,7 @@ export class IRCClient { ); } - onCapLs(serverId: string, cliCaps: string): void { + onCapLs(serverId: string, cliCaps: string, isFinal: boolean): void { const ourCaps = [ "multi-prefix", "message-tags", @@ -990,10 +1002,16 @@ export class IRCClient { "draft/account-registration", ]; + let accumulated = this.capLsAccumulated.get(serverId); + if (!accumulated) { + accumulated = new Set(); + this.capLsAccumulated.set(serverId, accumulated); + } + const caps = cliCaps.split(" "); - let toRequest = "CAP REQ :"; for (const c of caps) { const [cap, value] = c.split("=", 2); + accumulated.add(cap); if (cap === "sasl" && value) { const mechanisms = value.split(","); this.saslMechanisms.set(serverId, mechanisms); @@ -1001,21 +1019,32 @@ export class IRCClient { `Available SASL mechanisms for ${serverId}: ${mechanisms.join(", ")}`, ); } - if (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) { - if (toRequest.length + cap.length + 1 > 400) { - this.sendRaw(serverId, toRequest); - toRequest = "CAP REQ :"; + } + + if (isFinal) { + // Now request the caps we want from the accumulated list + let toRequest = "CAP REQ :"; + for (const cap of accumulated) { + if (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) { + if (toRequest.length + cap.length + 1 > 400) { + this.sendRaw(serverId, toRequest); + toRequest = "CAP REQ :"; + } + toRequest += `${cap} `; + console.log(`Requesting capability: ${cap}`); } - toRequest += `${cap} `; - console.log(`Requesting capability: ${cap}`); } + if (toRequest.length > 9) { + this.sendRaw(serverId, toRequest); + if (toRequest.includes("draft/extended-isupport")) + this.sendRaw(serverId, "ISUPPORT"); + } + console.log( + `Server ${serverId} supports capabilities: ${Array.from(accumulated).join(" ")}`, + ); + // Clean up + this.capLsAccumulated.delete(serverId); } - if (toRequest.length > 9) { - this.sendRaw(serverId, toRequest); - if (toRequest.includes("draft/extended-isupport")) - this.sendRaw(serverId, "ISUPPORT"); - } - console.log(`Server ${serverId} supports capabilities: ${cliCaps}`); } onCapNew(serverId: string, cliCaps: string): void { diff --git a/src/store/index.ts b/src/store/index.ts index 95190b2d..319241bd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -168,6 +168,16 @@ export interface AppState { connectionError: string | null; messages: Record; typingUsers: Record; + globalNotifications: { + id: string; + type: "fail" | "warn" | "note"; + command: string; + code: string; + message: string; + target?: string; + serverId: string; + timestamp: Date; + }[]; channelList: Record< string, { channel: string; userCount: number; topic: string }[] @@ -248,6 +258,16 @@ export interface AppState { ) => void; setName: (serverId: string, realname: string) => void; addMessage: (message: Message) => void; + addGlobalNotification: (notification: { + type: "fail" | "warn" | "note"; + command: string; + code: string; + message: string; + target?: string; + serverId: string; + }) => void; + removeGlobalNotification: (notificationId: string) => void; + clearGlobalNotifications: () => void; selectServer: (serverId: string | null) => void; selectChannel: (channelId: string | null) => void; selectPrivateChat: (privateChatId: string | null) => void; @@ -305,6 +325,7 @@ const useStore = create((set, get) => ({ connectionError: null, messages: {}, typingUsers: {}, + globalNotifications: [], channelList: {}, listingInProgress: {}, metadataSubscriptions: {}, @@ -634,6 +655,33 @@ const useStore = create((set, get) => ({ }); }, + addGlobalNotification: (notification) => { + set((state) => ({ + globalNotifications: [ + ...state.globalNotifications, + { + id: uuidv4(), + ...notification, + timestamp: new Date(), + }, + ], + })); + }, + + removeGlobalNotification: (notificationId) => { + set((state) => ({ + globalNotifications: state.globalNotifications.filter( + (n) => n.id !== notificationId, + ), + })); + }, + + clearGlobalNotifications: () => { + set(() => ({ + globalNotifications: [], + })); + }, + selectServer: (serverId) => { set((state) => { // Find the server @@ -1774,21 +1822,23 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { return { servers: updatedServers }; }); - const servers = loadSavedServers(); + // Check if we should prevent CAP END (for SASL or account registration) + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); let preventCapEnd = false; - for (const serv of servers) { - if (serv.id === serverId && serv.saslEnabled) { - preventCapEnd = true; - } + + // Check if SASL is enabled for this server (from saved config) + const savedServers = loadSavedServers(); + const savedServer = savedServers.find((s) => s.id === serverId); + if (savedServer?.saslEnabled) { + preventCapEnd = true; } // Check if there's pending account registration - const state = useStore.getState(); const pendingReg = state.pendingRegistration; if (pendingReg && pendingReg.serverId === serverId) { preventCapEnd = true; // Check if server supports account registration - const server = state.servers.find((s) => s.id === serverId); if (server?.capabilities?.includes("draft/account-registration")) { console.log(`Triggering account registration for server ${serverId}`); useStore @@ -2065,41 +2115,16 @@ ircClient.on("TAGMSG", (response) => { // Standard reply event handlers ircClient.on("FAIL", ({ serverId, command, code, target, message }) => { console.log(`[FAIL] ${command} ${code} ${target || ""}: ${message}`); - // Add notification to the current channel or show as system message + // Add to global notifications for visibility const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server) { - // For now, we'll add it as a system message to the first available channel - // In a real implementation, you might want to show it in a dedicated notification area - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "standard-reply", - content: `FAIL ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - standardReplyType: "FAIL", - standardReplyCommand: command, - standardReplyCode: code, - standardReplyTarget: target, - standardReplyMessage: message, - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } + state.addGlobalNotification({ + type: "fail", + command, + code, + message, + target, + serverId, + }); }); ircClient.on("WARN", ({ serverId, command, code, target, message }) => { @@ -2107,7 +2132,13 @@ ircClient.on("WARN", ({ serverId, command, code, target, message }) => { const state = useStore.getState(); const server = state.servers.find((s) => s.id === serverId); if (server) { - const channel = server.channels[0]; + // Try to add to the currently selected channel first, fallback to first channel + let channel = server.channels.find( + (c) => c.id === state.ui.selectedChannelId, + ); + if (!channel) { + channel = server.channels[0]; + } if (channel) { const notificationMessage: Message = { id: uuidv4(), @@ -2143,7 +2174,13 @@ ircClient.on("NOTE", ({ serverId, command, code, target, message }) => { const state = useStore.getState(); const server = state.servers.find((s) => s.id === serverId); if (server) { - const channel = server.channels[0]; + // Try to add to the currently selected channel first, fallback to first channel + let channel = server.channels.find( + (c) => c.id === state.ui.selectedChannelId, + ); + if (!channel) { + channel = server.channels[0]; + } if (channel) { const notificationMessage: Message = { id: uuidv4(), From a53bb9e8d8684734f9e04447251fabbcac6f8487 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Tue, 30 Sep 2025 07:36:15 +0100 Subject: [PATCH 17/47] More fixes --- src/components/layout/ChannelList.tsx | 12 ++--- src/lib/ircClient.ts | 66 ++++++++------------------- src/store/index.ts | 20 ++++++-- 3 files changed, 39 insertions(+), 59 deletions(-) diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index 2a87ce6a..32a1dd62 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -187,7 +187,7 @@ export const ChannelList: React.FC<{ >
selectChannel(channel.id)} @@ -201,7 +201,7 @@ export const ChannelList: React.FC<{ {/* Trash Button */} {selectedChannelId === channel.id && ( )}
@@ -318,7 +318,7 @@ export const ChannelList: React.FC<{ >
selectPrivateChat(privateChat.id)} @@ -332,7 +332,7 @@ export const ChannelList: React.FC<{ {/* Delete Button */} {selectedPrivateChatId === privateChat.id && ( )}
diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 1dbdbc90..a42a6cc5 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -148,6 +148,7 @@ export class IRCClient { private currentUser: User | null = null; private saslMechanisms: Map = new Map(); private capLsAccumulated: Map> = new Map(); + private saslEnabled: Map = new Map(); private pendingConnections: Map> = new Map(); private eventCallbacks: { @@ -208,6 +209,7 @@ export class IRCClient { }; this.servers.set(server.id, server); this.sockets.set(server.id, socket); + this.saslEnabled.set(server.id, !!_saslAccountName); this.currentUser = { id: uuidv4(), username: nickname, @@ -449,6 +451,10 @@ export class IRCClient { capEnd(_serverId: string) {} + getNick(serverId: string): string | undefined { + return this.nicks.get(serverId); + } + userOnConnect(serverId: string) { const nickname = this.nicks.get(serverId); if (!nickname) { @@ -627,54 +633,13 @@ export class IRCClient { console.log(names); const newUsers = parseNamesResponse(names); // Parse the user list console.log(newUsers); - // Find the server and channel - const server = this.servers.get(serverId); - if (server) { - const channel = server.channels.find((c) => c.name === channelName); - if (channel) { - // Merge new users with existing users - const existingUsers = channel.users || []; - const mergedUsers = existingUsers.map((existingUser) => { - const newUser = newUsers.find( - (u) => u.username === existingUser.username, - ); - if (newUser) { - // Update status if different - return { ...existingUser, status: newUser.status }; - } - return existingUser; - }); - - // Add new users - for (const newUser of newUsers) { - if ( - !existingUsers.some( - (user) => user.username === newUser.username, - ) - ) { - mergedUsers.push(newUser); - } - } - // Update the channel's user list - channel.users = mergedUsers; - - // Trigger an event to notify the UI - this.triggerEvent("NAMES", { - serverId, - channelName, - users: mergedUsers, - }); - } else { - console.warn( - `Channel ${channelName} not found when processing NAMES response`, - ); - } - } else { - console.warn( - `Server ${serverId} not found while processing NAMES response`, - ); - } + // Trigger an event to notify the UI + this.triggerEvent("NAMES", { + serverId, + channelName, + users: newUsers, + }); } else if (command === "CAP") { let i = 0; let caps = ""; @@ -683,6 +648,7 @@ export class IRCClient { // Handle CAP ACK which has nickname before subcommand if ( subcommand !== "LS" && + subcommand !== "ACK" && subcommand !== "NEW" && subcommand !== "DEL" && subcommand !== "NAK" @@ -1024,8 +990,12 @@ export class IRCClient { if (isFinal) { // Now request the caps we want from the accumulated list let toRequest = "CAP REQ :"; + const saslEnabled = this.saslEnabled.get(serverId) ?? false; for (const cap of accumulated) { - if (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) { + if ( + (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) && + (cap !== "sasl" || saslEnabled) + ) { if (toRequest.length + cap.length + 1 > 400) { this.sendRaw(serverId, toRequest); toRequest = "CAP REQ :"; diff --git a/src/store/index.ts b/src/store/index.ts index 319241bd..3ab717f4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1592,6 +1592,13 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => { return { servers: updatedServers }; }); + + // If we joined a channel, request channel information + const ourNick = ircClient.getNick(serverId); + if (username === ourNick) { + ircClient.sendRaw(serverId, `NAMES ${channelName}`); + ircClient.sendRaw(serverId, `TOPIC ${channelName}`); + } }); // Handle user being kicked from a channel @@ -1827,10 +1834,8 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { const server = state.servers.find((s) => s.id === serverId); let preventCapEnd = false; - // Check if SASL is enabled for this server (from saved config) - const savedServers = loadSavedServers(); - const savedServer = savedServers.find((s) => s.id === serverId); - if (savedServer?.saslEnabled) { + // Check if SASL was requested and acknowledged + if (caps.some((cap) => cap.startsWith("sasl"))) { preventCapEnd = true; } @@ -1843,7 +1848,12 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { console.log(`Triggering account registration for server ${serverId}`); useStore .getState() - .registerAccount(serverId, "*", pendingReg.email, pendingReg.password); + .registerAccount( + serverId, + pendingReg.account, + pendingReg.email, + pendingReg.password, + ); console.log(pendingReg); // Clear the pending registration useStore.setState({ pendingRegistration: null }); From deb3cc409db484d3b05a61f179590c838e1ce127 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Tue, 30 Sep 2025 07:47:50 +0100 Subject: [PATCH 18/47] Put the bin buttons back aaa --- src/components/layout/ChannelList.tsx | 12 ++++++------ src/components/layout/ServerList.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index 32a1dd62..2a87ce6a 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -187,7 +187,7 @@ export const ChannelList: React.FC<{ >
selectChannel(channel.id)} @@ -201,7 +201,7 @@ export const ChannelList: React.FC<{ {/* Trash Button */} {selectedChannelId === channel.id && ( )}
@@ -318,7 +318,7 @@ export const ChannelList: React.FC<{ >
selectPrivateChat(privateChat.id)} @@ -332,7 +332,7 @@ export const ChannelList: React.FC<{ {/* Delete Button */} {selectedPrivateChatId === privateChat.id && ( )}
diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index 76202115..1c33283f 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -121,7 +121,7 @@ export const ServerList: React.FC = () => {
)} {selectedServerId === server.id && ( -
+
{selectedChannel && (() => { - const { currentUser } = useStore.getState(); + const serverCurrentUser = selectedServerId + ? ircClient.getCurrentUser(selectedServerId) + : null; const channelUser = selectedChannel.users.find( - (u) => u.username === currentUser?.username, + (u) => u.username === serverCurrentUser?.username, ); const isOperator = channelUser?.status?.includes("@") || @@ -1055,42 +1096,80 @@ export const ChatArea: React.FC<{ ref={messagesContainerRef} className="flex-grow overflow-y-auto flex flex-col bg-discord-dark-200 text-discord-text-normal relative" > - {channelMessages.map((message, index) => { - const previousMessage = channelMessages[index - 1]; - const showHeader = - !previousMessage || - previousMessage.userId !== message.userId || - new Date(message.timestamp).getTime() - - new Date(previousMessage.timestamp).getTime() > - 5 * 60 * 1000; - - return ( - - handleUsernameClick(e, username, serverId, avatarElement) - } - onIrcLinkClick={handleIrcLinkClick} - onReactClick={handleReactClick} - selectedServerId={selectedServerId} - onReactionUnreact={handleReactionUnreact} - onOpenReactionModal={handleOpenReactionModal} - onDirectReaction={handleDirectReaction} - users={selectedChannel?.users || []} - onRedactMessage={handleRedactMessage} - /> - ); - })} + {(() => { + // Group consecutive events before rendering + const eventGroups = groupConsecutiveEvents(channelMessages); + + return eventGroups.map((group) => { + if (group.type === "eventGroup") { + // Create a stable key from the first and last message IDs in the group + const firstId = group.messages[0]?.id || ""; + const lastId = + group.messages[group.messages.length - 1]?.id || ""; + const groupKey = `group-${firstId}-${lastId}`; + + return ( + + handleUsernameClick(e, username, serverId, avatarElement) + } + /> + ); + } + // Single message - find its original index for date/header logic + const message = group.messages[0]; + const originalIndex = channelMessages.findIndex( + (m) => m.id === message.id, + ); + const previousMessage = channelMessages[originalIndex - 1]; + const showHeader = + !previousMessage || + previousMessage.userId !== message.userId || + new Date(message.timestamp).getTime() - + new Date(previousMessage.timestamp).getTime() > + 5 * 60 * 1000; + + return ( + + handleUsernameClick(e, username, serverId, avatarElement) + } + onIrcLinkClick={handleIrcLinkClick} + onReactClick={handleReactClick} + selectedServerId={selectedServerId} + onReactionUnreact={handleReactionUnreact} + onOpenReactionModal={handleOpenReactionModal} + onDirectReaction={handleDirectReaction} + users={selectedChannel?.users || []} + onRedactMessage={handleRedactMessage} + /> + ); + }); + })()}
diff --git a/src/components/layout/MemberList.tsx b/src/components/layout/MemberList.tsx index 004ac971..6f71a5d9 100644 --- a/src/components/layout/MemberList.tsx +++ b/src/components/layout/MemberList.tsx @@ -1,5 +1,5 @@ import type React from "react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { FaChevronLeft } from "react-icons/fa"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { getColorStyle } from "../../lib/ircUtils"; @@ -59,12 +59,20 @@ const UserItem: React.FC<{ avatarElement?: Element | null, ) => void; }> = ({ user, serverId, currentUser, onContextMenu }) => { + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); + // Display metadata like website or status const website = user.metadata?.url?.value || user.metadata?.website?.value; const status = user.metadata?.status?.value; const avatarUrl = user.metadata?.avatar?.value; const color = user.metadata?.color?.value; - const isBot = user.metadata?.bot?.value === "true"; + const isBot = user.isBot || user.metadata?.bot?.value === "true"; + const botInfo = user.metadata?.bot?.value; // Bot software info for tooltip + + // Reset avatar load failed state when avatar URL changes + useEffect(() => { + setAvatarLoadFailed(false); + }, []); return (
- {avatarUrl ? ( + {avatarUrl && !avatarLoadFailed ? ( {user.username} { - // Fallback to initial if image fails to load - e.currentTarget.style.display = "none"; - const parent = e.currentTarget.parentElement; - if (parent) { - parent.textContent = user.username.charAt(0).toUpperCase(); - } + onError={() => { + setAvatarLoadFailed(true); }} /> ) : ( @@ -118,7 +121,18 @@ const UserItem: React.FC<{ )} {user.username} - {isBot && 🤖} + {isBot && ( + + 🤖 + {botInfo && botInfo !== "true" && ( +
+
+ {botInfo} +
+
+ )} +
+ )}
{status && ( diff --git a/src/components/message/CollapsedEventMessage.tsx b/src/components/message/CollapsedEventMessage.tsx new file mode 100644 index 00000000..530a6f73 --- /dev/null +++ b/src/components/message/CollapsedEventMessage.tsx @@ -0,0 +1,123 @@ +import type React from "react"; +import { useState } from "react"; +import { + type EventGroup, + getEventGroupSummary, + getEventGroupTooltip, +} from "../../lib/eventGrouping"; +import ircClient from "../../lib/ircClient"; +import type { User } from "../../types"; + +interface CollapsedEventMessageProps { + eventGroup: EventGroup; + users: User[]; + onUsernameContextMenu: ( + e: React.MouseEvent, + username: string, + serverId: string, + avatarElement?: Element | null, + ) => void; +} + +export const CollapsedEventMessage: React.FC = ({ + eventGroup, + users, + onUsernameContextMenu, +}) => { + const [showTooltip, setShowTooltip] = useState(false); + const [failedAvatars, setFailedAvatars] = useState>(new Set()); + const serverId = eventGroup.messages[0]?.serverId || ""; + const ircCurrentUser = ircClient.getCurrentUser(serverId); + + if (eventGroup.type !== "eventGroup" || !eventGroup.usernames) { + return null; + } + + const formatTime = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + const handleMouseEnter = () => { + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + const summary = getEventGroupSummary(eventGroup, ircCurrentUser?.username); + const tooltip = getEventGroupTooltip(eventGroup); + const uniqueUsernames: string[] = Array.from(new Set(eventGroup.usernames)); + + return ( +
+ {/* Stacked small avatars - only the hovered one expands */} +
+
+ {uniqueUsernames.slice(0, 3).map((username, index) => { + const user = users.find((u) => u.username === username); + const handleAvatarClick = (e: React.MouseEvent) => { + onUsernameContextMenu(e, username, serverId, e.currentTarget); + }; + + return ( +
+ {user?.metadata?.avatar?.value && + !failedAvatars.has(username) ? ( + {username} { + setFailedAvatars((prev) => new Set(prev).add(username)); + }} + /> + ) : ( + username.charAt(0).toUpperCase() + )} +
+ ); + })} + + {/* Show "+X more" indicator if there are more than 3 users */} + {uniqueUsernames.length > 3 && ( +
+ +{uniqueUsernames.length - 3} +
+ )} +
+
+ + {/* Event summary */} +
+ + {summary} + +
+ + {/* Timestamp - only show on hover */} +
+ {formatTime(eventGroup.timestamp)} +
+ + {/* Detailed tooltip */} + {showTooltip && tooltip && ( +
+ {tooltip} +
+ )} +
+ ); +}; diff --git a/src/components/message/EventMessage.tsx b/src/components/message/EventMessage.tsx new file mode 100644 index 00000000..7058ef8b --- /dev/null +++ b/src/components/message/EventMessage.tsx @@ -0,0 +1,121 @@ +import type React from "react"; +import { useEffect, useState } from "react"; +import ircClient from "../../lib/ircClient"; +import type { Message as MessageType, User } from "../../types"; + +interface EventMessageProps { + message: MessageType; + messageUser?: User; + users: User[]; + showDate: boolean; + onUsernameContextMenu: ( + e: React.MouseEvent, + username: string, + serverId: string, + avatarElement?: Element | null, + ) => void; +} + +export const EventMessage: React.FC = ({ + message, + messageUser, + users, + onUsernameContextMenu, +}) => { + const [showTooltip, setShowTooltip] = useState(false); + const [imageLoadFailed, setImageLoadFailed] = useState(false); + + // Get server-specific current user instead of global currentUser + const currentUser = ircClient.getCurrentUser(message.serverId); + + // Reset image load failed state when avatar URL changes + useEffect(() => { + setImageLoadFailed(false); + }, []); + + const formatTime = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + const handleAvatarClick = (e: React.MouseEvent) => { + const username = message.userId.split("-")[0]; + onUsernameContextMenu(e, username, message.serverId, e.currentTarget); + }; + + const handleMouseEnter = () => { + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + const username = message.userId.split("-")[0]; + const displayName = + messageUser?.metadata?.["display-name"]?.value || username; + const userColor = messageUser?.metadata?.color?.value || "#888888"; + const isCurrentUser = currentUser?.username === username; + const displayText = isCurrentUser ? "You" : displayName; + + return ( +
+ {/* Small event avatar that expands on individual hover */} +
+
+ {messageUser?.metadata?.avatar?.value && !imageLoadFailed ? ( + {username} { + // Use React state instead of direct DOM manipulation + setImageLoadFailed(true); + }} + /> + ) : ( + username.charAt(0).toUpperCase() + )} +
+
+ + {/* Event content */} +
+ + {displayText} + + + {message.content} + +
+ + {/* Timestamp - only show on hover */} +
+ {formatTime(new Date(message.timestamp))} +
+ + {/* Tooltip for future collapsing functionality */} + {showTooltip && ( +
+ {message.type === "join" && "Joined the channel"} + {message.type === "part" && "Left the channel"} + {message.type === "quit" && "Quit the server"} + {message.type === "nick" && "Changed nickname"} +
+ )} +
+ ); +}; diff --git a/src/components/message/MessageAvatar.tsx b/src/components/message/MessageAvatar.tsx index 631e7538..0e5c6d2a 100644 --- a/src/components/message/MessageAvatar.tsx +++ b/src/components/message/MessageAvatar.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { useEffect, useState } from "react"; interface MessageAvatarProps { userId: string; @@ -19,8 +20,14 @@ export const MessageAvatar: React.FC = ({ onClick, isClickable = false, }) => { + const [imageLoadFailed, setImageLoadFailed] = useState(false); const username = userId.split("-")[0]; + // Reset image load failed state when avatar URL changes + useEffect(() => { + setImageLoadFailed(false); + }, []); + if (!showHeader) { return (
@@ -34,21 +41,15 @@ export const MessageAvatar: React.FC = ({ className={`mr-4 ${isClickable ? "cursor-pointer" : ""}`} onClick={onClick} > -
- {avatarUrl ? ( +
+ {avatarUrl && !imageLoadFailed ? ( {username} { - // Fallback to initial if image fails to load - e.currentTarget.style.display = "none"; - const parent = e.currentTarget.parentElement; - if (parent) { - parent.textContent = username.charAt(0).toUpperCase(); - } + onError={() => { + // Use React state instead of direct DOM manipulation + setImageLoadFailed(true); }} /> ) : ( diff --git a/src/components/message/MessageHeader.tsx b/src/components/message/MessageHeader.tsx index 670adf94..9c81bef2 100644 --- a/src/components/message/MessageHeader.tsx +++ b/src/components/message/MessageHeader.tsx @@ -10,6 +10,7 @@ interface MessageHeaderProps { isClickable?: boolean; onClick?: (e: React.MouseEvent) => void; isBot?: boolean; + isVerified?: boolean; } export const MessageHeader: React.FC = ({ @@ -21,6 +22,7 @@ export const MessageHeader: React.FC = ({ isClickable = false, onClick, isBot = false, + isVerified = false, }) => { const username = userId.split("-")[0]; const isSystem = userId === "system"; @@ -41,6 +43,14 @@ export const MessageHeader: React.FC = ({ > {isSystem ? "System" : displayName || username} {isBot && 🤖} + {isVerified && ( + + ✓ + + )} {displayName && ( {username} diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx index ff5a7943..770ecdc6 100644 --- a/src/components/message/MessageItem.tsx +++ b/src/components/message/MessageItem.tsx @@ -1,11 +1,13 @@ import type React from "react"; -import { mircToHtml } from "../../lib/ircUtils"; +import ircClient from "../../lib/ircClient"; +import { isUserVerified, mircToHtml } from "../../lib/ircUtils"; import useStore from "../../store"; import type { MessageType, User } from "../../types"; import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; import { ActionMessage, DateSeparator, + EventMessage, MessageActions, MessageAvatar, MessageHeader, @@ -54,8 +56,8 @@ export const MessageItem: React.FC = ({ users, onRedactMessage, }) => { - const { currentUser } = useStore(); - const isCurrentUser = currentUser?.username === message.userId; + const ircCurrentUser = ircClient.getCurrentUser(message.serverId); + const isCurrentUser = ircCurrentUser?.username === message.userId; // Find the user for this message const messageUser = users.find( @@ -66,7 +68,11 @@ export const MessageItem: React.FC = ({ const userColor = messageUser?.metadata?.color?.value; const userStatus = messageUser?.metadata?.status?.value; const isSystem = message.type === "system"; - const isBot = message.tags?.bot === ""; + const isBot = + messageUser?.isBot || + messageUser?.metadata?.bot?.value === "true" || + message.tags?.bot === ""; + const isVerified = isUserVerified(message.userId, message.tags); // Check if message redaction is supported and possible const server = useStore @@ -89,6 +95,24 @@ export const MessageItem: React.FC = ({ return ; } + // Handle event messages (join, part, quit, nick) + if (["join", "part", "quit", "nick"].includes(message.type)) { + return ( + <> + {showDate && ( + + )} + + + ); + } + // Handle standard reply messages if (message.type === "standard-reply") { // Ensure all required standard reply properties are present @@ -173,7 +197,7 @@ export const MessageItem: React.FC = ({ }; const isClickable = - message.userId !== "system" && currentUser?.username !== username; + message.userId !== "system" && ircCurrentUser?.username !== username; return (
@@ -203,6 +227,7 @@ export const MessageItem: React.FC = ({ isClickable={isClickable} onClick={handleUsernameClick} isBot={isBot} + isVerified={isVerified} /> )} @@ -223,7 +248,7 @@ export const MessageItem: React.FC = ({
diff --git a/src/components/message/index.ts b/src/components/message/index.ts index 5ca078c4..a7c55fa1 100644 --- a/src/components/message/index.ts +++ b/src/components/message/index.ts @@ -1,6 +1,8 @@ export { StandardReplyNotification } from "../ui/StandardReplyNotification"; export { ActionMessage } from "./ActionMessage"; +export { CollapsedEventMessage } from "./CollapsedEventMessage"; export { DateSeparator } from "./DateSeparator"; +export { EventMessage } from "./EventMessage"; export { MessageActions } from "./MessageActions"; export { MessageAvatar } from "./MessageAvatar"; export { MessageHeader } from "./MessageHeader"; diff --git a/src/components/ui/AddPrivateChatModal.tsx b/src/components/ui/AddPrivateChatModal.tsx index 8d852e17..30d99aab 100644 --- a/src/components/ui/AddPrivateChatModal.tsx +++ b/src/components/ui/AddPrivateChatModal.tsx @@ -1,6 +1,7 @@ import type React from "react"; import { useMemo, useState } from "react"; import { FaSearch, FaTimes, FaUser } from "react-icons/fa"; +import ircClient from "../../lib/ircClient"; import useStore from "../../store"; interface AddPrivateChatModalProps { @@ -14,7 +15,7 @@ export const AddPrivateChatModal: React.FC = ({ onClose, serverId, }) => { - const { openPrivateChat, currentUser, servers } = useStore(); + const { openPrivateChat, servers } = useStore(); const [searchTerm, setSearchTerm] = useState(""); const availableUsers = useMemo(() => { @@ -22,6 +23,9 @@ export const AddPrivateChatModal: React.FC = ({ const server = servers.find((s) => s.id === serverId); if (!server) return []; + // Get the current user for this specific server + const currentUser = ircClient.getCurrentUser(serverId); + const allUsers = new Map(); // Collect users from all channels @@ -43,7 +47,7 @@ export const AddPrivateChatModal: React.FC = ({ return filteredUsers.filter((user) => user.username.toLowerCase().includes(searchTerm.toLowerCase()), ); - }, [serverId, currentUser?.username, searchTerm, servers]); + }, [serverId, searchTerm, servers]); const handleUserSelect = (username: string) => { openPrivateChat(serverId, username); diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx index 3cf70c1b..60d13956 100644 --- a/src/components/ui/UserSettings.tsx +++ b/src/components/ui/UserSettings.tsx @@ -1,44 +1,520 @@ -import type React from "react"; -import { useEffect, useState } from "react"; -import { FaTimes } from "react-icons/fa"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + FaBell, + FaCog, + FaServer, + FaTimes, + FaUpload, + FaUser, +} from "react-icons/fa"; +import ircClient from "../../lib/ircClient"; import useStore, { serverSupportsMetadata } from "../../store"; -const UserSettings: React.FC = () => { +type SettingsCategory = "profile" | "notifications" | "preferences" | "account"; + +// Component for displaying setting fields +const SettingField: React.FC<{ + label: string; + description: string; + children: React.ReactNode; +}> = ({ label, description, children }) => ( +
+
+ +

{description}

+
+ {children} +
+); + +// Component for managing custom mentions list +const CustomMentionsList: React.FC<{ + globalCustomMentions: string[]; + updateGlobalSettings: (updates: Record) => void; +}> = ({ globalCustomMentions, updateGlobalSettings }) => { + const [newMention, setNewMention] = useState(""); + + const handleAddMention = () => { + if ( + newMention.trim() && + !globalCustomMentions.includes(newMention.trim()) + ) { + updateGlobalSettings({ + customMentions: [...globalCustomMentions, newMention.trim()], + }); + setNewMention(""); + } + }; + + const handleRemoveMention = (mention: string) => { + updateGlobalSettings({ + customMentions: globalCustomMentions.filter((m) => m !== mention), + }); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleAddMention(); + } + }; + + return ( +
+
+ setNewMention(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Add a word or phrase..." + className="flex-1 rounded border border-discord-button-secondary-default bg-discord-input-bg px-3 py-2 text-discord-text-normal placeholder-discord-text-muted focus:border-discord-text-link focus:outline-none" + /> + +
+ {globalCustomMentions.length > 0 && ( +
+ {globalCustomMentions.map((mention) => ( + + {mention} + + + ))} +
+ )} +
+ ); +}; + +const UserSettings: React.FC = React.memo(() => { const { toggleUserProfileModal, - currentUser, servers, ui, metadataSet, sendRaw, setName, + changeNick, + globalSettings: { + enableNotificationSounds: globalEnableNotificationSounds, + notificationSound: globalNotificationSound, + enableHighlights: globalEnableHighlights, + sendTypingNotifications: globalSendTypingNotifications, + nickname: globalNickname, + accountName: globalAccountName, + accountPassword: globalAccountPassword, + customMentions: globalCustomMentions, + showEvents: globalShowEvents, + showNickChanges: globalShowNickChanges, + showJoinsParts: globalShowJoinsParts, + showQuits: globalShowQuits, + }, + updateGlobalSettings, } = useStore(); - const currentServer = servers.find((s) => s.id === ui.selectedServerId); - const supportsMetadata = currentServer - ? serverSupportsMetadata(currentServer.id) - : false; - // Metadata state + // Memoize the current server and metadata support to prevent unnecessary re-renders + const currentServer = useMemo( + () => servers.find((s) => s.id === ui.selectedServerId), + [servers, ui.selectedServerId], + ); + + // Get the current user for the selected server with metadata from store + const currentUser = useMemo(() => { + if (!currentServer) return null; + + // Get the current user's username from IRCClient + const ircCurrentUser = ircClient.getCurrentUser(currentServer.id); + if (!ircCurrentUser) return null; + + // Find the current user in the server's channel data to get metadata + for (const channel of currentServer.channels) { + const userWithMetadata = channel.users.find( + (u) => u.username === ircCurrentUser.username, + ); + if (userWithMetadata) { + return userWithMetadata; + } + } + + // If not found in channels, return the basic IRC user + return ircCurrentUser; + }, [currentServer]); + + const supportsMetadata = useMemo( + () => (currentServer ? serverSupportsMetadata(currentServer.id) : false), + [currentServer], + ); + const isHostedChatMode = __HIDE_SERVER_LIST__; + + // Category state + const [activeCategory, setActiveCategory] = + useState("profile"); + + // Profile metadata state const [avatar, setAvatar] = useState(""); const [displayName, setDisplayName] = useState(""); const [realname, setRealname] = useState(""); const [homepage, setHomepage] = useState(""); const [status, setStatus] = useState(""); - const [color, setColor] = useState("#800040"); + const [color, setColor] = useState(""); const [bot, setBot] = useState(""); - // Load existing metadata on mount + // Settings state + const [enableNotificationSounds, setEnableNotificationSounds] = useState( + globalEnableNotificationSounds, + ); + const [notificationSound, setNotificationSound] = useState( + globalNotificationSound, + ); + const [notificationSoundFile, setNotificationSoundFile] = + useState(null); + const [enableHighlights, setEnableHighlights] = useState( + globalEnableHighlights, + ); + const [sendTypingNotifications, setSendTypingNotifications] = useState( + globalSendTypingNotifications, + ); + + // Account state (for hosted chat mode) + const [nickname, setNickname] = useState( + globalNickname || currentUser?.username || "", + ); + const [newNickname, setNewNickname] = useState(currentUser?.username || ""); + const [accountName, setAccountName] = useState(globalAccountName); + const [accountPassword, setAccountPassword] = useState(globalAccountPassword); + + // Original values for change tracking + const [originalValues, setOriginalValues] = useState<{ + avatar: string; + displayName: string; + realname: string; + homepage: string; + status: string; + color: string; + bot: string; + newNickname: string; + enableNotificationSounds: boolean; + notificationSound: string; + enableHighlights: boolean; + sendTypingNotifications: boolean; + nickname: string; + accountName: string; + accountPassword: string; + } | null>(null); + + // Track if there are unsaved changes + const hasUnsavedChanges = + originalValues && + (avatar !== originalValues.avatar || + displayName !== originalValues.displayName || + realname !== originalValues.realname || + homepage !== originalValues.homepage || + status !== originalValues.status || + color !== originalValues.color || + bot !== originalValues.bot || + newNickname !== originalValues.newNickname || + enableNotificationSounds !== originalValues.enableNotificationSounds || + notificationSound !== originalValues.notificationSound || + enableHighlights !== originalValues.enableHighlights || + sendTypingNotifications !== originalValues.sendTypingNotifications || + nickname !== originalValues.nickname || + accountName !== originalValues.accountName || + accountPassword !== originalValues.accountPassword); + + const fileInputRef = useRef(null); + + // Refs for input fields to preserve focus during re-renders + const nicknameInputRef = useRef(null); + const displayNameInputRef = useRef(null); + const avatarInputRef = useRef(null); + const statusInputRef = useRef(null); + const colorInputRef = useRef(null); + const botInputRef = useRef(null); + const realnameInputRef = useRef(null); + + // Memoized onChange handlers to prevent unnecessary re-renders + const handleNewNicknameChange = useCallback( + (e: React.ChangeEvent) => { + setNewNickname(e.target.value); + // Schedule focus restoration after React's render cycle + setTimeout(() => { + if (document.activeElement !== nicknameInputRef.current) { + nicknameInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleDisplayNameChange = useCallback( + (e: React.ChangeEvent) => { + setDisplayName(e.target.value); + setTimeout(() => { + if (document.activeElement !== displayNameInputRef.current) { + displayNameInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleAvatarChange = useCallback( + (e: React.ChangeEvent) => { + setAvatar(e.target.value); + setTimeout(() => { + if (document.activeElement !== avatarInputRef.current) { + avatarInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleHomepageChange = useCallback( + (e: React.ChangeEvent) => { + setHomepage(e.target.value); + }, + [], + ); + + const handleStatusChange = useCallback( + (e: React.ChangeEvent) => { + setStatus(e.target.value); + setTimeout(() => { + if (document.activeElement !== statusInputRef.current) { + statusInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + setColor(e.target.value); + setTimeout(() => { + if (document.activeElement !== colorInputRef.current) { + colorInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleBotChange = useCallback( + (e: React.ChangeEvent) => { + setBot(e.target.value); + setTimeout(() => { + if (document.activeElement !== botInputRef.current) { + botInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleRealnameChange = useCallback( + (e: React.ChangeEvent) => { + setRealname(e.target.value); + setTimeout(() => { + if (document.activeElement !== realnameInputRef.current) { + realnameInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleNicknameChange = useCallback( + (e: React.ChangeEvent) => { + setNickname(e.target.value); + }, + [], + ); + + const handleAccountNameChange = useCallback( + (e: React.ChangeEvent) => { + setAccountName(e.target.value); + }, + [], + ); + + const handleAccountPasswordChange = useCallback( + (e: React.ChangeEvent) => { + setAccountPassword(e.target.value); + }, + [], + ); + + // Function to handle closing with unsaved changes warning + const handleClose = () => { + if (hasUnsavedChanges) { + const confirmClose = window.confirm( + "You have unsaved changes. Are you sure you want to close without saving?", + ); + if (!confirmClose) { + return; + } + } + // Reset original values when closing so it will reinitialize next time + setOriginalValues(null); + toggleUserProfileModal(false); + }; + + // Audio playback utility + const playNotificationSound = async (soundFile?: File | string | null) => { + try { + let audioSrc: string; + + if (soundFile instanceof File) { + // Play custom uploaded sound from File object + audioSrc = URL.createObjectURL(soundFile); + } else if (typeof soundFile === "string") { + // Play custom sound from URL string (for previously saved sounds) + audioSrc = soundFile; + } else { + // Play default notification sound (we'll use a simple beep) + // Create a simple beep sound using Web Audio API + const AudioContextClass = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + const audioContext = new AudioContextClass(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime( + 0.1, + audioContext.currentTime + 0.01, + ); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.5, + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.5); + return; + } + + const audio = new Audio(audioSrc); + audio.volume = 0.5; // Set reasonable volume + await audio.play(); + + // Clean up object URL if it was created from a File + if (soundFile instanceof File) { + setTimeout(() => URL.revokeObjectURL(audioSrc), 1000); + } + } catch (error) { + console.error("Failed to play notification sound:", error); + // Fallback to default browser notification sound + if (soundFile) { + // If custom sound fails, try default sound + playNotificationSound(); + } + } + }; + + // Load existing metadata on mount - only once when modal opens useEffect(() => { - if (currentUser) { - setAvatar(currentUser.metadata?.avatar?.value || ""); - setDisplayName(currentUser.metadata?.["display-name"]?.value || ""); - setRealname(currentUser.displayName || ""); - setHomepage(currentUser.metadata?.homepage?.value || ""); - setStatus(currentUser.metadata?.status?.value || ""); - setColor(currentUser.metadata?.color?.value || "#800040"); - setBot(currentUser.metadata?.bot?.value || ""); + if (currentUser && !originalValues) { + const avatarValue = currentUser.metadata?.avatar?.value || ""; + const displayNameValue = + currentUser.metadata?.["display-name"]?.value || ""; + const realnameValue = currentUser.displayName || ""; + const homepageValue = currentUser.metadata?.homepage?.value || ""; + const statusValue = currentUser.metadata?.status?.value || ""; + const colorValue = currentUser.metadata?.color?.value || ""; + const botValue = currentUser.metadata?.bot?.value || ""; + const nicknameValue = currentUser.username || ""; + + // Set current form values + setAvatar(avatarValue); + setDisplayName(displayNameValue); + setRealname(realnameValue); + setHomepage(homepageValue); + setStatus(statusValue); + setColor(colorValue); + setBot(botValue); + setNewNickname(nicknameValue); + + // Set global settings values + setEnableNotificationSounds(globalEnableNotificationSounds); + setNotificationSound(globalNotificationSound); + setEnableHighlights(globalEnableHighlights); + setSendTypingNotifications(globalSendTypingNotifications); + setNickname(globalNickname || currentUser?.username || ""); + setAccountName(globalAccountName); + setAccountPassword(globalAccountPassword); + + // Set original values for change tracking - only once + setOriginalValues({ + avatar: avatarValue, + displayName: displayNameValue, + realname: realnameValue, + homepage: homepageValue, + status: statusValue, + color: colorValue, + bot: botValue, + newNickname: nicknameValue, + enableNotificationSounds: globalEnableNotificationSounds, + notificationSound: globalNotificationSound, + enableHighlights: globalEnableHighlights, + sendTypingNotifications: globalSendTypingNotifications, + nickname: globalNickname || currentUser?.username || "", + accountName: globalAccountName, + accountPassword: globalAccountPassword, + }); } - }, [currentUser]); + }, [ + currentUser?.id, + currentUser?.displayName, + currentUser?.metadata?.["display-name"]?.value, + currentUser?.metadata?.avatar?.value, + currentUser?.metadata?.bot?.value, + currentUser?.metadata?.color?.value, + currentUser?.metadata?.homepage?.value, + currentUser?.metadata?.status?.value, + currentUser?.username, + globalAccountName, + globalAccountPassword, + globalEnableHighlights, + globalEnableNotificationSounds, + globalNickname, + globalNotificationSound, + globalSendTypingNotifications, + currentUser, + originalValues, + ]); // Only depend on user ID - removed all other dependencies const handleSaveMetadata = (key: string, value: string) => { if (currentServer && currentUser) { @@ -51,242 +527,655 @@ const UserSettings: React.FC = () => { } }; + const handleSoundFileSelect = () => { + fileInputRef.current?.click(); + }; + + const handleSoundFileChange = ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (file) { + const url = URL.createObjectURL(file); + setNotificationSound(url); + setNotificationSoundFile(file); + } + }; + + const handleNickChange = () => { + if ( + currentServer && + newNickname.trim() && + newNickname.trim() !== currentUser?.username + ) { + changeNick(currentServer.id, newNickname.trim()); + } + }; + const handleSaveAll = () => { + console.log("[USER_SETTINGS] handleSaveAll called"); + console.log("[USER_SETTINGS] originalValues:", originalValues); + console.log("[USER_SETTINGS] current values:", { + displayName, + color, + avatar, + status, + homepage, + bot, + }); + console.log("[USER_SETTINGS] supportsMetadata:", supportsMetadata); + + if (!originalValues) { + console.log("[USER_SETTINGS] No original values, skipping save"); + return; // Don't save if original values aren't set yet + } + if (currentServer && currentUser) { - // Handle display name (only when metadata is supported) + console.log( + "[USER_SETTINGS] Processing metadata updates for server:", + currentServer.id, + ); + // Handle profile metadata (only when metadata is supported and values have changed) if (supportsMetadata) { - try { - metadataSet( - currentServer.id, - currentUser.username, - "display-name", - displayName || undefined, - ); - } catch (error) { - console.error("Failed to set display name metadata:", error); + // Only update display name if it changed + if (displayName !== originalValues.displayName) { + console.log("[USER_SETTINGS] Updating display-name:", displayName); + try { + metadataSet( + currentServer.id, + "*", // Use * to refer to current user (self) + "display-name", + displayName || undefined, + ); + } catch (error) { + console.error("Failed to set display name metadata:", error); + } } - } - if (supportsMetadata) { const metadataUpdates = [ - { key: "avatar", value: avatar }, - { key: "homepage", value: homepage }, - { key: "status", value: status }, - { key: "color", value: color }, - { key: "bot", value: bot }, + { key: "avatar", value: avatar, original: originalValues.avatar }, + { + key: "homepage", + value: homepage, + original: originalValues.homepage, + }, + { key: "status", value: status, original: originalValues.status }, + { key: "color", value: color, original: originalValues.color }, + { key: "bot", value: bot, original: originalValues.bot }, ]; - metadataUpdates.forEach(({ key, value }) => { - try { - metadataSet( - currentServer.id, - currentUser.username, - key, - value || undefined, + console.log( + "[USER_SETTINGS] Checking metadata updates:", + metadataUpdates, + ); + + metadataUpdates.forEach(({ key, value, original }) => { + // Only update if the value has changed + if (value !== original) { + console.log( + `[USER_SETTINGS] Updating ${key}: "${original}" -> "${value}"`, ); - } catch (error) { - console.error(`Failed to set ${key} metadata:`, error); + try { + metadataSet( + currentServer.id, + "*", // Use * to refer to current user (self) + key, + value || undefined, + ); + } catch (error) { + console.error(`Failed to set ${key} metadata:`, error); + } + } else { + console.log(`[USER_SETTINGS] No change for ${key}: "${value}"`); } }); } - // Handle realname - try { - setName(currentServer.id, realname); - } catch (error) { - console.error("Failed to set realname:", error); + // Handle realname only if it changed + if (realname !== originalValues.realname) { + try { + setName(currentServer.id, realname); + } catch (error) { + console.error("Failed to set realname:", error); + } } } + + // Save global settings only if they changed + const globalSettingsUpdates: Record = {}; + + if (enableNotificationSounds !== originalValues.enableNotificationSounds) { + globalSettingsUpdates.enableNotificationSounds = enableNotificationSounds; + } + if (notificationSound !== originalValues.notificationSound) { + globalSettingsUpdates.notificationSound = notificationSound; + } + if (enableHighlights !== originalValues.enableHighlights) { + globalSettingsUpdates.enableHighlights = enableHighlights; + } + if (sendTypingNotifications !== originalValues.sendTypingNotifications) { + globalSettingsUpdates.sendTypingNotifications = sendTypingNotifications; + } + + if (isHostedChatMode) { + if (nickname !== originalValues.nickname) { + globalSettingsUpdates.nickname = nickname; + } + if (accountName !== originalValues.accountName) { + globalSettingsUpdates.accountName = accountName; + } + if (accountPassword !== originalValues.accountPassword) { + globalSettingsUpdates.accountPassword = accountPassword; + } + } + + // Only update global settings if there are changes + if (Object.keys(globalSettingsUpdates).length > 0) { + updateGlobalSettings(globalSettingsUpdates); + } + + // Reset original values when closing after save + setOriginalValues(null); toggleUserProfileModal(false); }; - return ( -
-
-
-

User Settings

- + const categories = [ + { id: "profile" as const, name: "Profile", icon: FaUser }, + { id: "notifications" as const, name: "Notifications", icon: FaBell }, + { id: "preferences" as const, name: "Preferences", icon: FaCog }, + ...(isHostedChatMode + ? [{ id: "account" as const, name: "Account", icon: FaServer }] + : []), + ]; + + const renderProfileSettings = () => ( +
+ +
+ + {newNickname.trim() && + newNickname.trim() !== currentUser?.username && ( + + )}
+
-
-
- + {supportsMetadata && ( + <> + -
+ - {supportsMetadata && ( - <> -
- - setDisplayName(e.target.value)} - placeholder="Alternative display name" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ + + -
- - setAvatar(e.target.value)} - placeholder="https://example.com/avatar.jpg" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ + + -
- - setHomepage(e.target.value)} - placeholder="https://example.com" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ + + -
- - setStatus(e.target.value)} - placeholder="Working from home" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ +
+ + +
+
-
- -
+ + + + + )} + + + + + + {!supportsMetadata && ( +
+

Limited Profile Support

+

+ This server does not support user metadata. Advanced profile options + (display name, avatar, status, etc.) will appear when connecting to + a server with IRC metadata support. +

+
+ )} +
+ ); + + const renderNotificationSettings = () => ( +
+ +
+ + {enableNotificationSounds && ( + + )} +
+
+ + {enableNotificationSounds && ( + +
+ + {notificationSound && ( + <> + + Custom sound selected + + + + )} +
+ +
+ )} + + + + + + {enableHighlights && ( + + + + )} +
+ ); + + const renderPreferencesSettings = () => ( +
+ +
+ + + {globalShowEvents && ( +
+
+ + +
-
- -
- - setBot(e.target.value)} - placeholder="Bot software name" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
-
-
- - )} - - {!supportsMetadata && ( -
- This server does not support user metadata. Metadata options will - appear here when connecting to a server with draft/metadata - support.
)} +
+
+ + + + + + + + +
+ ); + + const renderAccountSettings = () => ( +
+ + + + + + + -
- - + +
+ ); + + const renderActiveCategory = () => { + switch (activeCategory) { + case "profile": + return renderProfileSettings(); + case "notifications": + return renderNotificationSettings(); + case "preferences": + return renderPreferencesSettings(); + case "account": + return renderAccountSettings(); + default: + return null; + } + }; + + return ( +
+
+ {/* Sidebar */} +
+
+

User Settings

+
+
+ +
+
+ + {/* Main content */} +
+
+

+ {categories.find((c) => c.id === activeCategory)?.name} +

+
-
- - +
+ {renderActiveCategory()}
-
-
- - +
+ + +
); -}; +}); export default UserSettings; diff --git a/src/lib/eventGrouping.ts b/src/lib/eventGrouping.ts new file mode 100644 index 00000000..6c8ed5d7 --- /dev/null +++ b/src/lib/eventGrouping.ts @@ -0,0 +1,157 @@ +import type { Message } from "../types"; + +export interface EventGroup { + type: "message" | "eventGroup"; + messages: Message[]; + eventType?: string; + usernames?: string[]; + timestamp: Date; +} + +/** + * Groups consecutive event messages (join, part, quit) into collapsed groups + * while preserving regular messages and other event types as individual items + */ +export function groupConsecutiveEvents(messages: Message[]): EventGroup[] { + const result: EventGroup[] = []; + const collapsibleEventTypes = ["join", "part", "quit"]; + + let i = 0; + while (i < messages.length) { + const currentMessage = messages[i]; + + // If it's not a collapsible event, add as individual message + if (!collapsibleEventTypes.includes(currentMessage.type)) { + result.push({ + type: "message", + messages: [currentMessage], + timestamp: new Date(currentMessage.timestamp), + }); + i++; + continue; + } + + // Start a new event group + const eventGroup: Message[] = [currentMessage]; + const eventType = currentMessage.type; + const startTime = new Date(currentMessage.timestamp); + + // Look ahead for consecutive events of the same type within 5 minutes + let j = i + 1; + while (j < messages.length) { + const nextMessage = messages[j]; + const timeDiff = + new Date(nextMessage.timestamp).getTime() - + new Date(eventGroup[eventGroup.length - 1].timestamp).getTime(); + + // Stop if it's not the same event type, or if there's more than 5 minutes gap + if (nextMessage.type !== eventType || timeDiff > 5 * 60 * 1000) { + break; + } + + eventGroup.push(nextMessage); + j++; + } + + // If we have multiple events of the same type, create a group + if (eventGroup.length > 1) { + const usernames = eventGroup.map((msg) => msg.userId.split("-")[0]); + result.push({ + type: "eventGroup", + messages: eventGroup, + eventType, + usernames, + timestamp: startTime, + }); + } else { + // Single event, add as individual message + result.push({ + type: "message", + messages: [currentMessage], + timestamp: startTime, + }); + } + + i = j; + } + + return result; +} + +/** + * Creates a summary text for collapsed event groups + */ +export function getEventGroupSummary( + eventGroup: EventGroup, + currentUsername?: string, +): string { + if ( + eventGroup.type !== "eventGroup" || + !eventGroup.usernames || + !eventGroup.eventType + ) { + return ""; + } + + const { usernames, eventType } = eventGroup; + const uniqueUsernames = Array.from(new Set(usernames)); + + // Replace current user's username with "You" + const displayNames = uniqueUsernames.map((username) => + username === currentUsername ? "You" : username, + ); + + let action = ""; + switch (eventType) { + case "join": + action = "joined"; + break; + case "part": + action = "left"; + break; + case "quit": + action = "quit"; + break; + default: + action = eventType; + } + + if (displayNames.length === 1) { + const count = usernames.filter((u) => u === uniqueUsernames[0]).length; + return count > 1 + ? `${displayNames[0]} ${action} ${count} times` + : `${displayNames[0]} ${action}`; + } + if (displayNames.length === 2) { + return `${displayNames[0]} and ${displayNames[1]} ${action}`; + } + if (displayNames.length === 3) { + return `${displayNames[0]}, ${displayNames[1]} and ${displayNames[2]} ${action}`; + } + const others = displayNames.length - 2; + return `${displayNames[0]}, ${displayNames[1]} and ${others} others ${action}`; +} + +/** + * Creates detailed tooltip information for event groups + */ +export function getEventGroupTooltip(eventGroup: EventGroup): string { + if (eventGroup.type !== "eventGroup" || !eventGroup.usernames) { + return ""; + } + + const userCounts = eventGroup.usernames.reduce( + (acc, username) => { + acc[username] = (acc[username] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return Object.entries(userCounts) + .map( + ([username, count]) => + `${username}: ${count} time${count > 1 ? "s" : ""}`, + ) + .join("\n"); +} diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 55ef9993..5b7f9180 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -22,8 +22,8 @@ export interface EventMap { oldNick: string; newNick: string; }; - QUIT: BaseUserActionEvent & { reason: string }; - JOIN: BaseUserActionEvent & { channelName: string }; + QUIT: BaseUserActionEvent & { reason: string; batchTag?: string }; + JOIN: BaseUserActionEvent & { channelName: string; batchTag?: string }; PART: BaseUserActionEvent & { channelName: string; reason?: string; @@ -38,6 +38,10 @@ export interface EventMap { channelName: string; }; USERMSG: BaseMessageEvent; + CHANNNOTICE: BaseMessageEvent & { + channelName: string; + }; + USERNOTICE: BaseMessageEvent; TAGMSG: EventWithTags & { sender: string; channelName: string; @@ -64,7 +68,11 @@ export interface EventMap { METADATA_UNSUBOK: BaseIRCEvent & { keys: string[] }; METADATA_SUBS: BaseIRCEvent & { keys: string[] }; METADATA_SYNCLATER: BaseIRCEvent & { target: string; retryAfter?: number }; - BATCH_START: BaseIRCEvent & { batchId: string; type: string }; + BATCH_START: BaseIRCEvent & { + batchId: string; + type: string; + parameters?: string[]; + }; BATCH_END: BaseIRCEvent & { batchId: string }; METADATA_FAIL: BaseIRCEvent & { subcommand: string; @@ -142,6 +150,13 @@ export interface EventMap { target: string; message: string; }; + NICK_ERROR: { + serverId: string; + code: string; + error: string; + nick?: string; + message: string; + }; } type EventKey = keyof EventMap; @@ -151,7 +166,7 @@ export class IRCClient { private sockets: Map = new Map(); private servers: Map = new Map(); private nicks: Map = new Map(); - private currentUser: User | null = null; + private currentUsers: Map = new Map(); // Per-server current users private saslMechanisms: Map = new Map(); private capLsAccumulated: Map> = new Map(); private saslEnabled: Map = new Map(); @@ -216,12 +231,12 @@ export class IRCClient { this.servers.set(server.id, server); this.sockets.set(server.id, socket); this.saslEnabled.set(server.id, !!_saslAccountName); - this.currentUser = { + this.currentUsers.set(server.id, { id: uuidv4(), username: nickname, isOnline: true, status: "online", - }; + }); this.nicks.set(server.id, nickname); socket.onopen = () => { @@ -294,8 +309,8 @@ export class IRCClient { sendRaw(serverId: string, command: string): void { const socket = this.sockets.get(serverId); if (socket && socket.readyState === WebSocket.OPEN) { - // Log metadata commands but not sensitive commands - if (command.startsWith("METADATA")) { + // Log metadata and command-related outgoing messages for debugging + if (command.startsWith("METADATA") || command.startsWith("/")) { console.log(`[IRC] Sending: ${command}`); } socket.send(command); @@ -397,6 +412,10 @@ export class IRCClient { this.sendRaw(serverId, `SETNAME :${realname}`); } + changeNick(serverId: string, newNick: string): void { + this.sendRaw(serverId, `NICK ${newNick}`); + } + // Metadata commands metadataGet(serverId: string, target: string, keys: string[]): void { const keysStr = keys.join(" "); @@ -414,11 +433,15 @@ export class IRCClient { value?: string, visibility?: string, ): void { - const visibilityStr = visibility ? ` ${visibility}` : ""; + // Use the provided target. If it's "*" or the current user's nickname, use "*" + // Otherwise use the provided target (for channels, other users if admin, etc.) + const currentNick = this.getNick(serverId); + const actualTarget = + target === "*" || target === currentNick ? "*" : target; const command = - value !== undefined - ? `METADATA * SET ${key} :${value}` - : `METADATA * SET ${key} :`; + value !== undefined && value !== "" + ? `METADATA ${actualTarget} SET ${key} :${value}` + : `METADATA ${actualTarget} SET ${key}`; console.log(`[IRC] Sending metadata SET command: ${command}`); this.sendRaw(serverId, command); } @@ -428,8 +451,12 @@ export class IRCClient { } metadataSub(serverId: string, keys: string[]): void { - const keysStr = keys.join(" "); - this.sendRaw(serverId, `METADATA * SUB ${keysStr}`); + // Send individual SUB commands for each key to avoid parsing issues + keys.forEach((key) => { + const command = `METADATA * SUB ${key}`; + console.log(`[IRC] Sending metadata subscription command: ${command}`); + this.sendRaw(serverId, command); + }); } metadataUnsub(serverId: string, keys: string[]): void { @@ -521,14 +548,23 @@ export class IRCClient { } else if (command === "NICK") { console.log("triggered nickchange"); const oldNick = getNickFromNuh(source); - const newNick = parv[0]; + let newNick = parv[0]; + + // Remove leading colon if present + if (newNick.startsWith(":")) { + newNick = newNick.substring(1); + } // We changed our own nick if (oldNick === this.nicks.get(serverId)) { this.nicks.set(serverId, newNick); - // Update current user's username - if (this.currentUser) { - this.currentUser.username = newNick; + // Update current user's username for this server + const currentUser = this.currentUsers.get(serverId); + if (currentUser) { + this.currentUsers.set(serverId, { + ...currentUser, + username: newNick, + }); } } @@ -542,11 +578,21 @@ export class IRCClient { } else if (command === "QUIT") { const username = getNickFromNuh(source); const reason = parv.join(" "); - this.triggerEvent("QUIT", { serverId, username, reason }); + this.triggerEvent("QUIT", { + serverId, + username, + reason, + batchTag: mtags?.batch, + }); } else if (command === "JOIN") { const username = getNickFromNuh(source); const channelName = parv[0][0] === ":" ? parv[0].substring(1) : parv[0]; - this.triggerEvent("JOIN", { serverId, username, channelName }); + this.triggerEvent("JOIN", { + serverId, + username, + channelName, + batchTag: mtags?.batch, + }); } else if (command === "PART") { const username = getNickFromNuh(source); const channelName = parv[0]; @@ -600,6 +646,33 @@ export class IRCClient { timestamp: getTimestampFromTags(mtags), }); } + } else if (command === "NOTICE") { + const target = parv[0]; + const isChannel = target.startsWith("#"); + const sender = getNickFromNuh(source); + + parv[0] = ""; + const message = parv.join(" ").trim().substring(1); + + if (isChannel) { + const channelName = target; + this.triggerEvent("CHANNNOTICE", { + serverId, + mtags, + sender, + channelName, + message, + timestamp: getTimestampFromTags(mtags), + }); + } else { + this.triggerEvent("USERNOTICE", { + serverId, + mtags, + sender, + message, + timestamp: getTimestampFromTags(mtags), + }); + } } else if (command === "TAGMSG") { const rawTarget = parv[0] || ""; const target = rawTarget.startsWith(":") @@ -707,20 +780,47 @@ export class IRCClient { } else if (command === "AUTHENTICATE") { const param = parv.join(" "); this.triggerEvent("AUTHENTICATE", { serverId, param }); + } else if (command === "BATCH") { + // BATCH +reference-tag type [parameters...] or BATCH -reference-tag + const batchRef = parv[0]; + const isStart = batchRef.startsWith("+"); + const batchId = batchRef.substring(1); // Remove + or - + + if (isStart) { + const batchType = parv[1]; + const parameters = parv.slice(2); + console.log( + `[IRC] Starting batch: id=${batchId}, type=${batchType}, params=${parameters.join(" ")}`, + ); + this.triggerEvent("BATCH_START", { + serverId, + batchId, + type: batchType, + parameters, + }); + } else { + console.log(`[IRC] Ending batch: id=${batchId}`); + this.triggerEvent("BATCH_END", { + serverId, + batchId, + }); + } } else if (command === "METADATA") { const target = parv[0]; const key = parv[1]; const visibility = parv[2]; - const value = parv.slice(3).join(" ").substring(1); // Remove leading : + const value = parv.slice(3).join(" "); + // Remove leading : only if it exists + const cleanValue = value.startsWith(":") ? value.substring(1) : value; console.log( - `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${value}`, + `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${cleanValue}`, ); this.triggerEvent("METADATA", { serverId, target, key, visibility, - value, + value: cleanValue, }); } else if (command === "760") { // RPL_WHOISKEYVALUE @@ -738,18 +838,18 @@ export class IRCClient { }); } else if (command === "761") { // RPL_KEYVALUE - // RPL_KEYVALUE : - // Note: Server sometimes sends target twice, so detect and handle this - const target = parv[0]; - let key = parv[1]; - let visibility = parv[2]; - let valueStartIndex = 3; - - // If target is duplicated (server bug), skip the duplicate - if (parv[0] === parv[1] && parv.length > 4) { - key = parv[2]; - visibility = parv[3]; - valueStartIndex = 4; + // Format: 761 : + const recipient = parv[0]; // The user receiving this message (usually current user) + const target = parv[1]; // The user whose metadata this is + let key = parv[2]; + let visibility = parv[3]; + let valueStartIndex = 4; + + // If target is duplicated (server bug), adjust parsing + if (parv[1] === parv[2] && parv.length > 5) { + key = parv[3]; + visibility = parv[4]; + valueStartIndex = 5; } const value = parv.slice(valueStartIndex).join(" "); @@ -771,18 +871,31 @@ export class IRCClient { this.triggerEvent("METADATA_KEYNOTSET", { serverId, target, key }); } else if (command === "770") { // RPL_METADATASUBOK - // RPL_METADATASUBOK [ ...] - const keys = parv.slice(0); + // Format: 770 [ ...] + const target = parv[0]; + const keys = parv + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); + console.log( + `[IRC] Received METADATA_SUBOK for target ${target}, keys:`, + keys, + ); this.triggerEvent("METADATA_SUBOK", { serverId, keys }); } else if (command === "771") { // RPL_METADATAUNSUBOK - // RPL_METADATAUNSUBOK [ ...] - const keys = parv.slice(0); + // Format: 771 [ ...] + const target = parv[0]; + const keys = parv + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); this.triggerEvent("METADATA_UNSUBOK", { serverId, keys }); } else if (command === "772") { // RPL_METADATASUBS - // RPL_METADATASUBS [ ...] - const keys = parv.slice(0); + // Format: 772 [ ...] + const target = parv[0]; + const keys = parv + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); this.triggerEvent("METADATA_SUBS", { serverId, keys }); } else if (command === "774") { // RPL_METADATASYNCLATER @@ -795,23 +908,24 @@ export class IRCClient { retryAfter, }); } else if (command === "FAIL" && parv[0] === "METADATA") { + // FAIL METADATA [] [] [] // ERR_METADATATOOMANY, ERR_METADATATARGETINVALID, ERR_METADATANOACCESS, ERR_METADATANOKEY, ERR_METADATARATELIMITED - const subcommand = parv[0]; - const code = parv[1]; + const subcommand = parv[1]; // The METADATA subcommand that failed (SUB, SET, etc.) + const code = parv[2]; // The error code let target: string | undefined; let key: string | undefined; let retryAfter: number | undefined; - if (parv[2]) target = parv[2]; - if (parv[3]) key = parv[3]; - if (parv[4] && code === "RATE_LIMITED") { - retryAfter = Number.parseInt(parv[4], 10); + if (parv[3]) target = parv[3]; + if (parv[4]) key = parv[4]; + if (parv[5] && code === "RATE_LIMITED") { + retryAfter = Number.parseInt(parv[5], 10); } console.log( - `[IRC] Received METADATA FAIL: subcommand=${parv[1]}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}`, + `[IRC] Received METADATA FAIL: subcommand=${subcommand}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}`, ); this.triggerEvent("METADATA_FAIL", { serverId, - subcommand: parv[1], + subcommand, code, target, key, @@ -862,6 +976,48 @@ export class IRCClient { const target = parv[1]; const message = parv.slice(2).join(" ").substring(1); this.triggerEvent("WHOIS_BOT", { serverId, nick, target, message }); + } else if (command === "431") { + // ERR_NONICKNAMEGIVEN: :No nickname given + const message = parv.join(" ").substring(1); + this.triggerEvent("NICK_ERROR", { + serverId, + code: "431", + error: "No nickname given", + message, + }); + } else if (command === "432") { + // ERR_ERRONEUSNICKNAME: :Erroneous nickname + const nick = parv[1]; + const message = parv.slice(2).join(" ").substring(1); + this.triggerEvent("NICK_ERROR", { + serverId, + code: "432", + error: "Invalid nickname", + nick, + message, + }); + } else if (command === "433") { + // ERR_NICKNAMEINUSE: :Nickname is already in use + const nick = parv[1]; + const message = parv.slice(2).join(" ").substring(1); + this.triggerEvent("NICK_ERROR", { + serverId, + code: "433", + error: "Nickname already in use", + nick, + message, + }); + } else if (command === "436") { + // ERR_NICKCOLLISION: :Nickname collision KILL from @ + const nick = parv[1]; + const message = parv.slice(2).join(" ").substring(1); + this.triggerEvent("NICK_ERROR", { + serverId, + code: "436", + error: "Nickname collision", + nick, + message, + }); } else if (command === "FAIL") { // Standard replies: FAIL : const cmd = parv[0]; @@ -985,6 +1141,7 @@ export class IRCClient { "draft/metadata-2", "draft/message-redaction", "draft/account-registration", + "batch", ]; let accumulated = this.capLsAccumulated.get(serverId); @@ -1091,8 +1248,10 @@ export class IRCClient { return Array.from(this.servers.values()); } - getCurrentUser(): User | null { - return this.currentUser; + getCurrentUser(serverId?: string): User | null { + // If no serverId provided, return null (we need server context now) + if (!serverId) return null; + return this.currentUsers.get(serverId) || null; } getAllUsers(serverId: string): User[] { diff --git a/src/lib/ircUtils.tsx b/src/lib/ircUtils.tsx index 9005fdef..70e0f8b3 100644 --- a/src/lib/ircUtils.tsx +++ b/src/lib/ircUtils.tsx @@ -43,6 +43,23 @@ export function parseMessageTags(tags: string): Record { return parsedTags; } +/** + * Check if a user is verified based on the account tag matching their nickname. + * According to IRCv3 account-tag spec, if the account tag matches the sender's nick + * (case-insensitively), the user is authenticated to that account. + */ +export function isUserVerified( + senderNick: string, + messageTags?: Record, +): boolean { + if (!messageTags?.account) { + return false; + } + + // Case-insensitive comparison as per the requirement + return senderNick.toLowerCase() === messageTags.account.toLowerCase(); +} + export function parseIsupport(tokens: string): Record { const tokenMap: Record = {}; const tokenPairs = tokens.split(" "); diff --git a/src/lib/notificationSounds.ts b/src/lib/notificationSounds.ts new file mode 100644 index 00000000..21fce6ec --- /dev/null +++ b/src/lib/notificationSounds.ts @@ -0,0 +1,110 @@ +/** + * Notification sound utilities for playing audio notifications + */ + +// Play notification sound based on current settings +export const playNotificationSound = async (globalSettings: { + enableNotificationSounds: boolean; + notificationSound: string; +}) => { + // Check if notification sounds are enabled + if (!globalSettings.enableNotificationSounds) { + return; + } + + try { + let audioSrc: string; + + if (globalSettings.notificationSound) { + // Play custom uploaded sound from URL string + audioSrc = globalSettings.notificationSound; + } else { + // Play default notification sound using Web Audio API + const AudioContextClass = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + const audioContext = new AudioContextClass(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime( + 0.1, + audioContext.currentTime + 0.01, + ); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.5, + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.5); + return; + } + + const audio = new Audio(audioSrc); + audio.volume = 0.3; // Set reasonable volume for notifications + await audio.play(); + } catch (error) { + console.error("Failed to play notification sound:", error); + // Fallback to default browser notification sound or do nothing + } +}; + +// Check if message should trigger a notification sound +export const shouldPlayNotificationSound = ( + message: { userId: string; content: string; type: string }, + currentUser: { username: string } | null, + globalSettings: { + enableHighlights: boolean; + enableNotificationSounds: boolean; + customMentions: string[]; + }, +): boolean => { + // Don't play sound if notification sounds are disabled + if (!globalSettings.enableNotificationSounds) { + return false; + } + + // Don't play sound for our own messages + if (currentUser && message.userId === currentUser.username) { + return false; + } + + // Only check highlights for actual messages (PRIVMSG) and notices (NOTICE) + const isUserMessage = message.type === "message"; + const isSystemMessage = ["system", "join", "part", "quit", "nick"].includes( + message.type, + ); + + if (!isUserMessage || isSystemMessage) { + return false; // Don't trigger sounds for system messages + } + + // If highlights are enabled, check for mentions + if (globalSettings.enableHighlights && currentUser) { + const content = message.content.toLowerCase(); + + // Check for username mention + const usernameMention = content.includes( + currentUser.username.toLowerCase(), + ); + + // Check for custom mentions + const customMention = globalSettings.customMentions.some( + (mention) => mention.trim() && content.includes(mention.toLowerCase()), + ); + + return usernameMention || customMention; + } + + // If highlights are disabled, play sound for all user messages (except our own) + return true; +}; diff --git a/src/store/index.ts b/src/store/index.ts index ebd67df7..d39f4dd3 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,10 @@ import { v4 as uuidv4 } from "uuid"; import { create } from "zustand"; import ircClient from "../lib/ircClient"; +import { + playNotificationSound, + shouldPlayNotificationSound, +} from "../lib/notificationSounds"; import { registerAllProtocolHandlers } from "../protocol"; import type { Channel, @@ -13,6 +17,7 @@ import type { const LOCAL_STORAGE_SERVERS_KEY = "savedServers"; const LOCAL_STORAGE_METADATA_KEY = "serverMetadata"; +const LOCAL_STORAGE_SETTINGS_KEY = "globalSettings"; // Type for saved metadata structure: serverId -> target -> key -> metadata type SavedMetadata = Record< @@ -20,6 +25,44 @@ type SavedMetadata = Record< Record> >; +// Types for batch event processing +interface JoinBatchEvent { + type: "JOIN"; + data: { + serverId: string; + username: string; + channelName: string; + }; +} + +interface QuitBatchEvent { + type: "QUIT"; + data: { + serverId: string; + username: string; + reason: string; + }; +} + +interface PartBatchEvent { + type: "PART"; + data: { + serverId: string; + username: string; + channelName: string; + reason?: string; + }; +} + +type BatchEvent = JoinBatchEvent | QuitBatchEvent | PartBatchEvent; + +interface BatchInfo { + type: string; + parameters?: string[]; + events: BatchEvent[]; + startTime: Date; +} + export const getChannelMessages = (serverId: string, channelId: string) => { const state = useStore.getState(); const key = `${serverId}-${channelId}`; @@ -49,15 +92,34 @@ function saveMetadataToLocalStorage(metadata: SavedMetadata) { localStorage.setItem(LOCAL_STORAGE_METADATA_KEY, JSON.stringify(metadata)); } +// Load saved global settings from localStorage +function loadSavedGlobalSettings(): Partial { + try { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_SETTINGS_KEY) || "{}"); + } catch { + return {}; + } +} + +// Save global settings to localStorage +function saveGlobalSettingsToLocalStorage(settings: GlobalSettings) { + localStorage.setItem(LOCAL_STORAGE_SETTINGS_KEY, JSON.stringify(settings)); +} + // Check if a server supports metadata function serverSupportsMetadata(serverId: string): boolean { const state = useStore.getState(); const server = state.servers.find((s) => s.id === serverId); - return ( + const supports = server?.capabilities?.some( (cap) => cap === "draft/metadata-2" || cap.startsWith("draft/metadata"), - ) ?? false + ) ?? false; + console.log( + `[SERVER_CAPS] Server ${serverId} capabilities:`, + server?.capabilities, ); + console.log(`[SERVER_CAPS] Server ${serverId} supports metadata:`, supports); + return supports; } export { serverSupportsMetadata }; @@ -160,6 +222,21 @@ interface UIState { interface GlobalSettings { enableNotifications: boolean; + notificationSound: string; + enableNotificationSounds: boolean; + enableHighlights: boolean; + sendTypingNotifications: boolean; + // Event visibility settings + showEvents: boolean; + showNickChanges: boolean; + showJoinsParts: boolean; + showQuits: boolean; + // Custom mentions + customMentions: string[]; + // Hosted chat mode settings + nickname: string; + accountName: string; + accountPassword: string; } export interface AppState { @@ -199,6 +276,13 @@ export interface AppState { }[]; } >; // batchId -> batch info + activeBatches: Record< + string, + Record< + string, + BatchInfo + > + >; // serverId -> batchId -> batch info // Account registration state pendingRegistration: { serverId: string; @@ -259,6 +343,7 @@ export interface AppState { reason?: string, ) => void; setName: (serverId: string, realname: string) => void; + changeNick: (serverId: string, newNick: string) => void; addMessage: (message: Message) => void; addGlobalNotification: (notification: { type: "fail" | "warn" | "note"; @@ -301,6 +386,8 @@ export interface AppState { ) => void; hideContextMenu: () => void; setMobileViewActiveColumn: (column: layoutColumn) => void; + // Settings actions + updateGlobalSettings: (settings: Partial) => void; // Metadata actions metadataGet: (serverId: string, target: string, keys: string[]) => void; metadataList: (serverId: string, target: string) => void; @@ -332,6 +419,7 @@ const useStore = create((set, get) => ({ listingInProgress: {}, metadataSubscriptions: {}, metadataBatches: {}, + activeBatches: {}, pendingRegistration: null, selectedServerId: null, @@ -362,6 +450,22 @@ const useStore = create((set, get) => ({ }, globalSettings: { enableNotifications: false, + notificationSound: "", + enableNotificationSounds: true, + enableHighlights: true, + sendTypingNotifications: true, + // Event visibility settings (enabled by default) + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + // Custom mentions + customMentions: [], + // Hosted chat mode settings + nickname: "", + accountName: "", + accountPassword: "", + ...loadSavedGlobalSettings(), // Load saved settings from localStorage }, // IRC client actions @@ -435,13 +539,11 @@ const useStore = create((set, get) => ({ ); if (alreadyExists) { return { - currentUser: ircClient.getCurrentUser(), isConnecting: false, }; } return { servers: [...state.servers, server], - currentUser: ircClient.getCurrentUser(), isConnecting: false, }; }); @@ -644,6 +746,10 @@ const useStore = create((set, get) => ({ ircClient.setName(serverId, realname); }, + changeNick: (serverId, newNick) => { + ircClient.changeNick(serverId, newNick); + }, + addMessage: (message) => { set((state) => { const channelKey = `${message.serverId}-${message.channelId}`; @@ -868,8 +974,11 @@ const useStore = create((set, get) => ({ const server = state.servers.find((s) => s.id === serverId); if (!server) return {}; + // Get the current user for this specific server + const currentUser = ircClient.getCurrentUser(serverId); + // Don't allow opening private chats with ourselves - if (state.currentUser?.username === username) { + if (currentUser?.username === username) { return {}; } @@ -1170,6 +1279,21 @@ const useStore = create((set, get) => ({ })); }, + // Settings actions + updateGlobalSettings: (settings: Partial) => { + set((state) => { + const newGlobalSettings = { + ...state.globalSettings, + ...settings, + }; + // Save to localStorage + saveGlobalSettingsToLocalStorage(newGlobalSettings); + return { + globalSettings: newGlobalSettings, + }; + }); + }, + // Metadata actions metadataGet: (serverId, target, keys) => { if (serverSupportsMetadata(serverId)) { @@ -1200,7 +1324,15 @@ const useStore = create((set, get) => ({ metadataSub: (serverId, keys) => { if (serverSupportsMetadata(serverId)) { + console.log( + `[METADATA_SUB] Subscribing to keys for server ${serverId}:`, + keys, + ); ircClient.metadataSub(serverId, keys); + } else { + console.log( + `[METADATA_SUB] Server ${serverId} does not support metadata`, + ); } }, @@ -1290,7 +1422,7 @@ ircClient.on("CHANMSG", (response) => { : null; const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) + ? findChannelMessageById(server.id, channel.id, replyId) || null : null; const newMessage = { @@ -1318,6 +1450,7 @@ ircClient.on("CHANMSG", (response) => { if (user.username === response.sender) { return { ...user, + isBot: true, // Set bot flag from message tags metadata: { ...user.metadata, bot: { value: "true", visibility: "public" }, @@ -1337,6 +1470,20 @@ ircClient.on("CHANMSG", (response) => { } useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + // Remove any typing users from the state useStore.setState((state) => { const key = `${server.id}-${channel.id}`; @@ -1357,7 +1504,7 @@ ircClient.on("USERMSG", (response) => { const { mtags, sender, message, timestamp } = response; // Don't create private chats with ourselves when the server echoes back our own messages - const currentUser = useStore.getState().currentUser; + const currentUser = ircClient.getCurrentUser(response.serverId); if (currentUser?.username === sender) { return; } @@ -1407,6 +1554,7 @@ ircClient.on("USERMSG", (response) => { if (user.username === sender) { return { ...user, + isBot: true, // Set bot flag from message tags metadata: { ...user.metadata, bot: { value: "true", visibility: "public" }, @@ -1427,6 +1575,19 @@ ircClient.on("USERMSG", (response) => { useStore.getState().addMessage(newMessage); + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + // Remove any typing users from the state useStore.setState((state) => { const key = `${server.id}-${privateChat.id}`; @@ -1467,60 +1628,156 @@ ircClient.on("USERMSG", (response) => { } }); -ircClient.on("NAMES", ({ serverId, channelName, users }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name === channelName) { - return { ...channel, users }; +ircClient.on("CHANNNOTICE", (response) => { + const { mtags, channelName, message, timestamp } = response; + + // Find the server and channel + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (!server) return; + + const channel = server.channels.find((c) => c.name === channelName); + + if (channel) { + const newMessage: Message = { + id: uuidv4(), + type: "notice", // Different message type for notices + content: message, + timestamp: timestamp, + userId: response.sender, + channelId: channel.id, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } +}); + +ircClient.on("USERNOTICE", (response) => { + const { mtags, message, timestamp } = response; + + // Find the server + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (server) { + // Create private chat for the sender if it doesn't exist + let privateChat = server.privateChats.find( + (pc) => pc.username === response.sender, + ); + + if (!privateChat) { + const newPrivateChat: PrivateChat = { + id: `${server.id}-${response.sender}`, + username: response.sender, + serverId: server.id, + unreadCount: 0, + isMentioned: false, + lastActivity: new Date(), + }; + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + return { ...s, privateChats: [...s.privateChats, newPrivateChat] }; } - return channel; + return s; }); + return { servers: updatedServers }; + }); - return { ...server, channels: updatedChannels }; - } - return server; - }); + privateChat = newPrivateChat; + } - return { servers: updatedServers }; - }); + if (privateChat) { + const newMessage: Message = { + id: uuidv4(), + type: "notice", // Different message type for notices + content: message, + timestamp: timestamp, + userId: response.sender, + channelId: privateChat.id, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; - // Request metadata for all users in the channel (except current user) - const currentState = useStore.getState(); - const currentUser = currentState.currentUser; - users.forEach((user, index) => { - if (currentUser && user.username !== currentUser.username) { - // Stagger requests to avoid overwhelming the server - setTimeout(() => { - useStore.getState().metadataList(serverId, user.username); - }, index * 200); // 200ms delay between requests - } - }); - const usersToFetch = users.filter( - (u) => u.username !== currentUser?.username, - ); + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } - // Process in batches with shorter delays - const batchSize = 10; - const batchDelay = 500; // 500ms between batches - - for (let i = 0; i < usersToFetch.length; i += batchSize) { - const batch = usersToFetch.slice(i, i + batchSize); - setTimeout( - () => { - batch.forEach((user, idx) => { - setTimeout(() => { - useStore.getState().metadataList(serverId, user.username); - }, idx * 50); // 50ms between requests in a batch + // Update private chat's last activity and unread count + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedPrivateChats = + s.privateChats?.map((pc) => { + if (pc.id === privateChat?.id) { + return { + ...pc, + lastActivity: new Date(), + unreadCount: pc.unreadCount + 1, + }; + } + return pc; + }) || []; + return { ...s, privateChats: updatedPrivateChats }; + } + return s; }); - }, - Math.floor(i / batchSize) * batchDelay, - ); + return { servers: updatedServers }; + }); + } } }); -ircClient.on("JOIN", ({ serverId, username, channelName }) => { +ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { + // If this event is part of a batch, store it for later processing + if (batchTag) { + const state = useStore.getState(); + const batch = state.activeBatches[serverId]?.[batchTag]; + if (batch) { + batch.events.push({ + type: "JOIN", + data: { serverId, username, channelName }, + }); + return; + } + } + useStore.setState((state) => { const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1553,11 +1810,10 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => { ); if (!userAlreadyExists) { // Check if this is the current user and copy their metadata - const isCurrentUser = state.currentUser?.username === username; + const ircCurrentUser = ircClient.getCurrentUser(serverId); + const isCurrentUser = ircCurrentUser?.username === username; const userMetadata = - isCurrentUser && state.currentUser - ? state.currentUser.metadata - : {}; + isCurrentUser && ircCurrentUser ? ircCurrentUser.metadata : {}; return { ...channel, @@ -1598,9 +1854,40 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => { // If we joined a channel, request channel information const ourNick = ircClient.getNick(serverId); if (username === ourNick) { - ircClient.sendRaw(serverId, `NAMES ${channelName}`); + // Only request topic - user list comes from WHO responses ircClient.sendRaw(serverId, `TOPIC ${channelName}`); } + + // Add join message if settings allow + const state = useStore.getState(); + if (state.globalSettings.showEvents && state.globalSettings.showJoinsParts) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.name === channelName); + if (channel) { + const joinMessage: Message = { + id: uuidv4(), + type: "join", + content: `joined ${channelName}`, + timestamp: new Date(), + userId: username, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), joinMessage], + }, + })); + } + } + } }); // Handle user being kicked from a channel @@ -1645,9 +1932,21 @@ ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { return server; }); - // Update currentUser if it was our nick that changed + // Update currentUser only if this nick change is for the currently selected server + // and it's our own nick that changed let updatedCurrentUser = state.currentUser; - if (state.currentUser && state.currentUser.username === oldNick) { + const isSelectedServer = state.ui.selectedServerId === serverId; + const serverCurrentUser = ircClient.getCurrentUser(serverId); + const isOurNick = + serverCurrentUser?.username === oldNick || + serverCurrentUser?.username === newNick; + + if ( + isSelectedServer && + isOurNick && + state.currentUser && + state.currentUser.username === oldNick + ) { updatedCurrentUser = { ...state.currentUser, username: newNick }; } @@ -1656,9 +1955,115 @@ ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { currentUser: updatedCurrentUser, }; }); + + // Add nick change messages to all channels where the user was present + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if ( + server && + state.globalSettings.showEvents && + state.globalSettings.showNickChanges + ) { + // Check if this was our own nick change + const ourNick = ircClient.getNick(serverId); + const isOurNickChange = oldNick === ourNick || newNick === ourNick; + + // Add message to each channel where the user was present + server.channels.forEach((channel) => { + const userWasInChannel = channel.users.some( + (user) => user.username === newNick, + ); + if (userWasInChannel) { + const nickChangeMessage: Message = { + id: uuidv4(), + type: "nick", + content: isOurNickChange + ? `are now known as **${newNick}**` + : `is now known as **${newNick}**`, + timestamp: new Date(), + userId: oldNick, // Use the old nick as the user ID for nick changes + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), nickChangeMessage], + }, + })); + } + }); + + // Also add to private chat if we have one open with this user + const privateChat = server.privateChats?.find( + (pc) => pc.username === oldNick || pc.username === newNick, + ); + if (privateChat) { + // Update the private chat username + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pc) => { + if (pc.username === oldNick) { + return { ...pc, username: newNick }; + } + return pc; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + return { servers: updatedServers }; + }); + + // Add nick change message to private chat + const nickChangeMessage: Message = { + id: uuidv4(), + type: "nick", + content: isOurNickChange + ? `are now known as **${newNick}**` + : `is now known as **${newNick}**`, + timestamp: new Date(), + userId: oldNick, + channelId: privateChat.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${privateChat.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), nickChangeMessage], + }, + })); + } + + // Note: IRC client already handles updating its internal nick storage + } }); -ircClient.on("QUIT", ({ serverId, username, reason }) => { +ircClient.on("QUIT", ({ serverId, username, reason, batchTag }) => { + // If this event is part of a batch, store it for later processing + if (batchTag) { + const state = useStore.getState(); + const batch = state.activeBatches[serverId]?.[batchTag]; + if (batch) { + batch.events.push({ + type: "QUIT", + data: { serverId, username, reason }, + }); + return; + } + } + useStore.setState((state) => { const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1676,6 +2081,42 @@ ircClient.on("QUIT", ({ serverId, username, reason }) => { return { servers: updatedServers }; }); + + // Add quit message if settings allow + const state = useStore.getState(); + if (state.globalSettings.showEvents && state.globalSettings.showQuits) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // Add quit message to all channels where the user was present + server.channels.forEach((channel) => { + const userWasInChannel = channel.users.some( + (user) => user.username === username, + ); + if (userWasInChannel) { + const quitMessage: Message = { + id: uuidv4(), + type: "quit", + content: reason ? `quit (${reason})` : "quit", + timestamp: new Date(), + userId: username, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), quitMessage], + }, + })); + } + }); + } + } }); ircClient.on("ready", ({ serverId, serverName, nickname }) => { @@ -1684,21 +2125,77 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { // Restore metadata for this server restoreServerMetadata(serverId); - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { ...server, name: serverName }; // Update the server name for display purposes - } - return server; - }); - - const ircCurrentUser = ircClient.getCurrentUser(); - const updatedCurrentUser = - state.currentUser && ircCurrentUser - ? { ...ircCurrentUser, metadata: state.currentUser.metadata } - : ircCurrentUser || state.currentUser; + // Send saved metadata to the server (after 001 ready) + // Only if server supports metadata + if (serverSupportsMetadata(serverId)) { + console.log( + `[READY] Server ${serverId} supports metadata, setting up subscriptions and restoring data`, + ); - return { + // First, subscribe to metadata updates + const currentSubs = + useStore.getState().metadataSubscriptions[serverId] || []; + if (currentSubs.length === 0) { + const defaultKeys = [ + "url", + "website", + "status", + "location", + "avatar", + "color", + "display-name", + "bot", + ]; + console.log( + `[READY] Subscribing to metadata keys for server ${serverId}:`, + defaultKeys, + ); + useStore.getState().metadataSub(serverId, defaultKeys); + } else { + console.log( + `[READY] Already subscribed to metadata keys for server ${serverId}:`, + currentSubs, + ); + } + + // Then restore saved metadata + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + if (serverMetadata) { + console.log(`Restoring metadata for server ${serverId}:`, serverMetadata); + // Send all saved metadata to the server + Object.entries(serverMetadata).forEach(([target, metadata]) => { + Object.entries(metadata).forEach(([key, { value, visibility }]) => { + if (value !== undefined) { + console.log( + `Sending metadata: target=${target}, key=${key}, value=${value}`, + ); + useStore + .getState() + .metadataSet(serverId, target, key, value, visibility); + } + }); + }); + } + } else { + console.log(`[READY] Server ${serverId} does not support metadata`); + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { ...server, name: serverName }; // Update the server name for display purposes + } + return server; + }); + + const ircCurrentUser = ircClient.getCurrentUser(serverId); + const updatedCurrentUser = + state.currentUser && ircCurrentUser + ? { ...ircCurrentUser, metadata: state.currentUser.metadata } + : ircCurrentUser || state.currentUser; + + return { servers: updatedServers, currentUser: updatedCurrentUser, }; @@ -1728,24 +2225,60 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { } }); -ircClient.on("PART", ({ username, channelName }) => { +ircClient.on("PART", ({ serverId, username, channelName, reason }) => { console.log(`User ${username} left channel ${channelName}`); useStore.setState((state) => { const updatedServers = state.servers.map((server) => { - const updatedChannels = server.channels.map((channel) => { - if (channel.name === channelName) { - return { - ...channel, - users: channel.users.filter((user) => user.username !== username), // Remove the user - }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name === channelName) { + return { + ...channel, + users: channel.users.filter((user) => user.username !== username), // Remove the user + }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; }); return { servers: updatedServers }; }); + + // Add part message if settings allow + const state = useStore.getState(); + if (state.globalSettings.showEvents && state.globalSettings.showJoinsParts) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.name === channelName); + if (channel) { + const partMessage: Message = { + id: uuidv4(), + type: "part", + content: reason + ? `left ${channelName} (${reason})` + : `left ${channelName}`, + timestamp: new Date(), + userId: username, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), partMessage], + }, + })); + } + } + } }); ircClient.on("KICK", ({ username, target, channelName, reason }) => { @@ -1942,9 +2475,9 @@ ircClient.on("CHANMSG", (response) => { ircClient.on("TAGMSG", (response) => { const { sender, mtags, channelName } = response; - // Check if the sender is not the current user + // Check if the sender is not the current user for this specific server // we don't care about showing our own typing status - const currentUser = useStore.getState().currentUser; + const currentUser = ircClient.getCurrentUser(response.serverId); if (sender !== currentUser?.username && mtags && mtags["+typing"]) { const isActive = mtags["+typing"] === "active"; const server = useStore @@ -2180,6 +2713,51 @@ ircClient.on("REDACT", ({ serverId, target, msgid, sender }) => { }); }); +// Nick error event handler +ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { + console.log(`[NICK_ERROR] ${code} ${error}: ${message}`); + // Add to global notifications for visibility + const state = useStore.getState(); + state.addGlobalNotification({ + type: "fail", + command: "NICK", + code, + message: `${error}: ${message}`, + target: nick, + serverId, + }); + + // Also add a system message to the current channel + const server = state.servers.find((s) => s.id === serverId); + if (server && state.ui.selectedChannelId) { + const channel = server.channels.find( + (c) => c.id === state.ui.selectedChannelId, + ); + if (channel) { + const errorMessage: Message = { + id: uuidv4(), + type: "system", + content: `Nick change failed: ${error} ${nick ? `(${nick})` : ""}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), errorMessage], + }, + })); + } + } +}); + // Standard reply event handlers ircClient.on("FAIL", ({ serverId, command, code, target, message }) => { console.log(`[FAIL] ${command} ${code} ${target || ""}: ${message}`); @@ -2384,26 +2962,46 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { `[METADATA] Received metadata: server=${serverId}, target=${target}, key=${key}, value=${value}, visibility=${visibility}`, ); useStore.setState((state) => { + // Resolve the target - if it's "*", it refers to the current user + const serverCurrentUser = ircClient.getCurrentUser(serverId); + const resolvedTarget = + target === "*" + ? ircClient.getNick(serverId) || serverCurrentUser?.username || target + : target; + + console.log( + `[METADATA] Resolving target "${target}" to "${resolvedTarget}"`, + ); + console.log( + `[METADATA] Looking for user in ${state.servers.find((s) => s.id === serverId)?.channels.length || 0} channels`, + ); + const updatedServers = state.servers.map((server) => { if (server.id === serverId) { // Update metadata for users in channels const updatedChannels = server.channels.map((channel) => { const updatedUsers = channel.users.map((user) => { - if (user.username === target) { - const metadata = user.metadata || {}; + if (user.username === resolvedTarget) { + const metadata = { ...(user.metadata || {}) }; if (value) { metadata[key] = { value, visibility }; } else { delete metadata[key]; } + console.log( + `[METADATA] Updated user ${resolvedTarget} in channel ${channel.name} with ${key}=${value}`, + ); return { ...user, metadata }; } return user; }); // Update metadata for the channel itself if target matches channel name - const channelMetadata = channel.metadata || {}; - if (target === channel.name || target.startsWith("#")) { + const channelMetadata = { ...(channel.metadata || {}) }; + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { if (value) { channelMetadata[key] = { value, visibility }; } else { @@ -2415,8 +3013,8 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { }); // Update metadata for the server itself if target is server - const updatedMetadata = server.metadata || {}; - if (target === server.name) { + const updatedMetadata = { ...(server.metadata || {}) }; + if (resolvedTarget === server.name) { if (value) { updatedMetadata[key] = { value, visibility }; } else { @@ -2433,16 +3031,26 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { return server; }); - // Update current user metadata + // Update current user metadata only if this is for the currently selected server let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === target) { - const metadata = state.currentUser.metadata || {}; + const isSelectedServer = state.ui.selectedServerId === serverId; + const currentUserForServer = ircClient.getCurrentUser(serverId); + + if ( + isSelectedServer && + currentUserForServer && + state.currentUser?.username === resolvedTarget + ) { + const metadata = { ...(state.currentUser.metadata || {}) }; if (value) { metadata[key] = { value, visibility }; } else { delete metadata[key]; } updatedCurrentUser = { ...state.currentUser, metadata }; + console.log( + `[METADATA] Updated current user ${resolvedTarget} with ${key}=${value}`, + ); } // Save metadata to localStorage @@ -2450,13 +3058,13 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { if (!savedMetadata[serverId]) { savedMetadata[serverId] = {}; } - if (!savedMetadata[serverId][target]) { - savedMetadata[serverId][target] = {}; + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; } if (value) { - savedMetadata[serverId][target][key] = { value, visibility }; + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; } else { - delete savedMetadata[serverId][target][key]; + delete savedMetadata[serverId][resolvedTarget][key]; } saveMetadataToLocalStorage(savedMetadata); @@ -2472,14 +3080,39 @@ ircClient.on( ); // Handle individual key-value responses (similar to METADATA) useStore.setState((state) => { + // Resolve the target - if it's "*", it refers to the current user + const resolvedTarget = + target === "*" + ? ircClient.getNick(serverId) || state.currentUser?.username || target + : target; + + console.log( + `[METADATA_KEYVALUE] Resolving target "${target}" to "${resolvedTarget}"`, + ); + console.log( + `[METADATA_KEYVALUE] Looking for user in ${state.servers.find((s) => s.id === serverId)?.channels.length || 0} channels`, + ); + const updatedServers = state.servers.map((server) => { if (server.id === serverId) { // Update metadata for users in channels const updatedChannels = server.channels.map((channel) => { + const userInChannel = channel.users.find( + (u) => u.username === resolvedTarget, + ); + if (userInChannel) { + console.log( + `[METADATA_KEYVALUE] Found user ${resolvedTarget} in channel ${channel.name}`, + ); + } + const updatedUsers = channel.users.map((user) => { - if (user.username === target) { - const metadata = user.metadata || {}; + if (user.username === resolvedTarget) { + const metadata = { ...(user.metadata || {}) }; metadata[key] = { value, visibility }; + console.log( + `[METADATA_KEYVALUE] Updated user ${resolvedTarget} in channel ${channel.name} with ${key}=${value}`, + ); return { ...user, metadata }; } return user; @@ -2487,7 +3120,10 @@ ircClient.on( // Update metadata for the channel itself if target matches channel name const channelMetadata = channel.metadata || {}; - if (target === channel.name || target.startsWith("#")) { + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { channelMetadata[key] = { value, visibility }; } @@ -2497,16 +3133,24 @@ ircClient.on( metadata: channelMetadata, }; }); + + return { + ...server, + channels: updatedChannels, + }; } return server; }); // Update current user metadata let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === target) { - const metadata = state.currentUser.metadata || {}; + if (state.currentUser?.username === resolvedTarget) { + const metadata = { ...(state.currentUser.metadata || {}) }; metadata[key] = { value, visibility }; updatedCurrentUser = { ...state.currentUser, metadata }; + console.log( + `[METADATA_KEYVALUE] Updated current user ${resolvedTarget} with ${key}=${value}`, + ); } // Save metadata to localStorage @@ -2514,10 +3158,10 @@ ircClient.on( if (!savedMetadata[serverId]) { savedMetadata[serverId] = {}; } - if (!savedMetadata[serverId][target]) { - savedMetadata[serverId][target] = {}; + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; } - savedMetadata[serverId][target][key] = { value, visibility }; + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; saveMetadataToLocalStorage(savedMetadata); return { servers: updatedServers, currentUser: updatedCurrentUser }; @@ -2561,6 +3205,10 @@ ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { }); ircClient.on("METADATA_SUBOK", ({ serverId, keys }) => { + console.log( + `[METADATA_SUBOK] Successfully subscribed to keys for server ${serverId}:`, + keys, + ); // Update subscriptions useStore.setState((state) => { const currentSubs = state.metadataSubscriptions[serverId] || []; @@ -2640,10 +3288,17 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { }); ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { + console.log( + `[CAP_ACKNOWLEDGED] Server ${serverId} acknowledged capability: ${key} (${capabilities})`, + ); if (capabilities?.startsWith("draft/metadata")) { // Check if already subscribed to avoid duplicate subscriptions const currentSubs = useStore.getState().metadataSubscriptions[serverId] || []; + console.log( + `[CAP_ACKNOWLEDGED] Current metadata subscriptions for server ${serverId}:`, + currentSubs, + ); if (currentSubs.length === 0) { // Subscribe to common metadata keys const defaultKeys = [ @@ -2654,26 +3309,17 @@ ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { "avatar", "color", "display-name", + "bot", // Subscribe to bot metadata for tooltip information ]; + console.log( + "[CAP_ACKNOWLEDGED] Attempting to subscribe to default metadata keys:", + defaultKeys, + ); useStore.getState().metadataSub(serverId, defaultKeys); } - // Restore saved metadata for this server - restoreServerMetadata(serverId); - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - if (serverMetadata) { - // Send all saved metadata to the server - Object.entries(serverMetadata).forEach(([target, metadata]) => { - Object.entries(metadata).forEach(([key, { value, visibility }]) => { - if (value !== undefined) { - useStore - .getState() - .metadataSet(serverId, target, key, value, visibility); - } - }); - }); - } + // Note: Metadata restoration/sending is now handled in the "ready" event + // to ensure the server is ready to receive METADATA commands } }); @@ -2764,41 +3410,98 @@ ircClient.on("SETNAME", ({ serverId, user, realname }) => { }); }); -ircClient.on("WHO_REPLY", ({ serverId, nick, flags }) => { - const server = useStore.getState().servers.find((s) => s.id === serverId); - if (!server || !server.botMode) return; +ircClient.on( + "WHO_REPLY", + ({ + serverId, + channel, + username, + host, + server, + nick, + flags, + hopcount, + realname, + }) => { + const state = useStore.getState(); + const serverData = state.servers.find((s) => s.id === serverId); + if (!serverData) return; + + // Find the channel this WHO reply belongs to + const channelData = serverData.channels.find((c) => c.name === channel); + if (!channelData) return; + + // Create user object from WHO data with proper User type + const user: User = { + id: nick, + username: nick, + avatar: undefined, + isOnline: true, + isBot: false, + metadata: {}, + }; + + // Check for bot flags if bot mode is enabled + if (serverData.botMode) { + const botFlag = serverData.botMode; + const isBot = flags.includes(botFlag); - const botFlag = server.botMode; - const isBot = flags.includes(botFlag); + if (isBot) { + user.isBot = true; + user.metadata = { + bot: { value: "true", visibility: "public" }, + }; + } + } - if (isBot) { - // Update user objects in channels + // Update the channel's user list with this user useStore.setState((state) => { const updatedServers = state.servers.map((s) => { if (s.id === serverId) { - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === nick) { - return { + const updatedChannels = s.channels.map((ch) => { + if (ch.name === channel) { + // Check if user already exists in the list + const existingUserIndex = ch.users.findIndex( + (u) => u.username === nick, + ); + + if (existingUserIndex !== -1) { + // Update existing user + const updatedUsers = [...ch.users]; + updatedUsers[existingUserIndex] = { + ...updatedUsers[existingUserIndex], ...user, metadata: { + ...updatedUsers[existingUserIndex].metadata, ...user.metadata, - bot: { value: "true", visibility: "public" }, }, }; + return { ...ch, users: updatedUsers }; } - return user; - }); - return { ...channel, users: updatedUsers }; + // Add new user + return { ...ch, users: [...ch.users, user] }; + } + return ch; }); + return { ...s, channels: updatedChannels }; } return s; }); + return { servers: updatedServers }; }); - } -}); + + // Request metadata for the user (except current user) + const currentUser = ircClient.getCurrentUser(serverId); + if (currentUser && user.username !== currentUser.username) { + // Add a small delay to avoid overwhelming the server + setTimeout(() => { + useStore.getState().metadataList(serverId, user.username); + }, 100); + } + }, +); ircClient.on("WHOIS_BOT", ({ serverId, target }) => { // Update user objects in channels @@ -2810,9 +3513,14 @@ ircClient.on("WHOIS_BOT", ({ serverId, target }) => { if (user.username === target) { return { ...user, + isBot: true, // Set the WHOIS-detected bot flag metadata: { ...user.metadata, - bot: { value: "true", visibility: "public" }, + // Keep bot metadata if it exists, but don't require it for display + bot: user.metadata?.bot || { + value: "true", + visibility: "public", + }, }, }; } @@ -2828,4 +3536,198 @@ ircClient.on("WHOIS_BOT", ({ serverId, target }) => { }); }); +// Batch event handlers +ircClient.on("BATCH_START", ({ serverId, batchId, type, parameters }) => { + console.log(`[BATCH] Starting batch: ${batchId} of type ${type}`); + useStore.setState((state) => { + const serverBatches = state.activeBatches[serverId] || {}; + return { + activeBatches: { + ...state.activeBatches, + [serverId]: { + ...serverBatches, + [batchId]: { + type, + parameters: parameters || [], + events: [], + startTime: new Date(), + }, + }, + }, + }; + }); +}); + +ircClient.on("BATCH_END", ({ serverId, batchId }) => { + console.log(`[BATCH] Ending batch: ${batchId}`); + useStore.setState((state) => { + const serverBatches = state.activeBatches[serverId]; + if (!serverBatches || !serverBatches[batchId]) { + console.warn(`Batch ${batchId} not found for server ${serverId}`); + return state; + } + + const batch = serverBatches[batchId]; + console.log( + `[BATCH] Processing ${batch.events.length} events for batch ${batchId} of type ${batch.type}`, + ); + + // Process the batch based on its type + if (batch.type === "netsplit") { + processBatchedNetsplit(serverId, batchId, batch); + } else if (batch.type === "netjoin") { + processBatchedNetjoin(serverId, batchId, batch); + } else { + // For unknown batch types, process events individually + console.log( + `Unknown batch type ${batch.type}, processing events individually`, + ); + batch.events.forEach((event) => { + // Re-trigger the event without batch context based on its type + switch (event.type) { + case "JOIN": + ircClient.triggerEvent("JOIN", event.data); + break; + case "QUIT": + ircClient.triggerEvent("QUIT", event.data); + break; + case "PART": + ircClient.triggerEvent("PART", event.data); + break; + } + }); + } + + // Remove the completed batch + const { [batchId]: removed, ...remainingBatches } = serverBatches; + return { + activeBatches: { + ...state.activeBatches, + [serverId]: remainingBatches, + }, + }; + }); +}); + +// Helper function to process netsplit batches +function processBatchedNetsplit(serverId: string, batchId: string, batch: BatchInfo) { + const store = useStore.getState(); + const batch_info = store.activeBatches[serverId]?.[batchId]; + if (!batch_info) return; + + const quitEvents = batch_info.events; + const [server1, server2] = batch_info.parameters || ["*.net", "*.split"]; + + console.log( + `Processing netsplit: ${quitEvents.length} users quit due to split between ${server1} and ${server2}`, + ); + + // Create a single netsplit message + const netsplitMessage = { + id: `netsplit-${batchId}`, + content: "Oops! The net split! ⚠️", + timestamp: new Date(), + userId: "system", + channelId: "", // Will be set per channel + serverId, + type: "netsplit" as const, + batchId, + quitUsers: quitEvents.map((e) => e.data.username), + server1, + server2, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + // Group affected channels and add the netsplit message to each + const affectedChannels = new Set(); + + // Process each quit event to remove users and track affected channels + quitEvents.forEach((event) => { + const { username } = event.data; + + // Find which channels this user was in and remove them + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + const userIndex = channel.users.findIndex( + (u) => u.username === username, + ); + if (userIndex !== -1) { + affectedChannels.add(channel.id); + // Remove the user from the channel + const updatedUsers = channel.users.filter( + (u) => u.username !== username, + ); + return { ...channel, users: updatedUsers }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + }); + + // Add netsplit message to each affected channel + affectedChannels.forEach((channelId) => { + const channelMessage = { ...netsplitMessage, channelId }; + useStore.getState().addMessage(channelMessage); + }); +} + +// Helper function to process netjoin batches +function processBatchedNetjoin(serverId: string, batchId: string, batch: BatchInfo) { + const store = useStore.getState(); + const batch_info = store.activeBatches[serverId]?.[batchId]; + if (!batch_info) return; + + const joinEvents = batch_info.events; + const [server1, server2] = batch_info.parameters || ["*.net", "*.join"]; + + console.log( + `Processing netjoin: ${joinEvents.length} users joined due to rejoin between ${server1} and ${server2}`, + ); + + // Process each join event normally first + joinEvents.forEach((event) => { + // Re-trigger the JOIN event to add users back + if (event.type === "JOIN") { + ircClient.triggerEvent("JOIN", event.data); + } + }); + + // Find and update any existing netsplit messages to show rejoin + useStore.setState((state) => { + const updatedMessages = { ...state.messages }; + + Object.keys(updatedMessages).forEach((channelKey) => { + const messages = updatedMessages[channelKey]; + const updatedChannelMessages = messages.map((message) => { + if ( + message.type === "netsplit" && + message.serverId === serverId && + message.server1 === server1 && + message.server2 === server2 + ) { + // Update the netsplit message to show rejoin + return { + ...message, + content: "The network split and rejoined. ✅", + type: "netjoin" as const, + }; + } + return message; + }); + updatedMessages[channelKey] = updatedChannelMessages; + }); + + return { messages: updatedMessages }; + }); +} + export default useStore; diff --git a/src/types/index.ts b/src/types/index.ts index 24fcbab2..061e4359 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ export interface User { account?: string; isOnline: boolean; status?: string; + isBot?: boolean; // Bot detection from WHO response metadata?: Record; } @@ -65,23 +66,28 @@ export interface Reaction { } export interface Message { - id?: string; - msgid?: string; - content: string; - timestamp: Date; - userId: string; - channelId: string; - serverId: string; + id: string; + msgid?: string; // IRC message ID from IRCv3 message-ids capability type: | "message" | "system" | "error" | "join" - | "leave" + | "part" + | "quit" | "nick" - | "standard-reply"; + | "leave" + | "standard-reply" + | "notice" + | "netsplit" + | "netjoin"; + content: string; + timestamp: Date; + userId: string; + channelId: string; + serverId: string; reactions: Reaction[]; - replyMessage: Message | null | undefined; + replyMessage: Message | null; mentioned: string[]; tags?: Record; // Standard reply fields @@ -90,6 +96,11 @@ export interface Message { standardReplyCode?: string; standardReplyTarget?: string; standardReplyMessage?: string; + // Batch-related fields for netsplit/netjoin + batchId?: string; + quitUsers?: string[]; + server1?: string; + server2?: string; } // Alias for backwards compatibility diff --git a/tests/components/ChatArea.test.tsx b/tests/components/ChatArea.test.tsx index 03f8b1fa..bd46ede5 100644 --- a/tests/components/ChatArea.test.tsx +++ b/tests/components/ChatArea.test.tsx @@ -11,6 +11,7 @@ vi.mock("../../src/lib/ircClient", () => ({ sendRaw: vi.fn(), sendTyping: vi.fn(), on: vi.fn(), + getCurrentUser: vi.fn(() => ({ id: "test-user", username: "tester" })), version: "1.0.0", }, })); diff --git a/tests/components/MetadataDisplay.test.tsx b/tests/components/MetadataDisplay.test.tsx index b19bb411..821adafa 100644 --- a/tests/components/MetadataDisplay.test.tsx +++ b/tests/components/MetadataDisplay.test.tsx @@ -11,6 +11,7 @@ vi.mock("../../src/lib/ircClient", () => ({ sendRaw: vi.fn(), sendTyping: vi.fn(), on: vi.fn(), + getCurrentUser: vi.fn(() => ({ id: "test-user", username: "tester" })), version: "1.0.0", }, })); diff --git a/tests/components/UserSettings.test.tsx b/tests/components/UserSettings.test.tsx new file mode 100644 index 00000000..e492530e --- /dev/null +++ b/tests/components/UserSettings.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import UserSettings from "../../src/components/ui/UserSettings"; + +// Mock the store +vi.mock("../../src/store", () => ({ + default: vi.fn(() => ({ + toggleUserProfileModal: vi.fn(), + servers: [ + { + id: "server1", + name: "Test Server", + host: "irc.example.com", + port: 6667, + capabilities: ["draft/metadata"], + channels: [ + { + id: "channel1", + name: "#test", + users: [ + { + id: "user1", + username: "testuser", + metadata: { + avatar: { value: "avatar-url" }, + "display-name": { value: "Display Name" }, + homepage: { value: "https://example.com" }, + status: { value: "Available" }, + color: { value: "#800040" }, + bot: { value: "" }, + }, + }, + ], + }, + ], + }, + ], + ui: { + selectedServerId: "server1", + isSettingsModalOpen: true, + }, + globalSettings: { + enableNotificationSounds: true, + notificationSound: "", + enableHighlights: true, + sendTypingNotifications: true, + nickname: "", + accountName: "", + accountPassword: "", + customMentions: [], + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + }, + updateGlobalSettings: vi.fn(), + metadataSet: vi.fn(), + sendRaw: vi.fn(), + setName: vi.fn(), + changeNick: vi.fn(), + })), + serverSupportsMetadata: vi.fn(() => true), +})); + +// Mock ircClient +vi.mock("../../src/lib/ircClient", () => ({ + default: { + getCurrentUser: vi.fn(() => ({ id: "user1", username: "testuser" })), + connect: vi.fn(), + sendRaw: vi.fn(), + on: vi.fn(), + version: "1.0.0", + }, +})); + +// Mock Audio API +global.Audio = vi.fn().mockImplementation(() => ({ + play: vi.fn(), + pause: vi.fn(), + load: vi.fn(), +})) as unknown as { + new (src?: string): HTMLAudioElement; + prototype: HTMLAudioElement; +}; + +global.URL.createObjectURL = vi.fn(() => "blob:test-url"); + +describe("UserSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the settings modal", () => { + render(); + expect(screen.getByText("User Settings")).toBeInTheDocument(); + }); + + it("displays notification settings with correct text", async () => { + render(); + + // Click on the Notifications tab first + fireEvent.click(screen.getByText("Notifications")); + + // Wait for the content to update and just check that the header changes + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Notifications' })).toBeInTheDocument(); + }); + + // If the content area shows the notifications heading, the tab navigation works + // This test verifies the tab switching functionality + }); + + it("displays account password field with correct text", async () => { + // Set the environment variable BEFORE rendering to ensure Account tab is visible + (window as any).__HIDE_SERVER_LIST__ = true; // Note: true means hosted chat mode, which shows Account tab + + render(); + + // Click on the Account tab first + fireEvent.click(screen.getByText("Account")); + + // Wait for the content to update + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + }); + + // This test verifies the Account tab navigation works + }); +}); diff --git a/tests/lib/notificationSounds.test.ts b/tests/lib/notificationSounds.test.ts new file mode 100644 index 00000000..a495e384 --- /dev/null +++ b/tests/lib/notificationSounds.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + playNotificationSound, + shouldPlayNotificationSound, +} from "../../src/lib/notificationSounds"; + +// Mock Web Audio API +const mockAudioContext = { + createOscillator: vi.fn(() => ({ + connect: vi.fn(), + frequency: { setValueAtTime: vi.fn() }, + type: "sine", + start: vi.fn(), + stop: vi.fn(), + })), + createGain: vi.fn(() => ({ + connect: vi.fn(), + gain: { + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + })), + destination: {}, + currentTime: 0, +}; + +// Mock HTML Audio API +const mockAudio = { + play: vi.fn(() => Promise.resolve()), + volume: 0.5, +}; + +// Mock URL API +const mockURL = { + createObjectURL: vi.fn(() => "blob:test-url"), + revokeObjectURL: vi.fn(), +}; + +describe("notificationSounds", () => { + beforeEach(() => { + // Mock globals + global.window = global.window || {}; + (global.window as unknown as { AudioContext: unknown; webkitAudioContext: unknown }).AudioContext = vi.fn(() => mockAudioContext); + (global.window as unknown as { AudioContext: unknown; webkitAudioContext: unknown }).webkitAudioContext = vi.fn(() => mockAudioContext); + (global as unknown as { Audio: unknown }).Audio = vi.fn(() => mockAudio); + (global as unknown as { URL: unknown }).URL = mockURL; + + // Reset mocks + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("playNotificationSound", () => { + it("should not play sound if notification sounds are disabled", async () => { + const globalSettings = { + enableNotificationSounds: false, + notificationSound: "", + }; + + await playNotificationSound(globalSettings); + + expect(global.Audio).not.toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).not.toHaveBeenCalled(); + }); + + it("should play default beep sound when no custom sound is set", async () => { + const globalSettings = { + enableNotificationSounds: true, + notificationSound: "", + }; + + await playNotificationSound(globalSettings); + + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + expect(mockAudioContext.createGain).toHaveBeenCalled(); + expect(global.Audio).not.toHaveBeenCalled(); + }); + + it("should play custom sound when notification sound is set", async () => { + const globalSettings = { + enableNotificationSounds: true, + notificationSound: "custom-sound-url", + }; + + await playNotificationSound(globalSettings); + + expect(global.Audio).toHaveBeenCalledWith("custom-sound-url"); + expect(mockAudio.play).toHaveBeenCalled(); + expect(mockAudio.volume).toBe(0.3); + }); + + it("should handle audio playback errors gracefully", async () => { + const globalSettings = { + enableNotificationSounds: true, + notificationSound: "invalid-url", + }; + + mockAudio.play.mockRejectedValueOnce(new Error("Audio failed")); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + await playNotificationSound(globalSettings); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to play notification sound:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + }); + + describe("shouldPlayNotificationSound", () => { + const mockCurrentUser = { username: "testuser" }; + + it("should return false if notification sounds are disabled", () => { + const message = { userId: "otheruser", content: "Hello testuser!", type: "message" }; + const globalSettings = { + enableNotificationSounds: false, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(false); + }); + + it("should return false for messages from current user", () => { + const message = { userId: "testuser", content: "Hello everyone!", type: "message" }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(false); + }); + + it("should return true for mentions when highlights are enabled", () => { + const message = { userId: "otheruser", content: "Hello testuser!", type: "message" }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(true); + }); + + it("should return false for non-mentions when highlights are enabled", () => { + const message = { userId: "otheruser", content: "Hello everyone!", type: "message" }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(false); + }); + + it("should return true for all messages when highlights are disabled", () => { + const message = { userId: "otheruser", content: "Hello everyone!", type: "message" }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: false, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(true); + }); + + it("should detect mentions case-insensitively", () => { + const message = { userId: "otheruser", content: "Hello TESTUSER!", type: "message" }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(true); + }); + + it("should handle null current user gracefully", () => { + const message = { userId: "otheruser", content: "Hello everyone!", type: "message" }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound(message, null, globalSettings); + expect(result).toBe(true); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index da9a4b4e..8c573b83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,10 +12,12 @@ "jsx": "react-jsx", "strict": true, "noFallthroughCasesInSwitch": true, - "noEmit": true + "noEmit": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": [ "src", - "vite.config.ts" + "vite.config.ts", + "tests" ] } diff --git a/vite.config.ts b/vite.config.ts index d0b5dd98..6cd82995 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ /// +/// import { defineConfig, loadEnv } from 'vite'; import react from "@vitejs/plugin-react"; From 49132137776e609e16f258f64711b955235d49ba Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 22:49:59 +0100 Subject: [PATCH 24/47] fix: Remove explicit any type in UserSettings test - Add proper Window interface extension for __HIDE_SERVER_LIST__ - Replace (window as any) with properly typed window property - Resolves lint warning: Unexpected any. Specify a different type. - All tests still passing (181 passed, 1 skipped) --- tests/components/UserSettings.test.tsx | 33 +++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/components/UserSettings.test.tsx b/tests/components/UserSettings.test.tsx index e492530e..6253509d 100644 --- a/tests/components/UserSettings.test.tsx +++ b/tests/components/UserSettings.test.tsx @@ -1,7 +1,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import UserSettings from "../../src/components/ui/UserSettings"; +// Extend window interface for test environment +declare global { + interface Window { + __HIDE_SERVER_LIST__?: boolean; + } +} + // Mock the store vi.mock("../../src/store", () => ({ default: vi.fn(() => ({ @@ -97,33 +104,37 @@ describe("UserSettings", () => { it("displays notification settings with correct text", async () => { render(); - + // Click on the Notifications tab first fireEvent.click(screen.getByText("Notifications")); - + // Wait for the content to update and just check that the header changes await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Notifications' })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Notifications" }), + ).toBeInTheDocument(); }); - + // If the content area shows the notifications heading, the tab navigation works // This test verifies the tab switching functionality }); it("displays account password field with correct text", async () => { // Set the environment variable BEFORE rendering to ensure Account tab is visible - (window as any).__HIDE_SERVER_LIST__ = true; // Note: true means hosted chat mode, which shows Account tab - + window.__HIDE_SERVER_LIST__ = true; // Note: true means hosted chat mode, which shows Account tab + render(); - + // Click on the Account tab first fireEvent.click(screen.getByText("Account")); - + // Wait for the content to update await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Account" }), + ).toBeInTheDocument(); }); - + // This test verifies the Account tab navigation works }); }); From 035e3ddef188ac7a850016a0485c6ee4c383a1b5 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 22:50:17 +0100 Subject: [PATCH 25/47] style: Improve code formatting in store and tests - Better formatting for multiline function parameters in store/index.ts - Improved object literal formatting in notificationSounds.test.ts - Consistent code style following project formatting standards --- src/store/index.ts | 20 +++++----- tests/lib/notificationSounds.test.ts | 56 +++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index d39f4dd3..0501ceb3 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -276,13 +276,7 @@ export interface AppState { }[]; } >; // batchId -> batch info - activeBatches: Record< - string, - Record< - string, - BatchInfo - > - >; // serverId -> batchId -> batch info + activeBatches: Record>; // serverId -> batchId -> batch info // Account registration state pendingRegistration: { serverId: string; @@ -3610,7 +3604,11 @@ ircClient.on("BATCH_END", ({ serverId, batchId }) => { }); // Helper function to process netsplit batches -function processBatchedNetsplit(serverId: string, batchId: string, batch: BatchInfo) { +function processBatchedNetsplit( + serverId: string, + batchId: string, + batch: BatchInfo, +) { const store = useStore.getState(); const batch_info = store.activeBatches[serverId]?.[batchId]; if (!batch_info) return; @@ -3681,7 +3679,11 @@ function processBatchedNetsplit(serverId: string, batchId: string, batch: BatchI } // Helper function to process netjoin batches -function processBatchedNetjoin(serverId: string, batchId: string, batch: BatchInfo) { +function processBatchedNetjoin( + serverId: string, + batchId: string, + batch: BatchInfo, +) { const store = useStore.getState(); const batch_info = store.activeBatches[serverId]?.[batchId]; if (!batch_info) return; diff --git a/tests/lib/notificationSounds.test.ts b/tests/lib/notificationSounds.test.ts index a495e384..9e36adcc 100644 --- a/tests/lib/notificationSounds.test.ts +++ b/tests/lib/notificationSounds.test.ts @@ -41,8 +41,18 @@ describe("notificationSounds", () => { beforeEach(() => { // Mock globals global.window = global.window || {}; - (global.window as unknown as { AudioContext: unknown; webkitAudioContext: unknown }).AudioContext = vi.fn(() => mockAudioContext); - (global.window as unknown as { AudioContext: unknown; webkitAudioContext: unknown }).webkitAudioContext = vi.fn(() => mockAudioContext); + ( + global.window as unknown as { + AudioContext: unknown; + webkitAudioContext: unknown; + } + ).AudioContext = vi.fn(() => mockAudioContext); + ( + global.window as unknown as { + AudioContext: unknown; + webkitAudioContext: unknown; + } + ).webkitAudioContext = vi.fn(() => mockAudioContext); (global as unknown as { Audio: unknown }).Audio = vi.fn(() => mockAudio); (global as unknown as { URL: unknown }).URL = mockURL; @@ -118,7 +128,11 @@ describe("notificationSounds", () => { const mockCurrentUser = { username: "testuser" }; it("should return false if notification sounds are disabled", () => { - const message = { userId: "otheruser", content: "Hello testuser!", type: "message" }; + const message = { + userId: "otheruser", + content: "Hello testuser!", + type: "message", + }; const globalSettings = { enableNotificationSounds: false, enableHighlights: true, @@ -134,7 +148,11 @@ describe("notificationSounds", () => { }); it("should return false for messages from current user", () => { - const message = { userId: "testuser", content: "Hello everyone!", type: "message" }; + const message = { + userId: "testuser", + content: "Hello everyone!", + type: "message", + }; const globalSettings = { enableNotificationSounds: true, enableHighlights: true, @@ -150,7 +168,11 @@ describe("notificationSounds", () => { }); it("should return true for mentions when highlights are enabled", () => { - const message = { userId: "otheruser", content: "Hello testuser!", type: "message" }; + const message = { + userId: "otheruser", + content: "Hello testuser!", + type: "message", + }; const globalSettings = { enableNotificationSounds: true, enableHighlights: true, @@ -166,7 +188,11 @@ describe("notificationSounds", () => { }); it("should return false for non-mentions when highlights are enabled", () => { - const message = { userId: "otheruser", content: "Hello everyone!", type: "message" }; + const message = { + userId: "otheruser", + content: "Hello everyone!", + type: "message", + }; const globalSettings = { enableNotificationSounds: true, enableHighlights: true, @@ -182,7 +208,11 @@ describe("notificationSounds", () => { }); it("should return true for all messages when highlights are disabled", () => { - const message = { userId: "otheruser", content: "Hello everyone!", type: "message" }; + const message = { + userId: "otheruser", + content: "Hello everyone!", + type: "message", + }; const globalSettings = { enableNotificationSounds: true, enableHighlights: false, @@ -198,7 +228,11 @@ describe("notificationSounds", () => { }); it("should detect mentions case-insensitively", () => { - const message = { userId: "otheruser", content: "Hello TESTUSER!", type: "message" }; + const message = { + userId: "otheruser", + content: "Hello TESTUSER!", + type: "message", + }; const globalSettings = { enableNotificationSounds: true, enableHighlights: true, @@ -214,7 +248,11 @@ describe("notificationSounds", () => { }); it("should handle null current user gracefully", () => { - const message = { userId: "otheruser", content: "Hello everyone!", type: "message" }; + const message = { + userId: "otheruser", + content: "Hello everyone!", + type: "message", + }; const globalSettings = { enableNotificationSounds: true, enableHighlights: true, From 25a8574d9d066724d75b7f4ed1439afdc410efa9 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 23:06:56 +0100 Subject: [PATCH 26/47] Fix TypeScript compilation errors and test isolation issues - Add missing interface properties for strict TypeScript compliance - Fix User interface: add required isOnline property - Fix UIState interface: add isChannelListModalOpen and isChannelRenameModalOpen - Fix Message interface: add reactions, replyMessage, mentioned properties - Convert timestamp strings to Date objects in test fixtures - Fix MediaQueryList type in test setup - Add proper test isolation with state cleanup in App.test.tsx - Add waitFor for async connection error handling - All tests now pass (181 passing, 1 skipped) - Build process now completes without TypeScript errors --- tests/App.test.tsx | 77 +++++++++++++++++++++-- tests/components/ChatArea.test.tsx | 2 + tests/components/MetadataDisplay.test.tsx | 23 +++++-- tests/setup.ts | 2 +- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/tests/App.test.tsx b/tests/App.test.tsx index b2d0467a..bbe55953 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import App from "../src/App"; @@ -30,6 +30,56 @@ describe("App", () => { afterEach(() => { vi.clearAllMocks(); + // Reset store state to prevent test interference + useStore.setState({ + servers: [], + currentUser: null, + isConnecting: false, + selectedServerId: null, + connectionError: null, + messages: {}, + typingUsers: {}, + ui: { + selectedServerId: null, + selectedChannelId: null, + selectedPrivateChatId: null, + isAddServerModalOpen: false, + isSettingsModalOpen: false, + isUserProfileModalOpen: false, + isDarkMode: true, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + mobileViewActiveColumn: "serverList", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + }, + globalNotifications: [], + globalSettings: { + enableNotifications: true, + notificationSound: "pop", + enableNotificationSounds: true, + enableHighlights: true, + sendTypingNotifications: true, + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + customMentions: [], + nickname: "", + accountName: "", + accountPassword: "", + }, + }); }); describe("Server Management", () => { @@ -52,7 +102,17 @@ describe("App", () => { const user = userEvent.setup(); // Mock successful connection - vi.mocked(ircClient.connect).mockResolvedValueOnce(); + vi.mocked(ircClient.connect).mockResolvedValueOnce({ + id: "test-server", + name: "Test Server", + host: "irc.test.com", + port: 443, + channels: [], + privateChats: [], + isConnected: true, + users: [], + capabilities: [], + }); // Open modal and fill form await user.click(screen.getByTestId("server-list-options-button")); @@ -104,6 +164,11 @@ describe("App", () => { await user.click(screen.getByTestId("server-list-options-button")); await user.click(screen.getByText(/Add Server/i)); + // Wait for modal to be open + await waitFor(() => { + expect(screen.getByPlaceholderText(/ExampleNET/i)).toBeInTheDocument(); + }); + await user.type( screen.getByPlaceholderText(/ExampleNET/i), "Test Server", @@ -117,8 +182,10 @@ describe("App", () => { // Submit form await user.click(screen.getByRole("button", { name: /^connect$/i })); - // Verify error message - expect(screen.getByText("Connection failed")).toBeInTheDocument(); + // Verify error message appears after async connection failure + await waitFor(() => { + expect(screen.getByText("Connection failed")).toBeInTheDocument(); + }); }); }); @@ -129,7 +196,7 @@ describe("App", () => { // Setup initial state with a user useStore.setState({ - currentUser: { id: "user1", username: "testuser" }, + currentUser: { id: "user1", username: "testuser", isOnline: true }, }); // Open settings diff --git a/tests/components/ChatArea.test.tsx b/tests/components/ChatArea.test.tsx index bd46ede5..627e0496 100644 --- a/tests/components/ChatArea.test.tsx +++ b/tests/components/ChatArea.test.tsx @@ -71,6 +71,8 @@ describe("ChatArea Tab Completion Integration", () => { isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, mobileViewActiveColumn: "serverList", isServerMenuOpen: false, contextMenu: { diff --git a/tests/components/MetadataDisplay.test.tsx b/tests/components/MetadataDisplay.test.tsx index 821adafa..d3bb1543 100644 --- a/tests/components/MetadataDisplay.test.tsx +++ b/tests/components/MetadataDisplay.test.tsx @@ -70,19 +70,25 @@ const mockChannel: Channel = { id: "msg1", userId: "alice-server1", content: "Hello everyone!", - timestamp: new Date().toISOString(), - type: "message", + timestamp: new Date(), + type: "message" as const, serverId: "server1", channelId: "channel1", + reactions: [], + replyMessage: null, + mentioned: [], }, { id: "msg2", userId: "bob-server1", content: "Hi Alice!", - timestamp: new Date().toISOString(), - type: "message", + timestamp: new Date(), + type: "message" as const, serverId: "server1", channelId: "channel1", + reactions: [], + replyMessage: null, + mentioned: [], }, ], users: mockUsersWithMetadata, @@ -127,6 +133,8 @@ describe("Metadata Display Features", () => { isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, mobileViewActiveColumn: "serverList", isServerMenuOpen: false, contextMenu: { @@ -314,10 +322,13 @@ describe("Metadata Display Features", () => { id: "msg3", userId: "alice-server1", content: "\u0001ACTION waves hello\u0001", - timestamp: new Date().toISOString(), - type: "message", + timestamp: new Date(), + type: "message" as const, serverId: "server1", channelId: "channel1", + reactions: [], + replyMessage: null, + mentioned: [], }; useStore.setState((state) => ({ diff --git a/tests/setup.ts b/tests/setup.ts index 11e5c1c1..4c1e70d4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -6,4 +6,4 @@ window.matchMedia = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), -})) as unknown as MediaQueryList; +})) as unknown as (query: string) => MediaQueryList; From f61af0c424af2cd2d69e780292ae8d9c883e92fa Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 23:16:35 +0100 Subject: [PATCH 27/47] Use alt nick if nick taken --- src/store/index.ts | 46 +++++++++++++- tests/lib/nicknameRetry.test.ts | 102 ++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/lib/nicknameRetry.test.ts diff --git a/src/store/index.ts b/src/store/index.ts index 0501ceb3..d81f27f5 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2710,7 +2710,51 @@ ircClient.on("REDACT", ({ serverId, target, msgid, sender }) => { // Nick error event handler ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { console.log(`[NICK_ERROR] ${code} ${error}: ${message}`); - // Add to global notifications for visibility + + // Handle 433 (nickname already in use) with automatic retry + if (code === "433" && nick) { + const newNick = nick + "_"; + console.log(`Nickname '${nick}' already in use, retrying with '${newNick}'`); + + // Attempt to change to the nick with underscore appended + ircClient.changeNick(serverId, newNick); + + // Add a system message about the retry + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server && state.ui.selectedChannelId) { + const channel = server.channels.find( + (c) => c.id === state.ui.selectedChannelId, + ); + if (channel) { + const retryMessage: Message = { + id: uuidv4(), + type: "system", + content: `Nickname '${nick}' already in use, retrying with '${newNick}'`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), retryMessage], + }, + })); + } + } + + // Don't show error notification for 433 since we're auto-retrying + return; + } + + // Add to global notifications for visibility (for other error codes) const state = useStore.getState(); state.addGlobalNotification({ type: "fail", diff --git a/tests/lib/nicknameRetry.test.ts b/tests/lib/nicknameRetry.test.ts new file mode 100644 index 00000000..814df6f6 --- /dev/null +++ b/tests/lib/nicknameRetry.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import ircClient from "../../src/lib/ircClient"; +import useStore from "../../src/store"; + +describe("Nickname retry functionality", () => { + it("should retry with underscore when receiving 433 error", () => { + // Mock the changeNick method + const changeNickSpy = vi.spyOn(ircClient, "changeNick"); + + // Mock the store state + const mockState = { + servers: [{ + id: "test-server", + channels: [{ + id: "test-channel", + name: "#test" + }] + }], + ui: { + selectedChannelId: "test-channel" + }, + addGlobalNotification: vi.fn() + }; + + // Mock useStore.getState to return our mock state + vi.spyOn(useStore, "getState").mockReturnValue(mockState as any); + vi.spyOn(useStore, "setState").mockImplementation(() => {}); + + // Simulate a 433 error event + const nickErrorEvent = { + serverId: "test-server", + code: "433", + error: "Nickname already in use", + nick: "testuser", + message: "Nickname is already in use" + }; + + // Trigger the NICK_ERROR event + ircClient.triggerEvent("NICK_ERROR", nickErrorEvent); + + // Verify that changeNick was called with the original nick + underscore + expect(changeNickSpy).toHaveBeenCalledWith("test-server", "testuser_"); + + // Verify that addGlobalNotification was NOT called for 433 errors (since we auto-retry) + expect(mockState.addGlobalNotification).not.toHaveBeenCalled(); + + // Clean up + changeNickSpy.mockRestore(); + }); + + it("should not retry for other error codes", () => { + // Mock the changeNick method + const changeNickSpy = vi.spyOn(ircClient, "changeNick"); + + // Mock the store state with addGlobalNotification method + const mockState = { + servers: [{ + id: "test-server", + channels: [{ + id: "test-channel", + name: "#test" + }] + }], + ui: { + selectedChannelId: "test-channel" + }, + addGlobalNotification: vi.fn() + }; + + // Mock useStore methods + vi.spyOn(useStore, "getState").mockReturnValue(mockState as any); + vi.spyOn(useStore, "setState").mockImplementation(() => {}); + + // Simulate a 432 error event (invalid nickname) + const nickErrorEvent = { + serverId: "test-server", + code: "432", + error: "Invalid nickname", + nick: "testuser", + message: "Invalid nickname format" + }; + + // Trigger the NICK_ERROR event + ircClient.triggerEvent("NICK_ERROR", nickErrorEvent); + + // Verify that changeNick was NOT called for non-433 errors + expect(changeNickSpy).not.toHaveBeenCalled(); + + // Verify that addGlobalNotification WAS called for other error codes + expect(mockState.addGlobalNotification).toHaveBeenCalledWith({ + type: "fail", + command: "NICK", + code: "432", + message: "Invalid nickname: Invalid nickname format", + target: "testuser", + serverId: "test-server" + }); + + // Clean up + changeNickSpy.mockRestore(); + }); +}); \ No newline at end of file From aa47c9fb197e934551cc2fed929610530360b365 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 23:35:11 +0100 Subject: [PATCH 28/47] Rely on 001 to know our nick at connect time --- src/lib/ircClient.ts | 7 +- src/store/index.ts | 32 +++++-- tests/lib/nicknameRetry.test.ts | 164 +++++++++++++++++++++++--------- 3 files changed, 148 insertions(+), 55 deletions(-) diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 5b7f9180..c3092a64 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -543,7 +543,12 @@ export class IRCClient { console.log(`PONG sent to server ${serverId} with key ${key}`); } else if (command === "001") { const serverName = source; - const nickname = parv.join(" "); + const nickname = parv[0]; // Our actual nick as assigned by the server + + // Update our stored nick to match what the server assigned us + this.nicks.set(serverId, nickname); + console.log(`[DEBUG] 001 received: Server assigned us nick "${nickname}"`); + this.triggerEvent("ready", { serverId, serverName, nickname }); } else if (command === "NICK") { console.log("triggered nickchange"); diff --git a/src/store/index.ts b/src/store/index.ts index d81f27f5..67ce069b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1759,6 +1759,8 @@ ircClient.on("USERNOTICE", (response) => { }); ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { + console.log(`[DEBUG] JOIN event: ${username} joined ${channelName} on server ${serverId}`); + // If this event is part of a batch, store it for later processing if (batchTag) { const state = useStore.getState(); @@ -1847,9 +1849,14 @@ ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { // If we joined a channel, request channel information const ourNick = ircClient.getNick(serverId); + console.log(`[DEBUG] Our nick: "${ourNick}", joining user: "${username}"`); if (username === ourNick) { - // Only request topic - user list comes from WHO responses + console.log(`[DEBUG] We joined channel ${channelName}, requesting TOPIC and WHO`); + // Request topic and user list ircClient.sendRaw(serverId, `TOPIC ${channelName}`); + ircClient.sendRaw(serverId, `WHO ${channelName}`); + } else { + console.log(`[DEBUG] Someone else joined: ${username} !== ${ourNick}`); } // Add join message if settings allow @@ -2710,15 +2717,17 @@ ircClient.on("REDACT", ({ serverId, target, msgid, sender }) => { // Nick error event handler ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { console.log(`[NICK_ERROR] ${code} ${error}: ${message}`); - + // Handle 433 (nickname already in use) with automatic retry if (code === "433" && nick) { - const newNick = nick + "_"; - console.log(`Nickname '${nick}' already in use, retrying with '${newNick}'`); - + const newNick = `${nick}_`; + console.log( + `Nickname '${nick}' already in use, retrying with '${newNick}'`, + ); + // Attempt to change to the nick with underscore appended ircClient.changeNick(serverId, newNick); - + // Add a system message about the retry const state = useStore.getState(); const server = state.servers.find((s) => s.id === serverId); @@ -2749,11 +2758,11 @@ ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { })); } } - + // Don't show error notification for 433 since we're auto-retrying return; } - + // Add to global notifications for visibility (for other error codes) const state = useStore.getState(); state.addGlobalNotification({ @@ -3461,13 +3470,18 @@ ircClient.on( hopcount, realname, }) => { + console.log(`[DEBUG] WHO_REPLY received: ${nick} in ${channel} on server ${serverId}`); const state = useStore.getState(); const serverData = state.servers.find((s) => s.id === serverId); if (!serverData) return; // Find the channel this WHO reply belongs to const channelData = serverData.channels.find((c) => c.name === channel); - if (!channelData) return; + if (!channelData) { + console.log(`[DEBUG] Channel ${channel} not found for WHO_REPLY on server ${serverId}`); + return; + } + console.log(`[DEBUG] Found channel ${channel} for WHO_REPLY, adding user ${nick}`); // Create user object from WHO data with proper User type const user: User = { diff --git a/tests/lib/nicknameRetry.test.ts b/tests/lib/nicknameRetry.test.ts index 814df6f6..d83a6f9e 100644 --- a/tests/lib/nicknameRetry.test.ts +++ b/tests/lib/nicknameRetry.test.ts @@ -1,102 +1,176 @@ import { describe, expect, it, vi } from "vitest"; import ircClient from "../../src/lib/ircClient"; -import useStore from "../../src/store"; +import useStore, { type AppState } from "../../src/store"; describe("Nickname retry functionality", () => { it("should retry with underscore when receiving 433 error", () => { // Mock the changeNick method const changeNickSpy = vi.spyOn(ircClient, "changeNick"); - - // Mock the store state - const mockState = { - servers: [{ - id: "test-server", - channels: [{ - id: "test-channel", - name: "#test" - }] - }], + + // Mock the store state with minimal required properties + const mockState: Partial = { + servers: [ + { + id: "test-server", + name: "Test Server", + host: "test.com", + port: 6667, + isConnected: true, + channels: [ + { + id: "test-channel", + name: "#test", + isPrivate: false, + serverId: "test-server", + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + }, + ], + privateChats: [], + users: [], + }, + ], ui: { - selectedChannelId: "test-channel" + selectedServerId: "test-server", + selectedChannelId: "test-channel", + selectedPrivateChatId: null, + isAddServerModalOpen: false, + isSettingsModalOpen: false, + isUserProfileModalOpen: false, + isDarkMode: false, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + mobileViewActiveColumn: "chatView", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, }, - addGlobalNotification: vi.fn() + addGlobalNotification: vi.fn(), }; - + // Mock useStore.getState to return our mock state - vi.spyOn(useStore, "getState").mockReturnValue(mockState as any); + vi.spyOn(useStore, "getState").mockReturnValue(mockState as AppState); vi.spyOn(useStore, "setState").mockImplementation(() => {}); - + // Simulate a 433 error event const nickErrorEvent = { serverId: "test-server", code: "433", error: "Nickname already in use", nick: "testuser", - message: "Nickname is already in use" + message: "Nickname is already in use", }; - + // Trigger the NICK_ERROR event ircClient.triggerEvent("NICK_ERROR", nickErrorEvent); - + // Verify that changeNick was called with the original nick + underscore expect(changeNickSpy).toHaveBeenCalledWith("test-server", "testuser_"); - + // Verify that addGlobalNotification was NOT called for 433 errors (since we auto-retry) expect(mockState.addGlobalNotification).not.toHaveBeenCalled(); - + // Clean up changeNickSpy.mockRestore(); }); - + it("should not retry for other error codes", () => { // Mock the changeNick method const changeNickSpy = vi.spyOn(ircClient, "changeNick"); - + // Mock the store state with addGlobalNotification method - const mockState = { - servers: [{ - id: "test-server", - channels: [{ - id: "test-channel", - name: "#test" - }] - }], + const mockState: Partial = { + servers: [ + { + id: "test-server", + name: "Test Server", + host: "test.com", + port: 6667, + isConnected: true, + channels: [ + { + id: "test-channel", + name: "#test", + isPrivate: false, + serverId: "test-server", + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + }, + ], + privateChats: [], + users: [], + }, + ], ui: { - selectedChannelId: "test-channel" + selectedServerId: "test-server", + selectedChannelId: "test-channel", + selectedPrivateChatId: null, + isAddServerModalOpen: false, + isSettingsModalOpen: false, + isUserProfileModalOpen: false, + isDarkMode: false, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + mobileViewActiveColumn: "chatView", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, }, - addGlobalNotification: vi.fn() + addGlobalNotification: vi.fn(), }; - + // Mock useStore methods - vi.spyOn(useStore, "getState").mockReturnValue(mockState as any); + vi.spyOn(useStore, "getState").mockReturnValue(mockState as AppState); vi.spyOn(useStore, "setState").mockImplementation(() => {}); - + // Simulate a 432 error event (invalid nickname) const nickErrorEvent = { - serverId: "test-server", + serverId: "test-server", code: "432", error: "Invalid nickname", nick: "testuser", - message: "Invalid nickname format" + message: "Invalid nickname format", }; - + // Trigger the NICK_ERROR event ircClient.triggerEvent("NICK_ERROR", nickErrorEvent); - + // Verify that changeNick was NOT called for non-433 errors expect(changeNickSpy).not.toHaveBeenCalled(); - + // Verify that addGlobalNotification WAS called for other error codes expect(mockState.addGlobalNotification).toHaveBeenCalledWith({ type: "fail", - command: "NICK", + command: "NICK", code: "432", message: "Invalid nickname: Invalid nickname format", target: "testuser", - serverId: "test-server" + serverId: "test-server", }); - + // Clean up changeNickSpy.mockRestore(); }); -}); \ No newline at end of file +}); From 7376cf4de433da4f3a710ee0cc08cb1b500c6fb2 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 23:36:15 +0100 Subject: [PATCH 29/47] Rely on 001 to know our nick at connect time --- src/lib/ircClient.ts | 8 +++++--- src/store/index.ts | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index c3092a64..da03b301 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -544,11 +544,13 @@ export class IRCClient { } else if (command === "001") { const serverName = source; const nickname = parv[0]; // Our actual nick as assigned by the server - + // Update our stored nick to match what the server assigned us this.nicks.set(serverId, nickname); - console.log(`[DEBUG] 001 received: Server assigned us nick "${nickname}"`); - + console.log( + `[DEBUG] 001 received: Server assigned us nick "${nickname}"`, + ); + this.triggerEvent("ready", { serverId, serverName, nickname }); } else if (command === "NICK") { console.log("triggered nickchange"); diff --git a/src/store/index.ts b/src/store/index.ts index 67ce069b..dcf283bf 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1759,8 +1759,10 @@ ircClient.on("USERNOTICE", (response) => { }); ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { - console.log(`[DEBUG] JOIN event: ${username} joined ${channelName} on server ${serverId}`); - + console.log( + `[DEBUG] JOIN event: ${username} joined ${channelName} on server ${serverId}`, + ); + // If this event is part of a batch, store it for later processing if (batchTag) { const state = useStore.getState(); @@ -1851,7 +1853,9 @@ ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { const ourNick = ircClient.getNick(serverId); console.log(`[DEBUG] Our nick: "${ourNick}", joining user: "${username}"`); if (username === ourNick) { - console.log(`[DEBUG] We joined channel ${channelName}, requesting TOPIC and WHO`); + console.log( + `[DEBUG] We joined channel ${channelName}, requesting TOPIC and WHO`, + ); // Request topic and user list ircClient.sendRaw(serverId, `TOPIC ${channelName}`); ircClient.sendRaw(serverId, `WHO ${channelName}`); @@ -3470,7 +3474,9 @@ ircClient.on( hopcount, realname, }) => { - console.log(`[DEBUG] WHO_REPLY received: ${nick} in ${channel} on server ${serverId}`); + console.log( + `[DEBUG] WHO_REPLY received: ${nick} in ${channel} on server ${serverId}`, + ); const state = useStore.getState(); const serverData = state.servers.find((s) => s.id === serverId); if (!serverData) return; @@ -3478,10 +3484,14 @@ ircClient.on( // Find the channel this WHO reply belongs to const channelData = serverData.channels.find((c) => c.name === channel); if (!channelData) { - console.log(`[DEBUG] Channel ${channel} not found for WHO_REPLY on server ${serverId}`); + console.log( + `[DEBUG] Channel ${channel} not found for WHO_REPLY on server ${serverId}`, + ); return; } - console.log(`[DEBUG] Found channel ${channel} for WHO_REPLY, adding user ${nick}`); + console.log( + `[DEBUG] Found channel ${channel} for WHO_REPLY, adding user ${nick}`, + ); // Create user object from WHO data with proper User type const user: User = { From 8703825f33efb128f3ade618e72f7fd4d8e23c5c Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Wed, 1 Oct 2025 23:55:28 +0100 Subject: [PATCH 30/47] Better self-nick tracking --- src/components/layout/ChannelList.tsx | 31 +++++++++--- src/lib/ircClient.ts | 10 ++++ src/store/index.ts | 73 ++++++++++++++++++++------- 3 files changed, 90 insertions(+), 24 deletions(-) diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index ea07e9de..ac65f09c 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -23,6 +23,7 @@ export const ChannelList: React.FC<{ }> = ({ onToggle }: { onToggle: () => void }) => { const { servers, + currentUser: globalCurrentUser, ui: { selectedServerId, selectedChannelId, selectedPrivateChatId }, selectChannel, selectPrivateChat, @@ -40,6 +41,14 @@ export const ChannelList: React.FC<{ const ircCurrentUser = ircClient.getCurrentUser(selectedServerId); if (!ircCurrentUser) return null; + // First, check if we have a global current user with metadata for this username + if ( + globalCurrentUser && + globalCurrentUser.username === ircCurrentUser.username + ) { + return globalCurrentUser; + } + // Find the current user in the server's channel data to get metadata const selectedServer = servers.find((s) => s.id === selectedServerId); if (!selectedServer) return ircCurrentUser; @@ -56,7 +65,7 @@ export const ChannelList: React.FC<{ // If not found in channels, return the basic IRC user return ircCurrentUser; - }, [selectedServerId, servers]); + }, [selectedServerId, servers, globalCurrentUser]); const [isTextChannelsOpen, setIsTextChannelsOpen] = useState(true); const [isVoiceChannelsOpen, setIsVoiceChannelsOpen] = useState(true); @@ -70,10 +79,18 @@ export const ChannelList: React.FC<{ (server) => server.id === selectedServerId, ); - // Reset avatar load failed state when user changes or server changes + // Reset avatar load failed state when user or server changes + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to reset when user/server changes useEffect(() => { setAvatarLoadFailed(false); - }, []); + }, [currentUser?.username, selectedServerId]); + + // Get user status from metadata or fallback to direct property + const userStatus = useMemo(() => { + return ( + currentUser?.metadata?.status?.value || currentUser?.status || "offline" + ); + }, [currentUser]); const handleAddChannel = () => { if (selectedServerId && newChannelName.trim()) { @@ -412,7 +429,7 @@ export const ChannelList: React.FC<{ )}
@@ -420,11 +437,11 @@ export const ChannelList: React.FC<{ {currentUser?.username || "User"}
- {currentUser?.status === "online" + {userStatus === "online" ? "Online" - : currentUser?.status === "idle" + : userStatus === "idle" ? "Idle" - : currentUser?.status === "dnd" + : userStatus === "dnd" ? "Do Not Disturb" : "Offline"}
diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index da03b301..a1a63068 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -547,6 +547,16 @@ export class IRCClient { // Update our stored nick to match what the server assigned us this.nicks.set(serverId, nickname); + + // Update current user's username to match server-assigned nick + const currentUser = this.currentUsers.get(serverId); + if (currentUser) { + this.currentUsers.set(serverId, { + ...currentUser, + username: nickname, + }); + } + console.log( `[DEBUG] 001 received: Server assigned us nick "${nickname}"`, ); diff --git a/src/store/index.ts b/src/store/index.ts index dcf283bf..9f975b27 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2195,10 +2195,30 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { }); const ircCurrentUser = ircClient.getCurrentUser(serverId); - const updatedCurrentUser = - state.currentUser && ircCurrentUser - ? { ...ircCurrentUser, metadata: state.currentUser.metadata } - : ircCurrentUser || state.currentUser; + let updatedCurrentUser = state.currentUser; + + if (ircCurrentUser) { + // Get saved metadata for this user on this server + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + const userMetadata = serverMetadata?.[ircCurrentUser.username] || {}; + + // Create current user with IRC data and any saved metadata + updatedCurrentUser = { + ...ircCurrentUser, + metadata: { + ...(state.currentUser?.metadata || {}), + ...userMetadata, + }, + }; + + console.log( + `[READY] Set current user for server ${serverId}:`, + updatedCurrentUser.username, + "with metadata:", + updatedCurrentUser.metadata, + ); + } return { servers: updatedServers, @@ -3082,26 +3102,45 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { return server; }); - // Update current user metadata only if this is for the currently selected server + // Update current user metadata if the target matches any connected user let updatedCurrentUser = state.currentUser; - const isSelectedServer = state.ui.selectedServerId === serverId; const currentUserForServer = ircClient.getCurrentUser(serverId); + // Check if this metadata is for the current user on this server if ( - isSelectedServer && currentUserForServer && - state.currentUser?.username === resolvedTarget + currentUserForServer.username === resolvedTarget ) { - const metadata = { ...(state.currentUser.metadata || {}) }; - if (value) { - metadata[key] = { value, visibility }; - } else { - delete metadata[key]; + // If this is the first time setting current user or it's for the selected server, update global state + if (!updatedCurrentUser || state.ui.selectedServerId === serverId) { + const metadata = { ...(currentUserForServer.metadata || {}) }; + if (value) { + metadata[key] = { value, visibility }; + } else { + delete metadata[key]; + } + updatedCurrentUser = { ...currentUserForServer, metadata }; + console.log( + `[METADATA] Updated current user ${resolvedTarget} on server ${serverId} with ${key}=${value}`, + ); + } + // If there's already a current user but it's for a different server, + // still update if this is the selected server or if there's no current user + else if ( + state.currentUser && + state.currentUser.username === resolvedTarget + ) { + const metadata = { ...(state.currentUser.metadata || {}) }; + if (value) { + metadata[key] = { value, visibility }; + } else { + delete metadata[key]; + } + updatedCurrentUser = { ...state.currentUser, metadata }; + console.log( + `[METADATA] Updated global current user ${resolvedTarget} with ${key}=${value}`, + ); } - updatedCurrentUser = { ...state.currentUser, metadata }; - console.log( - `[METADATA] Updated current user ${resolvedTarget} with ${key}=${value}`, - ); } // Save metadata to localStorage From 8b0ecf3d88293060ca344c6acd56ed89cefbfe33 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 00:50:42 +0100 Subject: [PATCH 31/47] Various improvement --- src/lib/ircClient.ts | 359 +++++++++++++++++++++++++++++++++++++------ src/store/index.ts | 19 --- 2 files changed, 309 insertions(+), 69 deletions(-) diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index a1a63068..3401dbc6 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -170,7 +170,10 @@ export class IRCClient { private saslMechanisms: Map = new Map(); private capLsAccumulated: Map> = new Map(); private saslEnabled: Map = new Map(); + private saslCredentials: Map = + new Map(); private pendingConnections: Map> = new Map(); + private pendingCapReqs: Map = new Map(); // Track how many CAP REQ batches are pending ACK private eventCallbacks: { [K in EventKey]?: EventCallback[]; @@ -231,6 +234,27 @@ export class IRCClient { this.servers.set(server.id, server); this.sockets.set(server.id, socket); this.saslEnabled.set(server.id, !!_saslAccountName); + console.log( + `[SASL] SASL enabled for ${server.id}: ${!!_saslAccountName}`, + ); + console.log(`[SASL] SASL account name: ${_saslAccountName}`); + console.log(`[SASL] SASL password provided: ${!!_saslPassword}`); + + // Store SASL credentials if provided + if (_saslAccountName && _saslPassword) { + this.saslCredentials.set(server.id, { + username: _saslAccountName, + password: _saslPassword, + }); + console.log( + `[SASL] Stored SASL credentials for ${server.id}: ${_saslAccountName}`, + ); + } else { + console.log( + `[SASL] No SASL credentials stored for ${server.id} - account: ${_saslAccountName}, password: ${!!_saslPassword}`, + ); + } + this.currentUsers.set(server.id, { id: uuidv4(), username: nickname, @@ -499,23 +523,57 @@ export class IRCClient { } private handleMessage(data: string, serverId: string): void { - console.log(`IRC Message from serverId=${serverId}:`, data); - const lines = data.split("\r\n"); for (let line of lines) { let mtags: Record | undefined; let source: string; - const parv = []; + const parv: string[] = []; let i = 0; let l: string[]; line = line.trim(); - l = line.split(" ") ?? line; - if (l[i][0] === "@") { - mtags = parseMessageTags(l[i]); - i++; + // Skip empty lines + if (!line) continue; + + // Debug: Log ALL lines that contain CAP to see if CAP ACK is even being processed + if (line.includes("CAP")) { + console.log(`[HANDLE-MSG] Processing line: '${line}'`); + } + + // Debug: Log all incoming IRC messages + console.log(`[IRC] ${serverId}: ${line}`); + + // Handle message tags first, before splitting on trailing parameter + let lineAfterTags = line; + if (line[0] === "@") { + const spaceIndex = line.indexOf(" "); + if (spaceIndex !== -1) { + console.log( + `[MTAGS] Parsing message tags from: '${line.substring(0, spaceIndex)}', original line length: ${line.length}`, + ); + mtags = parseMessageTags(line.substring(0, spaceIndex)); + lineAfterTags = line.substring(spaceIndex + 1); + console.log( + `[MTAGS] After parsing tags, remaining line: '${lineAfterTags}'`, + ); + } } + // Parse IRC message properly handling colon-prefixed trailing parameter + const spaceIndex = lineAfterTags.indexOf(" :"); + let trailing = ""; + let mainPart = lineAfterTags; + + if (spaceIndex !== -1) { + trailing = lineAfterTags.substring(spaceIndex + 2); // Skip ' :' + mainPart = lineAfterTags.substring(0, spaceIndex); + } + + l = mainPart.split(" ").filter((part) => part.length > 0); + + // Ensure we have at least one element + if (l.length === 0) continue; + // Determine the source. if none, spoof as host server if (l[i][0] !== ":") { const thisServ = this.servers.get(serverId); @@ -535,6 +593,40 @@ export class IRCClient { for (i++; l[i]; i++) { parv.push(l[i]); } + + // Add trailing parameter if it exists + if (trailing) { + parv.push(trailing); + } + + // Debug: ALWAYS log when line contains @time and CAP + if (line.includes("@time") && line.includes("CAP")) { + console.log(`[DEBUG-ALWAYS] Line: '${line}'`); + console.log(`[DEBUG-ALWAYS] Command detected: '${command}'`); + console.log(`[DEBUG-ALWAYS] l array: ${JSON.stringify(l)}`); + console.log(`[DEBUG-ALWAYS] i when command detected: ${i - 1}`); + console.log(`[DEBUG-ALWAYS] mtags: ${JSON.stringify(mtags)}`); + console.log(`[DEBUG-ALWAYS] source: '${source}'`); + } + + // Debug: log command and parv for CAP messages + if (command === "CAP" || line.includes("CAP")) { + console.log( + `[DEBUG] Command: '${command}', Source: '${source}', Parv: ${JSON.stringify(parv)}, Trailing: '${trailing}'`, + ); + } + + // Debug: for message tags, show what l array looks like + if (line.includes("@time") && line.includes("CAP")) { + console.log(`[DEBUG-TAGS] Original line: '${line}'`); + console.log(`[DEBUG-TAGS] mainPart: '${mainPart}'`); + console.log(`[DEBUG-TAGS] trailing: '${trailing}'`); + console.log(`[DEBUG-TAGS] l array: ${JSON.stringify(l)}`); + console.log( + `[DEBUG-TAGS] i when command parsed: ${i - 1}, command: '${command}'`, + ); + } + const parc = parv.length; if (command === "PING") { @@ -557,10 +649,6 @@ export class IRCClient { }); } - console.log( - `[DEBUG] 001 received: Server assigned us nick "${nickname}"`, - ); - this.triggerEvent("ready", { serverId, serverName, nickname }); } else if (command === "NICK") { console.log("triggered nickchange"); @@ -641,8 +729,8 @@ export class IRCClient { const isChannel = target.startsWith("#"); const sender = getNickFromNuh(source); - parv[0] = ""; - const message = parv.join(" ").trim().substring(1); + // Message content is in parv[1] and onwards after target + const message = parv.slice(1).join(" "); if (isChannel) { const channelName = target; @@ -720,7 +808,7 @@ export class IRCClient { const user = getNickFromNuh(source); const oldName = parv[0]; const newName = parv[1]; - const reason = parv.slice(2).join(" ").substring(1); // Remove leading : + const reason = parv.slice(2).join(" "); // No need to remove leading : anymore this.triggerEvent("RENAME", { serverId, oldName, @@ -730,7 +818,7 @@ export class IRCClient { }); } else if (command === "SETNAME") { const user = getNickFromNuh(source); - const realname = parv.join(" ").substring(1); // Remove leading : + const realname = parv.join(" "); // No need to remove leading : anymore this.triggerEvent("SETNAME", { serverId, user, @@ -750,10 +838,22 @@ export class IRCClient { users: newUsers, }); } else if (command === "CAP") { + console.log( + `[CAP] Processing CAP command, parv: ${JSON.stringify(parv)}, trailing: "${trailing}"`, + ); + console.log(`[CAP] Received CAP message: ${parv.join(" ")}`); + console.log(`[CAP] Full parv array: ${JSON.stringify(parv)}`); + console.log(`[CAP] Trailing parameter: "${trailing}"`); let i = 0; let caps = ""; - if (parv[i] === "*") i++; + if (parv[i] === "*") { + console.log(`[CAP] Skipping * at position ${i}`); + i++; + } let subcommand = parv[i++]; + console.log( + `[CAP] Subcommand: '${subcommand}', i after increment: ${i}, parv length: ${parv.length}`, + ); // Handle CAP ACK which has nickname before subcommand if ( subcommand !== "LS" && @@ -767,17 +867,36 @@ export class IRCClient { } const isFinal = subcommand === "LS" && parv[i] !== "*"; if (parv[i] === "*") i++; - parv[i] = parv[i].substring(1); // trim the ":" lol - while (parv[i]) { - caps += parv[i++]; - if (parv[i]) caps += " "; + + // Build caps string - use trailing parameter if available, otherwise join remaining parv + if (trailing) { + caps = trailing; + } else { + while (parv[i]) { + caps += parv[i++]; + if (parv[i]) caps += " "; + } } + console.log(`[CAP] Final caps string: "${caps}"`); + if (subcommand === "LS") this.onCapLs(serverId, caps, isFinal); - else if (subcommand === "ACK") - this.triggerEvent("CAP ACK", { serverId, cliCaps: caps }); - else if (subcommand === "NEW") this.onCapNew(serverId, caps); + else if (subcommand === "ACK") { + console.log(`[CAP ACK] Received for ${serverId}: ${caps}`); + this.onCapAck(serverId, caps); + } else if (subcommand === "NAK") { + console.log( + `[CAP NAK] Server rejected capabilities for ${serverId}: ${caps}`, + ); + // Server rejected some capabilities, but we should still end CAP negotiation + this.sendRaw(serverId, "CAP END"); + } else if (subcommand === "NEW") this.onCapNew(serverId, caps); else if (subcommand === "DEL") this.onCapDel(serverId, caps); + else { + console.log( + `[CAP] Unknown subcommand '${subcommand}' for ${serverId}: ${caps}`, + ); + } } else if (command === "005") { const capabilities = parseIsupport(parv.join(" ")); console.log("ISUPPORT capabilities:", capabilities); @@ -797,6 +916,15 @@ export class IRCClient { } else if (command === "AUTHENTICATE") { const param = parv.join(" "); this.triggerEvent("AUTHENTICATE", { serverId, param }); + + // Handle SASL PLAIN authentication + if (param === "+") { + const creds = this.saslCredentials.get(serverId); + if (creds) { + console.log(`Sending SASL PLAIN credentials for ${serverId}`); + this.sendSaslPlain(serverId, creds.username, creds.password); + } + } } else if (command === "BATCH") { // BATCH +reference-tag type [parameters...] or BATCH -reference-tag const batchRef = parv[0]; @@ -823,21 +951,29 @@ export class IRCClient { }); } } else if (command === "METADATA") { - const target = parv[0]; - const key = parv[1]; - const visibility = parv[2]; - const value = parv.slice(3).join(" "); - // Remove leading : only if it exists - const cleanValue = value.startsWith(":") ? value.substring(1) : value; + // METADATA PARAM1 PARAM2 [PARAM3 PARAM4 etc optional params] :the actual value + // The trailing value is the last parameter, optional params can be between PARAM2 and value + const target = parv[0]; // PARAM1 + const key = parv[1]; // PARAM2 + + // The actual value is the last parameter (trailing parameter from original message) + const value = parv[parv.length - 1] || ""; + + // Everything between key and value are optional parameters (visibility, etc.) + const optionalParams = parv.length > 2 ? parv.slice(2, -1) : []; + + // For backward compatibility, assume first optional param is visibility if present + const visibility = optionalParams.length > 0 ? optionalParams[0] : ""; + console.log( - `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${cleanValue}`, + `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${value}, optionalParams=${optionalParams.join(" ")}`, ); this.triggerEvent("METADATA", { serverId, target, key, visibility, - value: cleanValue, + value, }); } else if (command === "760") { // RPL_WHOISKEYVALUE @@ -845,7 +981,7 @@ export class IRCClient { const target = parv[0]; const key = parv[1]; const visibility = parv[2]; - const value = parv.slice(3).join(" ").substring(1); + const value = parv.slice(3).join(" "); // No need to remove leading : anymore this.triggerEvent("METADATA_WHOIS", { serverId, target, @@ -925,20 +1061,38 @@ export class IRCClient { retryAfter, }); } else if (command === "FAIL" && parv[0] === "METADATA") { - // FAIL METADATA [] [] [] + // FAIL METADATA [] [] [] :[] // ERR_METADATATOOMANY, ERR_METADATATARGETINVALID, ERR_METADATANOACCESS, ERR_METADATANOKEY, ERR_METADATARATELIMITED const subcommand = parv[1]; // The METADATA subcommand that failed (SUB, SET, etc.) const code = parv[2]; // The error code + + // Check if the last parameter is a trailing message (starts with original ":") + // If so, the parameters before it are the optional params + let paramCount = parv.length; + let errorMessage = ""; + + // If there are more than 3 params and the last one doesn't look like a number, + // it's likely a trailing error message + if (paramCount > 3) { + const lastParam = parv[paramCount - 1]; + if (lastParam && Number.isNaN(Number.parseInt(lastParam, 10))) { + errorMessage = lastParam; + paramCount = paramCount - 1; // Don't count the error message as a regular param + } + } + let target: string | undefined; let key: string | undefined; let retryAfter: number | undefined; - if (parv[3]) target = parv[3]; - if (parv[4]) key = parv[4]; - if (parv[5] && code === "RATE_LIMITED") { + + if (paramCount > 3) target = parv[3]; + if (paramCount > 4) key = parv[4]; + if (paramCount > 5 && code === "RATE_LIMITED") { retryAfter = Number.parseInt(parv[5], 10); } + console.log( - `[IRC] Received METADATA FAIL: subcommand=${subcommand}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}`, + `[IRC] Received METADATA FAIL: subcommand=${subcommand}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}, message=${errorMessage}`, ); this.triggerEvent("METADATA_FAIL", { serverId, @@ -952,7 +1106,7 @@ export class IRCClient { // RPL_LIST: : const channelName = parv[1]; const userCount = parv[2] ? Number.parseInt(parv[2], 10) : 0; - const topic = parv.slice(3).join(" ").substring(1); // Remove leading : + const topic = parv.slice(3).join(" "); // No need to remove leading : anymore this.triggerEvent("LIST_CHANNEL", { serverId, channel: channelName, @@ -971,7 +1125,7 @@ export class IRCClient { const nick = parv[5]; const flags = parv[6]; const hopcount = parv[7]; - const realname = parv.slice(8).join(" ").substring(1); + const realname = parv.slice(8).join(" "); // No need to remove leading : anymore this.triggerEvent("WHO_REPLY", { serverId, channel, @@ -991,17 +1145,41 @@ export class IRCClient { // RPL_WHOISBOT: : const nick = parv[0]; const target = parv[1]; - const message = parv.slice(2).join(" ").substring(1); + const message = parv.slice(2).join(" "); // No need to remove leading : anymore this.triggerEvent("WHOIS_BOT", { serverId, nick, target, message }); } else if (command === "431") { // ERR_NONICKNAMEGIVEN: :No nickname given - const message = parv.join(" ").substring(1); + const message = parv.join(" "); // No need to remove leading : anymore this.triggerEvent("NICK_ERROR", { serverId, code: "431", error: "No nickname given", message, }); + } else if ( + command === "900" || + command === "901" || + command === "902" || + command === "903" + ) { + // SASL authentication successful + const message = parv.slice(2).join(" "); + console.log( + `SASL authentication successful for ${serverId}: ${message}`, + ); + // Finish capability negotiation + this.sendRaw(serverId, "CAP END"); + } else if ( + command === "904" || + command === "905" || + command === "906" || + command === "907" + ) { + // SASL authentication failed + const message = parv.slice(2).join(" "); + console.log(`SASL authentication failed for ${serverId}: ${message}`); + // Still finish capability negotiation even if SASL failed + this.sendRaw(serverId, "CAP END"); } else if (command === "432") { // ERR_ERRONEUSNICKNAME: :Erroneous nickname const nick = parv[1]; @@ -1182,25 +1360,73 @@ export class IRCClient { if (isFinal) { // Now request the caps we want from the accumulated list - let toRequest = "CAP REQ :"; + const capsToRequest: string[] = []; const saslEnabled = this.saslEnabled.get(serverId) ?? false; for (const cap of accumulated) { if ( (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) && (cap !== "sasl" || saslEnabled) ) { - if (toRequest.length + cap.length + 1 > 400) { - this.sendRaw(serverId, toRequest); - toRequest = "CAP REQ :"; - } - toRequest += `${cap} `; + capsToRequest.push(cap); console.log(`Requesting capability: ${cap}`); } } - if (toRequest.length > 9) { - this.sendRaw(serverId, toRequest); - if (toRequest.includes("draft/extended-isupport")) + + if (capsToRequest.length > 0) { + // Send capabilities in batches to avoid IRC line length limits (512 bytes) + let currentBatch: string[] = []; + const baseLength = "CAP REQ :".length + 2; // +2 for \r\n + let currentLength = baseLength; + let batchCount = 0; + + for (const cap of capsToRequest) { + const capLength = cap.length + (currentBatch.length > 0 ? 1 : 0); // +1 for space if not first + + if (currentLength + capLength > 500 && currentBatch.length > 0) { + // Leave some margin + // Send current batch + const reqMessage = `CAP REQ :${currentBatch.join(" ")}`; + console.log( + `Sending CAP REQ batch ${batchCount + 1} (${reqMessage.length} chars): ${reqMessage}`, + ); + this.sendRaw(serverId, reqMessage); + batchCount++; + currentBatch = []; + currentLength = baseLength; + } + + currentBatch.push(cap); + currentLength += capLength; + } + + // Send remaining batch + if (currentBatch.length > 0) { + const reqMessage = `CAP REQ :${currentBatch.join(" ")}`; + console.log( + `Sending CAP REQ batch ${batchCount + 1} (${reqMessage.length} chars): ${reqMessage}`, + ); + this.sendRaw(serverId, reqMessage); + batchCount++; + } + + // Track how many CAP REQ batches we sent + this.pendingCapReqs.set(serverId, batchCount); + console.log(`Sent ${batchCount} CAP REQ batches for ${serverId}`); + + // Set a timeout to send CAP END if server doesn't respond + setTimeout(() => { + if (this.pendingCapReqs.has(serverId)) { + console.log( + `[CAP] Timeout waiting for CAP ACK from ${serverId}, sending CAP END`, + ); + this.pendingCapReqs.delete(serverId); + this.sendRaw(serverId, "CAP END"); + } + }, 5000); // 5 second timeout + + if (capsToRequest.includes("draft/extended-isupport")) { this.sendRaw(serverId, "ISUPPORT"); + } } console.log( `Server ${serverId} supports capabilities: ${Array.from(accumulated).join(" ")}`, @@ -1237,6 +1463,39 @@ export class IRCClient { } } + onCapAck(serverId: string, cliCaps: string): void { + console.log(`[CAP ACK] onCapAck called for ${serverId}: ${cliCaps}`); + + // Trigger the original event for compatibility + this.triggerEvent("CAP ACK", { serverId, cliCaps }); + + // Decrement pending CAP REQ count + const pendingCount = this.pendingCapReqs.get(serverId) || 0; + if (pendingCount > 0) { + const newCount = pendingCount - 1; + console.log( + `[CAP ACK] ${serverId}: ${pendingCount} -> ${newCount} pending batches`, + ); + + if (newCount === 0) { + // All CAP REQ batches acknowledged + this.pendingCapReqs.delete(serverId); + + // Note: SASL authentication is handled by the store's event handlers + // The store will check capabilities and initiate SASL if needed + console.log( + `[CAP ACK] All capability batches acknowledged for ${serverId}, SASL handled by store`, + ); + } else { + this.pendingCapReqs.set(serverId, newCount); + } + } else { + console.log( + `[CAP ACK] Warning: Received CAP ACK for ${serverId} but no pending requests`, + ); + } + } + on(event: K, callback: EventCallback): void { if (!this.eventCallbacks[event]) { this.eventCallbacks[event] = []; diff --git a/src/store/index.ts b/src/store/index.ts index 9f975b27..2ae8da42 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1759,10 +1759,6 @@ ircClient.on("USERNOTICE", (response) => { }); ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { - console.log( - `[DEBUG] JOIN event: ${username} joined ${channelName} on server ${serverId}`, - ); - // If this event is part of a batch, store it for later processing if (batchTag) { const state = useStore.getState(); @@ -1851,16 +1847,10 @@ ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { // If we joined a channel, request channel information const ourNick = ircClient.getNick(serverId); - console.log(`[DEBUG] Our nick: "${ourNick}", joining user: "${username}"`); if (username === ourNick) { - console.log( - `[DEBUG] We joined channel ${channelName}, requesting TOPIC and WHO`, - ); // Request topic and user list ircClient.sendRaw(serverId, `TOPIC ${channelName}`); ircClient.sendRaw(serverId, `WHO ${channelName}`); - } else { - console.log(`[DEBUG] Someone else joined: ${username} !== ${ourNick}`); } // Add join message if settings allow @@ -3513,9 +3503,6 @@ ircClient.on( hopcount, realname, }) => { - console.log( - `[DEBUG] WHO_REPLY received: ${nick} in ${channel} on server ${serverId}`, - ); const state = useStore.getState(); const serverData = state.servers.find((s) => s.id === serverId); if (!serverData) return; @@ -3523,14 +3510,8 @@ ircClient.on( // Find the channel this WHO reply belongs to const channelData = serverData.channels.find((c) => c.name === channel); if (!channelData) { - console.log( - `[DEBUG] Channel ${channel} not found for WHO_REPLY on server ${serverId}`, - ); return; } - console.log( - `[DEBUG] Found channel ${channel} for WHO_REPLY, adding user ${nick}`, - ); // Create user object from WHO data with proper User type const user: User = { From 302105f916fe2cb3cd4a8a16948d26f23c6dbf94 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 00:58:26 +0100 Subject: [PATCH 32/47] fix broken statusprefix parsing --- src/store/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/store/index.ts b/src/store/index.ts index 2ae8da42..7c9a1e1d 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -3513,6 +3513,16 @@ ircClient.on( return; } + // Parse channel status from flags (e.g., "H@" means here and operator) + let channelStatus = ""; + if (flags) { + // Extract channel status prefixes from flags + const statusChars = flags.match(/[~&@%+]/g); + if (statusChars) { + channelStatus = statusChars.join(""); + } + } + // Create user object from WHO data with proper User type const user: User = { id: nick, @@ -3520,6 +3530,7 @@ ircClient.on( avatar: undefined, isOnline: true, isBot: false, + status: channelStatus, // Set the channel status here metadata: {}, }; From d1dd9815e2cdc0bd3a740d3d662a937cc8df9960 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 02:47:58 +0100 Subject: [PATCH 33/47] Fix Android keyboard covering input box - Add android:windowSoftInputMode="adjustResize" to AndroidManifest.xml - Create useKeyboardResize hook for JavaScript keyboard handling - Add native Android keyboard detection in MainActivity.kt - Update viewport meta tag for better mobile handling - Add mobile-optimized CSS for keyboard transitions - Integrate keyboard handling into main App component - Fix IRC message parsing in ircClient.ts --- ANDROID_KEYBOARD_FIX.md | 81 ++++++++++++++ index.html | 2 +- .../android/app/src/main/AndroidManifest.xml | 3 +- .../java/com/obsidianirc/dev/MainActivity.kt | 30 ++++++ src/App.tsx | 5 + src/hooks/useKeyboardResize.ts | 100 ++++++++++++++++++ src/index.css | 29 +++++ src/lib/ircClient.ts | 4 +- 8 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 ANDROID_KEYBOARD_FIX.md create mode 100644 src/hooks/useKeyboardResize.ts diff --git a/ANDROID_KEYBOARD_FIX.md b/ANDROID_KEYBOARD_FIX.md new file mode 100644 index 00000000..c4bd38b0 --- /dev/null +++ b/ANDROID_KEYBOARD_FIX.md @@ -0,0 +1,81 @@ +# Android Keyboard Resize Fix + +## Problem +On Android devices using the Tauri app, when clicking on the channel input box, the keyboard opens but covers the bottom of the screen (including the input box). The viewport only properly resizes after navigating away from the app and returning. + +## Root Cause +The issue was caused by improper Android keyboard handling configuration: +1. Missing `android:windowSoftInputMode` in the AndroidManifest.xml +2. No viewport resize handling for keyboard events +3. Lack of proper mobile CSS for keyboard state transitions + +## Solution Implemented + +### 1. AndroidManifest.xml Configuration +**File:** `src-tauri/gen/android/app/src/main/AndroidManifest.xml` +- Added `android:windowSoftInputMode="adjustResize"` to the MainActivity declaration +- This tells Android to resize the viewport when the keyboard appears instead of covering content + +### 2. Enhanced HTML Viewport Settings +**File:** `index.html` +- Updated viewport meta tag to include `viewport-fit=cover, user-scalable=no` +- Provides better mobile viewport handling + +### 3. Native Android Keyboard Detection +**File:** `src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt` +- Added `setupKeyboardDetection()` method that monitors layout changes +- Detects keyboard open/close events and dispatches JavaScript events +- Provides immediate feedback to the web view when keyboard state changes + +### 4. JavaScript Keyboard Handling Hook +**File:** `src/hooks/useKeyboardResize.ts` (NEW) +- Created a React hook that handles keyboard visibility events +- Listens for both Visual Viewport API changes and native Android events +- Updates CSS custom properties to track keyboard height +- Triggers layout recalculations when keyboard state changes + +### 5. Mobile-Optimized CSS +**File:** `src/index.css` +- Added `--keyboard-height` CSS custom property +- Added mobile-specific CSS rules for keyboard handling +- Ensures proper viewport adjustments with smooth transitions +- Fixed viewport on mobile devices to prevent layout shifts + +### 6. App Integration +**File:** `src/App.tsx` +- Integrated the `useKeyboardResize` hook into the main App component +- Ensures keyboard handling is active throughout the application lifecycle + +## Technical Details + +### Android Window Soft Input Modes +- `adjustResize`: Resizes the window to make room for the keyboard +- This is preferred over `adjustPan` which just shifts content up + +### Visual Viewport API +- Modern browsers provide this API to detect viewport changes +- Especially useful for keyboard events on mobile devices +- Fallback handling for older browsers included + +### CSS Custom Properties +- `--keyboard-height` tracks the current keyboard height +- Allows responsive layout adjustments based on keyboard state +- Smooth transitions prevent jarring layout changes + +## Expected Behavior After Fix +1. User taps on the channel input box +2. Keyboard opens immediately +3. Viewport resizes instantly to accommodate keyboard +4. Input box remains visible above the keyboard +5. No need to navigate away and back to see proper layout + +## Testing Considerations +- Test on various Android devices and screen sizes +- Verify both portrait and landscape orientations +- Ensure keyboard animations are smooth +- Check that all input fields throughout the app behave consistently + +## Browser Compatibility +- Modern Android browsers with Visual Viewport API support +- Fallback handling for older browsers +- iOS support included for future compatibility \ No newline at end of file diff --git a/index.html b/index.html index ca34f712..bad90a49 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 97879757..5ded409a 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ android:launchMode="singleTask" android:label="@string/main_activity_title" android:name=".MainActivity" - android:exported="true"> + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt index f7111d7c..fe688643 100644 --- a/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt @@ -2,13 +2,43 @@ package com.obsidianirc.dev import android.webkit.WebView import android.annotation.SuppressLint +import android.view.ViewTreeObserver +import android.view.View +import android.graphics.Rect class MainActivity : TauriActivity() { private lateinit var wv: WebView + private var isKeyboardOpen = false override fun onWebViewCreate(webView: WebView) { wv = webView + setupKeyboardDetection() + } + + private fun setupKeyboardDetection() { + val rootView = findViewById(android.R.id.content) + val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + val rect = Rect() + rootView.getWindowVisibleDisplayFrame(rect) + val screenHeight = rootView.rootView.height + val keypadHeight = screenHeight - rect.bottom + + if (keypadHeight > screenHeight * 0.15) { // keyboard is opened + if (!isKeyboardOpen) { + isKeyboardOpen = true + // Force immediate layout adjustment + wv.evaluateJavascript("window.dispatchEvent(new Event('keyboardDidShow'));", null) + } + } else { // keyboard is closed + if (isKeyboardOpen) { + isKeyboardOpen = false + wv.evaluateJavascript("window.dispatchEvent(new Event('keyboardDidHide'));", null) + } + } + } + + rootView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) } @SuppressLint("MissingSuperCall", "SetTextI18n") diff --git a/src/App.tsx b/src/App.tsx index 1319f520..d65a5e0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import AddServerModal from "./components/ui/AddServerModal"; import ChannelListModal from "./components/ui/ChannelListModal"; import ChannelRenameModal from "./components/ui/ChannelRenameModal"; import UserSettings from "./components/ui/UserSettings"; +import { useKeyboardResize } from "./hooks/useKeyboardResize"; import ircClient from "./lib/ircClient"; import useStore, { loadSavedServers } from "./store"; @@ -77,6 +78,10 @@ const App: React.FC = () => { joinChannel, connectToSavedServers, } = useStore(); + + // Initialize keyboard resize handling for mobile platforms + useKeyboardResize(); + // askPermissions(); useEffect(() => { initializeEnvSettings(toggleAddServerModal, joinChannel); diff --git a/src/hooks/useKeyboardResize.ts b/src/hooks/useKeyboardResize.ts new file mode 100644 index 00000000..5d3ca2a1 --- /dev/null +++ b/src/hooks/useKeyboardResize.ts @@ -0,0 +1,100 @@ +import { platform } from "@tauri-apps/plugin-os"; +import { useEffect } from "react"; + +// Hook to handle keyboard visibility and viewport resizing on mobile platforms +export const useKeyboardResize = () => { + useEffect(() => { + // Only apply this for mobile platforms + if (!("__TAURI__" in window) || !["android", "ios"].includes(platform())) { + return; + } + + let isKeyboardVisible = false; + let initialViewportHeight = window.visualViewport?.height || window.innerHeight; + + const handleVisualViewportChange = () => { + if (!window.visualViewport) return; + + const currentHeight = window.visualViewport.height; + const heightDifference = initialViewportHeight - currentHeight; + + // Keyboard is considered visible if the viewport height decreased significantly + const keyboardWasVisible = isKeyboardVisible; + isKeyboardVisible = heightDifference > 150; // Adjust threshold as needed + + // Force a resize event when keyboard state changes + if (keyboardWasVisible !== isKeyboardVisible) { + updateKeyboardState(isKeyboardVisible, heightDifference); + } + }; + + const updateKeyboardState = (visible: boolean, heightDiff: number) => { + // Update CSS custom property for keyboard height + document.documentElement.style.setProperty( + '--keyboard-height', + visible ? `${heightDiff}px` : '0px' + ); + + // Trigger a resize event to force layout recalculation + window.dispatchEvent(new Event('resize')); + + // Small delay to ensure DOM updates are processed + setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 50); + }; + + const handleAndroidKeyboardShow = () => { + if (!isKeyboardVisible) { + isKeyboardVisible = true; + const heightDiff = initialViewportHeight - (window.visualViewport?.height || window.innerHeight); + updateKeyboardState(true, heightDiff); + } + }; + + const handleAndroidKeyboardHide = () => { + if (isKeyboardVisible) { + isKeyboardVisible = false; + updateKeyboardState(false, 0); + } + }; + + const handleWindowResize = () => { + // Update initial height when window is resized + if (window.visualViewport) { + if (!isKeyboardVisible) { + initialViewportHeight = window.visualViewport.height; + } + } else { + initialViewportHeight = window.innerHeight; + } + }; + + // Use visualViewport API if available (modern browsers) + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', handleVisualViewportChange); + window.visualViewport.addEventListener('scroll', handleVisualViewportChange); + } + + // Listen for native Android keyboard events + window.addEventListener('keyboardDidShow', handleAndroidKeyboardShow); + window.addEventListener('keyboardDidHide', handleAndroidKeyboardHide); + + // Fallback for older browsers or additional handling + window.addEventListener('resize', handleWindowResize); + + // Cleanup + return () => { + if (window.visualViewport) { + window.visualViewport.removeEventListener('resize', handleVisualViewportChange); + window.visualViewport.removeEventListener('scroll', handleVisualViewportChange); + } + window.removeEventListener('keyboardDidShow', handleAndroidKeyboardShow); + window.removeEventListener('keyboardDidHide', handleAndroidKeyboardHide); + window.removeEventListener('resize', handleWindowResize); + + // Reset CSS property + document.documentElement.style.removeProperty('--keyboard-height'); + }; + }, []); +}; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 25ff5df9..d79ccde4 100644 --- a/src/index.css +++ b/src/index.css @@ -33,6 +33,7 @@ body { --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --keyboard-height: 0px; } .dark { @@ -61,3 +62,31 @@ body { --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } + +/* Mobile keyboard handling */ +@media (max-width: 768px) { + body { + overflow: hidden; + } + + #root { + height: 100vh; + height: calc(100vh - var(--keyboard-height, 0px)); + transition: height 0.2s ease-in-out; + } + + /* Ensure proper viewport handling on mobile */ + html, body { + position: fixed; + width: 100%; + } +} + +/* Ensure chat layout adjusts properly to keyboard */ +@supports (-webkit-touch-callout: none) { + /* iOS Safari specific adjustments */ + #root { + height: 100vh; + height: -webkit-fill-available; + } +} diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 3401dbc6..f443c328 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -756,8 +756,8 @@ export class IRCClient { const isChannel = target.startsWith("#"); const sender = getNickFromNuh(source); - parv[0] = ""; - const message = parv.join(" ").trim().substring(1); + // The message content is now properly parsed as the trailing parameter + const message = trailing || parv.slice(1).join(" "); if (isChannel) { const channelName = target; From bfc2b8031c230cd5d8f4db65d7d2ce73bcede274 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 02:50:23 +0100 Subject: [PATCH 34/47] ... --- src/App.tsx | 4 +-- src/hooks/useKeyboardResize.ts | 57 +++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d65a5e0c..c72d454d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,10 +78,10 @@ const App: React.FC = () => { joinChannel, connectToSavedServers, } = useStore(); - + // Initialize keyboard resize handling for mobile platforms useKeyboardResize(); - + // askPermissions(); useEffect(() => { initializeEnvSettings(toggleAddServerModal, joinChannel); diff --git a/src/hooks/useKeyboardResize.ts b/src/hooks/useKeyboardResize.ts index 5d3ca2a1..5af68882 100644 --- a/src/hooks/useKeyboardResize.ts +++ b/src/hooks/useKeyboardResize.ts @@ -10,14 +10,15 @@ export const useKeyboardResize = () => { } let isKeyboardVisible = false; - let initialViewportHeight = window.visualViewport?.height || window.innerHeight; + let initialViewportHeight = + window.visualViewport?.height || window.innerHeight; const handleVisualViewportChange = () => { if (!window.visualViewport) return; const currentHeight = window.visualViewport.height; const heightDifference = initialViewportHeight - currentHeight; - + // Keyboard is considered visible if the viewport height decreased significantly const keyboardWasVisible = isKeyboardVisible; isKeyboardVisible = heightDifference > 150; // Adjust threshold as needed @@ -31,23 +32,25 @@ export const useKeyboardResize = () => { const updateKeyboardState = (visible: boolean, heightDiff: number) => { // Update CSS custom property for keyboard height document.documentElement.style.setProperty( - '--keyboard-height', - visible ? `${heightDiff}px` : '0px' + "--keyboard-height", + visible ? `${heightDiff}px` : "0px", ); // Trigger a resize event to force layout recalculation - window.dispatchEvent(new Event('resize')); - + window.dispatchEvent(new Event("resize")); + // Small delay to ensure DOM updates are processed setTimeout(() => { - window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event("resize")); }, 50); }; const handleAndroidKeyboardShow = () => { if (!isKeyboardVisible) { isKeyboardVisible = true; - const heightDiff = initialViewportHeight - (window.visualViewport?.height || window.innerHeight); + const heightDiff = + initialViewportHeight - + (window.visualViewport?.height || window.innerHeight); updateKeyboardState(true, heightDiff); } }; @@ -72,29 +75,41 @@ export const useKeyboardResize = () => { // Use visualViewport API if available (modern browsers) if (window.visualViewport) { - window.visualViewport.addEventListener('resize', handleVisualViewportChange); - window.visualViewport.addEventListener('scroll', handleVisualViewportChange); + window.visualViewport.addEventListener( + "resize", + handleVisualViewportChange, + ); + window.visualViewport.addEventListener( + "scroll", + handleVisualViewportChange, + ); } // Listen for native Android keyboard events - window.addEventListener('keyboardDidShow', handleAndroidKeyboardShow); - window.addEventListener('keyboardDidHide', handleAndroidKeyboardHide); + window.addEventListener("keyboardDidShow", handleAndroidKeyboardShow); + window.addEventListener("keyboardDidHide", handleAndroidKeyboardHide); // Fallback for older browsers or additional handling - window.addEventListener('resize', handleWindowResize); + window.addEventListener("resize", handleWindowResize); // Cleanup return () => { if (window.visualViewport) { - window.visualViewport.removeEventListener('resize', handleVisualViewportChange); - window.visualViewport.removeEventListener('scroll', handleVisualViewportChange); + window.visualViewport.removeEventListener( + "resize", + handleVisualViewportChange, + ); + window.visualViewport.removeEventListener( + "scroll", + handleVisualViewportChange, + ); } - window.removeEventListener('keyboardDidShow', handleAndroidKeyboardShow); - window.removeEventListener('keyboardDidHide', handleAndroidKeyboardHide); - window.removeEventListener('resize', handleWindowResize); - + window.removeEventListener("keyboardDidShow", handleAndroidKeyboardShow); + window.removeEventListener("keyboardDidHide", handleAndroidKeyboardHide); + window.removeEventListener("resize", handleWindowResize); + // Reset CSS property - document.documentElement.style.removeProperty('--keyboard-height'); + document.documentElement.style.removeProperty("--keyboard-height"); }; }, []); -}; \ No newline at end of file +}; From d70cbac11bf6936e4d6399d680f2442624f5f656 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 02:56:14 +0100 Subject: [PATCH 35/47] Fix mobile dark grey overlay issue - Remove problematic position: fixed CSS rules for mobile - Make keyboard resize hook work in regular mobile browsers - Add mobile debug indicators to identify layout issues - Ensure server list takes full width on mobile - Fix mobile layout column rendering logic --- src/components/layout/AppLayout.tsx | 11 +++++++++-- src/components/layout/ServerList.tsx | 5 +++++ src/hooks/useKeyboardResize.ts | 21 +++++++++++++++++++-- src/index.css | 7 +------ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 146249cd..c23b6f65 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -69,7 +69,7 @@ export const AppLayout: React.FC = () => { return ( <> {__HIDE_SERVER_LIST__ ? null : ( -
+
)} @@ -161,7 +161,8 @@ export const AppLayout: React.FC = () => { }, [isTooNarrowForMemberList, toggleMemberList, isNarrowView]); const getLayoutColumn = (column: layoutColumn) => { - if (isNarrowView && column !== mobileViewActiveColumn) return; + // On mobile, only show the active column + if (isNarrowView && column !== mobileViewActiveColumn) return null; return getLayoutColumnElement(column); }; @@ -191,6 +192,12 @@ export const AppLayout: React.FC = () => { isDarkMode ? "text-white" : "text-gray-900" }`} > + {/* Debug indicator for mobile */} + {isNarrowView && ( +
+ Mobile: {mobileViewActiveColumn} +
+ )} {getLayoutColumn("serverList")} {getLayoutColumn("chatView")} {selectedServerId && getLayoutColumn("memberList")} diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index 1c33283f..4302edfc 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -24,6 +24,11 @@ export const ServerList: React.FC = () => { return (
+ {/* Mobile debug indicator */} +
+ ServerList Active +
+ {/* Home button - in Discord this would be DMs */}
{ useEffect(() => { - // Only apply this for mobile platforms - if (!("__TAURI__" in window) || !["android", "ios"].includes(platform())) { + // Check if we're on a mobile device + const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + window.innerWidth <= 768; + + // Only apply this for mobile platforms, but be more permissive than just Tauri + if (!isMobile) { return; } + // If we're in Tauri, check the platform + if ("__TAURI__" in window) { + try { + const currentPlatform = platform(); + if (!["android", "ios"].includes(currentPlatform)) { + return; + } + } catch (error) { + // If platform() fails, continue anyway on mobile devices + console.warn("Failed to detect platform, continuing with keyboard handling:", error); + } + } + let isKeyboardVisible = false; let initialViewportHeight = window.visualViewport?.height || window.innerHeight; diff --git a/src/index.css b/src/index.css index d79ccde4..8d70375d 100644 --- a/src/index.css +++ b/src/index.css @@ -73,12 +73,7 @@ body { height: 100vh; height: calc(100vh - var(--keyboard-height, 0px)); transition: height 0.2s ease-in-out; - } - - /* Ensure proper viewport handling on mobile */ - html, body { - position: fixed; - width: 100%; + position: relative; } } From ecb5422c19a4cb5e1140c366cbb6a00cb1442d4b Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 03:23:33 +0100 Subject: [PATCH 36/47] Fix mobile dark grey overlay issue - Remove problematic position: fixed CSS rules for mobile - Make keyboard resize hook work in regular mobile browsers - Add mobile debug indicators to identify layout issues - Ensure server list takes full width on mobile - Fix mobile layout column rendering logic --- src/components/layout/AppLayout.tsx | 4 +++- src/components/layout/ServerList.tsx | 2 +- src/hooks/useKeyboardResize.ts | 13 +++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index c23b6f65..d87b16c4 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -69,7 +69,9 @@ export const AppLayout: React.FC = () => { return ( <> {__HIDE_SERVER_LIST__ ? null : ( -
+
)} diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index 4302edfc..211224ad 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -28,7 +28,7 @@ export const ServerList: React.FC = () => {
ServerList Active
- + {/* Home button - in Discord this would be DMs */}
{ useEffect(() => { // Check if we're on a mobile device - const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || - window.innerWidth <= 768; - + const isMobile = + /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ) || window.innerWidth <= 768; + // Only apply this for mobile platforms, but be more permissive than just Tauri if (!isMobile) { return; @@ -22,7 +24,10 @@ export const useKeyboardResize = () => { } } catch (error) { // If platform() fails, continue anyway on mobile devices - console.warn("Failed to detect platform, continuing with keyboard handling:", error); + console.warn( + "Failed to detect platform, continuing with keyboard handling:", + error, + ); } } From 205b4939b1b60931fe3d6bae7f83d0888af695d1 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 06:01:42 +0100 Subject: [PATCH 37/47] Add draft/multline and improve other batching --- src/components/layout/ChatArea.tsx | 447 ++++++++++++++---- src/components/message/MessageItem.tsx | 2 +- src/components/ui/AutocompleteDropdown.tsx | 2 +- .../ui/EmojiAutocompleteDropdown.tsx | 2 +- src/components/ui/LoadingSpinner.tsx | 34 ++ src/components/ui/UserContextMenu.tsx | 65 +++ src/components/ui/UserSettings.tsx | 194 ++++++++ src/lib/ignoreUtils.ts | 126 +++++ src/lib/ircClient.ts | 231 ++++++++- src/store/index.ts | 393 ++++++++++++++- src/types/index.ts | 2 + test_multiline.md | 58 +++ tests/App.test.tsx | 4 + tests/lib/defaultIgnore.test.ts | 30 ++ tests/lib/ignoreUtils.test.ts | 151 ++++++ 15 files changed, 1631 insertions(+), 110 deletions(-) create mode 100644 src/components/ui/LoadingSpinner.tsx create mode 100644 src/lib/ignoreUtils.ts create mode 100644 test_multiline.md create mode 100644 tests/lib/defaultIgnore.test.ts create mode 100644 tests/lib/ignoreUtils.test.ts diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 78d9d79f..0952489a 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -33,7 +33,7 @@ import { getPreviewStyles, isValidFormattingType, } from "../../lib/messageFormatter"; -import useStore from "../../store"; +import useStore, { serverSupportsMultiline } from "../../store"; import type { Message as MessageType, User } from "../../types"; import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; @@ -42,12 +42,87 @@ import BlankPage from "../ui/BlankPage"; import ColorPicker from "../ui/ColorPicker"; import EmojiAutocompleteDropdown from "../ui/EmojiAutocompleteDropdown"; import DiscoverGrid from "../ui/HomeScreen"; +import LoadingSpinner from "../ui/LoadingSpinner"; import ReactionModal from "../ui/ReactionModal"; import UserContextMenu from "../ui/UserContextMenu"; const EMPTY_ARRAY: User[] = []; let lastTypingTime = 0; +// Helper function to split long messages while respecting IRC protocol limits +const splitLongMessage = (message: string, target = "#channel"): string[] => { + // Calculate IRC protocol overhead for a PRIVMSG (excluding message tags) + // Format: :nick!user@host PRIVMSG #target :message\r\n + // Message tags don't count toward the 512-byte limit + + // Conservative estimates for variable parts (as per IRC spec recommendations) + const maxNickLength = 20; + const maxUserLength = 20; + const maxHostLength = 63; + const targetLength = target.length; + + // Fixed protocol parts (excluding tags) + const protocolOverhead = + 1 + // ':' + maxNickLength + + 1 + // '!' + maxUserLength + + 1 + // '@' + maxHostLength + + 1 + // ' ' + 7 + // 'PRIVMSG' + 1 + // ' ' + targetLength + + 2 + // ' :' + 2; // '\r\n' + + const safetyBuffer = 10; // Small safety margin + + // Available space for the actual message content + const maxMessageLength = 512 - protocolOverhead - safetyBuffer; + + console.log( + `[MULTILINE] Protocol overhead: ${protocolOverhead}, Max message length: ${maxMessageLength}, Input length: ${message.length}`, + ); + + if (message.length <= maxMessageLength) { + return [message]; + } + + const lines: string[] = []; + let currentLine = ""; + const words = message.split(" "); + + for (const word of words) { + if (word.length > maxMessageLength) { + // If a single word is too long, we have to break it + if (currentLine) { + lines.push(currentLine.trim()); + currentLine = ""; + } + + // Split the long word + for (let i = 0; i < word.length; i += maxMessageLength) { + lines.push(word.slice(i, i + maxMessageLength)); + } + } else if (`${currentLine} ${word}`.length > maxMessageLength) { + // Adding this word would exceed the limit + if (currentLine) { + lines.push(currentLine.trim()); + } + currentLine = word; + } else { + currentLine = currentLine ? `${currentLine} ${word}` : word; + } + } + + if (currentLine) { + lines.push(currentLine.trim()); + } + + return lines.filter((line) => line.length > 0); +}; + export const TypingIndicator: React.FC<{ serverId: string; channelId: string; @@ -112,7 +187,7 @@ export const ChatArea: React.FC<{ }); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); - const inputRef = useRef(null); + const inputRef = useRef(null); const { servers, @@ -129,6 +204,7 @@ export const ChatArea: React.FC<{ joinChannel, toggleAddServerModal, redactMessage, + globalSettings, } = useStore(); // Get the current user for the selected server with metadata from store @@ -327,20 +403,162 @@ export const ChatArea: React.FC<{ ircClient.sendRaw(selectedServerId, fullCommand); } } else { - // Format the message with color and styling - const formattedText = formatMessageForIrc(messageText, { - color: selectedColor || "inherit", - formatting: selectedFormatting, - }); - // Determine target: channel name or username for private messages const target = selectedChannel?.name ?? selectedPrivateChat?.username ?? ""; - ircClient.sendRaw( - selectedServerId, - `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${formattedText}`, - ); + // Check if message contains newlines or is very long + const lines = messageText.split("\n"); + const supportsMultiline = serverSupportsMultiline(selectedServerId); + const hasMultipleLines = lines.length > 1; + + // Calculate the same limit as splitLongMessage for consistency + const maxNickLength = 20; + const maxUserLength = 20; + const maxHostLength = 63; + const protocolOverhead = + 1 + + maxNickLength + + 1 + + maxUserLength + + 1 + + maxHostLength + + 1 + + 7 + + 1 + + target.length + + 2 + + 2; + const maxMessageLength = 512 - protocolOverhead - 10; // 10 byte safety buffer + const isSingleLongLine = + lines.length === 1 && messageText.length > maxMessageLength; + + if (supportsMultiline && (hasMultipleLines || isSingleLongLine)) { + // Send as multiline message using BATCH + const batchId = `ml_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const replyPrefix = localReplyTo + ? `@+draft/reply=${localReplyTo.id};` + : ""; + ircClient.sendRaw( + selectedServerId, + `${replyPrefix}BATCH +${batchId} draft/multiline ${target}`, + ); + + if (hasMultipleLines) { + // Case 1: Multi-line message (preserve line breaks) + lines.forEach((line) => { + const formattedLine = formatMessageForIrc(line, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Check if this individual line is too long and needs splitting + const maxLineLengthForTarget = + 512 - + (1 + 20 + 1 + 20 + 1 + 63 + 1 + 7 + 1 + target.length + 2 + 2) - + 10; + if (formattedLine.length > maxLineLengthForTarget) { + const splitLines = splitLongMessage(formattedLine, target); + splitLines.forEach((splitLine: string, index: number) => { + if (index === 0) { + // First part goes normally + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId} PRIVMSG ${target} :${splitLine}`, + ); + } else { + // Subsequent parts use multiline-concat to join without line break + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId};draft/multiline-concat PRIVMSG ${target} :${splitLine}`, + ); + } + }); + } else { + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId} PRIVMSG ${target} :${formattedLine}`, + ); + } + }); + } else { + // Case 2: Single very long line (split and concat) + const formattedText = formatMessageForIrc(messageText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + const splitLines = splitLongMessage(formattedText, target); + splitLines.forEach((splitLine: string, index: number) => { + if (index === 0) { + // First part goes normally + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId} PRIVMSG ${target} :${splitLine}`, + ); + } else { + // Subsequent parts use multiline-concat to join without separation + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId};draft/multiline-concat PRIVMSG ${target} :${splitLine}`, + ); + } + }); + } + + ircClient.sendRaw(selectedServerId, `BATCH -${batchId}`); + } else if (hasMultipleLines && !supportsMultiline) { + // Handle fallback based on user preference + if (globalSettings.autoFallbackToSingleLine) { + // Concatenate with spaces and send as single message + const combinedText = lines.join(" "); + const formattedText = formatMessageForIrc(combinedText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Split if too long + const splitLines = splitLongMessage(formattedText, target); + splitLines.forEach((line: string) => { + ircClient.sendRaw( + selectedServerId, + `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${line}`, + ); + }); + } else { + // Send as separate messages + lines.forEach((line) => { + const formattedLine = formatMessageForIrc(line, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Split long lines + const splitLines = splitLongMessage(formattedLine, target); + splitLines.forEach((splitLine: string) => { + ircClient.sendRaw( + selectedServerId, + `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${splitLine}`, + ); + }); + }); + } + } else { + // Send as regular single message + const formattedText = formatMessageForIrc(messageText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Split if too long + const splitLines = splitLongMessage(formattedText, target); + splitLines.forEach((line: string) => { + ircClient.sendRaw( + selectedServerId, + `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${line}`, + ); + }); + } // For private messages, manually add our own message to the chat // since the server doesn't echo private messages back to us @@ -370,10 +588,15 @@ export const ChatArea: React.FC<{ tabCompletion.resetCompletion(); } + // Reset textarea height + if (inputRef.current) { + inputRef.current.style.height = "auto"; + } + // Send typing done notification - const { globalSettings } = useStore.getState(); + const storeState = useStore.getState(); if ( - globalSettings.sendTypingNotifications && + storeState.globalSettings.sendTypingNotifications && (selectedChannel?.name || selectedPrivateChat?.username) ) { const target = selectedChannel?.name ?? selectedPrivateChat?.username; @@ -420,12 +643,22 @@ export const ChatArea: React.FC<{ return; } - if (e.key === "Enter" && !e.shiftKey) { + // Handle Enter key behavior based on settings + if (e.key === "Enter") { + const shouldCreateNewline = + globalSettings.enableMultilineInput && + (globalSettings.multilineOnShiftEnter ? e.shiftKey : !e.shiftKey); + + if (shouldCreateNewline) { + // Allow the default behavior (add newline) + return; + } + // Send message e.preventDefault(); handleSendMessage(); // Send typing done notification - const { globalSettings } = useStore.getState(); - if (globalSettings.sendTypingNotifications) { + const storeState = useStore.getState(); + if (storeState.globalSettings.sendTypingNotifications) { if (selectedChannel?.name) { ircClient.sendTyping( selectedServerId ?? "", @@ -527,7 +760,7 @@ export const ChatArea: React.FC<{ } }; - const handleInputChange = (e: React.ChangeEvent) => { + const handleInputChange = (e: React.ChangeEvent) => { const newText = e.target.value; const newCursorPosition = e.target.selectionStart || 0; @@ -535,6 +768,13 @@ export const ChatArea: React.FC<{ setCursorPosition(newCursorPosition); handleUpdatedText(newText); + // Auto-resize textarea + const textarea = e.target; + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = 128; // 8 lines (16px line height * 8) + textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; + // Reset tab completion if text changed from non-tab input if (tabCompletion.isActive) { tabCompletion.resetCompletion(); @@ -550,8 +790,8 @@ export const ChatArea: React.FC<{ setShowEmojiAutocomplete(false); }; - const handleInputClick = (e: React.MouseEvent) => { - const target = e.target as HTMLInputElement; + const handleInputClick = (e: React.MouseEvent) => { + const target = e.target as HTMLTextAreaElement; const newCursorPos = target.selectionStart || 0; setCursorPosition(newCursorPos); }; @@ -738,11 +978,11 @@ export const ChatArea: React.FC<{ } }; - const handleInputKeyUp = (e: React.KeyboardEvent) => { + const handleInputKeyUp = (e: React.KeyboardEvent) => { // Skip if it was Tab key (handled by keyDown) if (e.key === "Tab") return; - const target = e.target as HTMLInputElement; + const target = e.target as HTMLTextAreaElement; const newCursorPos = target.selectionStart || 0; setCursorPosition(newCursorPos); }; @@ -1096,23 +1336,76 @@ export const ChatArea: React.FC<{ ref={messagesContainerRef} className="flex-grow overflow-y-auto flex flex-col bg-discord-dark-200 text-discord-text-normal relative" > - {(() => { - // Group consecutive events before rendering - const eventGroups = groupConsecutiveEvents(channelMessages); - - return eventGroups.map((group) => { - if (group.type === "eventGroup") { - // Create a stable key from the first and last message IDs in the group - const firstId = group.messages[0]?.id || ""; - const lastId = - group.messages[group.messages.length - 1]?.id || ""; - const groupKey = `group-${firstId}-${lastId}`; + {selectedChannel?.isLoadingHistory ? ( + // Show loading spinner when channel is loading history +
+ +
+ ) : ( + // Show messages when not loading + (() => { + // Group consecutive events before rendering + const eventGroups = groupConsecutiveEvents(channelMessages); + + return eventGroups.map((group) => { + if (group.type === "eventGroup") { + // Create a stable key from the first and last message IDs in the group + const firstId = group.messages[0]?.id || ""; + const lastId = + group.messages[group.messages.length - 1]?.id || ""; + const groupKey = `group-${firstId}-${lastId}`; + + return ( + + handleUsernameClick( + e, + username, + serverId, + avatarElement, + ) + } + /> + ); + } + // Single message - find its original index for date/header logic + const message = group.messages[0]; + const originalIndex = channelMessages.findIndex( + (m) => m.id === message.id, + ); + const previousMessage = channelMessages[originalIndex - 1]; + const showHeader = + !previousMessage || + previousMessage.userId !== message.userId || + new Date(message.timestamp).getTime() - + new Date(previousMessage.timestamp).getTime() > + 5 * 60 * 1000; return ( - handleUsernameClick(e, username, serverId, avatarElement) } + onIrcLinkClick={handleIrcLinkClick} + onReactClick={handleReactClick} + selectedServerId={selectedServerId} + onReactionUnreact={handleReactionUnreact} + onOpenReactionModal={handleOpenReactionModal} + onDirectReaction={handleDirectReaction} + users={selectedChannel?.users || []} + onRedactMessage={handleRedactMessage} /> ); - } - // Single message - find its original index for date/header logic - const message = group.messages[0]; - const originalIndex = channelMessages.findIndex( - (m) => m.id === message.id, - ); - const previousMessage = channelMessages[originalIndex - 1]; - const showHeader = - !previousMessage || - previousMessage.userId !== message.userId || - new Date(message.timestamp).getTime() - - new Date(previousMessage.timestamp).getTime() > - 5 * 60 * 1000; - - return ( - - handleUsernameClick(e, username, serverId, avatarElement) - } - onIrcLinkClick={handleIrcLinkClick} - onReactClick={handleReactClick} - selectedServerId={selectedServerId} - onReactionUnreact={handleReactionUnreact} - onOpenReactionModal={handleOpenReactionModal} - onDirectReaction={handleDirectReaction} - users={selectedChannel?.users || []} - onRedactMessage={handleRedactMessage} - /> - ); - }); - })()} + }); + })() + )}
@@ -1214,9 +1471,8 @@ export const ChatArea: React.FC<{
)} -
diff --git a/src/components/ui/AutocompleteDropdown.tsx b/src/components/ui/AutocompleteDropdown.tsx index 3d9308fe..3096d458 100644 --- a/src/components/ui/AutocompleteDropdown.tsx +++ b/src/components/ui/AutocompleteDropdown.tsx @@ -9,7 +9,7 @@ interface AutocompleteDropdownProps { cursorPosition: number; onSelect: (username: string) => void; onClose: () => void; - inputElement?: HTMLInputElement | null; + inputElement?: HTMLInputElement | HTMLTextAreaElement | null; tabCompletionMatches?: string[]; currentMatchIndex?: number; onNavigate?: (username: string) => void; diff --git a/src/components/ui/EmojiAutocompleteDropdown.tsx b/src/components/ui/EmojiAutocompleteDropdown.tsx index 43163b54..9c7a8af3 100644 --- a/src/components/ui/EmojiAutocompleteDropdown.tsx +++ b/src/components/ui/EmojiAutocompleteDropdown.tsx @@ -14,7 +14,7 @@ interface EmojiAutocompleteDropdownProps { cursorPosition: number; onSelect: (emoji: string) => void; onClose: () => void; - inputElement?: HTMLInputElement | null; + inputElement?: HTMLInputElement | HTMLTextAreaElement | null; emojiMatches?: EmojiItem[]; currentMatchIndex?: number; onNavigate?: (emoji: string) => void; diff --git a/src/components/ui/LoadingSpinner.tsx b/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 00000000..b03b6aa5 --- /dev/null +++ b/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,34 @@ +import type React from "react"; + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg"; + className?: string; + text?: string; +} + +export const LoadingSpinner: React.FC = ({ + size = "md", + className = "", + text = "Loading...", +}) => { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-8 h-8", + lg: "w-12 h-12", + }; + + return ( +
+
+ {text && ( +

{text}

+ )} +
+ ); +}; + +export default LoadingSpinner; diff --git a/src/components/ui/UserContextMenu.tsx b/src/components/ui/UserContextMenu.tsx index 264d35b6..c9119358 100644 --- a/src/components/ui/UserContextMenu.tsx +++ b/src/components/ui/UserContextMenu.tsx @@ -1,5 +1,6 @@ import type React from "react"; import { useEffect, useRef } from "react"; +import { createIgnorePattern, isUserIgnored } from "../../lib/ignoreUtils"; import useStore from "../../store"; interface UserContextMenuProps { @@ -88,6 +89,36 @@ export const UserContextMenu: React.FC = ({ onClose(); }; + // Ignore list functionality + const globalSettings = useStore((state) => state.globalSettings); + const addToIgnoreList = useStore((state) => state.addToIgnoreList); + const removeFromIgnoreList = useStore((state) => state.removeFromIgnoreList); + + const isIgnored = isUserIgnored( + username, + undefined, + undefined, + globalSettings.ignoreList, + ); + + const handleIgnoreUser = () => { + const pattern = createIgnorePattern(username); + addToIgnoreList(pattern); + onClose(); + }; + + const handleUnignoreUser = () => { + // Find and remove any patterns that match this user + const matchingPatterns = globalSettings.ignoreList.filter((pattern) => + isUserIgnored(username, undefined, undefined, [pattern]), + ); + + matchingPatterns.forEach((pattern) => { + removeFromIgnoreList(pattern); + }); + onClose(); + }; + const getStatusPriority = (status?: string): number => { if (!status) return 1; let maxPriority = 1; @@ -166,6 +197,40 @@ export const UserContextMenu: React.FC = ({ Send Message + {!isOwnUser && ( + + )} {canModerate && !isOwnUser && ( <> +
+ {validationError && ( +

{validationError}

+ )} +

+ Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com, + nick123!user@host.net +

+
+ {ignoreList.length > 0 && ( +
+

+ {ignoreList.length} ignored pattern + {ignoreList.length !== 1 ? "s" : ""}: +

+
+ {ignoreList.map((pattern) => ( +
+ + {pattern} + + +
+ ))} +
+
+ )} + {ignoreList.length === 0 && ( +

+ No users ignored +

+ )} +
+ ); +}; + const UserSettings: React.FC = React.memo(() => { const { toggleUserProfileModal, @@ -126,12 +244,18 @@ const UserSettings: React.FC = React.memo(() => { accountName: globalAccountName, accountPassword: globalAccountPassword, customMentions: globalCustomMentions, + ignoreList: globalIgnoreList, showEvents: globalShowEvents, showNickChanges: globalShowNickChanges, showJoinsParts: globalShowJoinsParts, showQuits: globalShowQuits, + enableMultilineInput: globalEnableMultilineInput, + multilineOnShiftEnter: globalMultilineOnShiftEnter, + autoFallbackToSingleLine: globalAutoFallbackToSingleLine, }, updateGlobalSettings, + addToIgnoreList, + removeFromIgnoreList, } = useStore(); // Memoize the current server and metadata support to prevent unnecessary re-renders @@ -1029,6 +1153,76 @@ const UserSettings: React.FC = React.memo(() => { + +
+ + + {globalEnableMultilineInput && ( +
+ + + +
+ )} +
+
+ + + + + 0 && user.length > 0 && host.length > 0; +} + +/** + * Create an ignore pattern from nick, user, and host components + */ +export function createIgnorePattern( + nick?: string, + user?: string, + host?: string, +): string { + return `${nick || "*"}!${user || "*"}@${host || "*"}`; +} diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index f443c328..8686e24a 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -74,6 +74,11 @@ export interface EventMap { parameters?: string[]; }; BATCH_END: BaseIRCEvent & { batchId: string }; + MULTILINE_MESSAGE: BaseMessageEvent & { + channelName?: string; + lines: string[]; + messageIds: string[]; // All message IDs that make up this multiline message + }; METADATA_FAIL: BaseIRCEvent & { subcommand: string; code: string; @@ -157,6 +162,11 @@ export interface EventMap { nick?: string; message: string; }; + CHATHISTORY_LOADING: { + serverId: string; + channelName: string; + isLoading: boolean; + }; } type EventKey = keyof EventMap; @@ -174,6 +184,21 @@ export class IRCClient { new Map(); private pendingConnections: Map> = new Map(); private pendingCapReqs: Map = new Map(); // Track how many CAP REQ batches are pending ACK + private activeBatches: Map< + string, + Map< + string, + { + type: string; + parameters?: string[]; + messages: string[]; + concatFlags?: boolean[]; + sender?: string; + messageIds?: string[]; + batchMsgId?: string; + } + > + > = new Map(); // Track active batches per server private eventCallbacks: { [K in EventKey]?: EventCallback[]; @@ -363,8 +388,17 @@ export class IRCClient { isMentioned: false, messages: [], users: [], + isLoadingHistory: true, // Start in loading state }; server.channels.push(channel); + + // Trigger event to notify store that history loading started + this.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: true, + }); + return channel; } throw new Error(`Server with ID ${serverId} not found`); @@ -383,7 +417,74 @@ export class IRCClient { if (!server) throw new Error(`Server ${serverId} not found`); const channel = server.channels.find((c) => c.id === channelId); if (!channel) throw new Error(`Channel ${channelId} not found`); - this.sendRaw(serverId, `PRIVMSG ${channel.name} :${content}`); + + // Check if server supports multiline and message has newlines + // Note: We'll check server capabilities from the store later via helper function + const lines = content.split("\n"); + + if (lines.length > 1) { + // For now, send multiline if there are multiple lines + // Server capability check will be done by the calling code + this.sendMultilineMessage(serverId, channel.name, lines); + } else { + // Send as regular single message + this.sendRaw(serverId, `PRIVMSG ${channel.name} :${content}`); + } + } + + sendMultilineMessage( + serverId: string, + target: string, + lines: string[], + ): void { + const batchId = `ml_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Start multiline batch + this.sendRaw(serverId, `BATCH +${batchId} draft/multiline ${target}`); + + // Send each line as a separate PRIVMSG with batch tag + // Handle long lines by splitting them if needed + for (const line of lines) { + const splitLines = this.splitLongLine(line); + for (const splitLine of splitLines) { + this.sendRaw( + serverId, + `@batch=${batchId} PRIVMSG ${target} :${splitLine}`, + ); + } + } + + // End batch + this.sendRaw(serverId, `BATCH -${batchId}`); + } + + // Split long lines to respect IRC message length limits (512 bytes) + private splitLongLine(text: string, maxLength = 450): string[] { + if (!text) return [""]; + + // Account for IRC overhead (PRIVMSG + target + formatting) + // Conservative limit to account for formatting codes and IRC overhead + const lines: string[] = []; + let remaining = text; + + while (remaining.length > maxLength) { + // Try to split at word boundaries + let splitIndex = maxLength; + const lastSpace = remaining.lastIndexOf(" ", maxLength); + if (lastSpace > maxLength * 0.7) { + // Don't split too early + splitIndex = lastSpace; + } + + lines.push(remaining.substring(0, splitIndex)); + remaining = remaining.substring(splitIndex).trim(); + } + + if (remaining) { + lines.push(remaining); + } + + return lines.length > 0 ? lines : [""]; } sendTyping(serverId: string, target: string, isActive: boolean): void { @@ -732,6 +833,56 @@ export class IRCClient { // Message content is in parv[1] and onwards after target const message = parv.slice(1).join(" "); + // Check if this message is part of a multiline batch + const batchId = mtags?.batch; + if (batchId) { + const serverBatches = this.activeBatches.get(serverId); + const batch = serverBatches?.get(batchId); + if ( + batch && + (batch.type === "multiline" || batch.type === "draft/multiline") + ) { + // Add this message line to the batch + batch.messages.push(message); + + console.log( + `[IRC] Adding message to batch ${batchId}: mtags=`, + mtags, + `msgid=${mtags?.msgid}`, + ); + + // Store sender from the first message + if (!batch.sender) { + batch.sender = sender; + } + + // Track message IDs for redaction + if (!batch.messageIds) { + batch.messageIds = []; + } + if (mtags?.msgid) { + batch.messageIds.push(mtags.msgid); + console.log( + `[IRC] Added msgid ${mtags.msgid} to batch ${batchId}`, + ); + } else { + console.log( + `[IRC] No msgid found for message in batch ${batchId}`, + ); + } + + // Track if this message has the concat flag + if (!batch.concatFlags) { + batch.concatFlags = []; + } + const hasMultilineConcat = + mtags && mtags["draft/multiline-concat"] !== undefined; + batch.concatFlags.push(!!hasMultilineConcat); + + return; // Don't trigger individual message event, wait for batch completion + } + } + if (isChannel) { const channelName = target; this.triggerEvent("CHANMSG", { @@ -937,6 +1088,20 @@ export class IRCClient { console.log( `[IRC] Starting batch: id=${batchId}, type=${batchType}, params=${parameters.join(" ")}`, ); + + // Initialize batch tracking for this server if not exists + if (!this.activeBatches.has(serverId)) { + this.activeBatches.set(serverId, new Map()); + } + + // Track this batch + this.activeBatches.get(serverId)?.set(batchId, { + type: batchType, + parameters, + messages: [], + batchMsgId: mtags?.msgid, // Store the msgid from the BATCH command itself + }); + this.triggerEvent("BATCH_START", { serverId, batchId, @@ -945,6 +1110,69 @@ export class IRCClient { }); } else { console.log(`[IRC] Ending batch: id=${batchId}`); + + // Process completed batch + const serverBatches = this.activeBatches.get(serverId); + const batch = serverBatches?.get(batchId); + + if ( + batch && + (batch.type === "multiline" || batch.type === "draft/multiline") + ) { + // Handle completed multiline batch + // For multiline batches, parameters[0] is the target, sender comes from the PRIVMSG lines + const target = + batch.parameters && batch.parameters.length > 0 + ? batch.parameters[0] + : ""; + const sender = batch.sender || "unknown"; + + console.log( + `[IRC] Processing multiline batch: target=${target}, sender=${sender}, messages=${batch.messages.length}`, + ); + + // Combine messages, handling draft/multiline-concat tags + let combinedMessage = ""; + batch.messages.forEach((message, index) => { + const wasConcat = batch.concatFlags?.[index]; + console.log( + `[IRC] Message ${index}: concat=${wasConcat}, content="${message}"`, + ); + + if (index === 0) { + combinedMessage = message; + } else { + // Check if this message was tagged with draft/multiline-concat + if (wasConcat) { + // Concatenate directly without separator + console.log("[IRC] Concatenating without separator"); + combinedMessage += message; + } else { + // Join with newline (normal multiline) + console.log("[IRC] Adding newline separator"); + combinedMessage += `\n${message}`; + } + } + }); + + console.log( + `[IRC] Triggering MULTILINE_MESSAGE for batch ${batchId}, combined message length: ${combinedMessage.length}, batchMsgId: ${batch.batchMsgId}`, + ); + this.triggerEvent("MULTILINE_MESSAGE", { + serverId, + mtags: batch.batchMsgId ? { msgid: batch.batchMsgId } : undefined, // Use the msgid from the BATCH command + sender, + channelName: target.startsWith("#") ? target : undefined, + message: combinedMessage, + lines: batch.messages, + messageIds: batch.messageIds || [], + timestamp: getTimestampFromTags(mtags), + }); + } + + // Clean up batch tracking + serverBatches?.delete(batchId); + this.triggerEvent("BATCH_END", { serverId, batchId, @@ -1337,6 +1565,7 @@ export class IRCClient { "draft/message-redaction", "draft/account-registration", "batch", + "draft/multiline", ]; let accumulated = this.capLsAccumulated.get(serverId); diff --git a/src/store/index.ts b/src/store/index.ts index 7c9a1e1d..3d040d4e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,5 +1,6 @@ import { v4 as uuidv4 } from "uuid"; import { create } from "zustand"; +import { isUserIgnored } from "../lib/ignoreUtils"; import ircClient from "../lib/ircClient"; import { playNotificationSound, @@ -122,7 +123,19 @@ function serverSupportsMetadata(serverId: string): boolean { return supports; } -export { serverSupportsMetadata }; +// Check if a server supports multiline +function serverSupportsMultiline(serverId: string): boolean { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + const supports = server?.capabilities?.includes("draft/multiline") ?? false; + console.log( + `[SERVER_CAPS] Server ${serverId} supports draft/multiline:`, + supports, + ); + return supports; +} + +export { serverSupportsMetadata, serverSupportsMultiline }; function saveServersToLocalStorage(servers: ServerConfig[]) { localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers)); @@ -233,10 +246,16 @@ interface GlobalSettings { showQuits: boolean; // Custom mentions customMentions: string[]; + // Ignore list + ignoreList: string[]; // Hosted chat mode settings nickname: string; accountName: string; accountPassword: string; + // Multiline settings + enableMultilineInput: boolean; + multilineOnShiftEnter: boolean; + autoFallbackToSingleLine: boolean; } export interface AppState { @@ -382,6 +401,9 @@ export interface AppState { setMobileViewActiveColumn: (column: layoutColumn) => void; // Settings actions updateGlobalSettings: (settings: Partial) => void; + // Ignore list actions + addToIgnoreList: (pattern: string) => void; + removeFromIgnoreList: (pattern: string) => void; // Metadata actions metadataGet: (serverId: string, target: string, keys: string[]) => void; metadataList: (serverId: string, target: string) => void; @@ -455,10 +477,16 @@ const useStore = create((set, get) => ({ showQuits: true, // Custom mentions customMentions: [], + // Ignore list + ignoreList: ["HistServ!*@*"], // Hosted chat mode settings nickname: "", accountName: "", accountPassword: "", + // Multiline settings + enableMultilineInput: true, + multilineOnShiftEnter: true, + autoFallbackToSingleLine: true, ...loadSavedGlobalSettings(), // Load saved settings from localStorage }, @@ -1288,6 +1316,54 @@ const useStore = create((set, get) => ({ }); }, + // Ignore list actions + addToIgnoreList: (pattern: string) => { + set((state) => { + const trimmedPattern = pattern.trim(); + if ( + !trimmedPattern || + state.globalSettings.ignoreList.includes(trimmedPattern) + ) { + return state; + } + + const newIgnoreList = [ + ...state.globalSettings.ignoreList, + trimmedPattern, + ]; + const newGlobalSettings = { + ...state.globalSettings, + ignoreList: newIgnoreList, + }; + + // Save to localStorage + saveGlobalSettingsToLocalStorage(newGlobalSettings); + + return { + globalSettings: newGlobalSettings, + }; + }); + }, + + removeFromIgnoreList: (pattern: string) => { + set((state) => { + const newIgnoreList = state.globalSettings.ignoreList.filter( + (p) => p !== pattern, + ); + const newGlobalSettings = { + ...state.globalSettings, + ignoreList: newIgnoreList, + }; + + // Save to localStorage + saveGlobalSettingsToLocalStorage(newGlobalSettings); + + return { + globalSettings: newGlobalSettings, + }; + }); + }, + // Metadata actions metadataGet: (serverId, target, keys) => { if (serverSupportsMetadata(serverId)) { @@ -1401,6 +1477,23 @@ registerAllProtocolHandlers(ircClient, useStore); ircClient.on("CHANMSG", (response) => { const { mtags, channelName, message, timestamp } = response; + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + globalSettings.ignoreList, + ) + ) { + // User is ignored, skip processing this message + console.log( + `[IGNORE] Skipping message from ignored user: ${response.sender}`, + ); + return; + } + // Find the server and channel const server = useStore .getState() @@ -1493,6 +1586,174 @@ ircClient.on("CHANMSG", (response) => { } }); +// Handle multiline messages +ircClient.on("MULTILINE_MESSAGE", (response) => { + console.log("[STORE] Received MULTILINE_MESSAGE:", response); + const { mtags, channelName, sender, message, messageIds, timestamp } = + response; + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { + // User is ignored, skip processing this message + console.log( + `[IGNORE] Skipping multiline message from ignored user: ${sender}`, + ); + return; + } + + // Find the server and channel + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (server) { + const channel = channelName + ? server.channels.find((c) => c.name === channelName) + : null; + + if (channel) { + const replyId = mtags?.["+draft/reply"] + ? mtags["+draft/reply"].trim() + : null; + + const replyMessage = replyId + ? findChannelMessageById(server.id, channel.id, replyId) || null + : null; + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + multilineMessageIds: messageIds, // Store all message IDs for redaction + content: message, // Use the properly combined message from IRC client + timestamp, + userId: sender, + channelId: channel.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: replyMessage, + mentioned: [], // Add logic for mentions if needed + tags: mtags, + }; + + console.log("[STORE] Created multiline message with IDs:", messageIds); + + // If message has bot tag, mark user as bot + if (mtags?.bot !== undefined) { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === sender) { + return { + ...user, + isBot: true, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + return { servers: updatedServers }; + }); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + + // Remove any typing users from the state + useStore.setState((state) => { + const key = `${server.id}-${channel.id}`; + const currentUsers = state.typingUsers[key] || []; + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== sender), + }, + }; + }); + } else if (!channelName) { + // Handle multiline private messages + // Similar logic to USERMSG but for multiline content + const currentUser = ircClient.getCurrentUser(response.serverId); + if (currentUser && sender === currentUser.username) { + return; // Don't create private chats with ourselves + } + + // Create or find private chat + let privateChat = server.privateChats.find( + (chat) => chat.username === sender, + ); + if (!privateChat) { + const newPrivateChat = { + id: uuidv4(), + username: sender, + serverId: server.id, + unreadCount: 0, + isMentioned: false, + }; + privateChat = newPrivateChat; + useStore.setState((state) => ({ + servers: state.servers.map((s) => + s.id === server.id + ? { ...s, privateChats: [...s.privateChats, newPrivateChat] } + : s, + ), + })); + } + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + multilineMessageIds: messageIds, // Store all message IDs for redaction + content: message, // Use the properly combined message from IRC client + timestamp, + userId: sender, + channelId: privateChat.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + } +}); + // Handle private messages (USERMSG) ircClient.on("USERMSG", (response) => { const { mtags, sender, message, timestamp } = response; @@ -1503,6 +1764,16 @@ ircClient.on("USERMSG", (response) => { return; } + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { + // User is ignored, skip processing this message + console.log( + `[IGNORE] Skipping private message from ignored user: ${sender}`, + ); + return; + } + // Find the server const server = useStore .getState() @@ -1625,6 +1896,23 @@ ircClient.on("USERMSG", (response) => { ircClient.on("CHANNNOTICE", (response) => { const { mtags, channelName, message, timestamp } = response; + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + globalSettings.ignoreList, + ) + ) { + // User is ignored, skip processing this notice + console.log( + `[IGNORE] Skipping channel notice from ignored user: ${response.sender}`, + ); + return; + } + // Find the server and channel const server = useStore .getState() @@ -1669,6 +1957,23 @@ ircClient.on("CHANNNOTICE", (response) => { ircClient.on("USERNOTICE", (response) => { const { mtags, message, timestamp } = response; + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + globalSettings.ignoreList, + ) + ) { + // User is ignored, skip processing this notice + console.log( + `[IGNORE] Skipping user notice from ignored user: ${response.sender}`, + ); + return; + } + // Find the server const server = useStore .getState() @@ -1833,14 +2138,15 @@ ircClient.on("JOIN", ({ serverId, username, channelName, batchTag }) => { return server; }); - // Request metadata for the joining user - const currentUser = state.currentUser; - if (currentUser) { - // Small delay to avoid spamming the server - setTimeout(() => { - useStore.getState().metadataList(serverId, username); - }, 100); - } + // Request metadata for the joining user is not needed since we have subscriptions + // The metadata subscription (SUB) will automatically send us updates when metadata changes + // Commenting out to reduce server load and batch spam + // const currentUser = state.currentUser; + // if (currentUser) { + // setTimeout(() => { + // useStore.getState().metadataList(serverId, username); + // }, 100); + // } return { servers: updatedServers }; }); @@ -3585,14 +3891,15 @@ ircClient.on( return { servers: updatedServers }; }); - // Request metadata for the user (except current user) - const currentUser = ircClient.getCurrentUser(serverId); - if (currentUser && user.username !== currentUser.username) { - // Add a small delay to avoid overwhelming the server - setTimeout(() => { - useStore.getState().metadataList(serverId, user.username); - }, 100); - } + // Request metadata for the user (except current user) is not needed since we have subscriptions + // The metadata subscription (SUB) will automatically send us updates when metadata changes + // Commenting out to reduce server load and batch spam + // const currentUser = ircClient.getCurrentUser(serverId); + // if (currentUser && user.username !== currentUser.username) { + // setTimeout(() => { + // useStore.getState().metadataList(serverId, user.username); + // }, 100); + // } }, ); @@ -3670,6 +3977,36 @@ ircClient.on("BATCH_END", ({ serverId, batchId }) => { processBatchedNetsplit(serverId, batchId, batch); } else if (batch.type === "netjoin") { processBatchedNetjoin(serverId, batchId, batch); + } else if (batch.type === "draft/multiline" || batch.type === "multiline") { + // Multiline batches are handled by the IRC client directly via MULTILINE_MESSAGE events + // Don't process individual events here, the IRC client already combined them + console.log(`[BATCH] Multiline batch ${batchId} handled by IRC client`); + } else if (batch.type === "metadata") { + // Metadata batches are handled by the IRC client directly via individual METADATA events + // Don't process individual events here, metadata updates are already processed + console.log(`[BATCH] Metadata batch ${batchId} handled by IRC client`); + } else if (batch.type === "chathistory") { + // Chathistory batch completed - turn off loading state for the channel + console.log(`[BATCH] Chathistory batch ${batchId} completed`); + + // Try to determine the channel from batch parameters + // Chathistory batch parameters typically include the channel name + const channelName = + batch.parameters && batch.parameters.length > 0 + ? batch.parameters[0] + : null; + + if (channelName) { + console.log( + `[CHATHISTORY] History loading completed for ${channelName}`, + ); + // Trigger event to turn off loading state + ircClient.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: false, + }); + } } else { // For unknown batch types, process events individually console.log( @@ -3831,4 +4168,26 @@ function processBatchedNetjoin( }); } +// Handle chathistory loading state +ircClient.on("CHATHISTORY_LOADING", ({ serverId, channelName, isLoading }) => { + console.log( + `[CHATHISTORY] Setting loading state for ${channelName}: ${isLoading}`, + ); + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name === channelName) { + return { ...channel, isLoadingHistory: isLoading }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); +}); + export default useStore; diff --git a/src/types/index.ts b/src/types/index.ts index 061e4359..c6eea457 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -48,6 +48,7 @@ export interface Channel { messages: Message[]; users: User[]; isRead?: boolean; + isLoadingHistory?: boolean; metadata?: Record; } @@ -68,6 +69,7 @@ export interface Reaction { export interface Message { id: string; msgid?: string; // IRC message ID from IRCv3 message-ids capability + multilineMessageIds?: string[]; // For multiline messages: all message IDs that make up this message type: | "message" | "system" diff --git a/test_multiline.md b/test_multiline.md new file mode 100644 index 00000000..eb2c7903 --- /dev/null +++ b/test_multiline.md @@ -0,0 +1,58 @@ +# Multiline Implementation Test + +## Changes Made + +1. **Updated capability negotiation to use `draft/multiline`** instead of `multiline` +2. **Added proper `draft/multiline-concat` tag support** for concatenating long lines +3. **Implemented two distinct behaviors:** + - **Multi-line messages**: Lines joined with `\n` (normal multiline) + - **Long single-line messages**: Lines joined without separator using `draft/multiline-concat` tag + +## Key Behaviors + +### Case 1: Multi-line message (has newlines) +``` +Input: "Hello\nWorld\nHow are you?" +Output: + BATCH +abc123 draft/multiline #channel + @batch=abc123 PRIVMSG #channel :Hello + @batch=abc123 PRIVMSG #channel :World + @batch=abc123 PRIVMSG #channel :How are you? + BATCH -abc123 + +Result: "Hello\nWorld\nHow are you?" +``` + +### Case 2: Single very long line (over 400 chars) +``` +Input: "This is a very long message that exceeds the IRC line limit and needs to be split using multiline-concat to preserve it as a single logical line without line breaks when displayed" +Output: + BATCH +def456 draft/multiline #channel + @batch=def456 PRIVMSG #channel :This is a very long message that exceeds the IRC line limit and needs to be split + @batch=def456;draft/multiline-concat PRIVMSG #channel : using multiline-concat to preserve it as a single logical line without line breaks when displayed + BATCH -def456 + +Result: "This is a very long message that exceeds the IRC line limit and needs to be split using multiline-concat to preserve it as a single logical line without line breaks when displayed" +``` + +### Case 3: Multi-line with some long lines +``` +Input: "Short line\nThis is a very long line that needs to be split but should still be treated as a separate line from the short line above it\nAnother short line" +Output: + BATCH +ghi789 draft/multiline #channel + @batch=ghi789 PRIVMSG #channel :Short line + @batch=ghi789 PRIVMSG #channel :This is a very long line that needs to be split but should still be treated as a separate + @batch=ghi789;draft/multiline-concat PRIVMSG #channel : line from the short line above it + @batch=ghi789 PRIVMSG #channel :Another short line + BATCH -ghi789 + +Result: "Short line\nThis is a very long line that needs to be split but should still be treated as a separate line from the short line above it\nAnother short line" +``` + +## Testing Instructions + +1. Connect to an IRC server that supports `draft/multiline` (like Ergo) +2. Test sending multi-line messages (type with Shift+Enter for new lines) +3. Test sending very long single-line messages (over 400 characters) +4. Verify that line breaks are preserved in multi-line cases +5. Verify that long single lines are displayed as continuous text without unwanted line breaks \ No newline at end of file diff --git a/tests/App.test.tsx b/tests/App.test.tsx index bbe55953..849b8bcc 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -75,9 +75,13 @@ describe("App", () => { showJoinsParts: true, showQuits: true, customMentions: [], + ignoreList: ["HistServ!*@*"], nickname: "", accountName: "", accountPassword: "", + enableMultilineInput: true, + multilineOnShiftEnter: true, + autoFallbackToSingleLine: true, }, }); }); diff --git a/tests/lib/defaultIgnore.test.ts b/tests/lib/defaultIgnore.test.ts new file mode 100644 index 00000000..f52b7db2 --- /dev/null +++ b/tests/lib/defaultIgnore.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { isUserIgnored } from "../../src/lib/ignoreUtils"; + +describe("Default HistServ Ignore", () => { + it("should ignore HistServ by default", () => { + const defaultIgnoreList = ["HistServ!*@*"]; + + // Test that HistServ messages would be ignored + const result = isUserIgnored( + "HistServ", + "histserv", + "services.example.org", + defaultIgnoreList, + ); + expect(result).toBe(true); + }); + + it("should not ignore regular users with default ignore list", () => { + const defaultIgnoreList = ["HistServ!*@*"]; + + // Test that regular users are not ignored + const result = isUserIgnored( + "alice", + "alice_user", + "user.example.com", + defaultIgnoreList, + ); + expect(result).toBe(false); + }); +}); diff --git a/tests/lib/ignoreUtils.test.ts b/tests/lib/ignoreUtils.test.ts new file mode 100644 index 00000000..b427a230 --- /dev/null +++ b/tests/lib/ignoreUtils.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + createIgnorePattern, + isUserIgnored, + isValidIgnorePattern, + matchesIgnorePattern, +} from "../../src/lib/ignoreUtils"; + +describe("ignoreUtils", () => { + describe("matchesIgnorePattern", () => { + it("should match exact patterns", () => { + expect( + matchesIgnorePattern("nick!user@host.com", "nick!user@host.com"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@host.com", "different!user@host.com"), + ).toBe(false); + }); + + it("should match wildcard patterns", () => { + expect(matchesIgnorePattern("baduser!user@host.com", "baduser!*@*")).toBe( + true, + ); + expect( + matchesIgnorePattern("anynick!baduser@host.com", "*!baduser@*"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@badhost.com", "*!*@badhost.com"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@sub.badhost.com", "*!*@*.badhost.com"), + ).toBe(true); + }); + + it("should be case insensitive", () => { + expect( + matchesIgnorePattern("NICK!USER@HOST.COM", "nick!user@host.com"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@host.com", "NICK!USER@HOST.COM"), + ).toBe(true); + }); + + it("should handle invalid patterns gracefully", () => { + expect( + matchesIgnorePattern("nick!user@host.com", "invalid[pattern"), + ).toBe(false); + }); + }); + + describe("isUserIgnored", () => { + const ignoreList = [ + "baduser!*@*", + "*!spammer@*", + "*!*@badhost.com", + "exact!match@host.net", + ]; + + it("should ignore users by nick", () => { + expect( + isUserIgnored("baduser", "anyuser", "anyhost.com", ignoreList), + ).toBe(true); + expect( + isUserIgnored("gooduser", "anyuser", "anyhost.com", ignoreList), + ).toBe(false); + }); + + it("should ignore users by username", () => { + expect( + isUserIgnored("anynick", "spammer", "anyhost.com", ignoreList), + ).toBe(true); + expect( + isUserIgnored("anynick", "gooduser", "anyhost.com", ignoreList), + ).toBe(false); + }); + + it("should ignore users by host", () => { + expect( + isUserIgnored("anynick", "anyuser", "badhost.com", ignoreList), + ).toBe(true); + expect( + isUserIgnored("anynick", "anyuser", "goodhost.com", ignoreList), + ).toBe(false); + }); + + it("should handle partial information", () => { + expect(isUserIgnored("baduser", undefined, undefined, ignoreList)).toBe( + true, + ); + expect(isUserIgnored("anynick", "spammer", undefined, ignoreList)).toBe( + true, + ); + expect( + isUserIgnored("anynick", undefined, "badhost.com", ignoreList), + ).toBe(true); + }); + + it("should handle empty ignore list", () => { + expect(isUserIgnored("baduser", "spammer", "badhost.com", [])).toBe( + false, + ); + }); + }); + + describe("isValidIgnorePattern", () => { + it("should validate correct patterns", () => { + expect(isValidIgnorePattern("nick!user@host")).toBe(true); + expect(isValidIgnorePattern("*!*@*")).toBe(true); + expect(isValidIgnorePattern("baduser!*@*")).toBe(true); + expect(isValidIgnorePattern("*!spammer@*")).toBe(true); + expect(isValidIgnorePattern("*!*@badhost.com")).toBe(true); + expect(isValidIgnorePattern("nick123!user_name@sub.domain.com")).toBe( + true, + ); + }); + + it("should reject invalid patterns", () => { + expect(isValidIgnorePattern("")).toBe(false); + expect(isValidIgnorePattern(" ")).toBe(false); + expect(isValidIgnorePattern("nick@host")).toBe(false); // missing ! + expect(isValidIgnorePattern("nick!user")).toBe(false); // missing @ + expect(isValidIgnorePattern("nick!user@")).toBe(false); // empty host + expect(isValidIgnorePattern("!user@host")).toBe(false); // empty nick + expect(isValidIgnorePattern("nick!@host")).toBe(false); // empty user + expect(isValidIgnorePattern("nick!user@host!extra")).toBe(false); // too many ! + expect(isValidIgnorePattern("nick!user@host@extra")).toBe(false); // too many @ + }); + }); + + describe("createIgnorePattern", () => { + it("should create patterns with all components", () => { + expect(createIgnorePattern("nick", "user", "host")).toBe( + "nick!user@host", + ); + }); + + it("should use wildcards for missing components", () => { + expect(createIgnorePattern("nick")).toBe("nick!*@*"); + expect(createIgnorePattern(undefined, "user")).toBe("*!user@*"); + expect(createIgnorePattern(undefined, undefined, "host")).toBe( + "*!*@host", + ); + expect(createIgnorePattern("nick", "user")).toBe("nick!user@*"); + }); + + it("should handle empty strings", () => { + expect(createIgnorePattern("", "", "")).toBe("*!*@*"); + expect(createIgnorePattern("nick", "", "")).toBe("nick!*@*"); + }); + }); +}); From d135111d54c913f9b26996966a6c64510bba5b8b Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 06:13:03 +0100 Subject: [PATCH 38/47] Try out znc playback --- src/lib/ircClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 8686e24a..32f234de 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -1566,6 +1566,7 @@ export class IRCClient { "draft/account-registration", "batch", "draft/multiline", + "znc.in/playback", ]; let accumulated = this.capLsAccumulated.get(serverId); From 84795762b4972c53ab2c7f4578d8b55dce18f448 Mon Sep 17 00:00:00 2001 From: Valerie Liu <79415174+ValwareIRC@users.noreply.github.com> Date: Thu, 2 Oct 2025 18:56:27 +0100 Subject: [PATCH 39/47] Update src/store/index.ts Co-authored-by: Matheus Fillipe --- src/store/index.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/store/index.ts b/src/store/index.ts index 3d040d4e..9ed9ee15 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -3891,15 +3891,6 @@ ircClient.on( return { servers: updatedServers }; }); - // Request metadata for the user (except current user) is not needed since we have subscriptions - // The metadata subscription (SUB) will automatically send us updates when metadata changes - // Commenting out to reduce server load and batch spam - // const currentUser = ircClient.getCurrentUser(serverId); - // if (currentUser && user.username !== currentUser.username) { - // setTimeout(() => { - // useStore.getState().metadataList(serverId, user.username); - // }, 100); - // } }, ); From 95fa443f1c360df9107a0808988844fc3323595e Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 2 Oct 2025 18:58:55 +0100 Subject: [PATCH 40/47] Bump biome apparently O.o --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index d3316cdf..fe7f2f42 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", "vcs": { "enabled": false, "clientKind": "git", From e7bcd09216afa0659253bfa19274e918ebb97a0a Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 19:45:44 +0100 Subject: [PATCH 41/47] Fix mobile display issues --- biome.json | 2 +- src/components/layout/AppLayout.tsx | 14 +++++++------- src/components/layout/ChannelList.tsx | 25 +++++++++++++++++-------- src/components/layout/ServerList.tsx | 5 ----- src/store/index.ts | 9 ++++++++- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/biome.json b/biome.json index fe7f2f42..d3316cdf 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "vcs": { "enabled": false, "clientKind": "git", diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index d87b16c4..d2abddd5 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -70,7 +70,13 @@ export const AppLayout: React.FC = () => { <> {__HIDE_SERVER_LIST__ ? null : (
@@ -194,12 +200,6 @@ export const AppLayout: React.FC = () => { isDarkMode ? "text-white" : "text-gray-900" }`} > - {/* Debug indicator for mobile */} - {isNarrowView && ( -
- Mobile: {mobileViewActiveColumn} -
- )} {getLayoutColumn("serverList")} {getLayoutColumn("chatView")} {selectedServerId && getLayoutColumn("memberList")} diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index ac65f09c..d65c9d53 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -31,6 +31,7 @@ export const ChannelList: React.FC<{ leaveChannel, deletePrivateChat, toggleUserProfileModal, + setMobileViewActiveColumn, } = useStore(); // Get the current user for the selected server from the store data (includes metadata) @@ -111,6 +112,16 @@ export const ChannelList: React.FC<{ const isNarrowView = useMediaQuery(); + const handleCollapseClick = () => { + if (isNarrowView) { + // On mobile, navigate to chat view + setMobileViewActiveColumn("chatView"); + } else { + // On desktop, toggle the channel list + onToggle(); + } + }; + return (
{/* Server header */} @@ -118,14 +129,12 @@ export const ChannelList: React.FC<{

{selectedServer?.name || "Home"}

- {!isNarrowView && ( - - )} +
{/* Channel list */} diff --git a/src/components/layout/ServerList.tsx b/src/components/layout/ServerList.tsx index 211224ad..1c33283f 100644 --- a/src/components/layout/ServerList.tsx +++ b/src/components/layout/ServerList.tsx @@ -24,11 +24,6 @@ export const ServerList: React.FC = () => { return (
- {/* Mobile debug indicator */} -
- ServerList Active -
- {/* Home button - in Discord this would be DMs */}
((set, get) => ({ set((state) => { const openState = isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; + + // Only change mobileViewActiveColumn if we're not on the serverList view + // This prevents desktop member list toggles from affecting mobile navigation + const shouldUpdateMobileColumn = state.ui.mobileViewActiveColumn !== "serverList"; + return { ui: { ...state.ui, isMemberListVisible: openState !== undefined ? openState : !state.ui.isMemberListVisible, - mobileViewActiveColumn: openState ? "memberList" : "chatView", + mobileViewActiveColumn: shouldUpdateMobileColumn + ? (openState ? "memberList" : "chatView") + : state.ui.mobileViewActiveColumn, }, }; }); From 2410bc27d3672ca1e4a2cd42c3a09dfe7c07be59 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 19:45:49 +0100 Subject: [PATCH 42/47] Fix mobile display issues --- src/components/layout/AppLayout.tsx | 10 +++++----- src/store/index.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index d2abddd5..5294194c 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -71,11 +71,11 @@ export const AppLayout: React.FC = () => { {__HIDE_SERVER_LIST__ ? null : (
diff --git a/src/store/index.ts b/src/store/index.ts index 4082fb1e..3f71d786 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1207,18 +1207,21 @@ const useStore = create((set, get) => ({ set((state) => { const openState = isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; - + // Only change mobileViewActiveColumn if we're not on the serverList view // This prevents desktop member list toggles from affecting mobile navigation - const shouldUpdateMobileColumn = state.ui.mobileViewActiveColumn !== "serverList"; - + const shouldUpdateMobileColumn = + state.ui.mobileViewActiveColumn !== "serverList"; + return { ui: { ...state.ui, isMemberListVisible: openState !== undefined ? openState : !state.ui.isMemberListVisible, - mobileViewActiveColumn: shouldUpdateMobileColumn - ? (openState ? "memberList" : "chatView") + mobileViewActiveColumn: shouldUpdateMobileColumn + ? openState + ? "memberList" + : "chatView" : state.ui.mobileViewActiveColumn, }, }; From 20bc2d118b2cfad7033fa83cf20461e6ec795965 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 19:48:16 +0100 Subject: [PATCH 43/47] Bump biome apparently O.o --- src/store/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/store/index.ts b/src/store/index.ts index 3f71d786..bdb3f415 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -3900,7 +3900,6 @@ ircClient.on( return { servers: updatedServers }; }); - }, ); From 429f9f2d33be5e432ca62243728aa3574794af23 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 20:27:17 +0100 Subject: [PATCH 44/47] More fixes regarding metadata syncing sessions over shared sessions like in ergo --- src/components/layout/ChannelList.tsx | 24 +- src/components/layout/MemberList.tsx | 5 + src/components/message/ActionMessage.tsx | 1 + src/components/message/MessageAvatar.tsx | 7 + src/components/message/MessageItem.tsx | 1 + src/components/ui/AddServerModal.tsx | 11 +- src/lib/ircClient.ts | 46 +++- src/store/index.ts | 281 +++++++++++++++++++++-- src/types/index.ts | 5 + 9 files changed, 344 insertions(+), 37 deletions(-) diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index d65c9d53..888d204b 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -86,12 +86,16 @@ export const ChannelList: React.FC<{ setAvatarLoadFailed(false); }, [currentUser?.username, selectedServerId]); - // Get user status from metadata or fallback to direct property + // Get user status based on server connection and away status const userStatus = useMemo(() => { - return ( - currentUser?.metadata?.status?.value || currentUser?.status || "offline" - ); - }, [currentUser]); + if (!selectedServer || !selectedServer.isConnected) { + return "offline"; + } + if (selectedServer.isAway) { + return "away"; + } + return "online"; + }, [selectedServer]); const handleAddChannel = () => { if (selectedServerId && newChannelName.trim()) { @@ -438,7 +442,7 @@ export const ChannelList: React.FC<{ )}
@@ -448,11 +452,9 @@ export const ChannelList: React.FC<{
{userStatus === "online" ? "Online" - : userStatus === "idle" - ? "Idle" - : userStatus === "dnd" - ? "Do Not Disturb" - : "Offline"} + : userStatus === "away" + ? selectedServer?.awayMessage || "Away" + : "Offline"}
diff --git a/src/components/layout/MemberList.tsx b/src/components/layout/MemberList.tsx index 6f71a5d9..831e554c 100644 --- a/src/components/layout/MemberList.tsx +++ b/src/components/layout/MemberList.tsx @@ -99,6 +99,11 @@ const UserItem: React.FC<{ ) : ( user.username.charAt(0).toUpperCase() )} + {/* Presence indicator - green if here, yellow if away */} +
+ {/* Status metadata indicator (if set via metadata) */} {status && (
diff --git a/src/components/message/ActionMessage.tsx b/src/components/message/ActionMessage.tsx index b8e4b7c1..da6b6056 100644 --- a/src/components/message/ActionMessage.tsx +++ b/src/components/message/ActionMessage.tsx @@ -52,6 +52,7 @@ export const ActionMessage: React.FC = ({ userId={message.userId} avatarUrl={messageUser?.metadata?.avatar?.value} userStatus={messageUser?.metadata?.status?.value} + isAway={messageUser?.isAway} theme="discord" showHeader={true} onClick={(e) => { diff --git a/src/components/message/MessageAvatar.tsx b/src/components/message/MessageAvatar.tsx index 0e5c6d2a..aeba4b4b 100644 --- a/src/components/message/MessageAvatar.tsx +++ b/src/components/message/MessageAvatar.tsx @@ -5,6 +5,7 @@ interface MessageAvatarProps { userId: string; avatarUrl?: string; userStatus?: string; + isAway?: boolean; theme: string; showHeader: boolean; onClick?: (e: React.MouseEvent) => void; @@ -15,6 +16,7 @@ export const MessageAvatar: React.FC = ({ userId, avatarUrl, userStatus, + isAway, theme, showHeader, onClick, @@ -55,6 +57,11 @@ export const MessageAvatar: React.FC = ({ ) : ( username.charAt(0).toUpperCase() )} + {/* Presence indicator - green if here, yellow if away */} +
+ {/* Status metadata indicator (if set via metadata) */} {userStatus && (
diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx index 64e393b5..65c345df 100644 --- a/src/components/message/MessageItem.tsx +++ b/src/components/message/MessageItem.tsx @@ -210,6 +210,7 @@ export const MessageItem: React.FC = ({ userId={message.userId} avatarUrl={avatarUrl} userStatus={userStatus} + isAway={messageUser?.isAway} theme={theme} showHeader={showHeader} onClick={handleAvatarClick} diff --git a/src/components/ui/AddServerModal.tsx b/src/components/ui/AddServerModal.tsx index 263628b0..ba092aa3 100644 --- a/src/components/ui/AddServerModal.tsx +++ b/src/components/ui/AddServerModal.tsx @@ -40,7 +40,13 @@ export const AddServerModal: React.FC = () => { e.preventDefault(); setError(""); - if (!serverName.trim()) { + // Default server name to server host if empty + const finalServerName = serverName.trim() || serverHost.trim(); + + // Default SASL account name to nickname if empty + const finalSaslAccountName = saslAccountName.trim() || nickname.trim(); + + if (!finalServerName) { setError("Server name is required"); return; } @@ -62,12 +68,13 @@ export const AddServerModal: React.FC = () => { try { await connect( + finalServerName, serverHost, Number.parseInt(serverPort, 10), nickname, !!saslPassword, password, - saslAccountName, + finalSaslAccountName, saslPassword, registerAccount, registerEmail, diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 32f234de..df3312b5 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -155,6 +155,19 @@ export interface EventMap { target: string; message: string; }; + AWAY: { + serverId: string; + username: string; + awayMessage?: string; + }; + RPL_NOWAWAY: { + serverId: string; + message: string; + }; + RPL_UNAWAY: { + serverId: string; + message: string; + }; NICK_ERROR: { serverId: string; code: string; @@ -207,6 +220,7 @@ export class IRCClient { public version = __APP_VERSION__; connect( + name: string, host: string, port: number, nickname: string, @@ -246,9 +260,12 @@ export class IRCClient { } // Create server object immediately and add to servers map + // Use provided name, default to host if name is empty + const finalName = name?.trim() || host; + const server: Server = { id: serverId || uuidv4(), - name: host, + name: finalName, host, port, channels: [], @@ -790,6 +807,17 @@ export class IRCClient { reason, batchTag: mtags?.batch, }); + } else if (command === "AWAY") { + // AWAY command for away-notify extension + // Format: :nick!user@host AWAY :away message + // or: :nick!user@host AWAY (when user returns) + const username = getNickFromNuh(source); + const awayMessage = parv.length > 0 ? parv.join(" ") : undefined; + this.triggerEvent("AWAY", { + serverId, + username, + awayMessage, + }); } else if (command === "JOIN") { const username = getNickFromNuh(source); const channelName = parv[0][0] === ":" ? parv[0].substring(1) : parv[0]; @@ -1365,6 +1393,22 @@ export class IRCClient { hopcount, realname, }); + } else if (command === "305") { + // RPL_UNAWAY: : + // You are no longer marked as being away + const message = parv.slice(1).join(" "); + this.triggerEvent("RPL_UNAWAY", { + serverId, + message, + }); + } else if (command === "306") { + // RPL_NOWAWAY: : + // You have been marked as being away + const message = parv.slice(1).join(" "); + this.triggerEvent("RPL_NOWAWAY", { + serverId, + message, + }); } else if (command === "315") { // RPL_ENDOFWHO const mask = parv[1]; diff --git a/src/store/index.ts b/src/store/index.ts index bdb3f415..643d7211 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -208,6 +208,59 @@ function restoreServerMetadata(serverId: string) { }); } +// Fetch our own metadata from the server and update saved values +async function fetchAndMergeOwnMetadata(serverId: string): Promise { + return new Promise((resolve) => { + const nickname = ircClient.getNick(serverId); + if (!nickname) { + console.log(`[METADATA_FETCH] No nickname found for server ${serverId}`); + resolve(); + return; + } + + console.log( + `[METADATA_FETCH] Fetching metadata for ${nickname} on server ${serverId}`, + ); + + // Mark as fetching + useStore.setState((state) => ({ + metadataFetchInProgress: { + ...state.metadataFetchInProgress, + [serverId]: true, + }, + })); + + // Request all metadata for ourselves (target "*" means us) + const defaultKeys = [ + "url", + "website", + "status", + "location", + "avatar", + "color", + "display-name", + ]; + + // Get our metadata from the server + ircClient.metadataGet(serverId, "*", defaultKeys); + + // Wait a bit for responses to come in, then resolve + // The METADATA_KEYVALUE handler will update saved values + setTimeout(() => { + console.log( + `[METADATA_FETCH] Metadata fetch completed for server ${serverId}`, + ); + useStore.setState((state) => ({ + metadataFetchInProgress: { + ...state.metadataFetchInProgress, + [serverId]: false, + }, + })); + resolve(); + }, 1000); + }); +} + interface UIState { selectedServerId: string | null; selectedChannelId: string | null; @@ -296,6 +349,7 @@ export interface AppState { } >; // batchId -> batch info activeBatches: Record>; // serverId -> batchId -> batch info + metadataFetchInProgress: Record; // serverId -> is fetching own metadata // Account registration state pendingRegistration: { serverId: string; @@ -308,6 +362,7 @@ export interface AppState { globalSettings: GlobalSettings; // Actions connect: ( + name: string, host: string, port: number, nickname: string, @@ -436,6 +491,7 @@ const useStore = create((set, get) => ({ metadataSubscriptions: {}, metadataBatches: {}, activeBatches: {}, + metadataFetchInProgress: {}, pendingRegistration: null, selectedServerId: null, @@ -492,6 +548,7 @@ const useStore = create((set, get) => ({ // IRC client actions connect: async ( + name, host, port, nickname, @@ -523,6 +580,7 @@ const useStore = create((set, get) => ({ ); const server = await ircClient.connect( + name, host, port, nickname, @@ -544,6 +602,7 @@ const useStore = create((set, get) => ({ ); updatedServers.push({ id: server.id, // Include the server ID here + name: server.name, // Save the server name host, port, nickname, @@ -1092,6 +1151,7 @@ const useStore = create((set, get) => ({ connectToSavedServers: async () => { const savedServers = loadSavedServers(); for (const { + name, host, port, nickname, @@ -1103,6 +1163,7 @@ const useStore = create((set, get) => ({ } of savedServers) { try { const server = await get().connect( + name || host, // Use saved name, default to host host, port, nickname, @@ -2430,7 +2491,7 @@ ircClient.on("QUIT", ({ serverId, username, reason, batchTag }) => { } }); -ircClient.on("ready", ({ serverId, serverName, nickname }) => { +ircClient.on("ready", async ({ serverId, serverName, nickname }) => { console.log(`Server ready: serverId=${serverId}, serverName=${serverName}`); // Restore metadata for this server @@ -2440,7 +2501,7 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { // Only if server supports metadata if (serverSupportsMetadata(serverId)) { console.log( - `[READY] Server ${serverId} supports metadata, setting up subscriptions and restoring data`, + `[READY] Server ${serverId} supports metadata, setting up subscriptions and checking existing data`, ); // First, subscribe to metadata updates @@ -2469,24 +2530,32 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { ); } - // Then restore saved metadata + // Fetch our own metadata from the server first + // This will update saved values with what the server has + console.log(`[READY] Fetching own metadata from server ${serverId}`); + await fetchAndMergeOwnMetadata(serverId); + + // Now send any metadata we have saved (updated values after merge) const savedMetadata = loadSavedMetadata(); const serverMetadata = savedMetadata[serverId]; - if (serverMetadata) { - console.log(`Restoring metadata for server ${serverId}:`, serverMetadata); - // Send all saved metadata to the server - Object.entries(serverMetadata).forEach(([target, metadata]) => { - Object.entries(metadata).forEach(([key, { value, visibility }]) => { + const ourNick = ircClient.getNick(serverId); + + if (serverMetadata && ourNick) { + console.log(`[READY] Sending updated metadata for server ${serverId}`); + const ourMetadata = serverMetadata[ourNick]; + if (ourMetadata) { + // Send our own metadata to the server + Object.entries(ourMetadata).forEach(([key, { value, visibility }]) => { if (value !== undefined) { console.log( - `Sending metadata: target=${target}, key=${key}, value=${value}`, + `[READY] Sending metadata: target=*, key=${key}, value=${value}`, ); useStore .getState() - .metadataSet(serverId, target, key, value, visibility); + .metadataSet(serverId, "*", key, value, visibility); } }); - }); + } } } else { console.log(`[READY] Server ${serverId} does not support metadata`); @@ -3474,6 +3543,10 @@ ircClient.on( console.log( `[METADATA_KEYVALUE] Received: server=${serverId}, target=${target}, key=${key}, value=${value}, visibility=${visibility}`, ); + + const state = useStore.getState(); + const isFetchingOwn = state.metadataFetchInProgress[serverId]; + // Handle individual key-value responses (similar to METADATA) useStore.setState((state) => { // Resolve the target - if it's "*", it refers to the current user @@ -3485,6 +3558,24 @@ ircClient.on( console.log( `[METADATA_KEYVALUE] Resolving target "${target}" to "${resolvedTarget}"`, ); + + // If we're fetching our own metadata, update saved values + if (isFetchingOwn && target === "*") { + console.log( + `[METADATA_KEYVALUE] Updating saved metadata during fetch: ${key}=${value}`, + ); + const savedMetadata = loadSavedMetadata(); + if (!savedMetadata[serverId]) { + savedMetadata[serverId] = {}; + } + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; + } + // Overwrite saved value with server value + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; + saveMetadataToLocalStorage(savedMetadata); + } + console.log( `[METADATA_KEYVALUE] Looking for user in ${state.servers.find((s) => s.id === serverId)?.channels.length || 0} channels`, ); @@ -3549,16 +3640,18 @@ ircClient.on( ); } - // Save metadata to localStorage - const savedMetadata = loadSavedMetadata(); - if (!savedMetadata[serverId]) { - savedMetadata[serverId] = {}; - } - if (!savedMetadata[serverId][resolvedTarget]) { - savedMetadata[serverId][resolvedTarget] = {}; + // Save metadata to localStorage (unless we're in fetch mode - already saved above) + if (!isFetchingOwn || target !== "*") { + const savedMetadata = loadSavedMetadata(); + if (!savedMetadata[serverId]) { + savedMetadata[serverId] = {}; + } + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; + } + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; + saveMetadataToLocalStorage(savedMetadata); } - savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; - saveMetadataToLocalStorage(savedMetadata); return { servers: updatedServers, currentUser: updatedCurrentUser }; }); @@ -3567,8 +3660,30 @@ ircClient.on( ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { console.log( - `[METADATA] Key not set: server=${serverId}, target=${target}, key=${key}`, + `[METADATA_KEYNOTSET] Key not set: server=${serverId}, target=${target}, key=${key}`, ); + + const state = useStore.getState(); + const isFetchingOwn = state.metadataFetchInProgress[serverId]; + + // Resolve the target - if it's "*", it refers to the current user + const resolvedTarget = + target === "*" + ? ircClient.getNick(serverId) || state.currentUser?.username || target + : target; + + // If we're fetching our own metadata and the key is not set, delete it from saved values + if (isFetchingOwn && target === "*") { + console.log( + `[METADATA_KEYNOTSET] Removing key from saved metadata during fetch: ${key}`, + ); + const savedMetadata = loadSavedMetadata(); + if (savedMetadata[serverId]?.[resolvedTarget]?.[key]) { + delete savedMetadata[serverId][resolvedTarget][key]; + saveMetadataToLocalStorage(savedMetadata); + } + } + // Handle key not set responses useStore.setState((state) => { const updatedServers = state.servers.map((server) => { @@ -3576,7 +3691,7 @@ ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { // Remove metadata for users in channels const updatedChannels = server.channels.map((channel) => { const updatedUsers = channel.users.map((user) => { - if (user.username === target) { + if (user.username === resolvedTarget) { const metadata = user.metadata || {}; delete metadata[key]; return { ...user, metadata }; @@ -3586,12 +3701,16 @@ ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { // Remove metadata for the channel itself if target matches channel name const channelMetadata = channel.metadata || {}; - if (target === channel.name || target.startsWith("#")) { + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { delete channelMetadata[key]; } return { ...channel, users: updatedUsers, metadata: channelMetadata }; }); + return { ...server, channels: updatedChannels }; } return server; }); @@ -3831,7 +3950,16 @@ ircClient.on( // Parse channel status from flags (e.g., "H@" means here and operator) let channelStatus = ""; + let isAway = false; + if (flags) { + // First character indicates here (H) or gone/away (G) + if (flags[0] === "G") { + isAway = true; + } else if (flags[0] === "H") { + isAway = false; + } + // Extract channel status prefixes from flags const statusChars = flags.match(/[~&@%+]/g); if (statusChars) { @@ -3845,6 +3973,7 @@ ircClient.on( username: nick, avatar: undefined, isOnline: true, + isAway: isAway, isBot: false, status: channelStatus, // Set the channel status here metadata: {}, @@ -3936,6 +4065,112 @@ ircClient.on("WHOIS_BOT", ({ serverId, target }) => { }); }); +// AWAY event handler for away-notify extension +ircClient.on("AWAY", ({ serverId, username, awayMessage }) => { + console.log( + `[AWAY] User ${username} on server ${serverId} away status changed: ${awayMessage ? "away" : "here"}`, + ); + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + // Update user in all channels they're in + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === username) { + return { + ...user, + isAway: !!awayMessage, + awayMessage: awayMessage || undefined, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + + // Update current user if this is us + let updatedCurrentUser = state.currentUser; + if (state.currentUser?.username === username) { + updatedCurrentUser = { + ...state.currentUser, + isAway: !!awayMessage, + awayMessage: awayMessage || undefined, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + +// Handle 306 numeric - we are now marked as away +ircClient.on("RPL_NOWAWAY", ({ serverId, message }) => { + console.log( + `[RPL_NOWAWAY] We are now marked as away on server ${serverId}: ${message}`, + ); + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + isAway: true, + awayMessage: message, + }; + } + return s; + }); + + // Update current user if this is the selected server + let updatedCurrentUser = state.currentUser; + if (state.ui.selectedServerId === serverId && state.currentUser) { + updatedCurrentUser = { + ...state.currentUser, + isAway: true, + awayMessage: message, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + +// Handle 305 numeric - we are no longer marked as away +ircClient.on("RPL_UNAWAY", ({ serverId, message }) => { + console.log( + `[RPL_UNAWAY] We are no longer marked as away on server ${serverId}: ${message}`, + ); + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + isAway: false, + awayMessage: undefined, + }; + } + return s; + }); + + // Update current user if this is the selected server + let updatedCurrentUser = state.currentUser; + if (state.ui.selectedServerId === serverId && state.currentUser) { + updatedCurrentUser = { + ...state.currentUser, + isAway: false, + awayMessage: undefined, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + // Batch event handlers ircClient.on("BATCH_START", ({ serverId, batchId, type, parameters }) => { console.log(`[BATCH] Starting batch: ${batchId} of type ${type}`); diff --git a/src/types/index.ts b/src/types/index.ts index c6eea457..ff157c44 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,8 @@ export interface User { displayName?: string; account?: string; isOnline: boolean; + isAway?: boolean; // Whether user is marked as away (from WHO flags or AWAY notify) + awayMessage?: string; // Away message if user is away status?: string; isBot?: boolean; // Bot detection from WHO response metadata?: Record; @@ -19,6 +21,8 @@ export interface Server { privateChats: PrivateChat[]; icon?: string; isConnected: boolean; + isAway?: boolean; // Whether we are marked as away on this server + awayMessage?: string; // Our away message on this server users: User[]; capabilities?: string[]; metadata?: Record; @@ -27,6 +31,7 @@ export interface Server { } export interface ServerConfig { id: string; + name?: string; host: string; port: number; nickname: string; From ba83f8b56a6097bfb306339568b1121b4b361852 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 20:31:31 +0100 Subject: [PATCH 45/47] Update tests --- tests/App.test.tsx | 3 ++- tests/lib/ircClient.test.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/App.test.tsx b/tests/App.test.tsx index 849b8bcc..a2761067 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -145,11 +145,12 @@ describe("App", () => { // Verify connection attempt expect(ircClient.connect).toHaveBeenCalledWith( + "Test Server", "irc.test.com", 443, "tester", "", - "", + "tester", "c3VwZXIgYXdlc29tZSBwYXNzd29yZCBsbWFvIDEyMyAhPyE/IQ==", undefined, ); diff --git a/tests/lib/ircClient.test.ts b/tests/lib/ircClient.test.ts index 3caa920a..78ba0c59 100644 --- a/tests/lib/ircClient.test.ts +++ b/tests/lib/ircClient.test.ts @@ -83,6 +83,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -94,7 +95,7 @@ describe("IRCClient", () => { const server = await connectionPromise; expect(server).toBeDefined(); - expect(server.name).toBe("irc.example.com"); + expect(server.name).toBe("Test Server"); expect(server.isConnected).toBe(true); // Verify sent messages @@ -108,6 +109,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -130,6 +132,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket1); const firstConnectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -139,13 +142,14 @@ describe("IRCClient", () => { const firstServer = await firstConnectionPromise; expect(firstServer).toBeDefined(); - expect(firstServer.name).toBe("irc.example.com"); + expect(firstServer.name).toBe("Test Server"); expect(firstServer.isConnected).toBe(true); expect(mockSocket1.sentMessages).toContain("CAP LS 302"); expect(MockWebSocketSpy).toHaveBeenCalledTimes(1); // Second connection to same host/port should return existing server const secondConnectionPromise = client.connect( + "Test Server 2", "irc.example.com", 443, "testuser2", // Different nickname @@ -168,6 +172,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -212,6 +217,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", From c6d2b5c3e3baf5aff26e987581ad9bb0f729d9de Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 21:08:40 +0100 Subject: [PATCH 46/47] Modify input placeholder on mobiles --- src/components/layout/ChatArea.tsx | 38 +++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 0952489a..36cdeb88 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -196,6 +196,11 @@ export const ChatArea: React.FC<{ selectedChannelId, selectedPrivateChatId, isMemberListVisible, + isSettingsModalOpen, + isUserProfileModalOpen, + isAddServerModalOpen, + isChannelListModalOpen, + isChannelRenameModalOpen, }, toggleMemberList, openPrivateChat, @@ -1208,11 +1213,30 @@ export const ChatArea: React.FC<{ const isNarrowView = useMediaQuery(); // Focus input on channel change + // biome-ignore lint/correctness/useExhaustiveDependencies(selectedChannelId): Only focus when channel changes + // biome-ignore lint/correctness/useExhaustiveDependencies(selectedPrivateChatId): Only focus when private chat changes useEffect(() => { if ("__TAURI__" in window && ["android", "ios"].includes(platform())) return; + // Don't steal focus if any modal is open + if ( + isSettingsModalOpen || + isUserProfileModalOpen || + isAddServerModalOpen || + isChannelListModalOpen || + isChannelRenameModalOpen + ) + return; inputRef.current?.focus(); - }); + }, [ + selectedChannelId, + selectedPrivateChatId, + isSettingsModalOpen, + isUserProfileModalOpen, + isAddServerModalOpen, + isChannelListModalOpen, + isChannelRenameModalOpen, + ]); return (
@@ -1481,7 +1505,11 @@ export const ChatArea: React.FC<{ placeholder={ selectedChannel ? `Message #${selectedChannel.name.replace(/^#/, "")}${ - globalSettings.enableMultilineInput + globalSettings.enableMultilineInput && + !( + "__TAURI__" in window && + ["android", "ios"].includes(platform()) + ) ? globalSettings.multilineOnShiftEnter ? " (Shift+Enter for new line)" : " (Enter for new line, Shift+Enter to send)" @@ -1489,7 +1517,11 @@ export const ChatArea: React.FC<{ }` : selectedPrivateChat ? `Message @${selectedPrivateChat.username}${ - globalSettings.enableMultilineInput + globalSettings.enableMultilineInput && + !( + "__TAURI__" in window && + ["android", "ios"].includes(platform()) + ) ? globalSettings.multilineOnShiftEnter ? " (Shift+Enter for new line)" : " (Enter for new line, Shift+Enter to send)" From fb584e1ca87b5e4c7ef77ac816a7beea5cfad161 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Sat, 4 Oct 2025 21:12:06 +0100 Subject: [PATCH 47/47] Delete server info when deleting a server --- src/store/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/store/index.ts b/src/store/index.ts index 643d7211..57a033bc 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1192,6 +1192,11 @@ const useStore = create((set, get) => ({ ); saveServersToLocalStorage(updatedServers); + // Remove server's metadata from localStorage + const savedMetadata = loadSavedMetadata(); + delete savedMetadata[serverId]; + saveMetadataToLocalStorage(savedMetadata); + // Update state const remainingServers = state.servers.filter( (server) => server.id !== serverId,