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<{ 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 ( + + ); + })} +
+ )} +
+
+
+ ); +}; + +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 }) => ( -
-
- -

{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" - /> - -
- {globalCustomMentions.length > 0 && ( -
- {globalCustomMentions.map((mention) => ( - - {mention} - - - ))} -
- )} -
- ); + } + 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 ( -
-
-
- - -
- {validationError && ( -

{validationError}

- )} -

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

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

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

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

- No users ignored -

- )} -
- ); +// 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 && ( -
- -
- )} - - -
+ return ( +
+ {/* Nickname */} +
+ +

+ Your unique identifier on this server +

- {newNickname.trim() && - newNickname.trim() !== currentUser?.username && ( - - )}
- - {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 */} +

+ +

+ Your preferred display name (metadata: display-name)

+
- )} -
- ); - const renderNotificationSettings = () => ( -
- -
-
- -
+ ); + }; - const renderAccountSettings = () => ( -
- - - + // Render account settings + const renderAccountFields = () => { + if (!isHostedChatMode) { + return ( +
+ Account settings are only available in hosted chat mode. +
+ ); + } - - - + return ( +
+ {/* IRC Operator Authentication */} +
+

IRC Operator

+

+ Authenticate as an IRC Operator for administrative access +

- - - +
+ + setOperName(e.target.value)} + placeholder="Enter oper username" + className="w-full bg-discord-dark-500 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" + /> +
- - setOperName(e.target.value)} - placeholder="Operator username" - 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" - /> - +
+ + setOperPassword(e.target.value)} + placeholder="Enter oper password" + className="w-full bg-discord-dark-500 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" + /> +
- - setOperPassword(e.target.value)} - placeholder="Operator password" - 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" - /> - +
+ setOperOnConnect(e.target.checked)} + className="accent-discord-primary" + /> + +
- - - - - {operName && ( -
-
- )} -
- ); - - const renderMediaSettings = () => ( -
- - - - - -
- -

- ⚠️ Warning: Enabling this will load content from external servers and - may reveal your IP address. -

-
-
- - - - -
- ); - - 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(() => {
@@ -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? -

-
- - - -
-
-
-
- )} - - {/* 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. -

-
- - -
-
-
-
- )}
); }); +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" + /> + +
+ {mentions.length > 0 && ( +
+ {mentions.map((mention) => ( + + {mention} + + + ))} +
+ )} +
+ ); +}; + +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 ( +
+
+
+ + +
+ {validationError && ( +

{validationError}

+ )} +

+ Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com, + troll*!*@* +

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

+ Ignored patterns: +

+
+ {ignoreList.map((pattern) => ( +
+ + {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 ( + + ); + + 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 ( +