diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index fbd63b77..d0bcc29b 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -752,9 +752,9 @@ export const ChannelList: React.FC<{ ) : ( selectedChannelId !== channel.id && (channel.isMentioned && - channel.unreadCount > 0 ? ( + (channel.mentionCount ?? 0) > 0 ? ( - {channel.unreadCount} + {channel.mentionCount} ) : channel.unreadCount > 0 ? ( @@ -1202,9 +1202,9 @@ export const ChannelList: React.FC<{ {/* Unread/Mention indicators */} {selectedPrivateChatId !== privateChat.id && (privateChat.isMentioned && - privateChat.unreadCount > 0 ? ( + (privateChat.mentionCount ?? 0) > 0 ? ( - {privateChat.unreadCount} + {privateChat.mentionCount} ) : privateChat.unreadCount > 0 ? ( diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 2509eda7..ddc410bf 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -42,6 +42,10 @@ import { MiniMediaPlayer } from "../ui/MiniMediaPlayer"; import ModerationModal, { type ModerationAction } from "../ui/ModerationModal"; import ReactionModal from "../ui/ReactionModal"; import { ReactionPopover } from "../ui/ReactionPopover"; +import { + getActiveSlashQuery, + SlashCommandPopover, +} from "../ui/SlashCommandPopover"; import { TextArea } from "../ui/TextInput"; import { TopicMediaStrip } from "../ui/TopicMediaStrip"; import UserContextMenu from "../ui/UserContextMenu"; @@ -108,6 +112,10 @@ export const ChatArea: React.FC<{ // autocompleteInputText is updated only when autocomplete dropdowns are visible. const [hasText, setHasText] = useState(false); const [autocompleteInputText, setAutocompleteInputText] = useState(""); + // Slash-command popover state. Only updated when the input starts + // with "/" and we're still completing the command name -- otherwise + // typing wouldn't trigger a re-render for this popover at all. + const [slashInputValue, setSlashInputValue] = useState(""); const [isEmojiSelectorOpen, setIsEmojiSelectorOpen] = useState(false); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [selectedColor, setSelectedColor] = useState(null); @@ -1286,6 +1294,18 @@ export const ChatArea: React.FC<{ cursorPositionRef.current = newCursorPosition; handleUpdatedText(newText); + // obsidianirc/cmdslist: update slash state only when relevant. + // When the input doesn't look like a command (no leading slash, + // or already past the command name), re-render only on the + // active->inactive transition to clear the popover. + const slashActive = + getActiveSlashQuery(newText, newCursorPosition) !== null; + if (slashActive) { + setSlashInputValue(newText); + } else if (slashInputValue !== "") { + setSlashInputValue(""); + } + // Exit history mode if user starts typing messageHistory.exitHistory(); @@ -2138,6 +2158,42 @@ export const ChatArea: React.FC<{ inputElement={inputRef.current} /> + {/* obsidianirc/cmdslist: slash-command suggestion popover */} + {(() => { + const srv = servers.find((s) => s.id === selectedServerId); + const cmds = srv?.cmdsAvailable ?? []; + if (cmds.length === 0) return null; + const slashActive = + getActiveSlashQuery( + slashInputValue, + cursorPositionRef.current, + ) !== null; + return ( + { + // Replace the partial command with / + space + // and put the cursor right after the space. + const next = `/${cmd} `; + applyText(next); + cursorPositionRef.current = next.length; + setSlashInputValue(""); + inputRef.current?.focus(); + inputRef.current?.setSelectionRange( + next.length, + next.length, + ); + }} + onClose={() => { + setSlashInputValue(""); + }} + /> + ); + })()} + {/* Members dropdown triggered by @ button */} void; + onClose: () => void; +} + +const MAX_SUGGESTIONS = 10; + +export function getActiveSlashQuery( + inputValue: string, + cursorPosition: number, +): string | null { + // Only active when the input starts with a single "/" and the user + // has not yet typed a space (still completing the command name). + if (!inputValue.startsWith("/")) return null; + if (inputValue.startsWith("//")) return null; // escape for literal "/" + const beforeCursor = inputValue.slice(0, cursorPosition); + const firstSpace = beforeCursor.indexOf(" "); + if (firstSpace !== -1) return null; + return beforeCursor.slice(1).toLowerCase(); +} + +export const SlashCommandPopover: React.FC = ({ + isVisible, + inputValue, + commands, + inputElement, + onSelect, + onClose, +}) => { + const cursorPosition = inputElement?.selectionStart ?? inputValue.length; + const query = getActiveSlashQuery(inputValue, cursorPosition); + const [selectedIndex, setSelectedIndex] = useState(0); + const ref = useRef(null); + + const matches = useMemo(() => { + if (query === null) return [] as string[]; + if (commands.length === 0) return []; + return commands + .filter((c) => c.startsWith(query)) + .slice(0, MAX_SUGGESTIONS); + }, [commands, query]); + + // Reset highlight when the match set changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: matches identity changes drive the reset + useEffect(() => { + setSelectedIndex(0); + }, [matches.length, query]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!isVisible || matches.length === 0) return; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex((i) => (i + 1) % matches.length); + break; + case "ArrowUp": + e.preventDefault(); + e.stopPropagation(); + setSelectedIndex((i) => (i === 0 ? matches.length - 1 : i - 1)); + break; + case "Tab": + case "Enter": + e.preventDefault(); + e.stopPropagation(); + if (matches[selectedIndex]) onSelect(matches[selectedIndex]); + break; + case "Escape": + e.preventDefault(); + e.stopPropagation(); + onClose(); + break; + } + }; + document.addEventListener("keydown", onKey, true); + return () => document.removeEventListener("keydown", onKey, true); + }, [isVisible, matches, selectedIndex, onSelect, onClose]); + + if (!isVisible || query === null || matches.length === 0) return null; + + // Position above the input box, left-aligned. + const inputRect = inputElement?.getBoundingClientRect(); + const top = inputRect + ? inputRect.top + window.scrollY - matches.length * 32 - 32 + : 100; + const left = inputRect ? inputRect.left + window.scrollX : 100; + + return ( +
+
+
+ Slash commands +
+ {matches.map((cmd, index) => ( +
onSelect(cmd)} + onMouseEnter={() => setSelectedIndex(index)} + > + /{cmd} +
+ ))} +
+
+ ); +}; + +export default SlashCommandPopover; diff --git a/src/components/ui/TopicModal.tsx b/src/components/ui/TopicModal.tsx index cd225f39..fa26bf0a 100644 --- a/src/components/ui/TopicModal.tsx +++ b/src/components/ui/TopicModal.tsx @@ -1,10 +1,11 @@ import type React from "react"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import ircClient from "../../lib/ircClient"; import { hasOpPermission } from "../../lib/ircUtils"; import BaseModal from "../../lib/modal/BaseModal"; import { Button, ModalBody, ModalFooter } from "../../lib/modal/components"; import type { Channel, User } from "../../types"; +import { EnhancedLinkWrapper } from "./LinkWrapper"; import { TextArea } from "./TextInput"; interface TopicModalProps { @@ -15,6 +16,46 @@ interface TopicModalProps { currentUser: User | null; } +// Render plain topic text with http/https/irc URLs as tags carrying +// the "external-link-security" / "irc-link" classes, so EnhancedLinkWrapper +// intercepts the click and routes through ExternalLinkWarningModal / +// onIrcLinkClick (matching the message-rendering flow). +const URL_RX = /\b(?:https?|ircs?):\/\/[^\s<>"'`]+/gi; + +function renderTopicWithLinks(text: string): React.ReactNode[] { + const out: React.ReactNode[] = []; + let last = 0; + let i = 0; + for (const match of text.matchAll(URL_RX)) { + const idx = match.index ?? -1; + if (idx < 0) continue; + if (idx > last) { + out.push({text.slice(last, idx)}); + } + const url = match[0]; + const isIrc = url.startsWith("irc://") || url.startsWith("ircs://"); + out.push( + + {url} + , + ); + last = idx + url.length; + } + if (last < text.length) { + out.push({text.slice(last)}); + } + if (out.length === 0) return [text]; + return out; +} + export const TopicModal: React.FC = ({ isOpen, onClose, @@ -43,18 +84,31 @@ export const TopicModal: React.FC = ({ maxWidth="md" > -