diff --git a/src/components/ui/ChannelSettingsModal.tsx b/src/components/ui/ChannelSettingsModal.tsx index 141ec54b..8241eca8 100644 --- a/src/components/ui/ChannelSettingsModal.tsx +++ b/src/components/ui/ChannelSettingsModal.tsx @@ -18,9 +18,15 @@ import { import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useModalBehavior } from "../../hooks/useModalBehavior"; import ircClient from "../../lib/ircClient"; -import { hasOpPermission } from "../../lib/ircUtils"; +import { hasOpPermission, humanizeNamedMode } from "../../lib/ircUtils"; +import { + lookupNamedModeMeta, + NAMED_MODE_GROUP_LABELS, + NAMED_MODE_GROUP_ORDER, + type NamedModeGroup, +} from "../../lib/namedModeRegistry"; import useStore, { serverSupportsMetadata } from "../../store"; -import type { Channel } from "../../types"; +import type { Channel, NamedModeSpec } from "../../types"; import AvatarUpload from "./AvatarUpload"; import FloodSettingsModal from "./FloodSettingsModal"; @@ -87,6 +93,14 @@ const ChannelSettingsModal: React.FC = ({ const [isUpdatingTopic, setIsUpdatingTopic] = useState(false); const [isApplyingChanges, setIsApplyingChanges] = useState(false); + // Pending changes for the named-modes-aware Advanced tab. Keyed by + // long-form mode name (e.g. "topiclock" or "obsidianirc/floodprot"). + // Apply diffs this against `originalModesRef` (which records the + // current letter-keyed state) and emits a single sendNamedMode call. + const [pendingNamedModes, setPendingNamedModes] = useState< + Record + >({}); + // Flood settings modal state const [isFloodModalOpen, setIsFloodModalOpen] = useState(false); const [floodProfile, setFloodProfile] = useState(""); @@ -200,7 +214,8 @@ const ChannelSettingsModal: React.FC = ({ ...(userHasOpPermission && supportsMetadata ? [{ id: "settings" as const, name: "Settings", icon: FaCog, count: 0 }] : []), - ...(userHasOpPermission && server?.isUnrealIRCd + ...(userHasOpPermission && + (server?.namedModes?.supported || server?.isUnrealIRCd) ? [{ id: "advanced" as const, name: "Advanced", icon: FaCog, count: 0 }] : []), ]; @@ -223,6 +238,7 @@ const ChannelSettingsModal: React.FC = ({ if (!isOpen) { hasFetchedRef.current = false; clearPendingTimeouts(); + setPendingNamedModes({}); } }, [isOpen, clearPendingTimeouts]); @@ -987,6 +1003,24 @@ const ChannelSettingsModal: React.FC = ({ } }; + /** Apply path for the named-modes-aware Advanced tab. Walks the + * pendingNamedModes map and emits a single sendNamedMode call. */ + const applyNamedModesAdvancedChanges = async () => { + if (!server || !channel) return; + const items: Array<{ sign: "+" | "-"; name: string; param?: string }> = []; + for (const [name, change] of Object.entries(pendingNamedModes)) { + items.push({ sign: change.sign, name, param: change.param }); + } + if (!items.length) return; + setIsApplyingChanges(true); + try { + ircClient.sendNamedMode(serverId, channelName, items, server.namedModes); + setPendingNamedModes({}); + } finally { + setIsApplyingChanges(false); + } + }; + // Cancel pending timeouts when component unmounts useEffect(() => { return () => clearPendingTimeouts(); @@ -1026,6 +1060,155 @@ const ChannelSettingsModal: React.FC = ({ if (!isOpen) return null; + /** Look up the live state of a named mode in the parsed mode-state + * ref. Returns { set, param } where set indicates presence and + * param is the current parameter (if any). */ + const liveStateForNamedMode = ( + spec: NamedModeSpec, + ): { set: boolean; param: string | null } => { + if (!spec.letter) return { set: false, param: null }; + const stored = originalModesRef.current[spec.letter]; + if (stored === undefined) return { set: false, param: null }; + // null = present, no param. string = present with param. "__HIDDEN__" + // (used for +k/+L masking) counts as present without an exposable + // value. + return { set: true, param: stored }; + }; + + /** What the user has chosen the mode should become, taking pending + * edits into account. Returns null when there's no override. */ + const stagedStateForNamedMode = ( + name: string, + ): { sign: "+" | "-"; param?: string } | null => { + return pendingNamedModes[name] ?? null; + }; + + const stageNamedMode = ( + name: string, + next: { sign: "+" | "-"; param?: string } | null, + ) => { + setPendingNamedModes((prev) => { + const out = { ...prev }; + if (next === null) delete out[name]; + else out[name] = next; + return out; + }); + }; + + /** Render a single named-mode entry. The control type is dictated + * by the spec's mode type (1=list, 2=param-both, 3=param-add-only, + * 4=flag, 5=prefix). We skip 1 (the dedicated Bans/Exceptions/ + * Invitations tabs cover those) and 5 (member-prefix modes are + * set via the member context menu, not channel settings). + * + * Returns { node, group } so the caller can bucket rows into the + * section ordering from NAMED_MODE_GROUP_ORDER. Returns null when + * the registry marks the mode as hidden (covered by another tab or + * server-managed). */ + const renderNamedModeRow = ( + spec: NamedModeSpec, + ): { node: React.ReactNode; group: NamedModeGroup } | null => { + if (spec.type === 1 || spec.type === 5) return null; + const meta = lookupNamedModeMeta(spec.name); + if (meta?.hidden) return null; + + const fallback = humanizeNamedMode(spec.name); + const label = meta?.label ?? fallback.display; + const description = meta?.description ?? ""; + const group: NamedModeGroup = meta?.group ?? "properties"; + const placeholder = meta?.paramPlaceholder; + + const live = liveStateForNamedMode(spec); + const staged = stagedStateForNamedMode(spec.name); + + const isFlag = spec.type === 4; + const stagedSet = staged ? staged.sign === "+" : live.set; + const stagedParam = staged?.param ?? live.param ?? ""; + + const headerRow = ( +
+
+ + {label} + + {fallback.vendor && ( + + {fallback.vendor} + + )} +
+ {description && ( +

+ {description} +

+ )} +
+ ); + + if (isFlag) { + return { + group, + node: ( +
+ {headerRow} + { + const next = e.target.checked; + if (next === live.set) { + stageNamedMode(spec.name, null); + } else { + stageNamedMode(spec.name, { sign: next ? "+" : "-" }); + } + }} + className="w-4 h-4 mt-1 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
+ ), + }; + } + + // Param mode (type 2 or 3). Empty input means "unset"; non-empty + // means "set to this value". + return { + group, + node: ( +
+ {headerRow} +
+ { + const v = e.target.value; + if (v === (live.param ?? "")) { + stageNamedMode(spec.name, null); + } else if (!v) { + stageNamedMode(spec.name, { sign: "-" }); + } else { + stageNamedMode(spec.name, { sign: "+", param: v }); + } + }} + placeholder={ + live.set + ? "(set, edit to change)" + : (placeholder ?? "(not set)") + } + className="flex-1 p-2 bg-discord-dark-400 text-white rounded text-sm" + /> +
+
+ ), + }; + }; + const contentBody = (
@@ -1455,421 +1638,460 @@ const ChannelSettingsModal: React.FC = ({ ) : ( <> {/* Advanced tab content */} -
- {/* Flood Protection Settings */} -
-
- -

- Configure flood protection rules to prevent spam and abuse. - UnrealIRCd-specific feature. -

-
- - {/* Flood Profile Selection */} -
- - -
- - {/* Flood Parameters */} -
- -
- setFloodParams(e.target.value)} - placeholder="Default" - className="flex-1 p-2 bg-discord-dark-300 text-white rounded text-sm" - /> - + {server?.namedModes?.supported ? ( + (() => { + const grouped = new Map(); + for (const spec of server.namedModes.channelModes ?? []) { + const result = renderNamedModeRow(spec); + if (!result) continue; + const list = grouped.get(result.group) ?? []; + list.push(result.node); + grouped.set(result.group, list); + } + const sections = NAMED_MODE_GROUP_ORDER.filter( + (g) => (grouped.get(g)?.length ?? 0) > 0, + ); + if (sections.length === 0) { + return ( +
+

+ No advanced modes are advertised by this server. +

+
+ ); + } + return ( +
+ {sections.map((g) => ( +
+

+ {NAMED_MODE_GROUP_LABELS[g]} +

+
{grouped.get(g)}
+
+ ))}
-

- Use the Configure button for detailed flood rule management, - or enter parameters manually in the format: [rules]:seconds -

-
-
- - {/* Content Filtering */} -
-

- Content Filtering -

- - {/* Block Color Codes */} -
-
+ ); + })() + ) : ( +
+ {/* Flood Protection Settings */} +
+
-

- Block messages containing mIRC color codes +

+ Configure flood protection rules to prevent spam and + abuse. UnrealIRCd-specific feature.

- setBlockColorCodes(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No CTCPs */} -
-
-
- {/* Filter Bad Words */} -
-
-
- {/* Strip Color Codes */} -
-
- -

- Strip mIRC color codes from messages -

+ {/* Content Filtering */} +
+

+ Content Filtering +

+ + {/* Block Color Codes */} +
+
+ +

+ Block messages containing mIRC color codes +

+
+ setBlockColorCodes(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setStripColorCodes(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
-
- {/* Channel Behavior */} -
-

- Channel Behavior -

+ {/* No CTCPs */} +
+
+ +

+ Block CTCP commands in the channel +

+
+ setNoCTCPs(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
- {/* Delay Joins */} -
-
- -

- Delay showing joins until someone speaks -

+ {/* Filter Bad Words */} +
+
+ +

+ Filter out bad words with <censored> +

+
+ setFilterBadWords(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setDelayJoins(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Knocks */} -
-
- -

- /KNOCK command is not allowed -

+ {/* Strip Color Codes */} +
+
+ +

+ Strip mIRC color codes from messages +

+
+ setStripColorCodes(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoKnocks(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - />
- {/* No Nick Changes */} -
-
- -

- Nickname changes are not permitted -

+ {/* Channel Behavior */} +
+

+ Channel Behavior +

+ + {/* Delay Joins */} +
+
+ +

+ Delay showing joins until someone speaks +

+
+ setDelayJoins(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoNickChanges(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Kicks */} -
-
- -

- Kick commands are not allowed -

+ {/* No Knocks */} +
+
+ +

+ /KNOCK command is not allowed +

+
+ setNoKnocks(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoKicks(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Notices */} -
-
- -

- NOTICE commands are not allowed -

+ {/* No Nick Changes */} +
+
+ +

+ Nickname changes are not permitted +

+
+ setNoNickChanges(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoNotices(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Invites */} -
-
- -

- /INVITE command is not allowed -

+ {/* No Kicks */} +
+
+ +

+ Kick commands are not allowed +

+
+ setNoKicks(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoInvites(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
-
- {/* Access Control */} -
-

- Access Control -

+ {/* No Notices */} +
+
+ +

+ NOTICE commands are not allowed +

+
+ setNoNotices(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
- {/* Registered Nick Required */} -
-
- -

- Users must have a registered nickname (+r) to talk -

+ {/* No Invites */} +
+
+ +

+ /INVITE command is not allowed +

+
+ setNoInvites(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- - setRegisteredNickRequired(e.target.checked) - } - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - />
- {/* Registered Users Only */} -
-
- -

- Only registered users (+r) may join -

+ {/* Access Control */} +
+

+ Access Control +

+ + {/* Registered Nick Required */} +
+
+ +

+ Users must have a registered nickname (+r) to talk +

+
+ + setRegisteredNickRequired(e.target.checked) + } + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setRegisteredUsersOnly(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* IRC Operator Only */} -
-
- -

- Only IRC operators can join (settable by IRCops) -

+ {/* Registered Users Only */} +
+
+ +

+ Only registered users (+r) may join +

+
+ setRegisteredUsersOnly(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setIrcOperatorOnly(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* Secure Connection Required */} -
-
- -

- Only clients on secure connections (SSL/TLS) can join -

+ {/* IRC Operator Only */} +
+
+ +

+ Only IRC operators can join (settable by IRCops) +

+
+ setIrcOperatorOnly(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
+ + {/* Secure Connection Required */} +
+
+ +

+ Only clients on secure connections (SSL/TLS) can join +

+
+ + setSecureConnectionRequired(e.target.checked) + } + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- - setSecureConnectionRequired(e.target.checked) - } - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - />
-
- {/* Channel Properties */} -
-

- Channel Properties -

+ {/* Channel Properties */} +
+

+ Channel Properties +

+ + {/* Private Channel */} +
+
+ +

+ Channel is marked as private +

+
+ setPrivateChannel(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
+ + {/* Permanent Channel */} +
+
+ +

+ Channel won't be destroyed when empty (settable by + IRCops) +

+
+ setPermanentChannel(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
- {/* Private Channel */} -
-
+ {/* Channel History */} +
-

- Channel is marked as private +

+ Record channel history with max-lines:max-minutes. Leave + empty to disable.

+ setChannelHistory(e.target.value)} + placeholder="e.g., 100:1440" + className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" + />
- setPrivateChannel(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* Permanent Channel */} -
-
+ {/* Channel Link */} +
-

- Channel won't be destroyed when empty (settable by IRCops) +

+ Forward users to this channel if they can't join. Leave + empty to disable.

+ setChannelLink(e.target.value)} + placeholder="#overflow" + className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" + />
- setPermanentChannel(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- - {/* Channel History */} -
- -

- Record channel history with max-lines:max-minutes. Leave - empty to disable. -

- setChannelHistory(e.target.value)} - placeholder="e.g., 100:1440" - className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" - /> -
- - {/* Channel Link */} -
- -

- Forward users to this channel if they can't join. Leave - empty to disable. -

- setChannelLink(e.target.value)} - placeholder="#overflow" - className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" - />
-
+ )} )}
@@ -1911,7 +2133,11 @@ const ChannelSettingsModal: React.FC = ({ )} {activeTab === "advanced" && (