diff --git a/package-lock.json b/package-lock.json
index 5d5a0131..721ca727 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,9 @@
"emoji-datasource": "^16.0.0",
"emoji-picker-react": "^4.13.3",
"exifr": "^7.1.3",
+ "fuse.js": "^7.1.0",
"gh-pages": "^6.3.0",
+ "highlight.js": "^11.11.1",
"html-react-parser": "^5.2.7",
"marked": "^16.4.0",
"react": "^18.3.1",
@@ -45,7 +47,6 @@
"@vitest/ui": "^3.1.2",
"autoprefixer": "^10.4.20",
"globals": "^15.14.0",
- "highlight.js": "^11.11.1",
"jsdom": "^26.1.0",
"lefthook": "^1.11.10",
"postcss": "^8.5.1",
@@ -3970,6 +3971,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/fuse.js": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
+ "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4106,7 +4116,6 @@
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
- "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
diff --git a/package.json b/package.json
index b14a8026..9b8e2c3e 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"emoji-datasource": "^16.0.0",
"emoji-picker-react": "^4.13.3",
"exifr": "^7.1.3",
+ "fuse.js": "^7.1.0",
"gh-pages": "^6.3.0",
"highlight.js": "^11.11.1",
"html-react-parser": "^5.2.7",
diff --git a/src/App.tsx b/src/App.tsx
index 786ce01c..5775f3e1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -11,6 +11,7 @@ import ChannelListModal from "./components/ui/ChannelListModal";
import ChannelRenameModal from "./components/ui/ChannelRenameModal";
import { EditServerModal } from "./components/ui/EditServerModal";
import LinkSecurityWarningModal from "./components/ui/LinkSecurityWarningModal";
+import QuickActions from "./components/ui/QuickActions";
import UserProfileModal from "./components/ui/UserProfileModal";
import UserSettings from "./components/ui/UserSettings";
import { useKeyboardResize } from "./hooks/useKeyboardResize";
@@ -70,6 +71,7 @@ const App: React.FC = () => {
const {
toggleAddServerModal,
toggleEditServerModal,
+ toggleQuickActions,
ui: {
isAddServerModalOpen,
isUserProfileModalOpen,
@@ -77,6 +79,8 @@ const App: React.FC = () => {
isChannelRenameModalOpen,
isServerNoticesPopupOpen,
isEditServerModalOpen,
+ isSettingsModalOpen,
+ isQuickActionsOpen,
editServerId,
linkSecurityWarnings,
profileViewRequest,
@@ -147,6 +151,21 @@ const App: React.FC = () => {
connectToSavedServers,
]); // Removed connectToSavedServers from dependencies
+ // Global keyboard shortcut for Quick Actions (Cmd+K / Ctrl+K)
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === "k") {
+ event.preventDefault();
+ toggleQuickActions();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [toggleQuickActions]);
+
return (
@@ -157,7 +176,8 @@ const App: React.FC = () => {
onClose={() => toggleEditServerModal(false)}
/>
)}
- {isUserProfileModalOpen &&
}
+ {isSettingsModalOpen &&
}
+ {isQuickActionsOpen &&
}
{isChannelListModalOpen &&
}
{isChannelRenameModalOpen &&
}
diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx
index 09ae7b48..bc03e190 100644
--- a/src/components/layout/ChannelList.tsx
+++ b/src/components/layout/ChannelList.tsx
@@ -38,7 +38,7 @@ export const ChannelList: React.FC<{
pinPrivateChat,
unpinPrivateChat,
reorderPrivateChats,
- toggleUserProfileModal,
+ toggleSettingsModal,
setMobileViewActiveColumn,
reorderChannels,
} = useStore();
@@ -1276,7 +1276,7 @@ export const ChannelList: React.FC<{
toggleUserProfileModal(true)}
+ onClick={() => toggleSettingsModal(true)}
>
@@ -1341,7 +1341,7 @@ export const ChannelList: React.FC<{
toggleUserProfileModal(true)}
+ onClick={() => toggleSettingsModal(true)}
>
diff --git a/src/components/ui/QuickActions.tsx b/src/components/ui/QuickActions.tsx
new file mode 100644
index 00000000..8eed2130
--- /dev/null
+++ b/src/components/ui/QuickActions.tsx
@@ -0,0 +1,641 @@
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ FaCog,
+ FaHashtag,
+ FaSearch,
+ FaServer,
+ FaTimes,
+ FaUser,
+} from "react-icons/fa";
+import { fuzzyMatch } from "../../lib/fuzzySearch";
+import ircClient from "../../lib/ircClient";
+import { settingsRegistry } from "../../lib/settings";
+import type { SettingSearchResult } from "../../lib/settings/types";
+import useStore from "../../store";
+import type { Channel, PrivateChat, Server } from "../../types";
+
+type QuickActionResultType =
+ | "setting"
+ | "channel"
+ | "dm"
+ | "server"
+ | "join-channel"
+ | "start-dm";
+
+interface JoinChannelData {
+ channelName: string;
+}
+
+interface QuickActionResult {
+ type: QuickActionResultType;
+ id: string;
+ title: string;
+ description?: string;
+ serverId?: string;
+ score: number;
+ data?: SettingSearchResult | Channel | PrivateChat | Server | JoinChannelData;
+}
+
+const QuickActions: React.FC = () => {
+ const {
+ servers,
+ ui,
+ channelList,
+ toggleQuickActions,
+ toggleSettingsModal,
+ setSettingsNavigation,
+ selectChannel,
+ selectPrivateChat,
+ selectServer,
+ joinChannel,
+ openPrivateChat,
+ } = useStore();
+ const [searchQuery, setSearchQuery] = useState("");
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const inputRef = useRef
(null);
+
+ const handleClose = useCallback(() => {
+ toggleQuickActions(false);
+ setSearchQuery("");
+ setSelectedIndex(0);
+ }, [toggleQuickActions]);
+
+ const searchResults: QuickActionResult[] = useMemo(() => {
+ const query = searchQuery.trim();
+ const results: QuickActionResult[] = [];
+ const currentServerId = ui.selectedServerId;
+ const currentSelection = currentServerId
+ ? ui.perServerSelections[currentServerId]
+ : null;
+
+ // If no search query, show unread mentions and messages
+ if (query.length === 0) {
+ servers.forEach((server) => {
+ const isCurrentServer = server.id === currentServerId;
+
+ // Add channels with unread mentions or messages (excluding currently selected)
+ server.channels.forEach((channel) => {
+ const isCurrentlySelected =
+ isCurrentServer &&
+ currentSelection?.selectedChannelId === channel.id;
+
+ // Skip currently selected channel
+ if (isCurrentlySelected) return;
+
+ // Only show channels with unread mentions or messages
+ if (channel.isMentioned && channel.unreadCount > 0) {
+ // High priority for mentions (score: 1000 + unreadCount)
+ results.push({
+ type: "channel",
+ id: `channel-${server.id}-${channel.id}`,
+ title: channel.name,
+ description: `${server.name}${channel.topic ? ` - ${channel.topic}` : ""}`,
+ serverId: server.id,
+ score: 1000 + channel.unreadCount,
+ data: channel,
+ });
+ } else if (channel.unreadCount > 0) {
+ // Lower priority for unread messages (score: 500 + unreadCount)
+ results.push({
+ type: "channel",
+ id: `channel-${server.id}-${channel.id}`,
+ title: channel.name,
+ description: `${server.name}${channel.topic ? ` - ${channel.topic}` : ""}`,
+ serverId: server.id,
+ score: 500 + channel.unreadCount,
+ data: channel,
+ });
+ }
+ });
+
+ // Add private chats with unread mentions or messages (excluding currently selected)
+ server.privateChats.forEach((pm) => {
+ const isCurrentlySelected =
+ isCurrentServer &&
+ currentSelection?.selectedPrivateChatId === pm.id;
+
+ // Skip currently selected PM
+ if (isCurrentlySelected) return;
+
+ // Only show PMs with unread mentions or messages
+ if (pm.isMentioned && pm.unreadCount > 0) {
+ // High priority for mentions (score: 1000 + unreadCount)
+ results.push({
+ type: "dm",
+ id: `dm-${server.id}-${pm.id}`,
+ title: pm.username,
+ description: server.name,
+ serverId: server.id,
+ score: 1000 + pm.unreadCount,
+ data: pm,
+ });
+ } else if (pm.unreadCount > 0) {
+ // Lower priority for unread messages (score: 500 + unreadCount)
+ results.push({
+ type: "dm",
+ id: `dm-${server.id}-${pm.id}`,
+ title: pm.username,
+ description: server.name,
+ serverId: server.id,
+ score: 500 + pm.unreadCount,
+ data: pm,
+ });
+ }
+ });
+
+ // Add servers with mentions (excluding currently selected)
+ const hasMentions =
+ server.channels.some((ch) => ch.isMentioned) ||
+ server.privateChats?.some((pc) => pc.isMentioned);
+ const isCurrentlySelectedServer = server.id === currentServerId;
+
+ if (hasMentions && !isCurrentlySelectedServer) {
+ // Count total mentions in server
+ const totalMentions =
+ server.channels
+ .filter((ch) => ch.isMentioned)
+ .reduce((sum, ch) => sum + ch.unreadCount, 0) +
+ server.privateChats
+ .filter((pc) => pc.isMentioned)
+ .reduce((sum, pc) => sum + pc.unreadCount, 0);
+
+ results.push({
+ type: "server",
+ id: `server-${server.id}`,
+ title: server.name,
+ description: server.host,
+ serverId: server.id,
+ score: 800 + totalMentions, // Between unread messages and mentions
+ data: server,
+ });
+ }
+ });
+
+ return results.sort((a, b) => b.score - a.score).slice(0, 15);
+ }
+
+ settingsRegistry.search(query, { limit: 10 }).forEach((settingResult) => {
+ results.push({
+ type: "setting",
+ id: `setting-${settingResult.setting.id}`,
+ title: settingResult.setting.title,
+ description: settingResult.setting.description,
+ score: settingResult.score,
+ data: settingResult,
+ });
+ });
+
+ servers.forEach((server) => {
+ const isCurrentlySelectedServer = server.id === currentServerId;
+
+ const serverMatch = fuzzyMatch(query, server.name);
+ if (serverMatch.matches) {
+ let scoreAdjustment = 0;
+ if (isCurrentlySelectedServer) {
+ scoreAdjustment = -30;
+ }
+
+ results.push({
+ type: "server",
+ id: `server-${server.id}`,
+ title: server.name,
+ description: server.host,
+ serverId: server.id,
+ score: serverMatch.score + scoreAdjustment,
+ data: server,
+ });
+ }
+
+ server.channels.forEach((channel) => {
+ const channelMatch = fuzzyMatch(query, channel.name);
+ if (channelMatch.matches) {
+ const isCurrentlySelected =
+ isCurrentlySelectedServer &&
+ currentSelection?.selectedChannelId === channel.id;
+
+ let scoreAdjustment = 0;
+ if (isCurrentlySelected) {
+ scoreAdjustment = -30;
+ } else if (isCurrentlySelectedServer) {
+ scoreAdjustment = 20;
+ }
+
+ results.push({
+ type: "channel",
+ id: `channel-${server.id}-${channel.id}`,
+ title: channel.name,
+ description: `${server.name}${channel.topic ? ` - ${channel.topic}` : ""}`,
+ serverId: server.id,
+ score: channelMatch.score + scoreAdjustment,
+ data: channel,
+ });
+ }
+ });
+
+ server.privateChats.forEach((pm) => {
+ const pmMatch = fuzzyMatch(query, pm.username);
+ if (pmMatch.matches) {
+ const isCurrentlySelected =
+ isCurrentlySelectedServer &&
+ currentSelection?.selectedPrivateChatId === pm.id;
+
+ let scoreAdjustment = 0;
+ if (isCurrentlySelected) {
+ scoreAdjustment = -30;
+ } else if (isCurrentlySelectedServer) {
+ scoreAdjustment = 20;
+ }
+
+ results.push({
+ type: "dm",
+ id: `dm-${server.id}-${pm.id}`,
+ title: pm.username,
+ description: server.name,
+ serverId: server.id,
+ score: pmMatch.score + scoreAdjustment,
+ data: pm,
+ });
+ }
+ });
+
+ if (
+ currentServerId &&
+ server.id === currentServerId &&
+ query.startsWith("#")
+ ) {
+ const channelName = query.trim();
+ const availableChannels = channelList[server.id] || [];
+ const alreadyShown = new Set();
+
+ availableChannels.forEach((availChannel) => {
+ const channelMatch = fuzzyMatch(
+ query.slice(1),
+ availChannel.channel.slice(1),
+ );
+ if (channelMatch.matches) {
+ const alreadyJoined = server.channels.find(
+ (ch) => ch.name === availChannel.channel,
+ );
+ if (!alreadyJoined) {
+ results.push({
+ type: "join-channel",
+ id: `join-channel-${server.id}-${availChannel.channel}`,
+ title: `Join ${availChannel.channel}`,
+ description: `${availChannel.userCount} users - ${server.name}`,
+ serverId: server.id,
+ score: channelMatch.score + 50,
+ data: { channelName: availChannel.channel },
+ });
+ alreadyShown.add(availChannel.channel.toLowerCase());
+ }
+ }
+ });
+
+ if (
+ channelName.length > 1 &&
+ !alreadyShown.has(channelName.toLowerCase())
+ ) {
+ results.push({
+ type: "join-channel",
+ id: `join-channel-${server.id}-${channelName}`,
+ title: `Join ${channelName}`,
+ description: `Join channel on ${server.name}`,
+ serverId: server.id,
+ score: 100,
+ data: { channelName },
+ });
+ }
+ }
+
+ if (currentServerId && server.id === currentServerId) {
+ const allUsers = new Map<
+ string,
+ { username: string; isOnline: boolean }
+ >();
+ for (const channel of server.channels) {
+ for (const user of channel.users) {
+ allUsers.set(user.username, {
+ username: user.username,
+ isOnline: user.isOnline,
+ });
+ }
+ }
+
+ const currentUser = ircClient.getCurrentUser(server.id);
+ const availableUsers = Array.from(allUsers.values()).filter(
+ (user) => user.username !== currentUser?.username,
+ );
+
+ availableUsers.forEach((user) => {
+ const existingPM = server.privateChats.find(
+ (pm) => pm.username === user.username,
+ );
+ if (!existingPM) {
+ const userMatch = fuzzyMatch(query, user.username);
+ if (userMatch.matches) {
+ results.push({
+ type: "start-dm",
+ id: `start-dm-${server.id}-${user.username}`,
+ title: `Message ${user.username}`,
+ description: `Start private message on ${server.name}`,
+ serverId: server.id,
+ score: userMatch.score + (user.isOnline ? 10 : 0),
+ data: undefined,
+ });
+ }
+ }
+ });
+ }
+ });
+
+ return results.sort((a, b) => b.score - a.score).slice(0, 15);
+ }, [
+ searchQuery,
+ servers,
+ channelList,
+ ui.selectedServerId,
+ ui.perServerSelections,
+ ]);
+
+ const handleSelect = useCallback(
+ (result: QuickActionResult) => {
+ switch (result.type) {
+ case "setting": {
+ const settingResult = result.data as SettingSearchResult;
+ const setting = settingResult.setting;
+ setSettingsNavigation({
+ category: setting.category as
+ | "profile"
+ | "notifications"
+ | "preferences"
+ | "media"
+ | "account",
+ highlightedSettingId: setting.id,
+ });
+ toggleSettingsModal(true);
+ break;
+ }
+ case "channel": {
+ const channel = result.data as Channel;
+ selectServer(result.serverId || null);
+ selectChannel(channel.id);
+ break;
+ }
+ case "dm": {
+ const pm = result.data as PrivateChat;
+ selectServer(result.serverId || null);
+ selectPrivateChat(pm.id);
+ break;
+ }
+ case "server": {
+ selectServer(result.id.replace("server-", ""));
+ break;
+ }
+ case "join-channel": {
+ if (result.serverId && result.data) {
+ const channelName = (result.data as { channelName: string })
+ .channelName;
+ selectServer(result.serverId);
+ joinChannel(result.serverId, channelName);
+ }
+ break;
+ }
+ case "start-dm": {
+ if (result.serverId) {
+ const username = result.title.replace("Message ", "");
+ openPrivateChat(result.serverId, username);
+ selectServer(result.serverId);
+ const freshState = useStore.getState();
+ const server = freshState.servers.find(
+ (s) => s.id === result.serverId,
+ );
+ const privateChat = server?.privateChats?.find(
+ (pc) => pc.username === username,
+ );
+ if (privateChat) {
+ selectPrivateChat(privateChat.id);
+ }
+ }
+ break;
+ }
+ }
+ handleClose();
+ },
+ [
+ setSettingsNavigation,
+ toggleSettingsModal,
+ selectServer,
+ selectChannel,
+ selectPrivateChat,
+ handleClose,
+ joinChannel,
+ openPrivateChat,
+ ],
+ );
+
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, []);
+
+ const searchResultsRef = useRef([]);
+ const selectedIndexRef = useRef(selectedIndex);
+ const handleCloseRef = useRef(handleClose);
+ const handleSelectRef = useRef(handleSelect);
+
+ searchResultsRef.current = searchResults;
+ selectedIndexRef.current = selectedIndex;
+ handleCloseRef.current = handleClose;
+ handleSelectRef.current = handleSelect;
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ handleCloseRef.current();
+ return;
+ }
+
+ if (searchResultsRef.current.length === 0) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setSelectedIndex((prev) =>
+ prev < searchResultsRef.current.length - 1 ? prev + 1 : prev,
+ );
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
+ break;
+ case "Tab":
+ e.preventDefault();
+ if (e.shiftKey) {
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
+ } else {
+ setSelectedIndex((prev) =>
+ prev < searchResultsRef.current.length - 1 ? prev + 1 : prev,
+ );
+ }
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (searchResultsRef.current[selectedIndexRef.current]) {
+ handleSelectRef.current(
+ searchResultsRef.current[selectedIndexRef.current],
+ );
+ }
+ break;
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, []);
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+ setSearchQuery(e.target.value)}
+ className="flex-1 bg-transparent text-white placeholder-discord-text-muted outline-none"
+ autoFocus
+ />
+
+
+
+
+
+
+ {searchResults.length === 0 ? (
+
+ {searchQuery.trim().length === 0
+ ? "No unread mentions or messages"
+ : "No results found"}
+
+ ) : (
+
+ {searchResults.map((result, index) => {
+ const getIcon = () => {
+ switch (result.type) {
+ case "setting":
+ return
;
+ case "channel":
+ return
;
+ case "dm":
+ return
;
+ case "server":
+ return
;
+ case "join-channel":
+ return
;
+ case "start-dm":
+ return
;
+ }
+ };
+
+ const getTypeBadge = () => {
+ switch (result.type) {
+ case "setting":
+ return "Setting";
+ case "channel":
+ return "Channel";
+ case "dm":
+ return "DM";
+ case "server":
+ return "Server";
+ case "join-channel":
+ return "Join";
+ case "start-dm":
+ return "New DM";
+ }
+ };
+
+ // Get unread indicator for channels and DMs
+ const getUnreadIndicator = () => {
+ if (result.type === "channel" || result.type === "dm") {
+ const item = result.data as Channel | PrivateChat;
+ if (item.isMentioned && item.unreadCount > 0) {
+ // Red badge with count for mentions
+ return (
+
+ {item.unreadCount}
+
+ );
+ }
+ if (item.unreadCount > 0) {
+ // Blue dot for unread messages
+ return (
+
+ );
+ }
+ }
+ return null;
+ };
+
+ return (
+
handleSelect(result)}
+ className={`w-full flex flex-col px-4 py-3 rounded text-left transition-colors ${
+ index === selectedIndex
+ ? "bg-discord-primary text-white"
+ : "text-discord-text-normal hover:bg-discord-dark-400"
+ }`}
+ >
+
+
+ {getIcon()}
+
+ {result.title}
+
+ {getUnreadIndicator()}
+
+
+ {getTypeBadge()}
+
+
+ {result.description && (
+
+ {result.description}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+};
+
+export default QuickActions;
diff --git a/src/components/ui/UserSettings.tsx b/src/components/ui/UserSettings.tsx
index 6d44d22e..b1bf1b56 100644
--- a/src/components/ui/UserSettings.tsx
+++ b/src/components/ui/UserSettings.tsx
@@ -14,234 +14,119 @@ import {
FaUser,
} from "react-icons/fa";
import { useMediaQuery } from "../../hooks/useMediaQuery";
-import { isValidIgnorePattern } from "../../lib/ignoreUtils";
+import { useModalBehavior } from "../../hooks/useModalBehavior";
import ircClient from "../../lib/ircClient";
+import { settingsRegistry } from "../../lib/settings";
+import type { SettingValue } from "../../lib/settings/types";
import useStore, {
+ type GlobalSettings,
loadSavedServers,
serverSupportsMetadata,
} from "../../store";
-import type { ServerConfig } from "../../types";
import AvatarUpload from "./AvatarUpload";
+import { SettingField } from "./settings/SettingRenderer";
import UserProfileModal from "./UserProfileModal";
-type SettingsCategory =
- | "profile"
- | "notifications"
- | "preferences"
- | "media"
- | "account";
+// Deep clone utility for settings values
+const deepClone = (value: T): T => {
+ if (value === null || value === undefined) return value;
+ if (typeof value !== "object") return value;
-// Component for displaying setting fields
-const SettingField: React.FC<{
- label: string;
- description: string;
- children: React.ReactNode;
-}> = ({ label, description, children }) => (
-
-
-
- {label}
-
-
{description}
-
- {children}
-
-);
-
-// Component for managing custom mentions list
-const CustomMentionsList: React.FC<{
- globalCustomMentions: string[];
- updateGlobalSettings: (updates: Record) => void;
-}> = ({ globalCustomMentions, updateGlobalSettings }) => {
- const [newMention, setNewMention] = useState("");
-
- const handleAddMention = () => {
- if (
- newMention.trim() &&
- !globalCustomMentions.includes(newMention.trim())
- ) {
- updateGlobalSettings({
- customMentions: [...globalCustomMentions, newMention.trim()],
- });
- setNewMention("");
- }
- };
+ if (Array.isArray(value)) {
+ return value.map((item) => deepClone(item)) as T;
+ }
- const handleRemoveMention = (mention: string) => {
- updateGlobalSettings({
- customMentions: globalCustomMentions.filter((m) => m !== mention),
- });
- };
+ if (value instanceof Date) {
+ return new Date(value.getTime()) as T;
+ }
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- handleAddMention();
+ const cloned: Record = {};
+ for (const key in value) {
+ if (Object.hasOwn(value, key)) {
+ cloned[key] = deepClone((value as Record)[key]);
}
- };
-
- return (
-
-
- setNewMention(e.target.value)}
- onKeyPress={handleKeyPress}
- placeholder="Add a word or phrase..."
- className="flex-1 rounded border border-discord-button-secondary-default bg-discord-input-bg px-3 py-2 text-discord-text-normal placeholder-discord-text-muted focus:border-discord-text-link focus:outline-none"
- />
-
- Add
-
-
- {globalCustomMentions.length > 0 && (
-
- {globalCustomMentions.map((mention) => (
-
- {mention}
- handleRemoveMention(mention)}
- className="ml-2 text-discord-text-muted hover:text-discord-text-normal"
- >
-
-
-
- ))}
-
- )}
-
- );
+ }
+ return cloned as T;
};
-// Component for managing ignore list
-const IgnoreList: React.FC<{
- ignoreList: string[];
- addToIgnoreList: (pattern: string) => void;
- removeFromIgnoreList: (pattern: string) => void;
-}> = ({ ignoreList, addToIgnoreList, removeFromIgnoreList }) => {
- const [newPattern, setNewPattern] = useState("");
- const [validationError, setValidationError] = useState("");
-
- const handleAddPattern = () => {
- const trimmed = newPattern.trim();
- if (!trimmed) {
- setValidationError("Pattern cannot be empty");
- return;
- }
-
- if (!isValidIgnorePattern(trimmed)) {
- setValidationError(
- "Invalid pattern format. Use nick!user@host format (wildcards * allowed)",
- );
- return;
- }
-
- if (ignoreList.includes(trimmed)) {
- setValidationError("Pattern already exists");
- return;
- }
-
- addToIgnoreList(trimmed);
- setNewPattern("");
- setValidationError("");
- };
-
- const handleRemovePattern = (pattern: string) => {
- removeFromIgnoreList(pattern);
- };
-
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- handleAddPattern();
- }
- };
-
- const handleInputChange = (e: React.ChangeEvent) => {
- setNewPattern(e.target.value);
- setValidationError(""); // Clear error when user types
- };
-
- return (
-
-
-
-
-
- Add
-
-
- {validationError && (
-
{validationError}
- )}
-
- Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com,
- nick123!user@host.net
-
-
- {ignoreList.length > 0 && (
-
-
- {ignoreList.length} ignored pattern
- {ignoreList.length !== 1 ? "s" : ""}:
-
-
- {ignoreList.map((pattern) => (
-
-
- {pattern}
-
- handleRemovePattern(pattern)}
- className="ml-2 text-discord-text-muted hover:text-red-400 transition-colors"
- title="Remove pattern"
- >
-
-
-
- ))}
-
-
- )}
- {ignoreList.length === 0 && (
-
- No users ignored
-
- )}
-
- );
+// Deep equality check for comparing setting values
+const deepEqual = (a: unknown, b: unknown): boolean => {
+ if (a === b) return true;
+ if (a == null || b == null) return false;
+ if (typeof a !== typeof b) return false;
+
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) return false;
+ return a.every((item, index) => deepEqual(item, b[index]));
+ }
+
+ if (typeof a === "object" && typeof b === "object") {
+ const keysA = Object.keys(a as object);
+ const keysB = Object.keys(b as object);
+ if (keysA.length !== keysB.length) return false;
+ return keysA.every((key) =>
+ deepEqual(
+ (a as Record)[key],
+ (b as Record)[key],
+ ),
+ );
+ }
+
+ return false;
};
-const UserSettings: React.FC = React.memo(() => {
+type SettingsCategory =
+ | "profile"
+ | "notifications"
+ | "preferences"
+ | "media"
+ | "account";
+
+interface CategoryInfo {
+ id: SettingsCategory;
+ title: string;
+ icon: React.ReactNode;
+ description: string;
+}
+
+const categories: CategoryInfo[] = [
+ {
+ id: "profile",
+ title: "Profile",
+ icon: ,
+ description: "Manage your profile information and metadata",
+ },
+ {
+ id: "notifications",
+ title: "Notifications",
+ icon: ,
+ description: "Configure notification sounds and highlights",
+ },
+ {
+ id: "preferences",
+ title: "Preferences",
+ icon: ,
+ description: "Customize your IRC client experience",
+ },
+ {
+ id: "media",
+ title: "Media",
+ icon: ,
+ description: "Control media display and external content",
+ },
+ {
+ id: "account",
+ title: "Account",
+ icon: ,
+ description: "Manage your account and authentication",
+ },
+];
+
+export const UserSettings: React.FC = React.memo(() => {
const {
- toggleUserProfileModal,
+ toggleSettingsModal,
setProfileViewRequest,
+ clearSettingsNavigation,
servers,
ui,
isConnecting,
@@ -250,34 +135,12 @@ const UserSettings: React.FC = React.memo(() => {
setName,
changeNick,
updateServer,
- globalSettings: {
- enableNotificationSounds: globalEnableNotificationSounds,
- notificationSound: globalNotificationSound,
- enableHighlights: globalEnableHighlights,
- sendTypingNotifications: globalSendTypingNotifications,
- nickname: globalNickname,
- accountName: globalAccountName,
- accountPassword: globalAccountPassword,
- customMentions: globalCustomMentions,
- ignoreList: globalIgnoreList,
- showEvents: globalShowEvents,
- showNickChanges: globalShowNickChanges,
- showJoinsParts: globalShowJoinsParts,
- showQuits: globalShowQuits,
- showKicks: globalShowKicks,
- enableMultilineInput: globalEnableMultilineInput,
- multilineOnShiftEnter: globalMultilineOnShiftEnter,
- autoFallbackToSingleLine: globalAutoFallbackToSingleLine,
- showSafeMedia: globalShowSafeMedia,
- showExternalContent: globalShowExternalContent,
- enableMarkdownRendering: globalEnableMarkdownRendering,
- },
+ globalSettings,
updateGlobalSettings,
addToIgnoreList,
removeFromIgnoreList,
} = useStore();
- // Memoize the current server and metadata support to prevent unnecessary re-renders
const currentServer = useMemo(
() => servers.find((s) => s.id === ui.selectedServerId),
[servers, ui.selectedServerId],
@@ -286,15 +149,12 @@ const UserSettings: React.FC = React.memo(() => {
const savedServers = loadSavedServers();
const serverConfig = savedServers.find((s) => s.id === ui.selectedServerId);
- // Get the current user for the selected server with metadata from store
const currentUser = useMemo(() => {
if (!currentServer) return null;
- // Get the current user's username from IRCClient
const ircCurrentUser = ircClient.getCurrentUser(currentServer.id);
if (!ircCurrentUser) return null;
- // Find the current user in the server's channel data to get metadata
for (const channel of currentServer.channels) {
const userWithMetadata = channel.users.find(
(u) => u.username === ircCurrentUser.username,
@@ -304,7 +164,6 @@ const UserSettings: React.FC = React.memo(() => {
}
}
- // If not found in channels, return the basic IRC user
return ircCurrentUser;
}, [currentServer]);
@@ -318,10 +177,86 @@ const UserSettings: React.FC = React.memo(() => {
// Category state
const [activeCategory, setActiveCategory] =
useState("profile");
+ const [highlightedSetting, setHighlightedSetting] = useState(
+ null,
+ );
+
+ // Refs to store timeout IDs to prevent premature clearing
+ const highlightTimeoutRef = useRef(null);
+ const scrollTimeoutRef = useRef(null);
+
+ // Clear highlight when modal closes
+ useEffect(() => {
+ if (!ui.isSettingsModalOpen) {
+ setHighlightedSetting(null);
+ if (highlightTimeoutRef.current) {
+ clearTimeout(highlightTimeoutRef.current);
+ highlightTimeoutRef.current = null;
+ }
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ scrollTimeoutRef.current = null;
+ }
+ }
+ }, [ui.isSettingsModalOpen]);
+
+ // Apply navigation from Quick Actions
+ useEffect(() => {
+ if (!ui.settingsNavigation) return;
+
+ if (ui.settingsNavigation.category) {
+ setActiveCategory(ui.settingsNavigation.category);
+ }
+
+ if (ui.settingsNavigation.highlightedSettingId) {
+ const settingId = ui.settingsNavigation.highlightedSettingId;
+ setHighlightedSetting(settingId);
+
+ // Clear any existing timeouts
+ if (highlightTimeoutRef.current) {
+ clearTimeout(highlightTimeoutRef.current);
+ }
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ }
+
+ // Scroll to the highlighted element after a brief delay
+ scrollTimeoutRef.current = setTimeout(() => {
+ const element = document.getElementById(`setting-${settingId}`);
+ if (element) {
+ // Find the scrollable container
+ const scrollContainer = element.closest(".overflow-y-auto");
+ if (scrollContainer) {
+ // Scroll within the container
+ const elementTop = element.offsetTop;
+ const containerHeight = scrollContainer.clientHeight;
+ const scrollTo =
+ elementTop - containerHeight / 2 + element.clientHeight / 2;
+ scrollContainer.scrollTo({ top: scrollTo, behavior: "smooth" });
+ } else {
+ // Fallback to normal scrollIntoView
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ }
+ scrollTimeoutRef.current = null;
+ }, 200);
+
+ // Clear highlight after 2 seconds
+ highlightTimeoutRef.current = setTimeout(() => {
+ setHighlightedSetting(null);
+ highlightTimeoutRef.current = null;
+ }, 2000);
+
+ // Clear navigation state
+ clearSettingsNavigation();
+ } else {
+ // Clear navigation state if no highlighted setting
+ clearSettingsNavigation();
+ }
+ }, [ui.settingsNavigation, clearSettingsNavigation]);
// User Profile Modal state
const [viewProfileModalOpen, setViewProfileModalOpen] = useState(false);
- const [showUnsavedChangesModal, setShowUnsavedChangesModal] = useState(false);
const [showExternalContentWarning, setShowExternalContentWarning] =
useState(false);
@@ -334,40 +269,11 @@ const UserSettings: React.FC = React.memo(() => {
const [color, setColor] = useState("");
const [bot, setBot] = useState("");
- // Settings state
- const [enableNotificationSounds, setEnableNotificationSounds] = useState(
- globalEnableNotificationSounds,
- );
- const [notificationSound, setNotificationSound] = useState(
- globalNotificationSound,
- );
- const [notificationSoundFile, setNotificationSoundFile] =
- useState(null);
- const [enableHighlights, setEnableHighlights] = useState(
- globalEnableHighlights,
- );
- const [sendTypingNotifications, setSendTypingNotifications] = useState(
- globalSendTypingNotifications,
- );
-
- // Media settings state
- const [showSafeMedia, setShowSafeMedia] = useState(globalShowSafeMedia);
- const [showExternalContent, setShowExternalContent] = useState(
- globalShowExternalContent,
- );
- const [enableMarkdownRendering, setEnableMarkdownRendering] = useState(
- globalEnableMarkdownRendering,
- );
+ // Settings state - consolidated
+ const [settings, setSettings] = useState>({});
- // Account state (for hosted chat mode)
- const [nickname, setNickname] = useState(
- globalNickname || currentUser?.username || "",
- );
+ // Account state
const [newNickname, setNewNickname] = useState(currentUser?.username || "");
- const [accountName, setAccountName] = useState(globalAccountName);
- const [accountPassword, setAccountPassword] = useState(globalAccountPassword);
-
- // IRC Operator state (for hosted chat mode)
const [operName, setOperName] = useState(serverConfig?.operUsername || "");
const [operPassword, setOperPassword] = useState("");
const [operOnConnect, setOperOnConnect] = useState(
@@ -375,42 +281,134 @@ const UserSettings: React.FC = React.memo(() => {
);
// Original values for change tracking
- const [originalValues, setOriginalValues] = useState<{
- avatar: string;
- displayName: string;
- realname: string;
- homepage: string;
- status: string;
- color: string;
- bot: string;
- newNickname: string;
- enableNotificationSounds: boolean;
- notificationSound: string;
- enableHighlights: boolean;
- sendTypingNotifications: boolean;
- nickname: string;
- accountName: string;
- accountPassword: string;
- operName: string;
- operPassword: string;
- operOnConnect: boolean;
- showSafeMedia: boolean;
- showExternalContent: boolean;
- enableMarkdownRendering: boolean;
- showEvents: boolean;
- showNickChanges: boolean;
- showJoinsParts: boolean;
- showQuits: boolean;
- showKicks: boolean;
- enableMultilineInput: boolean;
- multilineOnShiftEnter: boolean;
- autoFallbackToSingleLine: boolean;
- } | null>(null);
+ const [originalValues, setOriginalValues] = useState | null>(null);
+
+ // Notification sound file
+ const [notificationSoundFile, setNotificationSoundFile] =
+ useState(null);
+
+ // Refs for input fields
+ const nicknameInputRef = useRef(null);
+ const displayNameInputRef = useRef(null);
+ const avatarInputRef = useRef(null);
+ const statusInputRef = useRef(null);
+ const colorInputRef = useRef(null);
+ const botInputRef = useRef(null);
+ const realnameInputRef = useRef(null);
+ const fileInputRef = useRef(null);
+
+ // Track if we've initialized for this modal open
+ const initializedRef = useRef(false);
+
+ // Initialize settings from global state and current user metadata
+ useEffect(() => {
+ if (!ui.isSettingsModalOpen) {
+ initializedRef.current = false;
+ return;
+ }
+
+ // Only initialize once per modal open
+ if (initializedRef.current) return;
+ initializedRef.current = true;
+
+ const initialSettings: Record = {
+ ...globalSettings,
+ customMentions: deepClone(globalSettings.customMentions),
+ ignoreList: deepClone(globalSettings.ignoreList),
+ };
+
+ setSettings(initialSettings);
+
+ // Initialize profile metadata if metadata is supported
+ let initialAvatar = "";
+ let initialDisplayName = "";
+ let initialRealname = "";
+ let initialHomepage = "";
+ let initialStatus = "";
+ let initialColor = "";
+ let initialBot = "";
+
+ if (currentUser && supportsMetadata) {
+ const meta = currentUser.metadata || {};
+ initialAvatar =
+ typeof meta.avatar === "object"
+ ? meta.avatar.value || ""
+ : meta.avatar || "";
+ initialDisplayName =
+ typeof meta["display-name"] === "object"
+ ? meta["display-name"].value || ""
+ : meta["display-name"] || "";
+ initialRealname =
+ typeof meta.realname === "object"
+ ? meta.realname.value || ""
+ : meta.realname || "";
+ initialHomepage =
+ typeof meta.homepage === "object"
+ ? meta.homepage.value || ""
+ : meta.homepage || "";
+ initialStatus =
+ typeof meta.status === "object"
+ ? meta.status.value || ""
+ : meta.status || "";
+ initialColor =
+ typeof meta.color === "object"
+ ? meta.color.value || ""
+ : meta.color || "";
+ initialBot =
+ typeof meta.bot === "object" ? meta.bot.value || "" : meta.bot || "";
+
+ setAvatar(initialAvatar);
+ setDisplayName(initialDisplayName);
+ setRealname(initialRealname);
+ setHomepage(initialHomepage);
+ setStatus(initialStatus);
+ setColor(initialColor);
+ setBot(initialBot);
+ }
+
+ const initialNickname = currentUser?.username || "";
+ setNewNickname(initialNickname);
+
+ const initialOperName = serverConfig?.operUsername || "";
+ const initialOperPassword = "";
+ const initialOperOnConnect = serverConfig?.operOnConnect || false;
+ setOperName(initialOperName);
+ setOperPassword(initialOperPassword);
+ setOperOnConnect(initialOperOnConnect);
+
+ // Store original values for change tracking (deep clone to avoid reference issues)
+ setOriginalValues({
+ ...deepClone(initialSettings),
+ avatar: initialAvatar,
+ displayName: initialDisplayName,
+ realname: initialRealname,
+ homepage: initialHomepage,
+ status: initialStatus,
+ color: initialColor,
+ bot: initialBot,
+ newNickname: initialNickname,
+ operName: initialOperName,
+ operPassword: initialOperPassword,
+ operOnConnect: initialOperOnConnect,
+ });
+ }, [
+ ui.isSettingsModalOpen,
+ currentUser,
+ supportsMetadata,
+ globalSettings,
+ serverConfig,
+ ]);
// Track if there are unsaved changes
- const hasUnsavedChanges =
- originalValues &&
- (avatar !== originalValues.avatar ||
+ const hasUnsavedChanges = useMemo(() => {
+ if (!originalValues) return false;
+
+ // Check profile metadata
+ if (
+ avatar !== originalValues.avatar ||
displayName !== originalValues.displayName ||
realname !== originalValues.realname ||
homepage !== originalValues.homepage ||
@@ -418,54 +416,64 @@ const UserSettings: React.FC = React.memo(() => {
color !== originalValues.color ||
bot !== originalValues.bot ||
newNickname !== originalValues.newNickname ||
- enableNotificationSounds !== originalValues.enableNotificationSounds ||
- notificationSound !== originalValues.notificationSound ||
- enableHighlights !== originalValues.enableHighlights ||
- sendTypingNotifications !== originalValues.sendTypingNotifications ||
- nickname !== originalValues.nickname ||
- accountName !== originalValues.accountName ||
- accountPassword !== originalValues.accountPassword ||
operName !== originalValues.operName ||
operPassword !== originalValues.operPassword ||
- operOnConnect !== originalValues.operOnConnect ||
- showSafeMedia !== originalValues.showSafeMedia ||
- showExternalContent !== originalValues.showExternalContent ||
- enableMarkdownRendering !== originalValues.enableMarkdownRendering ||
- globalShowEvents !== originalValues.showEvents ||
- globalShowNickChanges !== originalValues.showNickChanges ||
- globalShowJoinsParts !== originalValues.showJoinsParts ||
- globalShowQuits !== originalValues.showQuits ||
- globalShowKicks !== originalValues.showKicks ||
- globalEnableMultilineInput !== originalValues.enableMultilineInput ||
- globalMultilineOnShiftEnter !== originalValues.multilineOnShiftEnter ||
- globalAutoFallbackToSingleLine !==
- originalValues.autoFallbackToSingleLine);
+ operOnConnect !== originalValues.operOnConnect
+ ) {
+ return true;
+ }
- const fileInputRef = useRef(null);
+ // Check settings using deep equality
+ for (const [key, value] of Object.entries(settings)) {
+ if (!deepEqual(originalValues[key], value)) {
+ return true;
+ }
+ }
- // Refs for input fields to preserve focus during re-renders
- const nicknameInputRef = useRef(null);
- const displayNameInputRef = useRef(null);
- const avatarInputRef = useRef(null);
- const statusInputRef = useRef(null);
- const colorInputRef = useRef(null);
- const botInputRef = useRef(null);
- const realnameInputRef = useRef(null);
+ return false;
+ }, [
+ originalValues,
+ settings,
+ avatar,
+ displayName,
+ realname,
+ homepage,
+ status,
+ color,
+ bot,
+ newNickname,
+ operName,
+ operPassword,
+ operOnConnect,
+ ]);
+
+ const handleSettingChange = useCallback(
+ (settingKey: string, value: SettingValue) => {
+ setSettings((prev) => ({
+ ...prev,
+ [settingKey]: value,
+ }));
+ },
+ [],
+ );
- // Memoized onChange handlers to prevent unnecessary re-renders
- const handleNewNicknameChange = useCallback(
+ // Profile field change handlers
+ const handleAvatarChange = useCallback(
(e: React.ChangeEvent) => {
- setNewNickname(e.target.value);
- // Schedule focus restoration after React's render cycle
+ setAvatar(e.target.value);
setTimeout(() => {
- if (document.activeElement !== nicknameInputRef.current) {
- nicknameInputRef.current?.focus();
+ if (document.activeElement !== avatarInputRef.current) {
+ avatarInputRef.current?.focus();
}
}, 0);
},
[],
);
+ const handleAvatarUrlChange = useCallback((url: string) => {
+ setAvatar(url);
+ }, []);
+
const handleDisplayNameChange = useCallback(
(e: React.ChangeEvent) => {
setDisplayName(e.target.value);
@@ -478,22 +486,18 @@ const UserSettings: React.FC = React.memo(() => {
[],
);
- const handleAvatarChange = useCallback(
+ const handleRealnameChange = useCallback(
(e: React.ChangeEvent) => {
- setAvatar(e.target.value);
+ setRealname(e.target.value);
setTimeout(() => {
- if (document.activeElement !== avatarInputRef.current) {
- avatarInputRef.current?.focus();
+ if (document.activeElement !== realnameInputRef.current) {
+ realnameInputRef.current?.focus();
}
}, 0);
},
[],
);
- const handleAvatarUrlChange = useCallback((url: string) => {
- setAvatar(url);
- }, []);
-
const handleHomepageChange = useCallback(
(e: React.ChangeEvent) => {
setHomepage(e.target.value);
@@ -537,39 +541,18 @@ const UserSettings: React.FC = React.memo(() => {
[],
);
- const handleRealnameChange = useCallback(
+ const handleNewNicknameChange = useCallback(
(e: React.ChangeEvent) => {
- setRealname(e.target.value);
+ setNewNickname(e.target.value);
setTimeout(() => {
- if (document.activeElement !== realnameInputRef.current) {
- realnameInputRef.current?.focus();
+ if (document.activeElement !== nicknameInputRef.current) {
+ nicknameInputRef.current?.focus();
}
}, 0);
},
[],
);
- const handleNicknameChange = useCallback(
- (e: React.ChangeEvent) => {
- setNickname(e.target.value);
- },
- [],
- );
-
- const handleAccountNameChange = useCallback(
- (e: React.ChangeEvent) => {
- setAccountName(e.target.value);
- },
- [],
- );
-
- const handleAccountPasswordChange = useCallback(
- (e: React.ChangeEvent) => {
- setAccountPassword(e.target.value);
- },
- [],
- );
-
const handleOperUp = () => {
if (operName.trim() && operPassword.trim() && currentServer) {
sendRaw(
@@ -579,1103 +562,432 @@ const UserSettings: React.FC = React.memo(() => {
}
};
- // Function to handle closing with unsaved changes warning
- const handleClose = () => {
- if (hasUnsavedChanges) {
- const confirmClose = window.confirm(
- "You have unsaved changes. Are you sure you want to close without saving?",
- );
- if (!confirmClose) {
- return;
- }
- }
- // Reset original values when closing so it will reinitialize next time
- setOriginalValues(null);
- toggleUserProfileModal(false);
- };
-
// Audio playback utility
const playNotificationSound = async (soundFile?: File | string | null) => {
try {
- let audioSrc: string;
-
- if (soundFile instanceof File) {
- // Play custom uploaded sound from File object
- audioSrc = URL.createObjectURL(soundFile);
- } else if (typeof soundFile === "string") {
- // Play custom sound from URL string (for previously saved sounds)
- audioSrc = soundFile;
+ if (!soundFile) return;
+
+ let audioUrl: string;
+
+ if (typeof soundFile === "string") {
+ audioUrl = soundFile;
} else {
- // Play default notification sound (we'll use a simple beep)
- // Create a simple beep sound using Web Audio API
- const AudioContextClass =
- window.AudioContext ||
- (window as unknown as { webkitAudioContext: typeof AudioContext })
- .webkitAudioContext;
- const audioContext = new AudioContextClass();
- const oscillator = audioContext.createOscillator();
- const gainNode = audioContext.createGain();
-
- oscillator.connect(gainNode);
- gainNode.connect(audioContext.destination);
-
- oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
- oscillator.type = "sine";
-
- gainNode.gain.setValueAtTime(0, audioContext.currentTime);
- gainNode.gain.linearRampToValueAtTime(
- 0.1,
- audioContext.currentTime + 0.01,
- );
- gainNode.gain.exponentialRampToValueAtTime(
- 0.01,
- audioContext.currentTime + 0.5,
- );
-
- oscillator.start(audioContext.currentTime);
- oscillator.stop(audioContext.currentTime + 0.5);
- return;
+ audioUrl = URL.createObjectURL(soundFile);
}
- const audio = new Audio(audioSrc);
- audio.volume = 0.5; // Set reasonable volume
+ const audio = new Audio(audioUrl);
await audio.play();
- // Clean up object URL if it was created from a File
- if (soundFile instanceof File) {
- setTimeout(() => URL.revokeObjectURL(audioSrc), 1000);
+ if (typeof soundFile !== "string") {
+ setTimeout(() => URL.revokeObjectURL(audioUrl), 1000);
}
} catch (error) {
console.error("Failed to play notification sound:", error);
- // Fallback to default browser notification sound
- if (soundFile) {
- // If custom sound fails, try default sound
- playNotificationSound();
- }
}
};
- // Load existing metadata on mount - only once when modal opens
- useEffect(() => {
- if (currentUser && !originalValues) {
- const avatarValue = currentUser.metadata?.avatar?.value || "";
- const displayNameValue =
- currentUser.metadata?.["display-name"]?.value || "";
- const realnameValue = currentUser.displayName || "";
- const homepageValue = currentUser.metadata?.homepage?.value || "";
- const statusValue = currentUser.metadata?.status?.value || "";
- const colorValue = currentUser.metadata?.color?.value || "";
- const botValue = currentUser.metadata?.bot?.value || "";
- const nicknameValue = currentUser.username || "";
-
- // Set current form values
- setAvatar(avatarValue);
- setDisplayName(displayNameValue);
- setRealname(realnameValue);
- setHomepage(homepageValue);
- setStatus(statusValue);
- setColor(colorValue);
- setBot(botValue);
- setNewNickname(nicknameValue);
-
- // Set global settings values
- setEnableNotificationSounds(globalEnableNotificationSounds);
- // Migrate old default (empty string) to notif1
- const migratedNotificationSound =
- globalNotificationSound === ""
- ? "/sounds/notif1.mp3"
- : globalNotificationSound;
- setNotificationSound(migratedNotificationSound);
-
- // Save migrated setting back to store if it was changed
- if (globalNotificationSound === "") {
- updateGlobalSettings({ notificationSound: "/sounds/notif1.mp3" });
+ // Handle save
+ const handleSave = useCallback(async () => {
+ if (!currentServer) return;
+
+ // Save profile metadata
+ if (supportsMetadata) {
+ const metadata: Record = {};
+ if (avatar) metadata.avatar = avatar;
+ if (displayName) metadata["display-name"] = displayName;
+ if (realname) metadata.realname = realname;
+ if (homepage) metadata.homepage = homepage;
+ if (status) metadata.status = status;
+ if (color) metadata.color = color;
+ if (bot) metadata.bot = bot;
+
+ for (const [key, value] of Object.entries(metadata)) {
+ sendRaw(currentServer.id, `METADATA * SET ${key} :${value}`);
}
+ }
+
+ // Handle nickname change
+ if (newNickname && newNickname !== currentUser?.username) {
+ changeNick(currentServer.id, newNickname);
+ }
+
+ updateGlobalSettings(settings as Partial);
- setEnableHighlights(globalEnableHighlights);
- setSendTypingNotifications(globalSendTypingNotifications);
- setNickname(globalNickname || currentUser?.username || "");
- setAccountName(globalAccountName);
- setAccountPassword(globalAccountPassword);
-
- // Set original values for change tracking - only once
- setOriginalValues({
- avatar: avatarValue,
- displayName: displayNameValue,
- realname: realnameValue,
- homepage: homepageValue,
- status: statusValue,
- color: colorValue,
- bot: botValue,
- newNickname: nicknameValue,
- enableNotificationSounds: globalEnableNotificationSounds,
- notificationSound: migratedNotificationSound,
- enableHighlights: globalEnableHighlights,
- sendTypingNotifications: globalSendTypingNotifications,
- nickname: globalNickname || currentUser?.username || "",
- accountName: globalAccountName,
- accountPassword: globalAccountPassword,
- showSafeMedia: globalShowSafeMedia,
- showExternalContent: globalShowExternalContent,
- enableMarkdownRendering: globalEnableMarkdownRendering,
- showEvents: globalShowEvents,
- showNickChanges: globalShowNickChanges,
- showJoinsParts: globalShowJoinsParts,
- showQuits: globalShowQuits,
- showKicks: globalShowKicks,
- enableMultilineInput: globalEnableMultilineInput,
- multilineOnShiftEnter: globalMultilineOnShiftEnter,
- autoFallbackToSingleLine: globalAutoFallbackToSingleLine,
- operName: operName,
- operPassword: operPassword,
- operOnConnect: operOnConnect,
+ // Save notification sound file
+ if (notificationSoundFile) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const dataUrl = e.target?.result as string;
+ updateGlobalSettings({ notificationSound: dataUrl });
+ };
+ reader.readAsDataURL(notificationSoundFile);
+ }
+
+ // Save oper settings if in hosted chat mode
+ if (isHostedChatMode && serverConfig) {
+ updateServer(serverConfig.id, {
+ ...serverConfig,
+ operUsername: operName,
+ operOnConnect,
});
}
+
+ // Reset original values
+ setOriginalValues(null);
+ toggleSettingsModal(false);
}, [
- currentUser?.id,
- currentUser?.displayName,
- currentUser?.metadata?.["display-name"]?.value,
- currentUser?.metadata?.avatar?.value,
- currentUser?.metadata?.bot?.value,
- currentUser?.metadata?.color?.value,
- currentUser?.metadata?.homepage?.value,
- currentUser?.metadata?.status?.value,
- currentUser?.username,
- globalAccountName,
- globalAccountPassword,
- globalEnableHighlights,
- globalEnableNotificationSounds,
- globalNickname,
- globalNotificationSound,
- globalSendTypingNotifications,
- globalShowSafeMedia,
- globalShowExternalContent,
+ currentServer,
+ supportsMetadata,
+ avatar,
+ displayName,
+ realname,
+ homepage,
+ status,
+ color,
+ bot,
+ newNickname,
currentUser,
- originalValues,
- updateGlobalSettings,
- globalAutoFallbackToSingleLine,
- globalEnableMarkdownRendering,
- globalEnableMultilineInput,
- globalMultilineOnShiftEnter,
- globalShowEvents,
- globalShowJoinsParts,
- globalShowKicks,
- globalShowNickChanges,
- globalShowQuits,
+ settings,
+ notificationSoundFile,
+ isHostedChatMode,
+ serverConfig,
operName,
- operPassword,
operOnConnect,
- ]); // Only depend on user ID - removed all other dependencies
+ sendRaw,
+ changeNick,
+ updateGlobalSettings,
+ updateServer,
+ toggleSettingsModal,
+ ]);
- const handleSaveMetadata = (key: string, value: string) => {
- if (currentServer && currentUser) {
- metadataSet(
- currentServer.id,
- currentUser.username,
- key,
- value || undefined,
+ // Handle close
+ const handleClose = useCallback(() => {
+ if (hasUnsavedChanges) {
+ const confirmClose = window.confirm(
+ "You have unsaved changes. Are you sure you want to close without saving?",
);
+ if (!confirmClose) {
+ return;
+ }
}
- };
-
- const handleSoundFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- const handleSoundFileChange = (
- event: React.ChangeEvent,
- ) => {
- const file = event.target.files?.[0];
- if (file) {
- const url = URL.createObjectURL(file);
- setNotificationSound(url);
- setNotificationSoundFile(file);
- }
- };
-
- const handleNickChange = () => {
- if (
- currentServer &&
- newNickname.trim() &&
- newNickname.trim() !== currentUser?.username
- ) {
- changeNick(currentServer.id, newNickname.trim());
- }
- };
-
- const handleSaveAll = () => {
- if (!originalValues) {
- return; // Don't save if original values aren't set yet
+ setOriginalValues(null);
+ toggleSettingsModal(false);
+ }, [hasUnsavedChanges, toggleSettingsModal]);
+
+ const { getBackdropProps, getContentProps } = useModalBehavior({
+ onClose: handleClose,
+ isOpen: ui.isSettingsModalOpen,
+ });
+
+ // Get settings for active category
+ const categorySettings = useMemo(() => {
+ return settingsRegistry.getByCategory(activeCategory);
+ }, [activeCategory]);
+
+ // Render profile metadata fields
+ const renderProfileFields = () => {
+ if (!supportsMetadata) {
+ return (
+
+ This server does not support metadata (IRCv3 METADATA extension).
+
+ );
}
- if (currentServer && currentUser) {
- // Handle profile metadata (only when metadata is supported and values have changed)
- if (supportsMetadata) {
- // Only update display name if it changed
- if (displayName !== originalValues.displayName) {
- try {
- metadataSet(
- currentServer.id,
- "*", // Use * to refer to current user (self)
- "display-name",
- displayName || undefined,
- );
- } catch (error) {
- console.error("Failed to set display name metadata:", error);
- }
- }
-
- const metadataUpdates = [
- { key: "avatar", value: avatar, original: originalValues.avatar },
- {
- key: "homepage",
- value: homepage,
- original: originalValues.homepage,
- },
- { key: "status", value: status, original: originalValues.status },
- { key: "color", value: color, original: originalValues.color },
- { key: "bot", value: bot, original: originalValues.bot },
- ];
-
- metadataUpdates.forEach(({ key, value, original }) => {
- // Only update if the value has changed
- if (value !== original) {
- try {
- metadataSet(
- currentServer.id,
- "*", // Use * to refer to current user (self)
- key,
- value || undefined,
- );
- } catch (error) {
- console.error(`Failed to set ${key} metadata:`, error);
- }
- } else {
- }
- });
- }
-
- // Handle realname only if it changed
- if (realname !== originalValues.realname) {
- try {
- setName(currentServer.id, realname);
- } catch (error) {
- console.error("Failed to set realname:", error);
- }
- }
- }
-
- // Save global settings only if they changed
- const globalSettingsUpdates: Record = {};
-
- if (enableNotificationSounds !== originalValues.enableNotificationSounds) {
- globalSettingsUpdates.enableNotificationSounds = enableNotificationSounds;
- }
- if (notificationSound !== originalValues.notificationSound) {
- globalSettingsUpdates.notificationSound = notificationSound;
- }
- if (enableHighlights !== originalValues.enableHighlights) {
- globalSettingsUpdates.enableHighlights = enableHighlights;
- }
- if (sendTypingNotifications !== originalValues.sendTypingNotifications) {
- globalSettingsUpdates.sendTypingNotifications = sendTypingNotifications;
- }
- if (showSafeMedia !== originalValues.showSafeMedia) {
- globalSettingsUpdates.showSafeMedia = showSafeMedia;
- }
- if (showExternalContent !== originalValues.showExternalContent) {
- globalSettingsUpdates.showExternalContent = showExternalContent;
- }
- if (enableMarkdownRendering !== originalValues.enableMarkdownRendering) {
- globalSettingsUpdates.enableMarkdownRendering = enableMarkdownRendering;
- }
- if (globalShowEvents !== originalValues.showEvents) {
- globalSettingsUpdates.showEvents = globalShowEvents;
- }
- if (globalShowNickChanges !== originalValues.showNickChanges) {
- globalSettingsUpdates.showNickChanges = globalShowNickChanges;
- }
- if (globalShowJoinsParts !== originalValues.showJoinsParts) {
- globalSettingsUpdates.showJoinsParts = globalShowJoinsParts;
- }
- if (globalShowQuits !== originalValues.showQuits) {
- globalSettingsUpdates.showQuits = globalShowQuits;
- }
- if (globalShowKicks !== originalValues.showKicks) {
- globalSettingsUpdates.showKicks = globalShowKicks;
- }
- if (globalEnableMultilineInput !== originalValues.enableMultilineInput) {
- globalSettingsUpdates.enableMultilineInput = globalEnableMultilineInput;
- }
- if (globalMultilineOnShiftEnter !== originalValues.multilineOnShiftEnter) {
- globalSettingsUpdates.multilineOnShiftEnter = globalMultilineOnShiftEnter;
- }
- if (
- globalAutoFallbackToSingleLine !== originalValues.autoFallbackToSingleLine
- ) {
- globalSettingsUpdates.autoFallbackToSingleLine =
- globalAutoFallbackToSingleLine;
- }
-
- if (isHostedChatMode) {
- if (nickname !== originalValues.nickname) {
- globalSettingsUpdates.nickname = nickname;
- }
- if (accountName !== originalValues.accountName) {
- globalSettingsUpdates.accountName = accountName;
- }
- if (accountPassword !== originalValues.accountPassword) {
- globalSettingsUpdates.accountPassword = accountPassword;
- }
- }
-
- // Save oper settings to server config if changed
- if (currentServer) {
- const serverConfigUpdates: Partial = {};
- if (operName !== originalValues.operName) {
- serverConfigUpdates.operUsername = operName || undefined;
- }
- if (operPassword !== originalValues.operPassword) {
- serverConfigUpdates.operPassword = operPassword || undefined;
- }
- if (operOnConnect !== originalValues.operOnConnect) {
- serverConfigUpdates.operOnConnect = operOnConnect;
- }
- if (Object.keys(serverConfigUpdates).length > 0) {
- updateServer(currentServer.id, serverConfigUpdates);
- }
- }
-
- // Only update global settings if there are changes
- if (Object.keys(globalSettingsUpdates).length > 0) {
- updateGlobalSettings(globalSettingsUpdates);
- }
-
- // Reset original values when closing after save
- setOriginalValues(null);
- toggleUserProfileModal(false);
- };
-
- const categories = [
- { id: "profile" as const, name: "Profile", icon: FaUser },
- { id: "notifications" as const, name: "Notifications", icon: FaBell },
- { id: "preferences" as const, name: "Preferences", icon: FaCog },
- { id: "media" as const, name: "Media", icon: FaImage },
- ...(isHostedChatMode
- ? [{ id: "account" as const, name: "Account", icon: FaServer }]
- : []),
- ];
-
- const renderProfileSettings = () => (
-
- {/* View Profile Button */}
- {currentUser && currentServer && (
-
- {
- if (hasUnsavedChanges) {
- setShowUnsavedChangesModal(true);
- } else {
- // Close User Settings and request to open User Profile
- setProfileViewRequest(currentServer.id, currentUser.username);
- toggleUserProfileModal(false);
- }
- }}
- className="px-4 py-2 bg-[#5865F2] hover:bg-[#4752C4] text-white rounded-lg font-medium transition-all hover:shadow-lg hover:shadow-[#5865F2]/20 flex items-center gap-2"
- >
-
- View Profile
-
-
- )}
-
-
-
+ return (
+
+ {/* Nickname */}
+
+
+ Nickname
+
+
+ Your unique identifier on this server
+
- {newNickname.trim() &&
- newNickname.trim() !== currentUser?.username && (
-
- Change Nick
-
- )}
-
- {supportsMetadata && (
- <>
-
-
-
-
-
- {currentServer?.filehost ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
-
-
- {!supportsMetadata && (
-
-
Limited Profile Support
-
- This server does not support user metadata. Advanced profile options
- (display name, avatar, status, etc.) will appear when connecting to
- a server with IRC metadata support.
+ {/* Display Name */}
+
+
+ Display Name
+
+
+ Your preferred display name (metadata: display-name)
+
- )}
-
- );
- const renderNotificationSettings = () => (
-
-
-
-
- setEnableNotificationSounds(e.target.checked)}
- className="mr-3 accent-discord-primary"
- />
-
- Play notification sounds
-
+ {/* Avatar */}
+
+
+ Avatar URL
+
+ URL to your avatar image (metadata: avatar)
+
+
+ {currentServer && (
+
+ )}
-
- {enableNotificationSounds && (
-
-
-
{
- const value = e.target.value;
- if (value === "notif1") {
- setNotificationSound("/sounds/notif1.mp3");
- setNotificationSoundFile(null);
- } else if (value === "notif2") {
- setNotificationSound("/sounds/notif2.mp3");
- setNotificationSoundFile(null);
- } else if (value === "custom") {
- handleSoundFileSelect();
- }
- }}
- className="w-full px-3 py-2 bg-discord-input-bg border border-discord-button-secondary-default rounded text-discord-text-normal focus:border-discord-text-link focus:outline-none"
- >
- Notif 1
- Notif 2
- Custom Upload
-
-
- {(notificationSound.startsWith("blob:") ||
- notificationSound.startsWith("data:")) && (
-
-
- Custom sound selected
-
-
- playNotificationSound(
- notificationSoundFile || notificationSound,
- )
- }
- className="px-3 py-1 bg-discord-primary hover:bg-discord-primary-hover text-white rounded text-sm transition-colors"
- >
- Test Sound
-
-
- )}
-
- {notificationSound === "/sounds/notif1.mp3" && (
-
-
- Notif 1 sound selected
-
- playNotificationSound("/sounds/notif1.mp3")}
- className="px-3 py-1 bg-discord-primary hover:bg-discord-primary-hover text-white rounded text-sm transition-colors"
- >
- Test Sound
-
-
- )}
-
- {notificationSound === "/sounds/notif2.mp3" && (
-
-
- Notif 2 sound selected
-
- playNotificationSound("/sounds/notif2.mp3")}
- className="px-3 py-1 bg-discord-primary hover:bg-discord-primary-hover text-white rounded text-sm transition-colors"
- >
- Test Sound
-
-
- )}
-
-
+
+ Real Name
+
+
+ Your real or full name (metadata: realname)
+
-
- )}
+
-
-
+ {/* Homepage */}
+
+
+ Homepage
+
+
+ Your personal website or homepage URL (metadata: homepage)
+
setEnableHighlights(e.target.checked)}
- className="mr-3 accent-discord-primary"
+ type="text"
+ value={homepage}
+ onChange={handleHomepageChange}
+ placeholder="https://example.com"
+ className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary"
/>
-
Highlight mentions
-
-
-
- {enableHighlights && (
-
+
+ {/* Status */}
+
- );
+
- const renderPreferencesSettings = () => (
-
-
-
-
+ {/* Color */}
+
+
+ Color
+
+
+ Your preferred color code (metadata: color)
+
+
- updateGlobalSettings({ showEvents: e.target.checked })
- }
- className="mr-3 accent-discord-primary"
+ type="color"
+ value={color || "#000000"}
+ onChange={handleColorChange}
+ className="w-12 h-8 rounded border-none cursor-pointer"
/>
-
Show chat events
-
-
- {globalShowEvents && (
-
- )}
-
-
-
-
-
- setSendTypingNotifications(e.target.checked)}
- className="mr-3 accent-discord-primary"
- />
-
- Send typing indicators
-
-
-
-
-
-
-
-
-
-
-
-
-
- Online
- Idle
- Do Not Disturb
- Invisible
-
-
-
- );
+
+ Bot
+
+
+ Mark as bot (metadata: bot) - usually 'on' or empty
+
+
+
+
+ );
+ };
- const renderAccountSettings = () => (
-
-
-
-
+ // Render account settings
+ const renderAccountFields = () => {
+ if (!isHostedChatMode) {
+ return (
+
+ Account settings are only available in hosted chat mode.
+
+ );
+ }
-
-
-
+ return (
+
+ {/* IRC Operator Authentication */}
+
- );
-
- const renderMediaSettings = () => (
-
- );
-
- const renderActiveCategory = () => {
- switch (activeCategory) {
- case "profile":
- return renderProfileSettings();
- case "notifications":
- return renderNotificationSettings();
- case "preferences":
- return renderPreferencesSettings();
- case "media":
- return renderMediaSettings();
- case "account":
- return renderAccountSettings();
- default:
- return null;
- }
+
+ );
};
return (
-
-
+
+
{/* Sidebar */}
@@ -1687,27 +999,24 @@ const UserSettings: React.FC = React.memo(() => {
- {categories.map((category) => {
- const Icon = category.icon;
- return (
- setActiveCategory(category.id)}
- className={`flex items-center ${isMobile ? "justify-center px-2" : "w-full px-3 text-left"} py-2 mb-1 rounded transition-colors overflow-hidden min-w-0 ${
- activeCategory === category.id
- ? "bg-discord-primary text-white"
- : "text-discord-text-muted hover:text-white hover:bg-discord-dark-400"
- }`}
- >
-
-
- {category.name}
-
-
- );
- })}
+ {categories.map((category) => (
+ setActiveCategory(category.id)}
+ className={`flex items-center ${isMobile ? "justify-center px-2" : "w-full px-3 text-left"} py-2 mb-1 rounded transition-colors overflow-hidden min-w-0 ${
+ activeCategory === category.id
+ ? "bg-discord-primary text-white"
+ : "text-discord-text-muted hover:text-white hover:bg-discord-dark-400"
+ }`}
+ >
+
+ {category.icon}
+
+
+ {category.title}
+
+
+ ))}
@@ -1716,7 +1025,7 @@ const UserSettings: React.FC = React.memo(() => {
- {categories.find((c) => c.id === activeCategory)?.name}
+ {categories.find((c) => c.id === activeCategory)?.title}
{
- {renderActiveCategory()}
+ {/* Profile category - custom rendering */}
+ {activeCategory === "profile" && renderProfileFields()}
+
+ {/* Account category - custom rendering */}
+ {activeCategory === "account" && renderAccountFields()}
+
+ {/* Other categories - use SettingRenderer */}
+ {activeCategory !== "profile" && activeCategory !== "account" && (
+
+ {categorySettings.map((setting) => (
+
+ handleSettingChange(setting.key, value)
+ }
+ isHighlighted={highlightedSetting === setting.id}
+ />
+ ))}
+
+ )}
@@ -1738,7 +1068,7 @@ const UserSettings: React.FC = React.memo(() => {
Cancel
{
{/* User Profile Modal */}
- {viewProfileModalOpen && currentUser && currentServer && (
+ {viewProfileModalOpen && currentServer && currentUser && (
setViewProfileModalOpen(false)}
@@ -1760,109 +1090,10 @@ const UserSettings: React.FC = React.memo(() => {
username={currentUser.username}
/>
)}
-
- {/* Unsaved Changes Warning Modal */}
- {showUnsavedChangesModal && (
-
-
-
-
- Unsaved Changes
-
-
- You have unsaved changes. Would you like to save them before
- viewing your profile?
-
-
- {
- setShowUnsavedChangesModal(false);
- }}
- className="px-4 py-2 bg-discord-dark-400 text-discord-text-normal rounded font-medium hover:bg-discord-dark-500 transition-colors"
- >
- Cancel
-
- {
- if (currentUser && currentServer) {
- setShowUnsavedChangesModal(false);
- // Close User Settings and request to open User Profile
- setProfileViewRequest(
- currentServer.id,
- currentUser.username,
- );
- toggleUserProfileModal(false);
- }
- }}
- className="px-4 py-2 bg-black text-white rounded font-medium hover:bg-gray-900 transition-colors"
- >
- No
-
- {
- if (currentUser && currentServer) {
- handleSaveAll();
- setShowUnsavedChangesModal(false);
- // Close User Settings and request to open User Profile
- setProfileViewRequest(
- currentServer.id,
- currentUser.username,
- );
- toggleUserProfileModal(false);
- }
- }}
- className="px-4 py-2 bg-[#5865F2] text-white rounded font-medium hover:bg-[#4752C4] transition-colors"
- >
- Yes
-
-
-
-
-
- )}
-
- {/* External Content Warning Modal */}
- {showExternalContentWarning && (
-
-
-
-
- ⚠️ External Content Warning
-
-
- Enabling external content display will load images and media
- from external servers. This may reveal your IP address to those
- servers.
-
-
- Only enable this if you understand the privacy implications and
- trust the content sources.
-
-
- {
- setShowExternalContentWarning(false);
- }}
- className="px-4 py-2 bg-discord-dark-400 text-discord-text-normal rounded font-medium hover:bg-discord-dark-500 transition-colors"
- >
- Cancel
-
- {
- setShowExternalContentWarning(false);
- setShowExternalContent(true);
- }}
- className="px-4 py-2 bg-red-600 text-white rounded font-medium hover:bg-red-700 transition-colors"
- >
- Enable Anyway
-
-
-
-
-
- )}
);
});
+UserSettings.displayName = "UserSettings";
+
export default UserSettings;
diff --git a/src/components/ui/settings/CustomMentionsField.tsx b/src/components/ui/settings/CustomMentionsField.tsx
new file mode 100644
index 00000000..817d6975
--- /dev/null
+++ b/src/components/ui/settings/CustomMentionsField.tsx
@@ -0,0 +1,80 @@
+import { XMarkIcon } from "@heroicons/react/24/solid";
+import type React from "react";
+import { useState } from "react";
+import type { SettingComponentProps } from "../../../lib/settings/types";
+
+/**
+ * Custom component for managing custom mentions list
+ */
+export const CustomMentionsField: React.FC
= ({
+ value,
+ onChange,
+ disabled,
+}) => {
+ const [newMention, setNewMention] = useState("");
+ const mentions = (value as string[]) || [];
+
+ const handleAddMention = () => {
+ if (newMention.trim() && !mentions.includes(newMention.trim())) {
+ onChange([...mentions, newMention.trim()]);
+ setNewMention("");
+ }
+ };
+
+ const handleRemoveMention = (mention: string) => {
+ onChange(mentions.filter((m) => m !== mention));
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddMention();
+ }
+ };
+
+ return (
+
+
+ setNewMention(e.target.value)}
+ onKeyPress={handleKeyPress}
+ disabled={disabled}
+ placeholder="Add a word or phrase..."
+ className="flex-1 rounded border border-discord-button-secondary-default bg-discord-input-bg px-3 py-2 text-discord-text-normal placeholder-discord-text-muted focus:border-discord-text-link focus:outline-none disabled:opacity-50"
+ />
+
+ Add
+
+
+ {mentions.length > 0 && (
+
+ {mentions.map((mention) => (
+
+ {mention}
+ handleRemoveMention(mention)}
+ disabled={disabled}
+ className="ml-2 text-discord-text-muted hover:text-discord-text-normal disabled:opacity-50"
+ >
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default CustomMentionsField;
diff --git a/src/components/ui/settings/IgnoreListField.tsx b/src/components/ui/settings/IgnoreListField.tsx
new file mode 100644
index 00000000..a582cd89
--- /dev/null
+++ b/src/components/ui/settings/IgnoreListField.tsx
@@ -0,0 +1,125 @@
+import { XMarkIcon } from "@heroicons/react/24/solid";
+import type React from "react";
+import { useState } from "react";
+import { isValidIgnorePattern } from "../../../lib/ignoreUtils";
+import type { SettingComponentProps } from "../../../lib/settings/types";
+
+/**
+ * Custom component for managing ignore list
+ */
+export const IgnoreListField: React.FC = ({
+ value,
+ onChange,
+ disabled,
+}) => {
+ const [newPattern, setNewPattern] = useState("");
+ const [validationError, setValidationError] = useState("");
+ const ignoreList = (value as string[]) || [];
+
+ const handleAddPattern = () => {
+ const trimmed = newPattern.trim();
+ if (!trimmed) {
+ setValidationError("Pattern cannot be empty");
+ return;
+ }
+
+ if (!isValidIgnorePattern(trimmed)) {
+ setValidationError(
+ "Invalid pattern format. Use nick!user@host format (wildcards * allowed)",
+ );
+ return;
+ }
+
+ if (ignoreList.includes(trimmed)) {
+ setValidationError("Pattern already exists");
+ return;
+ }
+
+ onChange([...ignoreList, trimmed]);
+ setNewPattern("");
+ setValidationError("");
+ };
+
+ const handleRemovePattern = (pattern: string) => {
+ onChange(ignoreList.filter((p) => p !== pattern));
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAddPattern();
+ }
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setNewPattern(e.target.value);
+ setValidationError("");
+ };
+
+ return (
+
+
+
+
+
+ Add
+
+
+ {validationError && (
+
{validationError}
+ )}
+
+ Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com,
+ troll*!*@*
+
+
+ {ignoreList.length > 0 && (
+
+
+ Ignored patterns:
+
+
+ {ignoreList.map((pattern) => (
+
+
+ {pattern}
+
+ handleRemovePattern(pattern)}
+ disabled={disabled}
+ className="text-red-400 hover:text-red-300 disabled:opacity-50"
+ title="Remove pattern"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default IgnoreListField;
diff --git a/src/components/ui/settings/SettingRenderer.tsx b/src/components/ui/settings/SettingRenderer.tsx
new file mode 100644
index 00000000..2d056172
--- /dev/null
+++ b/src/components/ui/settings/SettingRenderer.tsx
@@ -0,0 +1,301 @@
+import type React from "react";
+import { useCallback } from "react";
+import type {
+ SettingDefinition,
+ SettingValue,
+} from "../../../lib/settings/types";
+
+export interface SettingRendererProps {
+ setting: SettingDefinition;
+ value: SettingValue;
+ onChange: (value: SettingValue) => void;
+ error?: string;
+ disabled?: boolean;
+ isHighlighted?: boolean;
+}
+
+/**
+ * Renders a setting field based on its type definition
+ */
+export const SettingRenderer: React.FC = ({
+ setting,
+ value,
+ onChange,
+ error,
+ disabled = false,
+ isHighlighted = false,
+}) => {
+ const handleChange = useCallback(
+ (newValue: SettingValue) => {
+ onChange(newValue);
+ },
+ [onChange],
+ );
+
+ // Render custom component if specified
+ if (setting.customComponent) {
+ const CustomComponent = setting.customComponent;
+ return (
+
+ );
+ }
+
+ // Render based on type
+ switch (setting.type) {
+ case "toggle":
+ return (
+
+ handleChange(e.target.checked)}
+ disabled={disabled}
+ className="mr-3 accent-discord-primary disabled:opacity-50"
+ />
+ {setting.title}
+
+ );
+
+ case "text":
+ return (
+ handleChange(e.target.value)}
+ placeholder={setting.placeholder}
+ disabled={disabled}
+ className={`w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary disabled:opacity-50 ${
+ error ? "border-2 border-red-500" : ""
+ }`}
+ />
+ );
+
+ case "number":
+ return (
+ handleChange(Number(e.target.value))}
+ placeholder={setting.placeholder}
+ min={setting.min}
+ max={setting.max}
+ step={setting.step}
+ disabled={disabled}
+ className={`w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary disabled:opacity-50 ${
+ error ? "border-2 border-red-500" : ""
+ }`}
+ />
+ );
+
+ case "textarea":
+ return (
+