feat: slash commands with autocomplete dropdown

Type / at start of message to see available commands.
Commands: /shrug, /tableflip, /unflip, /lenny, /bear,
/disapprove, /facepalm, /cry, /dance, /hug, /greet,
/me (action text), /spoiler (hidden text).

Autocomplete dropdown with tab-complete. Commands transform
input text client-side before sending. Unknown /commands
pass through as regular messages.
This commit is contained in:
2026-07-02 12:22:43 -04:00
parent 722eab8e94
commit d8b4defaff
4 changed files with 114 additions and 2 deletions
+32 -1
View File
@@ -8,6 +8,8 @@ import { useAuthStore } from "../stores/auth.ts";
import { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { CommandDropdown } from "./CommandDropdown";
import { findCommand } from "../lib/slashCommands";
import { MentionDropdown } from "./MentionDropdown";
import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch";
@@ -269,6 +271,7 @@ export function ChatArea() {
const [showKaomoji, setShowKaomoji] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [commandQuery, setCommandQuery] = useState<string | null>(null);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const [showThreads, setShowThreads] = useState(false);
@@ -434,6 +437,13 @@ export function ChatArea() {
}
const beforeCursor = value.slice(0, cursor);
// Slash command detection (only at start of input)
if (beforeCursor.startsWith('/') && !beforeCursor.includes(' ')) {
setCommandQuery(beforeCursor.slice(1));
setMentionQuery(null);
return;
}
setCommandQuery(null);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex === -1) {
setMentionQuery(null);
@@ -478,7 +488,18 @@ export function ChatArea() {
if (!activeChannelId || !input.trim() || slowmodeRemaining > 0) return;
setError(null);
try {
await sendMessage(activeChannelId, input.trim(), replyToMessage?.id);
let messageText = input.trim();
// Slash command transform
if (messageText.startsWith('/')) {
const spaceIdx = messageText.indexOf(' ');
const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx);
const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim();
const cmd = findCommand(cmdName);
if (cmd) {
messageText = cmd.transform(args, currentUser?.username || 'user');
}
}
await sendMessage(activeChannelId, messageText, replyToMessage?.id);
setInput("");
setReplyToMessage(null);
setMentionQuery(null);
@@ -714,6 +735,16 @@ export function ChatArea() {
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
)}
{commandQuery !== null && (
<CommandDropdown
query={commandQuery}
onSelect={(name) => {
setInput('/' + name + ' ');
setCommandQuery(null);
inputRef.current?.focus();
}}
/>
)}
</div>
<button
type="button"
+35
View File
@@ -0,0 +1,35 @@
import { SLASH_COMMANDS } from "../lib/slashCommands";
interface CommandDropdownProps {
query: string;
onSelect: (command: string) => void;
}
export function CommandDropdown({ query, onSelect }: CommandDropdownProps) {
const q = query.toLowerCase();
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
if (filtered.length === 0) return null;
return (
<div className="absolute bottom-full left-0 mb-1 z-50 w-72 max-h-48 overflow-y-auto bg-gb-bg-s border border-gb-bg-t shadow-lg">
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
COMMANDS
</div>
{filtered.map((cmd) => (
<button
key={cmd.name}
type="button"
onMouseDown={(e) => {
e.preventDefault();
onSelect(cmd.name);
}}
className="w-full text-left px-2 py-1 text-sm font-mono hover:bg-gb-bg-t text-gb-fg flex items-center gap-2"
>
<span className="text-gb-orange">/{cmd.name}</span>
<span className="text-gb-fg-f text-xs truncate">{cmd.description}</span>
</button>
))}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
export interface SlashCommand {
name: string;
description: string;
transform: (args: string, username: string) => string;
}
const SHRUG = '\u00AF\\_(\u30C4)_/\u00AF';
const TABLEFLIP = '(\u256F\u00B0\u25A1\u00B0\uFF09\u256F\uFE35 \u253B\u2501\u253B';
const UNFLIP = '\u252C\u2500\u252C\u30CE( \u00BA _ \u00BA\u30CE)';
const LENNY = '( \u0361\u00B0 \u035C\u0325 \u0361\u00B0)';
const BEAR = '\u0295\u2022\u1D25\u2022\u0294';
const DISAPPROVE = '\u0CA0_\u0CA0';
const FACEPALM = '(\uFF0D\u1DD9\uFF4D)';
const CRY = '(\u2568_\u2568)';
const DANCE = '\u250C(\u30FB\u30FB\u30FB)\u2518\u266A';
const HUG = '(\u2283\u301C\u203F\u2022\u032F\u2022\uFF65\u0300)\u2283';
const GREET = '(\u30CE\u00B4\uFF89`)\u30CE';
function appendOrReturn(kaomoji: string, args: string): string {
return args ? args + ' ' + kaomoji : kaomoji;
}
export const SLASH_COMMANDS: SlashCommand[] = [
{ name: 'shrug', description: 'Append shrug kaomoji', transform: (a) => appendOrReturn(SHRUG, a) },
{ name: 'tableflip', description: 'Flip a table', transform: (a) => appendOrReturn(TABLEFLIP, a) },
{ name: 'unflip', description: 'Put the table back', transform: (a) => appendOrReturn(UNFLIP, a) },
{ name: 'lenny', description: '( \u0361\u00B0 \u035C\u0325 \u0361\u00B0)', transform: (a) => appendOrReturn(LENNY, a) },
{ name: 'bear', description: 'Kaomoji bear', transform: (a) => a ? BEAR + ' ' + a : BEAR },
{ name: 'disapprove', description: 'Look of disapproval', transform: (a) => appendOrReturn(DISAPPROVE, a) },
{ name: 'facepalm', description: 'Facepalm', transform: (a) => appendOrReturn(FACEPALM, a) },
{ name: 'cry', description: 'Crying', transform: (a) => appendOrReturn(CRY, a) },
{ name: 'dance', description: 'Dance', transform: (a) => appendOrReturn(DANCE, a) },
{ name: 'hug', description: 'Hug', transform: (a) => a ? HUG + ' ' + a : HUG },
{ name: 'greet', description: 'Friendly wave hello', transform: (a) => a ? GREET + ' ' + a : GREET + ' Hello!' },
{ name: 'me', description: 'Send an action message', transform: (a, u) => '*' + u + ' ' + a + '*' },
{ name: 'spoiler', description: 'Hide text as a spoiler', transform: (a) => '||' + a + '||' },
];
export function findCommand(name: string): SlashCommand | undefined {
return SLASH_COMMANDS.find((c) => c.name === name.toLowerCase());
}
export function matchingCommands(query: string): SlashCommand[] {
const q = query.toLowerCase();
return SLASH_COMMANDS.filter((c) => c.name.startsWith(q));
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/PinnedMessages.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/PinnedMessages.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}