Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/components/layout/ChannelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -752,9 +752,9 @@ export const ChannelList: React.FC<{
) : (
selectedChannelId !== channel.id &&
(channel.isMentioned &&
channel.unreadCount > 0 ? (
(channel.mentionCount ?? 0) > 0 ? (
<span className="bg-red-500 text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[20px] text-center">
{channel.unreadCount}
{channel.mentionCount}
</span>
) : channel.unreadCount > 0 ? (
<span className="w-2 h-2 bg-blue-500 rounded-full" />
Expand Down Expand Up @@ -1202,9 +1202,9 @@ export const ChannelList: React.FC<{
{/* Unread/Mention indicators */}
{selectedPrivateChatId !== privateChat.id &&
(privateChat.isMentioned &&
privateChat.unreadCount > 0 ? (
(privateChat.mentionCount ?? 0) > 0 ? (
<span className="bg-red-500 text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[20px] text-center">
{privateChat.unreadCount}
{privateChat.mentionCount}
</span>
) : privateChat.unreadCount > 0 ? (
<span className="w-2 h-2 bg-blue-500 rounded-full" />
Expand Down
56 changes: 56 additions & 0 deletions src/components/layout/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 (
<SlashCommandPopover
isVisible={slashActive}
inputValue={slashInputValue}
commands={cmds}
inputElement={inputRef.current}
onSelect={(cmd) => {
// Replace the partial command with /<cmd> + 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 */}
<AutocompleteDropdown
isNarrowView={isNarrowView}
Expand Down
140 changes: 140 additions & 0 deletions src/components/ui/SlashCommandPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Slash-command suggestion popover, anchored above the chat input.
//
// Activates when the input value starts with "/" and the user is still
// typing the command name (no space yet). Filters the server's
// cmdsAvailable set (populated by the obsidianirc/cmdslist cap) by
// prefix match.
//
// Keyboard:
// ArrowUp / ArrowDown -- cycle highlighted suggestion
// Tab / Enter -- accept current suggestion
// Escape -- close
//
// onSelect receives the bare command name (without the leading slash).

import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";

interface SlashCommandPopoverProps {
isVisible: boolean;
inputValue: string;
commands: string[];
inputElement?: HTMLInputElement | HTMLTextAreaElement | null;
onSelect: (command: string) => 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<SlashCommandPopoverProps> = ({
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<HTMLDivElement>(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 (
<div
ref={ref}
className="fixed z-[9999] bg-discord-dark-300 border border-discord-dark-500 rounded-md shadow-xl min-w-56 max-w-md"
style={{ top, left }}
>
<div className="py-1 max-h-60 overflow-y-auto">
<div className="px-3 py-1 text-xs text-discord-text-muted font-semibold uppercase tracking-wide border-b border-discord-dark-500">
Slash commands
</div>
{matches.map((cmd, index) => (
<div
key={cmd}
data-cmd-index={index}
className={`px-3 py-1.5 cursor-pointer flex items-center gap-2 transition-colors duration-150 ${
index === selectedIndex
? "bg-discord-text-link text-white"
: "text-discord-text-normal hover:bg-discord-dark-200 hover:text-white"
}`}
onClick={() => onSelect(cmd)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className="font-mono text-sm">/{cmd}</span>
</div>
))}
</div>
</div>
);
};

export default SlashCommandPopover;
80 changes: 67 additions & 13 deletions src/components/ui/TopicModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,6 +16,46 @@ interface TopicModalProps {
currentUser: User | null;
}

// Render plain topic text with http/https/irc URLs as <a> 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(<Fragment key={`t-${i}`}>{text.slice(last, idx)}</Fragment>);
}
const url = match[0];
const isIrc = url.startsWith("irc://") || url.startsWith("ircs://");
out.push(
<a
key={`u-${i++}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className={`underline text-blue-400 hover:text-blue-300 break-all ${
isIrc ? "irc-link" : "external-link-security"
}`}
>
{url}
</a>,
);
last = idx + url.length;
}
if (last < text.length) {
out.push(<Fragment key="tail">{text.slice(last)}</Fragment>);
}
if (out.length === 0) return [text];
return out;
}

export const TopicModal: React.FC<TopicModalProps> = ({
isOpen,
onClose,
Expand Down Expand Up @@ -43,18 +84,31 @@ export const TopicModal: React.FC<TopicModalProps> = ({
maxWidth="md"
>
<ModalBody>
<TextArea
value={editedTopic}
onChange={(e) => 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 ? (
<TextArea
value={editedTopic}
onChange={(e) => 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).
<EnhancedLinkWrapper>
<section
className="w-full p-3 rounded min-h-[120px] text-sm leading-relaxed bg-discord-dark-400/60 text-discord-text-muted whitespace-pre-wrap break-words select-text"
aria-label="Channel topic"
>
{channel.topic ? (
renderTopicWithLinks(channel.topic)
) : (
<span className="italic">No topic set</span>
)}
</section>
</EnhancedLinkWrapper>
)}
</ModalBody>

{canEdit && (
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useMessageSending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions src/lib/irc/IRCClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
];

Expand Down
Loading
Loading