Skip to content

Commit 11e1611

Browse files
committed
feat: improve ama guest discovery
1 parent a2de29a commit 11e1611

8 files changed

Lines changed: 137 additions & 35 deletions

File tree

apps/website/src/api/routes/guilds.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { BotId } from '@chatsift/core';
1212
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1313
import { apiFetch } from '../fetch';
1414
import { queryKeys } from '../queryClient';
15+
import { useGuildAccess } from '@/hooks/useGuildAccess';
1516

1617
export type { GuildChannelInfo, GuildEmojiInfo, GuildRoleInfo } from '@chatsift/api';
1718

@@ -29,8 +30,21 @@ type DeleteGrantContract = InferRouteContract<typeof deleteGrantRoute>;
2930
export type DeleteGrantBody = DeleteGrantContract['body'];
3031

3132
export function useGuildInfo(guildId: string, forBot: BotId) {
33+
// `GET /v3/guilds/:guildId` is hard manager-only (`isGuildManager: true` in the API's `isAuthed`) with no
34+
// AMA-guest carve-out, and shouldn't have one -- it returns the guild's entire channel/role/emoji list,
35+
// well beyond what being a guest on a single session is meant to expose (and the route takes no `amaId` to
36+
// scope such a check against anyway). So skip the request outright for a non-manager rather than having
37+
// every page that mounts this fire a guaranteed 403: an AMA guest viewing a session detail page hit exactly
38+
// that. Gated here rather than at each call site so no future consumer has to remember. Consumers already
39+
// cope with a missing result -- they read through `guildInfo?.x ?? []`, and the forms that genuinely need
40+
// channels/roles (config editors, create flows) are `canManage`-gated or on manager-only pages regardless.
41+
const { canManage } = useGuildAccess(guildId);
42+
3243
return useQuery({
3344
queryKey: queryKeys.guilds.info(guildId, forBot),
45+
// A disabled query stays `isPending` but never `isFetching`, so `isLoading` is `false` for a guest --
46+
// call sites that disable controls on `isLoading` don't get stuck in a permanent loading state.
47+
enabled: canManage,
3448
queryFn: async () =>
3549
apiFetch<GuildInfo>('get', `/v3/guilds/${guildId}`, {
3650
query: { for_bot: forBot, force_fresh: false },

apps/website/src/app/dashboard/[id]/_components/GuildNav.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ export function GuildNav() {
4747
const branding = resolveBotBranding(guild, bot);
4848
return {
4949
label: branding.label,
50-
href: `/dashboard/${guild.id}/${bot.toLowerCase()}`,
50+
// Skip the AMA hub for a guest -- `NavGateCheck` would only bounce them off it to the sessions
51+
// list anyway, and a nav tab that visibly redirects reads as broken.
52+
href:
53+
isAmaGuestOnly && bot === 'AMA'
54+
? `/dashboard/${guild.id}/ama/amas`
55+
: `/dashboard/${guild.id}/${bot.toLowerCase()}`,
5156
icon: <BotIcon bot={bot} branding={branding} height={16} width={16} />,
5257
};
5358
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import { useParams } from 'next/navigation';
4+
import { Heading } from '@/components/common/Heading';
5+
import { useGuildAccess } from '@/hooks/useGuildAccess';
6+
7+
/**
8+
* Thin client wrapper around `Heading`, purely so this page's subtitle can match the viewer's access tier.
9+
* This is a guest's landing page (see `NavGateCheck`'s guest redirect) and the manager copy -- "create and
10+
* manage AMAs in your community" -- describes nothing they can actually do: sessions are filtered server-side
11+
* to the ones they're a guest on (`getAMAs.ts`), and every create/edit control is gated on `canManage`.
12+
*/
13+
export function AMASessionsHeading() {
14+
const params = useParams<{ id: string }>();
15+
const { isAmaGuestOnly } = useGuildAccess(params.id);
16+
17+
return (
18+
<Heading
19+
subtitle={
20+
isAmaGuestOnly
21+
? "AMA sessions you've been invited to answer questions in"
22+
: 'Create and manage AMAs in your community'
23+
}
24+
title="AMA sessions"
25+
/>
26+
);
27+
}

apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionsList.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import type { SortOption } from './SortMenu';
99
import { useSortOption } from './SortMenu';
1010
import type { AMASessionWithCount } from '@/api/routes/ama';
1111
import { useAMAs } from '@/api/routes/ama';
12-
import { useMe } from '@/api/routes/auth';
1312
import { EmptyState } from '@/components/common/EmptyState';
1413
import { Skeleton } from '@/components/common/Skeleton';
1514
import { UserErrorHandler } from '@/components/user/UserErrorHandler';
15+
import { useGuildAccess } from '@/hooks/useGuildAccess';
1616

1717
function AMASessionSkeleton() {
1818
return (
@@ -52,12 +52,10 @@ export function AMASessionsList() {
5252
const includeEnded = searchParams.get('include_ended') === 'true';
5353

5454
const { data: sessions, isLoading, error } = useAMAs(params.id, includeEnded);
55-
const { data: me } = useMe();
56-
const guild = me?.guilds.find((g) => g.id === params.id);
5755
// Creating a session is manager-only -- a guest only ever sees the specific AMA(s) they're scoped to
5856
// (already filtered server-side, see `getAMAs.ts`), so there's nothing for a "create" card to do here.
59-
const canCreate = Boolean(me?.isGlobalAdmin || guild?.meCanManage);
60-
const createCardItem = canCreate ? (
57+
const { canManage } = useGuildAccess(params.id);
58+
const createCardItem = canManage ? (
6159
<li>
6260
<CreateAMACard />
6361
</li>
@@ -101,8 +99,12 @@ export function AMASessionsList() {
10199
{includeEnded ? (
102100
<EmptyState
103101
icon={<FaComments className="h-8 w-8 text-secondary dark:text-secondary-dark" />}
104-
subtitle="Create your first AMA session to get started."
105-
title="No AMA sessions yet"
102+
subtitle={
103+
canManage
104+
? 'Create your first AMA session to get started.'
105+
: 'Sessions show up here once a server manager adds you as a guest on one.'
106+
}
107+
title={canManage ? 'No AMA sessions yet' : 'No AMA sessions shared with you'}
106108
/>
107109
) : (
108110
<EmptyState

apps/website/src/app/dashboard/[id]/ama/amas/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { AMASessionsHeading } from './_components/AMASessionsHeading';
12
import { AMASessionsList } from './_components/AMASessionsList';
23
import { IncludeEndedToggle } from './_components/IncludeEndedToggle';
34
import { SortMenu } from './_components/SortMenu';
4-
import { Heading } from '@/components/common/Heading';
55
import { SearchBar } from '@/components/common/SearchBar';
66
import { DashboardCrumbs } from '@/components/dashboard/DashboardCrumbs';
77

@@ -10,7 +10,7 @@ export default function AMAMangementPage() {
1010
<>
1111
<div className="flex flex-col [&>*:not(:first-of-type)]:mt-8 [&>*]:first-of-type:mb-4">
1212
<DashboardCrumbs />
13-
<Heading subtitle="Create and manage AMAs in your community" title="AMA sessions" />
13+
<AMASessionsHeading />
1414
<SearchBar placeholder="Search AMA sessions...">
1515
<SortMenu />
1616
<IncludeEndedToggle />

apps/website/src/app/dashboard/_components/GuildCard.tsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,29 +9,48 @@ import { cn } from '@/utils/util';
99

1010
interface GuildCardProps {
1111
readonly data: MeGuild;
12+
/**
13+
* True when the viewer's only access to this guild is AMA-guest access -- no `meCanManage`, just an entry
14+
* in some session's `guest_ids` (see `useGuildAccess`'s `isAmaGuestOnly`). The card then advertises only
15+
* AMA (other bots installed here are ones they can't open, and `GuildNav` hides them for the same reason)
16+
* and links straight at the sessions list rather than the manager-only guild root.
17+
*/
18+
readonly isGuest?: boolean;
1219
}
1320

1421
const BOT_ICON_SIZE = 28;
1522

16-
export default function GuildCard({ data }: GuildCardProps) {
17-
const hasBots = data.bots.length > 0;
18-
const url = hasBots ? `/dashboard/${data.id}` : undefined;
23+
export default function GuildCard({ data, isGuest }: GuildCardProps) {
24+
const bots = isGuest ? data.bots.filter((bot) => bot === 'AMA') : data.bots;
25+
const hasBots = bots.length > 0;
26+
// A guest always has somewhere to go even with no bot icons to show -- their access comes from a session
27+
// row, not from the AMA bot still being in this guild's `GuildList`.
28+
const isLinked = isGuest || hasBots;
29+
// Deliberately not the guild root: that page is the manager-only settings hub, and a guest landing there
30+
// only gets bounced onwards by `NavGateCheck` anyway.
31+
const url = isGuest ? `/dashboard/${data.id}/ama/amas` : hasBots ? `/dashboard/${data.id}` : undefined;
1932

2033
return (
2134
<div
2235
className={cn(
2336
'relative flex h-[9.5rem] w-full min-w-0 flex-col gap-3 overflow-hidden rounded-lg border-[1px] border-on-secondary p-3 dark:border-on-secondary-dark',
24-
hasBots ? 'bg-card dark:bg-card-dark' : 'group',
37+
isLinked ? 'bg-card dark:bg-card-dark' : 'group',
2538
)}
2639
>
27-
<GuildIcon data={data} disableLink={hasBots} hasBots={hasBots} />
40+
<GuildIcon data={data} disableLink={isLinked} hasBots={isLinked} />
41+
42+
{isGuest && (
43+
<span className="absolute right-3 top-3 rounded bg-misc-accent/10 px-2 py-1 text-xs font-medium text-misc-accent">
44+
Guest
45+
</span>
46+
)}
2847

2948
<div className="flex min-w-0 flex-col gap-1">
3049
<p className="truncate text-lg font-medium text-primary dark:text-primary-dark">{data.name}</p>
3150

32-
{hasBots ? (
51+
{isLinked ? (
3352
<ul className="flex flex-row items-center gap-1">
34-
{data.bots.map((bot) => {
53+
{bots.map((bot) => {
3554
const branding = resolveBotBranding(data, bot);
3655
return (
3756
<li key={bot}>
@@ -68,7 +87,7 @@ export default function GuildCard({ data }: GuildCardProps) {
6887
)}
6988
</div>
7089

71-
{hasBots && url ? (
90+
{isLinked && url ? (
7291
<Link
7392
className="absolute inset-0 rounded-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-misc-accent"
7493
href={url}

apps/website/src/app/dashboard/_components/GuildList.tsx

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { refreshMeMutationKey, useMe } from '@/api/routes/auth';
1010
import { EmptyState } from '@/components/common/EmptyState';
1111
import { Skeleton } from '@/components/common/Skeleton';
1212
import { Tooltip } from '@/components/common/Tooltip';
13+
import { resolveGuildAccess } from '@/hooks/useGuildAccess';
1314
import { cn, sortGuilds } from '@/utils/util';
1415

1516
function GuildListSkeleton() {
@@ -31,17 +32,40 @@ export function GuildList() {
3132

3233
const searchQuery = searchParams.get('search') ?? '';
3334

34-
const manageable = useMemo(() => me?.guilds.filter((g) => g.meCanManage) ?? [], [me]);
35+
// AMA guests belong here too, not just managers: a guest-only guild always has `meCanManage: false` (see
36+
// `fetchMe`'s guest-guild synthesis), so filtering on that alone left a guest who manages nothing staring at
37+
// the "no servers" empty state below -- with no way to reach the AMA they were invited to answer, even
38+
// though every gate under `/dashboard/[id]/ama` already grants them access. Tiers come from the shared
39+
// `resolveGuildAccess` rather than a local `meCanManage` read, so this list agrees with what `NavGateCheck`
40+
// will actually let the viewer do when they click through (global admins included).
41+
const visible = useMemo(
42+
() =>
43+
(me?.guilds ?? [])
44+
.map((guild) => {
45+
const { canManage, isAmaGuestOnly } = resolveGuildAccess(me, guild.id);
46+
return { guild, canManage, isAmaGuestOnly };
47+
})
48+
.filter((entry) => entry.canManage || entry.isAmaGuestOnly),
49+
[me],
50+
);
51+
const hasGuestGuilds = useMemo(() => visible.some((entry) => entry.isAmaGuestOnly), [visible]);
3552
const sorted = useMemo(() => {
3653
const lower = searchQuery.toLowerCase();
3754

38-
if (!manageable.length) {
55+
if (!visible.length) {
3956
return [];
4057
}
4158

42-
const filtered = manageable.filter((guild) => guild.name.toLowerCase().includes(lower));
43-
return sortGuilds(filtered);
44-
}, [manageable, searchQuery]);
59+
const filtered = visible.filter((entry) => entry.guild.name.toLowerCase().includes(lower));
60+
// Managed servers first, then guest-only ones -- guest access is a much narrower thing (one or two
61+
// sessions someone added you to) and shouldn't outrank a server you actually run. `sortGuilds` works on
62+
// bare `MeGuild`s, so the tier flags are re-attached by id afterwards.
63+
const byId = new Map(filtered.map((entry) => [entry.guild.id, entry]));
64+
return [
65+
...sortGuilds(filtered.filter((entry) => entry.canManage).map((entry) => entry.guild)),
66+
...sortGuilds(filtered.filter((entry) => !entry.canManage).map((entry) => entry.guild)),
67+
].map((guild) => ({ guild, isAmaGuestOnly: byId.get(guild.id)!.isAmaGuestOnly }));
68+
}, [visible, searchQuery]);
4569

4670
// `me` is only `undefined` while the query is still in flight — a resolved-but-logged-out `me` never reaches
4771
// this component (`NavGateProvider` gates the whole `/dashboard` tree on it), but guarding here too keeps
@@ -50,11 +74,11 @@ export function GuildList() {
5074
return <GuildListSkeleton />;
5175
}
5276

53-
if (manageable.length === 0) {
77+
if (visible.length === 0) {
5478
return (
5579
<EmptyState
5680
icon={<FaServer className="h-8 w-8 text-secondary dark:text-secondary-dark" />}
57-
subtitle="You need Manage Server permissions on a Discord server to configure it here. Just got promoted? Hit Refresh above."
81+
subtitle="You need Manage Server permissions on a Discord server to configure it here, or an invite to answer questions in someone's AMA. Just got promoted? Hit Refresh above."
5882
title="No servers to manage yet"
5983
/>
6084
);
@@ -86,15 +110,21 @@ export function GuildList() {
86110
</Tooltip>
87111
</div>
88112
)}
113+
{hasGuestGuilds && (
114+
<p className="mb-4 text-sm text-secondary dark:text-secondary-dark">
115+
Servers marked <span className="font-medium text-primary dark:text-primary-dark">Guest</span> are ones you
116+
were invited to answer an AMA in — you&apos;ll only see those sessions there, nothing else.
117+
</p>
118+
)}
89119
<ul
90120
className={cn(
91121
'grid grid-cols-1 gap-4 transition-opacity md:grid-cols-3 lg:grid-cols-4',
92122
isRefreshing && 'opacity-50',
93123
)}
94124
>
95-
{sorted.map((guild) => (
125+
{sorted.map(({ guild, isAmaGuestOnly }) => (
96126
<li className="min-w-0" key={guild.id}>
97-
<GuildCard data={guild} />
127+
<GuildCard data={guild} isGuest={isAmaGuestOnly} />
98128
</li>
99129
))}
100130
</ul>

apps/website/src/components/common/NavGate.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ export function NavGateCheck({ children, checkForGlobalAdmin, checkForGuildAcces
111111
? resolveGuildAccess(user, params.id)
112112
: { canManage: true, isAmaGuestOnly: false };
113113
const isGuildRoot = /^\/dashboard\/[^/]+\/?$/.test(pathname ?? '');
114-
const isAmaGuestOnlyOnGuildRoot = isAmaGuestOnly && isGuildRoot;
114+
// The AMA hub (`/dashboard/[id]/ama`) is the same kind of dead end for a guest: manager-framed
115+
// "configure AMA for your server" copy wrapped around a single link to the sessions list, which is the
116+
// only thing under it they can open. Folded into the guild-root redirect rather than given its own
117+
// guest-aware variant, so a guest only ever sees pages that were written for them.
118+
const isAmaHub = /^\/dashboard\/[^/]+\/ama\/?$/.test(pathname ?? '');
119+
const isAmaGuestOnlyOnHub = isAmaGuestOnly && (isGuildRoot || isAmaHub);
115120

116121
useEffect(() => {
117122
if (!isAuthenticated) {
@@ -123,12 +128,12 @@ export function NavGateCheck({ children, checkForGlobalAdmin, checkForGuildAcces
123128
return;
124129
}
125130

126-
// Centralized here rather than in every page that could be the guild root, so none of them need to
131+
// Centralized here rather than in every page that could be a guest dead end, so none of them need to
127132
// know guest status exists at all -- they just never render while this is in flight.
128-
if (isAmaGuestOnlyOnGuildRoot) {
129-
router.replace(`/dashboard/${params.id}/ama`);
133+
if (isAmaGuestOnlyOnHub) {
134+
router.replace(`/dashboard/${params.id}/ama/amas`);
130135
}
131-
}, [isAuthenticated, checkForGlobalAdmin, user, router, isAmaGuestOnlyOnGuildRoot, params.id]);
136+
}, [isAuthenticated, checkForGlobalAdmin, user, router, isAmaGuestOnlyOnHub, params.id]);
132137

133138
// Guild access is checked during render (not the effects above) and uses `notFound()` rather than a
134139
// redirect: it needs to block the first render of `children` outright, since descendants
@@ -154,9 +159,9 @@ export function NavGateCheck({ children, checkForGlobalAdmin, checkForGuildAcces
154159
}
155160
}
156161

157-
// Render nothing on the guild root while the redirect effect above fires, instead of flashing the
158-
// manager-only overview page's content at a guest-only viewer for one frame.
159-
if (isAmaGuestOnlyOnGuildRoot) {
162+
// Render nothing on those pages while the redirect effect above fires, instead of flashing manager-only
163+
// content at a guest-only viewer for one frame.
164+
if (isAmaGuestOnlyOnHub) {
160165
return null;
161166
}
162167

0 commit comments

Comments
 (0)