diff --git a/src/components/chat/completionProviders.tsx b/src/components/chat/completionProviders.tsx index f3ceef523..01fceb15e 100644 --- a/src/components/chat/completionProviders.tsx +++ b/src/components/chat/completionProviders.tsx @@ -296,18 +296,27 @@ export function slashProvider( const signature = formatOptions(cmd.options); const source = sourceLabel(cmd.source); return ( - - - /{cmd.name} + + + + /{cmd.name} + {signature && ( - {signature} + + {signature} + )} - + {source.label} {cmd.description && ( - {cmd.description} + + {cmd.description} + )} ); diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 9dd72f2f5..cff527865 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -18,10 +18,12 @@ import TitleTooltipLayer from "../ui/TitleTooltipLayer"; import { VoicePipOverlay } from "../voice/VoicePipOverlay"; import { ChannelList } from "./ChannelList"; import { ChatArea } from "./ChatArea"; +import { MemberList } from "./MemberList"; import { PageLayer } from "./PageLayer"; import { ResizableSidebar } from "./ResizableSidebar"; import { ServerList } from "./ServerList"; import { SidePane } from "./SidePane"; +import SwipeBackScreen from "./SwipeBackScreen"; import { VoiceChannelView } from "./VoiceChannelView"; const PAGE_ORDER: layoutColumn[] = ["serverList", "chatView", "memberList"]; @@ -312,7 +314,16 @@ export const AppLayout: React.FC = () => { if (isNarrowView) { return (
- + {sidePane?.kind === "profile" || sidePane?.kind === "bot" ? ( + } + onBack={() => useStore.getState().closeProfile()} + > + + + ) : ( + + )}
); } diff --git a/src/components/pages/BotsPage.tsx b/src/components/layout/BotPane.tsx similarity index 66% rename from src/components/pages/BotsPage.tsx rename to src/components/layout/BotPane.tsx index 638185344..be5f53cbd 100644 --- a/src/components/pages/BotsPage.tsx +++ b/src/components/layout/BotPane.tsx @@ -1,17 +1,14 @@ import { t } from "@lingui/core/macro"; import { Trans, useLingui } from "@lingui/react/macro"; import type React from "react"; -import { useEffect, useMemo, useState } from "react"; -import { FaCircle } from "react-icons/fa"; +import { useMemo } from "react"; +import { FaChevronLeft, FaCircle, FaTimes } from "react-icons/fa"; import ircClient from "../../lib/ircClient"; import { confirmDestructive } from "../../lib/modal/ConfirmDialog"; import useStore from "../../store"; +import type { BotPane as BotPaneTarget } from "../../store/types"; import type { BotCommand, PushBotInfo } from "../../types"; import LoadingSpinner from "../ui/LoadingSpinner"; -import { type PageSection, PageShell } from "../ui/page/PageShell"; -import type { PageProps } from "./PageHost"; - -type FilterMode = "all" | "server" | "channel"; const STATUS_CLS: Record = { active: "bg-emerald-700/40 text-emerald-300 border border-emerald-600/50", @@ -59,15 +56,6 @@ function useScopeBadge(): Record< }; } -function useFilterLabels(): Record { - const { t } = useLingui(); - return { - all: t`All`, - server: t`Server-wide`, - channel: t`Channel`, - }; -} - interface BotDetailProps { bot: PushBotInfo; isOper: boolean; @@ -271,155 +259,112 @@ function isBotReachable( return !!bot.channels?.some((ch) => joinedChannels.has(ch.toLowerCase())); } -const BotsPage: React.FC> = ({ page }) => { - const { serverId, botNick } = page; +export const BotPane: React.FC<{ target: BotPaneTarget }> = ({ target }) => { + const { serverId, botNick } = target; const server = useStore((s) => s.servers.find((srv) => srv.id === serverId)); const currentUser = useStore((s) => s.currentUser); - const closePage = useStore((s) => s.closePage); + const isNarrowView = useStore((s) => s.ui.isNarrowView); + const closeProfile = useStore((s) => s.closeProfile); + const openBot = useStore((s) => s.openBot); const requestBotCommandPick = useStore((s) => s.requestBotCommandPick); - const FILTER_LABELS = useFilterLabels(); - const [filter, setFilter] = useState("all"); - const [query, setQuery] = useState(""); - const [selectedNick, setSelectedNick] = useState( - botNick ?? null, - ); - useEffect(() => { - if (botNick) setSelectedNick(botNick); - }, [botNick]); - const isOper = (() => { - const myNick = currentUser?.username; - if (!myNick || !server) return false; - const me = server.users.find((u) => u.username === myNick); - return !!me?.isIrcOp; - })(); + const isOper = !!server?.users.find( + (u) => u.username === currentUser?.username, + )?.isIrcOp; const bots: PushBotInfo[] = useMemo(() => { - if (!server?.bots) return []; - // Reachability filter: server-scope bots are always shown - // (reachable from any context). Channel-scope bots only appear - // when the local user still shares a channel with them -- this - // matches the server-side discovery contract (server-scope on - // burst, channel-scope on LOCAL_JOIN of a shared channel) and - // hides stale entries left over after the user parts a channel. const joinedChannels = new Set( - (server.channels ?? []).map((c) => c.name.toLowerCase()), + (server?.channels ?? []).map((c) => c.name.toLowerCase()), ); - return Object.values(server.bots) + return Object.values(server?.bots ?? {}) .filter((b) => isBotReachable(b, isOper, joinedChannels)) - .filter((b) => filter === "all" || b.scope === filter) - .filter((b) => - query - ? b.nick.toLowerCase().includes(query.toLowerCase()) || - (b.realname ?? "").toLowerCase().includes(query.toLowerCase()) - : true, - ) - .sort((a, b) => { - const sa = a.status === "active" ? 0 : a.status === "pending" ? 1 : 2; - const sb = b.status === "active" ? 0 : b.status === "pending" ? 1 : 2; - if (sa !== sb) return sa - sb; - return a.nick.localeCompare(b.nick); - }); - }, [server?.bots, server?.channels, filter, query, isOper]); + .sort((a, b) => a.nick.localeCompare(b.nick)); + }, [server?.bots, server?.channels, isOper]); - const selected = selectedNick - ? server?.bots?.[selectedNick.toLowerCase()] - : undefined; + const selected = botNick ? server?.bots?.[botNick.toLowerCase()] : undefined; const send = (subcmd: string) => { if (!isOper || !selected) return; ircClient.sendRaw(serverId, `PUSHBOT ${subcmd} ${selected.nick}`); - if (subcmd === "DELETE") setSelectedNick(null); + if (subcmd === "DELETE") openBot(serverId); }; - const sections: PageSection[] = bots.map((b) => ({ - id: b.nick, - label: b.nick, - icon: server?.botCommandsLoading?.includes(b.nick.toLowerCase()) ? ( - - ) : ( - - ), - description: b.realname || undefined, - })); - - const controls = ( -
- setQuery(e.target.value)} - placeholder={t`Search bots`} - aria-label={t`Search bots`} - className="field-input" - /> -
- {(["all", "server", "channel"] as FilterMode[]).map((f) => ( + return ( +
+
+ {(isNarrowView || selected) && ( - ))} -
- {bots.length} -
+ )} +

+ Bots +

+ {!isNarrowView && ( + + )} +
+
+ {selected ? ( + { + requestBotCommandPick(serverId, selected.nick, cmd); + closeProfile(); + }} + /> + ) : bots.length === 0 ? ( +
+ No bots registered on this network yet. +
+ ) : ( +
    + {bots.map((b) => ( +
  • + +
  • + ))} +
+ )}
); - - const hasAnyBot = Object.keys(server?.bots ?? {}).length > 0; - - const detail = selected ? ( - { - requestBotCommandPick(serverId, selected.nick, cmd); - closePage(); - }} - /> - ) : ( -
- Select a bot to see its commands and management actions. -
- ); - - return ( - - {bots.length === 0 ? ( -
- {hasAnyBot ? ( - No bots match. Clear the search or change the filter. - ) : ( - No bots registered on this network yet. - )} -
- ) : ( - detail - )} -
- ); }; -export default BotsPage; +export default BotPane; diff --git a/src/components/layout/ChannelMessageList.tsx b/src/components/layout/ChannelMessageList.tsx index 6b7a7b042..6660df42e 100644 --- a/src/components/layout/ChannelMessageList.tsx +++ b/src/components/layout/ChannelMessageList.tsx @@ -279,19 +279,6 @@ export const ChannelMessageList = forwardRef< } }, [isScrolledUp]); - // Reset ref-tracked windowing state when switching channels. - // visibleMessageCount is NOT reset here — useState(initialScrollState?.visibleCount ?? DEFAULT_VISIBLE_MESSAGE_COUNT) - // already initializes it correctly on mount, and this effect runs once on mount for the - // same channelKey (each instance is bound to exactly one channel by the parent key={}). - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional full reset on channel change - useEffect(() => { - prevFilteredLengthRef.current = 0; - prevFirstMsgIdRef.current = null; - prevScrollHeightRef.current = 0; - pendingPrependRef.current = false; - resizeObserverPrevSHRef.current = 0; - }, [channelKey]); - // Whether we moved the scroll to put the anchored row back where it was. const keepAnchoredRow = (): boolean => { const container = messagesContainerRef.current; @@ -1063,18 +1050,19 @@ export const ChannelMessageList = forwardRef< return ( {message.id === firstUnreadId && ( - // We draw the line over the boundary with no height of its own, so the messages stay put when it comes and goes. + // We draw the line with no height of its own, so the messages stay put when it comes and goes. + // A row with a header has a 16px gap above it, and the line sits in the middle of it. // Above the first row there is nothing to overlap, so there it takes its own height.
- - New messages + + New
diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 48825c50e..2052c1383 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -418,6 +418,7 @@ export const ChatArea: React.FC<{ const toggleAddServerModal = useStore((state) => state.toggleAddServerModal); const openPage = useStore((state) => state.openPage); const openProfile = useStore((state) => state.openProfile); + const openBot = useStore((state) => state.openBot); const redactMessage = useStore((state) => state.redactMessage); const warnUser = useStore((state) => state.warnUser); const kickUser = useStore((state) => state.kickUser); @@ -2111,10 +2112,7 @@ export const ChatArea: React.FC<{ }) } onOpenInviteUser={() => setInviteUserModalOpen(true)} - onOpenBots={() => - selectedServerId && - openPage({ kind: "bots", serverId: selectedServerId }) - } + onOpenBots={() => selectedServerId && openBot(selectedServerId)} /> {topSlot && ( diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index f0ad69970..e01fab47a 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -38,6 +38,7 @@ import type { Channel, PrivateChat, User } from "../../types"; import { BotToolsHistoryButton } from "../ui/BotToolsHistoryButton"; import E2EELockControl from "../ui/E2EELockControl"; import Menu, { type MenuItem } from "../ui/Menu"; +import OperatorKey from "../ui/OperatorKey"; import TopicModal from "../ui/TopicModal"; interface ChatHeaderProps { @@ -273,15 +274,7 @@ export const ChatHeader: React.FC = ({ 🤖 )} - {isIrcOp && ( - - 🔑 - - )} + {isIrcOp && } ); }; @@ -336,7 +329,7 @@ export const ChatHeader: React.FC = ({ : t`Select a channel` : t`Home`; return ( -
+
{(isNarrowView || !isChanListVisible) && (