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" > - setEditedTopic(e.target.value)} - readOnly={!canEdit} - className={`w-full p-3 rounded min-h-[120px] resize-y text-sm leading-relaxed focus:outline-none transition-colors ${ - canEdit - ? "bg-discord-dark-400 text-white focus:ring-1 focus:ring-discord-primary" - : "bg-discord-dark-400/60 text-discord-text-muted cursor-default select-all" - }`} - placeholder={canEdit ? "Set a topic…" : "No topic set"} - autoFocus={canEdit} - /> + {canEdit ? ( + setEditedTopic(e.target.value)} + className="w-full p-3 rounded min-h-[120px] resize-y text-sm leading-relaxed focus:outline-none transition-colors bg-discord-dark-400 text-white focus:ring-1 focus:ring-discord-primary" + placeholder="Set a topic…" + autoFocus + /> + ) : ( + // Read-only path: render as a div with linkified URLs so the + // text doesn't sit inside a textarea (which strips markup and + // can't have clickable links). + + + {channel.topic ? ( + renderTopicWithLinks(channel.topic) + ) : ( + No topic set + )} + + + )} {canEdit && ( diff --git a/src/hooks/useMessageSending.ts b/src/hooks/useMessageSending.ts index 9763e1f4..576fcf70 100644 --- a/src/hooks/useMessageSending.ts +++ b/src/hooks/useMessageSending.ts @@ -271,7 +271,10 @@ export function useMessageSending({ 10; if (formattedLine.length > maxLineLengthForTarget) { - const splitLines = splitLongMessage(formattedLine, target); + // preserveBoundarySpace=true so concat reconstructs the + // original spacing. Without it the receiver sees + // "AAA BBBCCC" instead of "AAA BBB CCC". + const splitLines = splitLongMessage(formattedLine, target, true); splitLines.forEach((splitLine: string, index: number) => { if (index === 0) { ircClient.sendRaw( @@ -298,7 +301,7 @@ export function useMessageSending({ formatting: selectedFormatting, }); - const splitLines = splitLongMessage(formattedText, target); + const splitLines = splitLongMessage(formattedText, target, true); splitLines.forEach((splitLine: string, index: number) => { if (index === 0) { ircClient.sendRaw( diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index 5b1d500e..b8d7857d 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -245,6 +245,13 @@ export interface EventMap { serviceName: string; jwtToken: string; }; + // obsidianirc/cmdslist: server is reporting an add/remove delta of + // commands the user can invoke right now. Ops are individual + // tokens of the form "+cmd" or "-cmd" (multiple per wire line). + CMDSLIST: BaseIRCEvent & { + additions: string[]; + removals: string[]; + }; WHOIS_BOT: { serverId: string; nick: string; @@ -470,6 +477,7 @@ export class IRCClient implements IRCClientContext { "invite-notify", "monitor", "extended-monitor", + "obsidianirc/cmdslist", // Note: unrealircd.org/link-security is informational only, don't request it ]; diff --git a/src/lib/irc/handlers/cmdslist.ts b/src/lib/irc/handlers/cmdslist.ts new file mode 100644 index 00000000..d383110c --- /dev/null +++ b/src/lib/irc/handlers/cmdslist.ts @@ -0,0 +1,33 @@ +// obsidianirc/cmdslist: parse the per-line CMDSLIST add/remove +// payload into a single typed event. +// +// Wire format: +// :server CMDSLIST +foo +bar -baz +quux +// +// Each parameter token is "+" or "-". We separate them so +// the store can union-and-difference its existing set without having +// to re-walk every entry. + +import type { IRCClientContext } from "../IRCClientContext"; + +export function handleCmdslist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const additions: string[] = []; + const removals: string[] = []; + for (const tok of parv) { + if (!tok) continue; + if (tok[0] === "+") { + const name = tok.slice(1).trim(); + if (name) additions.push(name); + } else if (tok[0] === "-") { + const name = tok.slice(1).trim(); + if (name) removals.push(name); + } + } + ctx.triggerEvent("CMDSLIST", { serverId, additions, removals }); +} diff --git a/src/lib/irc/handlers/index.ts b/src/lib/irc/handlers/index.ts index 15aaf834..cb5baa3c 100644 --- a/src/lib/irc/handlers/index.ts +++ b/src/lib/irc/handlers/index.ts @@ -27,6 +27,7 @@ import { handleRplTopicWhoTime, handleTopic, } from "./channels"; +import { handleCmdslist } from "./cmdslist"; import { handleCap, handleError, @@ -288,6 +289,8 @@ export const IRC_DISPATCH: Record = { handleVerify(ctx, serverId, source, parv, mtags), EXTJWT: (ctx, serverId, source, parv, mtags) => handleExtjwt(ctx, serverId, source, parv, mtags), + CMDSLIST: (ctx, serverId, source, parv, mtags) => + handleCmdslist(ctx, serverId, source, parv, mtags), "730": (ctx, serverId, source, parv, mtags) => handleMonOnline(ctx, serverId, source, parv, mtags), diff --git a/src/lib/messageProtocol.ts b/src/lib/messageProtocol.ts index e2837dcd..317dcbc6 100644 --- a/src/lib/messageProtocol.ts +++ b/src/lib/messageProtocol.ts @@ -6,16 +6,26 @@ * Helper function to split long messages while respecting IRC protocol limits * @param message - The message to split * @param target - The channel or username target + * @param preserveBoundarySpace - When true, each non-final chunk + * carries the original boundary space at its trailing edge so a + * downstream draft/multiline-concat join reconstructs the original + * text with its spacing intact. Default false preserves the legacy + * "send as independent PRIVMSGs" behaviour where the split-point + * space simply becomes a line break. * @returns Array of message chunks within IRC limits */ export const splitLongMessage = ( message: string, target = "#channel", + preserveBoundarySpace = false, ): string[] => { const protocolOverhead = calculateProtocolOverhead(target); - // Available space for the actual message content - const maxMessageLength = 512 - protocolOverhead; + // Available space for the actual message content. Reserve one byte + // when we're going to re-attach a boundary space so the wire line + // still fits inside the 512-byte IRC limit. + const maxMessageLength = + 512 - protocolOverhead - (preserveBoundarySpace ? 1 : 0); if (message.length <= maxMessageLength) { return [message]; @@ -52,7 +62,11 @@ export const splitLongMessage = ( lines.push(currentLine); } - return lines.filter((line) => line.length > 0); + const filtered = lines.filter((line) => line.length > 0); + if (!preserveBoundarySpace || filtered.length < 2) return filtered; + return filtered.map((line, idx) => + idx < filtered.length - 1 ? `${line} ` : line, + ); }; /** diff --git a/src/store/handlers/auth.ts b/src/store/handlers/auth.ts index d383166c..7943a163 100644 --- a/src/store/handlers/auth.ts +++ b/src/store/handlers/auth.ts @@ -337,4 +337,19 @@ export function registerAuthHandlers(store: StoreApi): void { }); }, ); + + // obsidianirc/cmdslist: maintain a lowercase set of invocable + // commands per server. Additions and removals can arrive in the + // same wire line, so apply both atomically. + ircClient.on("CMDSLIST", ({ serverId, additions, removals }) => { + store.setState((state) => ({ + servers: state.servers.map((server) => { + if (server.id !== serverId) return server; + const next = new Set(server.cmdsAvailable ?? []); + for (const cmd of additions) next.add(cmd.toLowerCase()); + for (const cmd of removals) next.delete(cmd.toLowerCase()); + return { ...server, cmdsAvailable: Array.from(next).sort() }; + }), + })); + }); } diff --git a/src/store/handlers/messages.ts b/src/store/handlers/messages.ts index 68f3644e..193c13f9 100644 --- a/src/store/handlers/messages.ts +++ b/src/store/handlers/messages.ts @@ -151,6 +151,8 @@ export function registerMessageHandlers(store: StoreApi): void { return { ...ch, unreadCount: ch.unreadCount + 1, + mentionCount: + (ch.mentionCount ?? 0) + (hasMention ? 1 : 0), isMentioned: hasMention || ch.isMentioned, }; } @@ -906,13 +908,12 @@ export function registerMessageHandlers(store: StoreApi): void { const isActive = getCurrentSelection(state).selectedPrivateChatId === pc.id; + const reset = isActive || isHistoricalMessage; return { ...pc, lastActivity: new Date(), - unreadCount: - isActive || isHistoricalMessage - ? 0 - : pc.unreadCount + 1, + unreadCount: reset ? 0 : pc.unreadCount + 1, + mentionCount: reset ? 0 : (pc.mentionCount ?? 0) + 1, isMentioned: !isHistoricalMessage && true, // All PMs are considered mentions (except historical) }; } @@ -1337,11 +1338,12 @@ export function registerMessageHandlers(store: StoreApi): void { if (pc.id === privateChat.id) { const isActive = getCurrentSelection(state).selectedPrivateChatId === pc.id; + const reset = isActive || isHistoricalMessage; return { ...pc, lastActivity: new Date(), - unreadCount: - isActive || isHistoricalMessage ? 0 : pc.unreadCount + 1, + unreadCount: reset ? 0 : pc.unreadCount + 1, + mentionCount: reset ? 0 : (pc.mentionCount ?? 0) + 1, isMentioned: !isHistoricalMessage && true, // All PMs are considered mentions (except historical) }; } diff --git a/src/store/index.ts b/src/store/index.ts index 7ef1cff3..04acaaa4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1602,7 +1602,55 @@ const useStore = create((set, get) => ({ server?.privateChats?.find((pc) => pc.id === selectedPrivateChatId) ?.username || null; + // When the user switches *to* a server, the channel or PM that + // restores into focus has now been "looked at" -- clear its + // unread / mention indicators. Without this the badge sticks + // on the just-foregrounded buffer until the user clicks somewhere + // else and back. + let updatedServers = state.servers; + if (selectedChannelId || selectedPrivateChatId) { + updatedServers = state.servers.map((s) => { + if (s.id !== serverId) return s; + let touched = false; + const channels = s.channels.map((ch) => { + if (ch.id !== selectedChannelId) return ch; + if ( + ch.unreadCount === 0 && + !ch.isMentioned && + (ch.mentionCount ?? 0) === 0 + ) + return ch; + touched = true; + return { + ...ch, + unreadCount: 0, + mentionCount: 0, + isMentioned: false, + }; + }); + const privateChats = s.privateChats?.map((pc) => { + if (pc.id !== selectedPrivateChatId) return pc; + if ( + pc.unreadCount === 0 && + !pc.isMentioned && + (pc.mentionCount ?? 0) === 0 + ) + return pc; + touched = true; + return { + ...pc, + unreadCount: 0, + mentionCount: 0, + isMentioned: false, + }; + }); + if (!touched) return s; + return { ...s, channels, privateChats }; + }); + } + return { + servers: updatedServers, ui: { ...state.ui, selectedServerId: serverId, @@ -1697,6 +1745,7 @@ const useStore = create((set, get) => ({ return { ...channel, unreadCount: 0, + mentionCount: 0, isMentioned: false, }; } @@ -1784,6 +1833,7 @@ const useStore = create((set, get) => ({ return { ...channel, unreadCount: 0, + mentionCount: 0, isMentioned: false, }; } @@ -1891,6 +1941,7 @@ const useStore = create((set, get) => ({ return { ...privateChat, unreadCount: 0, + mentionCount: 0, isMentioned: false, }; } diff --git a/src/types/index.ts b/src/types/index.ts index d5694eff..8d66cef7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,10 @@ export interface Server { jwtToken?: string; // JWT token for filehost authentication isUnrealIRCd?: boolean; // Whether this server is running UnrealIRCd elist?: string; // ELIST ISUPPORT value for extended LIST capabilities + // obsidianirc/cmdslist: lowercase set of commands this user can + // currently invoke on this server. Used to drive the slash-command + // suggestion popover. undefined = the cap is not negotiated. + cmdsAvailable?: string[]; } export interface ServerConfig { @@ -74,6 +78,11 @@ export interface Channel { isPrivate: boolean; serverId: string; unreadCount: number; + // Number of *highlight* events since the channel was last marked + // read. Distinct from unreadCount (every message) so the badge + // can show "you were pinged 3 times" instead of "33 messages + // happened since your first ping". + mentionCount?: number; isMentioned: boolean; messages: Message[]; users: User[]; @@ -95,6 +104,8 @@ export interface PrivateChat { username: string; serverId: string; unreadCount: number; + // Highlight counter (PMs always count as mentions; this is per-PM). + mentionCount?: number; isMentioned: boolean; lastActivity?: Date; isPinned?: boolean;