Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
234 changes: 225 additions & 9 deletions src/components/layout/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isScrolledToBottom } from "../../hooks/useScrollToBottom";
import { useTabCompletion } from "../../hooks/useTabCompletion";
import { useTypingNotification } from "../../hooks/useTypingNotification";
import { waitForAuthToken } from "../../lib/authToken";
import { CLIENT_COMMANDS } from "../../lib/clientCommands";
import { useEmojiResolver } from "../../lib/customEmoji";
import {
emojiClickValue,
Expand All @@ -36,11 +37,13 @@ import {
} from "../../lib/messageFormatter";
import { isMobileDevice, isTauriMobile } from "../../lib/platformUtils";
import useStore from "../../store";
import type { Message as MessageType, User } from "../../types";
import { queryUncachedBotsInChannel } from "../../store/handlers/pushbot";
import type { BotCommand, Message as MessageType, User } from "../../types";
import { MessageItem } from "../message/MessageItem";
import { MessageReply } from "../message/MessageReply";
import AutocompleteDropdown from "../ui/AutocompleteDropdown";
import BlankPage from "../ui/BlankPage";
import BotsModal from "../ui/BotsModal";
import ChannelSettingsModal from "../ui/ChannelSettingsModal";
import ColorPicker from "../ui/ColorPicker";
import EmojiAutocompleteDropdown from "../ui/EmojiAutocompleteDropdown";
Expand All @@ -55,10 +58,13 @@ import { MiniMediaPlayer } from "../ui/MiniMediaPlayer";
import ModerationModal, { type ModerationAction } from "../ui/ModerationModal";
import ReactionModal from "../ui/ReactionModal";
import { ReactionPopover } from "../ui/ReactionPopover";
import { SlashCommandParamModal } from "../ui/SlashCommandParamModal";
import {
getActiveSlashQuery,
SlashCommandPopover,
type SlashSuggestion,
} from "../ui/SlashCommandPopover";
import { SlashParamHint } from "../ui/SlashParamHint";
import { TextArea } from "../ui/TextInput";
import { TopicMediaStrip } from "../ui/TopicMediaStrip";
import {
Expand Down Expand Up @@ -140,6 +146,10 @@ export const ChatArea: React.FC<{
// 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 [paramModal, setParamModal] = useState<{
botNick: string;
command: BotCommand;
} | null>(null);
const [isEmojiSelectorOpen, setIsEmojiSelectorOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [selectedColor, setSelectedColor] = useState<string | null>(null);
Expand Down Expand Up @@ -218,6 +228,7 @@ export const ChatArea: React.FC<{
useState(false);
const [userProfileModalOpen, setUserProfileModalOpen] = useState(false);
const [inviteUserModalOpen, setInviteUserModalOpen] = useState(false);
const [botsModalOpen, setBotsModalOpen] = useState(false);
const [selectedProfileUsername, setSelectedProfileUsername] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -1473,6 +1484,13 @@ export const ChatArea: React.FC<{
getActiveSlashQuery(newText, newCursorPosition) !== null;
if (slashActive) {
setSlashInputValue(newText);
// Lazy bot-cmds discovery: the first time the user starts a
// slash command in this channel, query any +B users we don't
// already have schemas for so the popover updates as soon as
// the response arrives.
if (!slashInputValue && selectedServerId && selectedChannel) {
queryUncachedBotsInChannel(selectedServerId, selectedChannel.name);
}
} else if (slashInputValue !== "") {
setSlashInputValue("");
}
Expand Down Expand Up @@ -2023,6 +2041,7 @@ export const ChatArea: React.FC<{
onToggleNotificationVolume={handleToggleNotificationVolume}
onOpenChannelSettings={() => setChannelSettingsModalOpen(true)}
onOpenInviteUser={() => setInviteUserModalOpen(true)}
onOpenBots={() => setBotsModalOpen(true)}
/>

{topSlot && (
Expand Down Expand Up @@ -2353,11 +2372,81 @@ export const ChatArea: React.FC<{
inputElement={inputRef.current}
/>

{/* obsidianirc/cmdslist: slash-command suggestion popover */}
{/* obsidianirc/cmdslist + draft/bot-cmds: slash-command suggestion popover */}
{(() => {
const srv = servers.find((s) => s.id === selectedServerId);
const cmds = srv?.cmdsAvailable ?? [];
if (cmds.length === 0) return null;
const suggestions: SlashSuggestion[] = [];
const seen = new Set<string>();

// 1. Client-side handlers (e.g. /me, /msg, /nick) —
// listed first because they own those names and the
// server's cmdslist may shadow them.
const inChannelView = !!selectedChannel;
for (const c of CLIENT_COMMANDS) {
if (c.scope === "channel-only" && !inChannelView) continue;
suggestions.push({
name: c.name,
description: c.description,
options: c.options,
source: { kind: "client" },
});
seen.add(c.name.toLowerCase());
}

// 2. PushBot schemas — done before the server's
// cmdsAvailable so that bot-defined names win the
// dedup set. Channel-scope bots only appear when
// they're a member of the active channel; server-
// scope bots (helpbot, dicebot) are reachable from
// any context and always show.
if (srv?.botCommands) {
const chanUsers = new Set<string>(
selectedChannel?.users.map((u) =>
u.username.toLowerCase(),
) ?? [],
);
for (const [botNick, list] of Object.entries(
srv.botCommands,
)) {
const botScope: "channel" | "server" =
srv.bots?.[botNick]?.scope ?? "channel";
const inChannel = chanUsers.has(botNick);
if (botScope === "channel" && selectedChannel && !inChannel)
continue;
Comment on lines +2411 to +2415
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Channel-bot visibility logic is incorrect (case + DM gating).

Two issues in both suggestion and schema paths:

  1. chanUsers is lowercased, but membership checks use raw botNick (case mismatch risk).
  2. Channel-scoped bots are only filtered when selectedChannel exists, so they can still appear in DM context.
Suggested fix pattern (apply in both blocks)
-    const inChannel = chanUsers.has(botNick);
-    if (botScope === "channel" && selectedChannel && !inChannel) continue;
+    const inChannel = chanUsers.has(botNick.toLowerCase());
+    if (botScope === "channel" && (!selectedChannel || !inChannel)) continue;

Also applies to: 2544-2548

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/ChatArea.tsx` around lines 2411 - 2415, The bot
visibility check incorrectly compares case-sensitive botNick to a lowercased
chanUsers set and only gates channel-scoped bots when selectedChannel exists;
update the logic in both the suggestion and schema paths (locations using
botScope, chanUsers, botNick, selectedChannel) to first normalize botNick (e.g.,
toLowerCase()) before membership testing against chanUsers, and always enforce
the channel-scoped exclusion (i.e., if botScope === "channel" and the normalized
botNick is not in chanUsers, skip the bot regardless of selectedChannel/DM
context).

const scope: "channel" | "server" = botScope;
for (const c of list) {
// Cross-bot collisions intentionally NOT dedup'd:
// both /help entries (helpbot + another) should
// show so the user picks which bot to invoke.
// The picker fills `/cmd@botnick` so dispatch
// routes unambiguously.
suggestions.push({
name: c.name,
description: c.description,
options: c.options,
source: { kind: "bot", botNick, scope },
});
// Reserve the bare name so the server's
// cmdsAvailable can't list a duplicate /HELP
// after a bot's /help.
seen.add(c.name.toLowerCase());
}
}
}

// 3. Server-provided permission list (obsidianirc/cmdslist).
// No schema -- just names; skip anything the client
// or a bot already owns so /help, /report etc. don't
// get shadowed by the built-in /HELP.
for (const name of srv?.cmdsAvailable ?? []) {
if (seen.has(name.toLowerCase())) continue;
suggestions.push({
name,
source: { kind: "server" },
});
seen.add(name.toLowerCase());
}
if (suggestions.length === 0) return null;
const slashActive =
getActiveSlashQuery(
slashInputValue,
Expand All @@ -2367,12 +2456,36 @@ export const ChatArea: React.FC<{
<SlashCommandPopover
isVisible={slashActive}
inputValue={slashInputValue}
commands={cmds}
commands={suggestions}
inputElement={inputRef.current}
onSelect={(cmd) => {
// Replace the partial command with /<cmd> + space
// and put the cursor right after the space.
const next = `/${cmd} `;
onSelect={(sug) => {
// Bot commands with declared options open the
// param modal so values are entered via typed
// form controls (user/channel pickers, date,
// etc). Bots without options and non-bot
// sources stay on the freeform inline path.
if (
sug.source.kind === "bot" &&
(sug.options?.length ?? 0) > 0
) {
const botNick = sug.source.botNick;
setParamModal({
botNick,
command: {
name: sug.name,
description: sug.description,
options: sug.options,
},
});
setSlashInputValue("");
applyText("");
return;
}
const target =
sug.source.kind === "bot"
? `@${sug.source.botNick}`
: "";
const next = `/${sug.name}${target} `;
applyText(next);
cursorPositionRef.current = next.length;
setSlashInputValue("");
Expand All @@ -2389,6 +2502,80 @@ export const ChatArea: React.FC<{
);
})()}

{/* Per-arg hint shown after the cmd name + a space.
Pulls schemas from both client-side commands and
any cached PushBot schemas. */}
{(() => {
const srv = servers.find((s) => s.id === selectedServerId);
const schemas: Record<
string,
{
command: import("../../types").BotCommand;
source: "client" | "bot";
botNick?: string;
scope: "channel" | "server" | "dm";
}
> = {};

// Client-side commands first; bot schemas overwrite
// only if there's a real collision (none in practice
// because the client owns /me, /msg, etc).
for (const c of CLIENT_COMMANDS) {
schemas[c.name.toLowerCase()] = {
command: {
name: c.name,
description: c.description,
options: c.options,
},
source: "client",
scope: "dm",
};
}

if (srv?.botCommands) {
const chanUsers = new Set<string>(
selectedChannel?.users.map((u) =>
u.username.toLowerCase(),
) ?? [],
);
for (const [botNick, list] of Object.entries(
srv.botCommands,
)) {
const botScope: "channel" | "server" =
srv.bots?.[botNick]?.scope ?? "channel";
const inChannel = chanUsers.has(botNick);
if (botScope === "channel" && selectedChannel && !inChannel)
continue;
for (const c of list) {
const entry = {
command: c,
source: "bot" as const,
botNick,
scope: botScope,
};
// Specific key handles `/cmd@bot foo` lookups;
// bare key is a fallback for `/cmd foo` (last
// bot wins, which matches dispatch's fallback
// ordering).
schemas[
`${c.name.toLowerCase()}@${botNick.toLowerCase()}`
] = entry;
schemas[c.name.toLowerCase()] = entry;
}
}
}

if (Object.keys(schemas).length === 0) return null;
return (
<SlashParamHint
inputValue={messageTextRef.current ?? ""}
cursorPosition={cursorPositionRef.current}
schemas={schemas}
inputElement={inputRef.current}
/>
);
})()}

{/* Members dropdown triggered by @ button */}
<AutocompleteDropdown
isNarrowView={isNarrowView}
Expand Down Expand Up @@ -2486,6 +2673,16 @@ export const ChatArea: React.FC<{
channelName={selectedChannel.name}
/>
)}
{selectedServerId && (
<BotsModal
isOpen={botsModalOpen}
onClose={() => setBotsModalOpen(false)}
serverId={selectedServerId}
onPickCommand={(botNick, command) => {
setParamModal({ botNick, command });
}}
/>
)}
{selectedServerId && (
<UserProfileModal
isOpen={userProfileModalOpen}
Expand Down Expand Up @@ -2621,6 +2818,25 @@ export const ChatArea: React.FC<{
</div>,
document.body,
)}
{paramModal &&
selectedServerId &&
createPortal(
<SlashCommandParamModal
serverId={selectedServerId}
botNick={paramModal.botNick}
command={paramModal.command}
channel={selectedChannel ?? null}
privateChat={selectedPrivateChat ?? null}
channelMembers={selectedChannel?.users.map((u) => u.username) ?? []}
joinedChannels={
servers
.find((s) => s.id === selectedServerId)
?.channels.map((c) => c.name) ?? []
}
onClose={() => setParamModal(null)}
/>,
document.body,
)}
</div>
);
};
Expand Down
17 changes: 17 additions & 0 deletions src/components/layout/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
FaList,
FaMicrophone,
FaPenAlt,
FaRobot,
FaSearch,
FaThumbtack,
FaTimes,
Expand Down Expand Up @@ -56,6 +57,7 @@ interface ChatHeaderProps {
onToggleNotificationVolume: () => void;
onOpenChannelSettings: () => void;
onOpenInviteUser: () => void;
onOpenBots: () => void;
}

export const ChatHeader: React.FC<ChatHeaderProps> = ({
Expand All @@ -75,6 +77,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onToggleNotificationVolume,
onOpenChannelSettings,
onOpenInviteUser,
onOpenBots,
}) => {
const { t } = useLingui();
const {
Expand Down Expand Up @@ -320,6 +323,12 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onClick: onOpenInviteUser,
show: !!selectedChannel,
},
{
label: t`Bots`,
icon: <FaRobot />,
onClick: onOpenBots,
show: !!selectedServerId,
},
{
label: "Play Tic-Tac-Toe",
icon: <span aria-hidden="true">🎮</span>,
Expand Down Expand Up @@ -590,6 +599,14 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
>
<FaUserPlus />
</button>
<button
className="hidden md:block hover:text-discord-text-normal"
onClick={onOpenBots}
title={t`Bots on this network`}
aria-label={t`Bots`}
>
<FaRobot />
</button>
<button
className="hidden md:block hover:text-discord-text-normal"
onClick={() => toggleChannelListModal(true)}
Expand Down
Loading
Loading