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
99 changes: 92 additions & 7 deletions src/packages/socket/src/components/ServerBotsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ type BotEntry = {
awaitingClaim: boolean;
createdAt: string | Date;
decidedAt: string | Date | null;
/** Asked for since it was let in (GRYT-2006). An older server sends none. */
more?: string[];
};

/**
Expand Down Expand Up @@ -63,6 +65,9 @@ export function ServerBotsTab({
if (b.status === "pending" && !next[b.registrationId]) {
next[b.registrationId] = new Set(b.requested);
}
if (b.status === "approved" && b.more?.length && !next[b.registrationId]) {
next[b.registrationId] = new Set(b.more);
}
}
return next;
});
Expand Down Expand Up @@ -103,6 +108,7 @@ export function ServerBotsTab({
};

const pending = useMemo(() => (bots ?? []).filter((b) => b.status === "pending"), [bots]);
const wantsMore = useMemo(() => (bots ?? []).filter((b) => b.status === "approved" && (b.more?.length ?? 0) > 0), [bots]);
const settled = useMemo(() => (bots ?? []).filter((b) => b.status !== "pending"), [bots]);

if (!bots) {
Expand All @@ -113,8 +119,8 @@ export function ServerBotsTab({
<div className="flex flex-col gap-6">
<span className="text-sm text-gryt-muted">
A bot is a member like any other — it holds only what you give it, and it can never
give itself more. What it asked for is fixed the first time it turns up, so a bot
that has been tampered with since cannot come back asking for the keys.
give itself more. If it asks for more later, it keeps what it has and the new ask
waits here for you, so a bot that has been tampered with can ask but can't take.
</span>

<Surface>
Expand Down Expand Up @@ -177,10 +183,66 @@ export function ServerBotsTab({
</span>
</div>

{pending.length === 0 ? (
{wantsMore.map((bot) => {
const chosen = ticked[bot.registrationId] ?? new Set<string>();
const more = bot.more ?? [];
return (
<Surface key={`more-${bot.registrationId}`}>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-sm font-bold">{bot.nickname} wants new permissions</span>
{bot.botId && (
<span className="text-xs text-gryt-muted font-mono select-all" style={{ wordBreak: "break-all" }}>
{bot.botId}
</span>
)}
</div>
<div className="flex flex-col gap-1">
{bot.granted.map((permission) => (
<PermissionRow key={permission} permission={permission} checked disabled />
))}
{more.map((permission) => (
<PermissionRow
key={permission}
permission={permission}
checked={chosen.has(permission)}
onChange={(checked) => toggle(bot.registrationId, permission, checked)}
/>
))}
</div>
<div className="flex justify-end gap-2">
<Button
tone="neutral"
size="small"
disabled={busy}
onClick={() => emit("server:bots:decide", { botId: bot.botId, decision: "denied" })}
>
Decline
</Button>
<Button
size="small"
disabled={busy}
onClick={() =>
emit("server:bots:decide", {
botId: bot.botId,
decision: "approved",
permissions: [...bot.granted, ...more.filter((p) => chosen.has(p))],
rank: bot.rank,
})
}
>
Approve
</Button>
</div>
</div>
</Surface>
);
})}

{pending.length === 0 && wantsMore.length === 0 && (
<span className="text-sm text-gryt-muted">Nothing waiting.</span>
) : (
pending.map((bot) => {
)}
{pending.map((bot) => {
const chosen = ticked[bot.registrationId] ?? new Set<string>();
return (
<Surface key={bot.registrationId}>
Expand Down Expand Up @@ -259,8 +321,7 @@ export function ServerBotsTab({
</div>
</Surface>
);
})
)}
})}
</div>

<div className="flex flex-col gap-3">
Expand Down Expand Up @@ -361,3 +422,27 @@ function RegisterBot({ busy, onCreate }: { busy: boolean; onCreate: (nickname: s
</Surface>
);
}

/** One permission with its explanation. A disabled, ticked one is something the bot already has. */
function PermissionRow({
permission,
checked,
disabled,
onChange,
}: {
permission: string;
checked: boolean;
disabled?: boolean;
onChange?: (checked: boolean) => void;
}) {
const meta = describePermission(permission);
return (
<label className="flex items-start gap-2 text-sm" style={{ cursor: disabled ? "default" : "pointer" }}>
<Checkbox checked={checked} disabled={disabled} onCheckedChange={(c) => onChange?.(c === true)} style={{ marginTop: "0.3em" }} />
<span className="flex flex-col">
<span>{meta.label}</span>
{meta.description && <span className="text-xs text-gryt-muted">{meta.description}</span>}
</span>
</label>
);
}
6 changes: 5 additions & 1 deletion src/packages/socket/src/components/serverView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { SIDEBAR_HOVER_PX, SIDEBAR_WIDTH_PX, useMediaAutoShow, useSidebarHover,
import { useSidebarEditor } from "../hooks/useSidebarEditor";
import { useSockets } from "../hooks/useSockets";
import { getUpdateAvailable } from "../hooks/useVersionStatus";
import { useWaitingBots } from "../hooks/useWaitingBots";
import { takeVoiceJoin, usePendingVoiceJoin } from "../hooks/voiceJoinRequest";
import { AVATAR_COLUMN_WIDTH, CHANNEL_ICON_LIST_WIDTH, THREAD_PANEL_WIDTH } from "../lib/narrowLayout";
import { withSharing } from "../lib/screenItems";
Expand Down Expand Up @@ -542,12 +543,15 @@ export const ServerView = ({ dmSpace = false }: { dmSpace?: boolean }) => {

const viewerPermissions = useServerPermissions(currentlyViewingServer?.host || "");

const { pendingReportCount, memberListMap } = useServerReports({
const { pendingReportCount: waitingReports, memberListMap } = useServerReports({
currentConnection, accessToken, currentlyViewingServer, memberLists,
/* `has`, not `can`: this drives an automatic `reports:list` on join, before
`server:details` arrives, so `can` made a refusal a new member's first sight. */
canViewReports: viewerPermissions.has("view_reports"),
});
// One badge on Server settings for anything waiting in there: reports and bots.
const waitingBots = useWaitingBots(currentConnection, accessToken, viewerPermissions.has("manage_bots"));
const pendingReportCount = waitingReports + waitingBots;

const viewingHost = currentlyViewingServer?.host;
const [updateAvailable, setUpdateAvailable] = useState(() =>
Expand Down
29 changes: 29 additions & 0 deletions src/packages/socket/src/hooks/useWaitingBots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import type { Socket } from "socket.io-client";

/** Bots at the door, and known bots asking for more (GRYT-2006). Re-asked on every
`server:details`, which is what a knock sends round. */
export function useWaitingBots(connection: Socket | null, accessToken: string | null, canManageBots: boolean): number {
const [waiting, setWaiting] = useState(0);

useEffect(() => {
if (!connection || !canManageBots || !accessToken) {
setWaiting(0);
return;
}
const onBots = (payload: { bots?: { status?: string; more?: string[] }[] }) => {
const bots = Array.isArray(payload?.bots) ? payload.bots : [];
setWaiting(bots.filter((b) => b.status === "pending" || (b.status === "approved" && (b.more?.length ?? 0) > 0)).length);
};
const ask = () => connection.emit("server:bots:list", { accessToken });
connection.on("server:bots", onBots);
connection.on("server:details", ask);
ask();
return () => {
connection.off("server:bots", onBots);
connection.off("server:details", ask);
};
}, [connection, accessToken, canManageBots]);

return waiting;
}
Loading