+ );
+
+ 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
+
+
+
+ {categories.map((category) => {
+ const Icon = category.icon;
+ return (
+ setActiveCategory(category.id)}
+ className={`w-full flex items-center px-3 py-2 mb-1 rounded text-left transition-colors ${
+ activeCategory === category.id
+ ? "bg-discord-primary text-white"
+ : "text-discord-text-muted hover:text-white hover:bg-discord-dark-400"
+ }`}
+ >
+
+ {category.name}
+
+ );
+ })}
+
+
+
+
+ {/* Main content */}
+
+
+
+ {categories.find((c) => c.id === activeCategory)?.name}
+
+
- Online
- Idle
- Do Not Disturb
- Invisible
-
+
+
-
-
- Email (Read-Only)
-
-
+
+ {renderActiveCategory()}
-
-
-
toggleUserProfileModal(false)}
- className="px-4 py-2 bg-discord-dark-400 text-discord-text-normal rounded font-medium hover:bg-discord-dark-300"
- >
- Cancel
-
-
- {currentServer
- ? supportsMetadata
- ? "Save Changes"
- : "Save Display Name"
- : "No Server Selected"}
-
+
+
+ Cancel
+
+
+ {hasUnsavedChanges ? "Save Changes" : "No Changes"}
+
+
);
-};
+});
export default UserSettings;
diff --git a/src/hooks/useKeyboardResize.ts b/src/hooks/useKeyboardResize.ts
new file mode 100644
index 00000000..fac24297
--- /dev/null
+++ b/src/hooks/useKeyboardResize.ts
@@ -0,0 +1,137 @@
+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(() => {
+ // 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;
+
+ 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");
+ };
+ }, []);
+};
diff --git a/src/index.css b/src/index.css
index 25ff5df9..8d70375d 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,26 @@ 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;
+ position: relative;
+ }
+}
+
+/* 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/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/ignoreUtils.ts b/src/lib/ignoreUtils.ts
new file mode 100644
index 00000000..bae86871
--- /dev/null
+++ b/src/lib/ignoreUtils.ts
@@ -0,0 +1,126 @@
+/**
+ * Utility functions for handling ignore list patterns and matching
+ */
+
+/**
+ * Check if a hostmask (nick!user@host) matches an ignore pattern
+ * Supports patterns like:
+ * - nick!*@* (ignore by nick)
+ * - *!user@* (ignore by user)
+ * - *!*@host (ignore by host)
+ * - nick!user@host (exact match)
+ * - *!*@*.domain.com (wildcard matching)
+ */
+export function matchesIgnorePattern(
+ hostmask: string,
+ pattern: string,
+): boolean {
+ // Normalize both strings to lowercase for case-insensitive matching
+ const normalizedHostmask = hostmask.toLowerCase();
+ const normalizedPattern = pattern.toLowerCase();
+
+ // Convert IRC wildcard pattern to regex
+ // * matches any number of characters (including none)
+ // ? matches exactly one character
+ const regexPattern = normalizedPattern
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ?
+ .replace(/\*/g, ".*") // Convert * to .*
+ .replace(/\?/g, "."); // Convert ? to .
+
+ try {
+ const regex = new RegExp(`^${regexPattern}$`);
+ return regex.test(normalizedHostmask);
+ } catch (error) {
+ console.warn(`Invalid ignore pattern: ${pattern}`, error);
+ return false;
+ }
+}
+
+/**
+ * Check if a user should be ignored based on the ignore list
+ * @param nick - User's nickname
+ * @param user - User's username (optional)
+ * @param host - User's hostname (optional)
+ * @param ignoreList - Array of ignore patterns
+ */
+export function isUserIgnored(
+ nick: string,
+ user?: string,
+ host?: string,
+ ignoreList: string[] = [],
+): boolean {
+ if (ignoreList.length === 0) return false;
+
+ // Build possible hostmask variations to check
+ const hostmasks: string[] = [];
+
+ // Full hostmask if all parts available
+ if (user && host) {
+ hostmasks.push(`${nick}!${user}@${host}`);
+ }
+
+ // Partial hostmasks
+ if (user) {
+ hostmasks.push(`${nick}!${user}@*`);
+ }
+ if (host) {
+ hostmasks.push(`${nick}!*@${host}`);
+ }
+
+ // Nick-only
+ hostmasks.push(`${nick}!*@*`);
+
+ // Check each hostmask against all ignore patterns
+ for (const hostmask of hostmasks) {
+ for (const pattern of ignoreList) {
+ if (matchesIgnorePattern(hostmask, pattern)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Validate an ignore pattern format
+ */
+export function isValidIgnorePattern(pattern: string): boolean {
+ if (!pattern || pattern.trim().length === 0) {
+ return false;
+ }
+
+ const trimmed = pattern.trim();
+
+ // Must contain at least one ! and one @
+ const exclamationCount = (trimmed.match(/!/g) || []).length;
+ const atCount = (trimmed.match(/@/g) || []).length;
+
+ if (exclamationCount !== 1 || atCount !== 1) {
+ return false;
+ }
+
+ // Should be in format nick!user@host
+ const parts = trimmed.split("!");
+ if (parts.length !== 2) return false;
+
+ const [nick, userHost] = parts;
+ const userHostParts = userHost.split("@");
+ if (userHostParts.length !== 2) return false;
+
+ const [user, host] = userHostParts;
+
+ // All parts should have at least some content (even if it's just *)
+ return nick.length > 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 55ef9993..df3312b5 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,8 +68,17 @@ 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 };
+ 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;
@@ -142,6 +155,31 @@ 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;
+ error: string;
+ nick?: string;
+ message: string;
+ };
+ CHATHISTORY_LOADING: {
+ serverId: string;
+ channelName: string;
+ isLoading: boolean;
+ };
}
type EventKey = keyof EventMap;
@@ -151,11 +189,29 @@ 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();
+ 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 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[];
@@ -164,6 +220,7 @@ export class IRCClient {
public version = __APP_VERSION__;
connect(
+ name: string,
host: string,
port: number,
nickname: string,
@@ -203,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: [],
@@ -216,12 +276,33 @@ export class IRCClient {
this.servers.set(server.id, server);
this.sockets.set(server.id, socket);
this.saslEnabled.set(server.id, !!_saslAccountName);
- this.currentUser = {
+ 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,
isOnline: true,
status: "online",
- };
+ });
this.nicks.set(server.id, nickname);
socket.onopen = () => {
@@ -294,8 +375,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);
@@ -324,8 +405,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`);
@@ -344,7 +434,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 {
@@ -397,6 +554,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 +575,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 +593,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 {
@@ -472,23 +641,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);
@@ -508,6 +711,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") {
@@ -516,19 +753,41 @@ 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);
+
+ // 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,
+ });
+ }
+
this.triggerEvent("ready", { serverId, serverName, nickname });
} 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 +801,32 @@ 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 === "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];
- 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];
@@ -578,8 +858,58 @@ 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(" ");
+
+ // 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;
@@ -600,6 +930,33 @@ export class IRCClient {
timestamp: getTimestampFromTags(mtags),
});
}
+ } else if (command === "NOTICE") {
+ const target = parv[0];
+ const isChannel = target.startsWith("#");
+ const sender = getNickFromNuh(source);
+
+ // The message content is now properly parsed as the trailing parameter
+ const message = trailing || parv.slice(1).join(" ");
+
+ 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(":")
@@ -630,7 +987,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,
@@ -640,7 +997,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,
@@ -660,10 +1017,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" &&
@@ -677,17 +1046,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);
@@ -707,13 +1095,134 @@ 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];
+ 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(" ")}`,
+ );
+
+ // 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,
+ type: batchType,
+ parameters,
+ });
+ } 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,
+ });
+ }
} 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 :
+ // 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=${value}`,
+ `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${value}, optionalParams=${optionalParams.join(" ")}`,
);
this.triggerEvent("METADATA", {
serverId,
@@ -728,7 +1237,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,
@@ -738,18 +1247,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 +1280,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 +1317,42 @@ 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
+
+ // 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[2]) target = parv[2];
- if (parv[3]) key = parv[3];
- if (parv[4] && code === "RATE_LIMITED") {
- retryAfter = Number.parseInt(parv[4], 10);
+
+ 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=${parv[1]}, 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,
- subcommand: parv[1],
+ subcommand,
code,
target,
key,
@@ -821,7 +1362,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,
@@ -840,7 +1381,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,
@@ -852,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];
@@ -860,8 +1417,74 @@ 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(" "); // 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];
+ 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 +1608,9 @@ export class IRCClient {
"draft/metadata-2",
"draft/message-redaction",
"draft/account-registration",
+ "batch",
+ "draft/multiline",
+ "znc.in/playback",
];
let accumulated = this.capLsAccumulated.get(serverId);
@@ -1008,25 +1634,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(" ")}`,
@@ -1063,6 +1737,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] = [];
@@ -1091,8 +1798,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..57a033bc 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -1,6 +1,11 @@
import { v4 as uuidv4 } from "uuid";
import { create } from "zustand";
+import { isUserIgnored } from "../lib/ignoreUtils";
import ircClient from "../lib/ircClient";
+import {
+ playNotificationSound,
+ shouldPlayNotificationSound,
+} from "../lib/notificationSounds";
import { registerAllProtocolHandlers } from "../protocol";
import type {
Channel,
@@ -13,6 +18,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 +26,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,18 +93,49 @@ 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;
+}
+
+// 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 };
+export { serverSupportsMetadata, serverSupportsMultiline };
function saveServersToLocalStorage(servers: ServerConfig[]) {
localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers));
@@ -133,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;
@@ -160,6 +288,27 @@ 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[];
+ // 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 {
@@ -199,6 +348,8 @@ 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;
@@ -211,6 +362,7 @@ export interface AppState {
globalSettings: GlobalSettings;
// Actions
connect: (
+ name: string,
host: string,
port: number,
nickname: string,
@@ -259,6 +411,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 +454,11 @@ export interface AppState {
) => void;
hideContextMenu: () => void;
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;
@@ -332,6 +490,8 @@ const useStore = create((set, get) => ({
listingInProgress: {},
metadataSubscriptions: {},
metadataBatches: {},
+ activeBatches: {},
+ metadataFetchInProgress: {},
pendingRegistration: null,
selectedServerId: null,
@@ -362,10 +522,33 @@ 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: [],
+ // 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
},
// IRC client actions
connect: async (
+ name,
host,
port,
nickname,
@@ -397,6 +580,7 @@ const useStore = create((set, get) => ({
);
const server = await ircClient.connect(
+ name,
host,
port,
nickname,
@@ -418,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,
@@ -435,13 +620,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 +827,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 +1055,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 {};
}
@@ -961,6 +1151,7 @@ const useStore = create((set, get) => ({
connectToSavedServers: async () => {
const savedServers = loadSavedServers();
for (const {
+ name,
host,
port,
nickname,
@@ -972,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,
@@ -1000,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,
@@ -1076,12 +1273,22 @@ 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";
+
return {
ui: {
...state.ui,
isMemberListVisible:
openState !== undefined ? openState : !state.ui.isMemberListVisible,
- mobileViewActiveColumn: openState ? "memberList" : "chatView",
+ mobileViewActiveColumn: shouldUpdateMobileColumn
+ ? openState
+ ? "memberList"
+ : "chatView"
+ : state.ui.mobileViewActiveColumn,
},
};
});
@@ -1170,6 +1377,69 @@ 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,
+ };
+ });
+ },
+
+ // 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)) {
@@ -1200,7 +1470,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`,
+ );
}
},
@@ -1275,6 +1553,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()
@@ -1290,7 +1585,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 +1613,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 +1633,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}`;
@@ -1352,16 +1662,194 @@ 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;
// 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;
}
+ // 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()
@@ -1407,6 +1895,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 +1916,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,64 +1969,194 @@ 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 };
- }
- return channel;
- });
+ircClient.on("CHANNNOTICE", (response) => {
+ const { mtags, channelName, message, timestamp } = response;
- return { ...server, channels: updatedChannels };
- }
- return server;
- });
+ // 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;
+ }
- return { servers: updatedServers };
- });
+ // 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);
- // 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
+ // Play notification sound if appropriate
+ const state = useStore.getState();
+ const serverCurrentUser = ircClient.getCurrentUser(response.serverId);
+ if (
+ shouldPlayNotificationSound(
+ newMessage,
+ serverCurrentUser,
+ state.globalSettings,
+ )
+ ) {
+ playNotificationSound(state.globalSettings);
}
- });
- const usersToFetch = users.filter(
- (u) => u.username !== currentUser?.username,
- );
+ }
+});
- // 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
- });
- },
- Math.floor(i / batchSize) * batchDelay,
+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;
}
-});
-ircClient.on("JOIN", ({ serverId, username, channelName }) => {
- useStore.setState((state) => {
- const updatedServers = state.servers.map((server) => {
- if (server.id === serverId) {
- const existingChannel = server.channels.find(
+ // 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 s;
+ });
+ return { servers: updatedServers };
+ });
+
+ privateChat = newPrivateChat;
+ }
+
+ 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,
+ };
+
+ 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);
+ }
+
+ // 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;
+ });
+ return { servers: updatedServers };
+ });
+ }
+ }
+});
+
+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) {
+ const existingChannel = server.channels.find(
(channel) => channel.name === channelName,
);
@@ -1553,11 +2185,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,
@@ -1583,14 +2214,15 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => {
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 };
});
@@ -1598,8 +2230,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}`);
+ // Request topic and user list
ircClient.sendRaw(serverId, `TOPIC ${channelName}`);
+ ircClient.sendRaw(serverId, `WHO ${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],
+ },
+ }));
+ }
+ }
}
});
@@ -1645,9 +2309,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 +2332,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,14 +2458,114 @@ 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 }) => {
+ircClient.on("ready", async ({ serverId, serverName, nickname }) => {
console.log(`Server ready: serverId=${serverId}, serverName=${serverName}`);
// Restore metadata for this server
restoreServerMetadata(serverId);
+ // 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 checking existing data`,
+ );
+
+ // 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,
+ );
+ }
+
+ // 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];
+ 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(
+ `[READY] Sending metadata: target=*, key=${key}, value=${value}`,
+ );
+ useStore
+ .getState()
+ .metadataSet(serverId, "*", 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) {
@@ -1692,11 +2574,31 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => {
return server;
});
- const ircCurrentUser = ircClient.getCurrentUser();
- const updatedCurrentUser =
- state.currentUser && ircCurrentUser
- ? { ...ircCurrentUser, metadata: state.currentUser.metadata }
- : ircCurrentUser || state.currentUser;
+ const ircCurrentUser = ircClient.getCurrentUser(serverId);
+ 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,
@@ -1728,24 +2630,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 +2880,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 +3118,97 @@ 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}'`,
+ );
+
+ // 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",
+ 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 +3413,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 +3464,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 +3482,45 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => {
return server;
});
- // Update current user metadata
+ // Update current user metadata if the target matches any connected user
let updatedCurrentUser = state.currentUser;
- if (state.currentUser?.username === target) {
- const metadata = state.currentUser.metadata || {};
- if (value) {
- metadata[key] = { value, visibility };
- } else {
- delete metadata[key];
+ const currentUserForServer = ircClient.getCurrentUser(serverId);
+
+ // Check if this metadata is for the current user on this server
+ if (
+ currentUserForServer &&
+ currentUserForServer.username === resolvedTarget
+ ) {
+ // 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 };
}
// Save metadata to localStorage
@@ -2450,13 +3528,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);
@@ -2470,16 +3548,63 @@ 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
+ const resolvedTarget =
+ target === "*"
+ ? ircClient.getNick(serverId) || state.currentUser?.username || target
+ : target;
+
+ 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`,
+ );
+
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 +3612,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,28 +3625,38 @@ 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
- const savedMetadata = loadSavedMetadata();
- if (!savedMetadata[serverId]) {
- savedMetadata[serverId] = {};
- }
- if (!savedMetadata[serverId][target]) {
- savedMetadata[serverId][target] = {};
+ // 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][target][key] = { value, visibility };
- saveMetadataToLocalStorage(savedMetadata);
return { servers: updatedServers, currentUser: updatedCurrentUser };
});
@@ -2527,8 +3665,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) => {
@@ -2536,7 +3696,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 };
@@ -2546,12 +3706,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;
});
@@ -2561,6 +3725,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 +3808,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 +3829,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 +3930,112 @@ 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;
+ }
+
+ // 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) {
+ channelStatus = statusChars.join("");
+ }
+ }
+
+ // Create user object from WHO data with proper User type
+ const user: User = {
+ id: nick,
+ username: nick,
+ avatar: undefined,
+ isOnline: true,
+ isAway: isAway,
+ isBot: false,
+ status: channelStatus, // Set the channel status here
+ 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 };
});
- }
-});
+ },
+);
ircClient.on("WHOIS_BOT", ({ serverId, target }) => {
// Update user objects in channels
@@ -2810,9 +4047,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 +4070,364 @@ 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}`);
+ 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 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(
+ `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 };
+ });
+}
+
+// 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 24fcbab2..ff157c44 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -5,7 +5,10 @@ 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;
}
@@ -18,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;
@@ -26,6 +31,7 @@ export interface Server {
}
export interface ServerConfig {
id: string;
+ name?: string;
host: string;
port: number;
nickname: string;
@@ -47,6 +53,7 @@ export interface Channel {
messages: Message[];
users: User[];
isRead?: boolean;
+ isLoadingHistory?: boolean;
metadata?: Record;
}
@@ -65,23 +72,29 @@ 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
+ multilineMessageIds?: string[]; // For multiline messages: all message IDs that make up this message
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 +103,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/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 b2d0467a..a2761067 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,60 @@ 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: [],
+ ignoreList: ["HistServ!*@*"],
+ nickname: "",
+ accountName: "",
+ accountPassword: "",
+ enableMultilineInput: true,
+ multilineOnShiftEnter: true,
+ autoFallbackToSingleLine: true,
+ },
+ });
});
describe("Server Management", () => {
@@ -52,7 +106,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"));
@@ -81,11 +145,12 @@ describe("App", () => {
// Verify connection attempt
expect(ircClient.connect).toHaveBeenCalledWith(
+ "Test Server",
"irc.test.com",
443,
"tester",
"",
- "",
+ "tester",
"c3VwZXIgYXdlc29tZSBwYXNzd29yZCBsbWFvIDEyMyAhPyE/IQ==",
undefined,
);
@@ -104,6 +169,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 +187,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 +201,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 03f8b1fa..627e0496 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",
},
}));
@@ -70,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 b19bb411..d3bb1543 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",
},
}));
@@ -69,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,
@@ -126,6 +133,8 @@ describe("Metadata Display Features", () => {
isUserProfileModalOpen: false,
isDarkMode: true,
isMobileMenuOpen: false,
+ isChannelListModalOpen: false,
+ isChannelRenameModalOpen: false,
mobileViewActiveColumn: "serverList",
isServerMenuOpen: false,
contextMenu: {
@@ -313,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/components/UserSettings.test.tsx b/tests/components/UserSettings.test.tsx
new file mode 100644
index 00000000..6253509d
--- /dev/null
+++ b/tests/components/UserSettings.test.tsx
@@ -0,0 +1,140 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+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(() => ({
+ 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.__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/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!*@*");
+ });
+ });
+});
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",
diff --git a/tests/lib/nicknameRetry.test.ts b/tests/lib/nicknameRetry.test.ts
new file mode 100644
index 00000000..d83a6f9e
--- /dev/null
+++ b/tests/lib/nicknameRetry.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it, vi } from "vitest";
+import ircClient from "../../src/lib/ircClient";
+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 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: {
+ 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(),
+ };
+
+ // Mock useStore.getState to return our mock state
+ 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",
+ };
+
+ // 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: 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: {
+ 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(),
+ };
+
+ // Mock useStore methods
+ 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",
+ 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();
+ });
+});
diff --git a/tests/lib/notificationSounds.test.ts b/tests/lib/notificationSounds.test.ts
new file mode 100644
index 00000000..9e36adcc
--- /dev/null
+++ b/tests/lib/notificationSounds.test.ts
@@ -0,0 +1,266 @@
+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/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;
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";