Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
13c050d
docs(spec): people lists design (NIP-51 kind 30000)
rabble May 5, 2026
29234d7
docs(plan): people lists implementation plan (8 chunks)
rabble May 5, 2026
57cc7e7
docs(plan): incorporate fresh-eyes review feedback
rabble May 5, 2026
cd3668d
feat(types): add PeopleList type and parser (kind 30000)
rabble May 5, 2026
86af7b6
feat(routing): add people-list sub-route path helpers
rabble May 5, 2026
2b89027
fix(nostr): publish kind 30003 to multi-relay LIST_KINDS set
rabble May 5, 2026
2f97627
fix(lists): include k tag in NIP-09 kind 5 deletion (NIP-09 conformance)
rabble May 5, 2026
d31df53
refactor(list-detail): widen relay query to kind 30000+30005
rabble May 5, 2026
7e41487
feat(hooks): add usePeopleLists for fetching authored people lists
rabble May 5, 2026
73ff29c
feat(hooks): add usePeopleList for single-list fetch
rabble May 5, 2026
1768ef8
feat(hooks): add usePeopleListMembers (list + batched profiles)
rabble May 5, 2026
6d37742
feat(hooks): add usePeopleListStats (members + videos; loops deferred)
rabble May 5, 2026
824b4ad
feat(hooks): add usePeopleListMemberVideos (relay-only v1; REST TODO)
rabble May 5, 2026
94cdc29
feat(hooks): add useCreatePeopleList (kind 30000 publish)
rabble May 5, 2026
f447666
feat(hooks): add useUpdatePeopleList (metadata edit)
rabble May 5, 2026
c171b44
feat(hooks): add useAddToPeopleList / useRemoveFromPeopleList with op…
rabble May 5, 2026
1d519af
feat(hooks): add useDeletePeopleList (NIP-09 with k tag)
rabble May 5, 2026
65ce434
feat(hooks): add useSavedLists (raw kind 30003 read)
rabble May 5, 2026
d458d7e
feat(hooks): add useResolvedSavedLists with stale-reference filtering
rabble May 5, 2026
e3a2bd2
feat(hooks): add useSaveList / useUnsaveList (kind 30003)
rabble May 5, 2026
ed59d6a
feat(hooks): add useUnifiedLists (combined video + people)
rabble May 5, 2026
ae68287
feat(components): add CreatePeopleListDialog
rabble May 5, 2026
c31c079
feat(components): add EditPeopleListDialog
rabble May 5, 2026
1c1f509
feat(components): add DeletePeopleListDialog
rabble May 5, 2026
6506f28
feat(components): add AddToPeopleListDialog with quick-toggle rows
rabble May 5, 2026
ac6db86
feat(components): add PeopleListDetailHeader
rabble May 5, 2026
8e7aadc
feat(components): add PeopleListMembersGrid
rabble May 5, 2026
b3eeff1
feat(components): add PeopleListVideosGrid (VideoGrid + member-feed)
rabble May 5, 2026
b6cb520
feat(components): add PeopleListEditMode (owner curate)
rabble May 5, 2026
11796a2
refactor(list-detail): extract VideoListContent component
rabble May 5, 2026
62f6dab
feat(list-detail): dispatch to PeopleListContent for kind 30000
rabble May 5, 2026
d111c32
feat(routes): add /list/.../members sub-route
rabble May 5, 2026
b0744e2
feat(routes): add /list/.../videos sub-route
rabble May 5, 2026
a7490cf
feat(routes): add owner-guarded /list/.../edit sub-route
rabble May 5, 2026
4ab1a8d
feat(components): add PeopleListCard discovery card
rabble May 5, 2026
43f0f49
feat(components): add UnifiedListCard polymorphic over kind 30000/30005
rabble May 5, 2026
f08718f
feat(profile): add Lists tab to ProfilePage
rabble May 5, 2026
7968cfc
feat(discovery): add Lists tab to DiscoveryPage
rabble May 5, 2026
992e87d
feat(search): add Lists tab to SearchPage
rabble May 5, 2026
c770ef7
feat(sidebar): add Lists section (authored + saved)
rabble May 5, 2026
31dcab5
feat(lists-page): show authored + saved across both kinds
rabble May 5, 2026
29f9ff5
feat(profile): add 'Add to list' overflow item
rabble May 5, 2026
ec7490a
feat(video-card): add 'Add creator to list' overflow item
rabble May 5, 2026
2a918dc
feat(user-list): add 'Add to list' per-row overflow
rabble May 5, 2026
8fadd0c
fix(ci): satisfy strict tsconfig.app.json + bump PWA cache limit
rabble May 5, 2026
17f3412
fix(ci): satisfy strict eslint (no inline disables; no any)
rabble May 5, 2026
8566bd0
fix(lists): filter system kind 30000 events from people-list surfaces
rabble May 5, 2026
75ab7d8
fix(lists): route VideoListCard to canonical /list/<pubkey>/<id>
rabble May 5, 2026
fe54d70
fix(lists): hide empty lists from discovery and search
rabble May 5, 2026
3865b9c
feat(lists): real avatars + video thumbnails on discovery cards
rabble May 5, 2026
7afb2d2
fix(lists): drop 7-day since filter from discovery query
rabble May 5, 2026
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
1,543 changes: 1,543 additions & 0 deletions docs/superpowers/plans/2026-05-05-people-lists.md

Large diffs are not rendered by default.

280 changes: 280 additions & 0 deletions docs/superpowers/specs/2026-05-05-people-lists-design.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import VideoPage from "./pages/VideoPage";
import { TagPage } from "./pages/TagPage";
import ListsPage from "./pages/ListsPage";
import ListDetailPage from "./pages/ListDetailPage";
import ListEditPage from "./pages/ListEditPage";
import ListMembersPage from "./pages/ListMembersPage";
import ListVideosPage from "./pages/ListVideosPage";
import ModerationSettingsPage from "./pages/ModerationSettingsPage";
import LinkedAccountsSettingsPage from "./pages/LinkedAccountsSettingsPage";
// import { NIP05ProfilePage } from "./pages/NIP05ProfilePage";
Expand Down Expand Up @@ -130,6 +133,8 @@ export function AppRouter() {
<Route path="/merch" element={<MerchPage />} />
<Route path="/u/:userId" element={<UniversalUserPage />} />
<Route path="/list/:pubkey/:listId" element={<ListDetailPage />} />
<Route path="/list/:pubkey/:listId/members" element={<ListMembersPage />} />
<Route path="/list/:pubkey/:listId/videos" element={<ListVideosPage />} />
<Route path="/event/:eventId" element={<EventPage />} />
<Route path="/event/a/:kind/:pubkey/:identifier" element={<EventPage />} />
<Route path="/:nip19" element={<NIP19Page />} />
Expand All @@ -143,6 +148,7 @@ export function AppRouter() {
<Route path="/messages/:conversationId" element={<ConversationPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/lists" element={<ListsPage />} />
<Route path="/list/:pubkey/:listId/edit" element={<ListEditPage />} />
{/* DISABLED: Upload route - not supported on web at this time
<Route path="/upload" element={<UploadPage />} />
*/}
Expand Down
206 changes: 206 additions & 0 deletions src/components/AddToPeopleListDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// ABOUTME: Tests for AddToPeopleListDialog component
// ABOUTME: Covers list rendering, pre-check logic, toggle mutations, and empty state

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AddToPeopleListDialog } from './AddToPeopleListDialog';
import type { PeopleList } from '@/types/peopleList';

const MEMBER_PUBKEY = 'aaaa'.repeat(16);
const OTHER_PUBKEY = 'bbbb'.repeat(16);
const CURRENT_USER_PUBKEY = 'cccc'.repeat(16);

const LIST_A: PeopleList = {
id: 'list-a',
pubkey: CURRENT_USER_PUBKEY,
name: 'Alpha List',
members: [],
createdAt: 1700000001,
};

const LIST_B: PeopleList = {
id: 'list-b',
pubkey: CURRENT_USER_PUBKEY,
name: 'Beta List',
members: [MEMBER_PUBKEY],
createdAt: 1700000002,
};

const { mockAddMutateAsync, mockRemoveMutateAsync } = vi.hoisted(() => ({
mockAddMutateAsync: vi.fn(),
mockRemoveMutateAsync: vi.fn(),
}));

vi.mock('@/hooks/usePeopleLists', () => ({
usePeopleLists: vi.fn(),
}));

vi.mock('@/hooks/usePeopleListMutations', () => ({
useAddToPeopleList: () => ({
mutateAsync: mockAddMutateAsync,
isPending: false,
}),
useRemoveFromPeopleList: () => ({
mutateAsync: mockRemoveMutateAsync,
isPending: false,
}),
}));

vi.mock('@/hooks/useCurrentUser', () => ({
useCurrentUser: () => ({
user: { pubkey: CURRENT_USER_PUBKEY },
}),
}));

vi.mock('@/components/CreatePeopleListDialog', () => ({
CreatePeopleListDialog: ({ open }: { open: boolean; onOpenChange: (v: boolean) => void; prefilledMembers?: string[] }) =>
open ? <div data-testid="create-dialog">Create Dialog</div> : null,
}));

vi.mock('@/components/ui/dialog', () => ({
Dialog: ({
open,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
}));

vi.mock('@/components/ui/checkbox', () => ({
Checkbox: ({
id,
checked,
onCheckedChange,
}: {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) => (
<input
type="checkbox"
id={id}
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
data-testid={`checkbox-${id}`}
/>
),
}));

import { usePeopleLists } from '@/hooks/usePeopleLists';
const mockUsePeopleLists = vi.mocked(usePeopleLists);

beforeEach(() => {
vi.clearAllMocks();
mockAddMutateAsync.mockResolvedValue(undefined);
mockRemoveMutateAsync.mockResolvedValue(undefined);
});

describe('AddToPeopleListDialog', () => {
it('renders one row per list with the correct names', () => {
mockUsePeopleLists.mockReturnValue({
data: [LIST_A, LIST_B],
isLoading: false,
} as unknown as ReturnType<typeof usePeopleLists>);

render(
<AddToPeopleListDialog
open
onOpenChange={vi.fn()}
memberPubkey={OTHER_PUBKEY}
/>,
);

expect(screen.getByText('Alpha List')).toBeInTheDocument();
expect(screen.getByText('Beta List')).toBeInTheDocument();
});

it('pre-checks rows for lists that already contain memberPubkey', () => {
mockUsePeopleLists.mockReturnValue({
data: [LIST_A, LIST_B],
isLoading: false,
} as unknown as ReturnType<typeof usePeopleLists>);

render(
<AddToPeopleListDialog
open
onOpenChange={vi.fn()}
memberPubkey={MEMBER_PUBKEY}
/>,
);

// LIST_B contains MEMBER_PUBKEY — its checkbox should be checked
expect(screen.getByTestId('checkbox-list-b')).toBeChecked();
// LIST_A does not — its checkbox should be unchecked
expect(screen.getByTestId('checkbox-list-a')).not.toBeChecked();
});

it('toggling an unchecked row calls addMutation, toggling a checked row calls removeMutation', async () => {
const user = userEvent.setup();

mockUsePeopleLists.mockReturnValue({
data: [LIST_A, LIST_B],
isLoading: false,
} as unknown as ReturnType<typeof usePeopleLists>);

render(
<AddToPeopleListDialog
open
onOpenChange={vi.fn()}
memberPubkey={MEMBER_PUBKEY}
/>,
);

// LIST_A is unchecked → checking it should call add
await user.click(screen.getByTestId('checkbox-list-a'));
await waitFor(() =>
expect(mockAddMutateAsync).toHaveBeenCalledWith({
listId: 'list-a',
memberPubkey: MEMBER_PUBKEY,
}),
);

// LIST_B is checked → unchecking it should call remove
await user.click(screen.getByTestId('checkbox-list-b'));
await waitFor(() =>
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
listId: 'list-b',
memberPubkey: MEMBER_PUBKEY,
}),
);
});

it('shows empty state headline and create CTA when user has no lists', () => {
mockUsePeopleLists.mockReturnValue({
data: [],
isLoading: false,
} as unknown as ReturnType<typeof usePeopleLists>);

render(
<AddToPeopleListDialog
open
onOpenChange={vi.fn()}
memberPubkey={OTHER_PUBKEY}
/>,
);

expect(screen.getByText(/you don't have any lists yet/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create new list/i })).toBeInTheDocument();
// No list rows
expect(screen.queryByTestId(/^checkbox-/)).not.toBeInTheDocument();
});
});
131 changes: 131 additions & 0 deletions src/components/AddToPeopleListDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// ABOUTME: Dialog for quickly adding/removing a person from the current user's people lists
// ABOUTME: Checkbox rows give instant per-list toggle; footer opens CreatePeopleListDialog

import { useState } from 'react';
import { usePeopleLists } from '@/hooks/usePeopleLists';
import {
useAddToPeopleList,
useRemoveFromPeopleList,
} from '@/hooks/usePeopleListMutations';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Plus, Users } from '@phosphor-icons/react';
import { CreatePeopleListDialog } from '@/components/CreatePeopleListDialog';

interface AddToPeopleListDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
memberPubkey: string;
}

export function AddToPeopleListDialog({
open,
onOpenChange,
memberPubkey,
}: AddToPeopleListDialogProps) {
const { user } = useCurrentUser();
const { data: userLists, isLoading } = usePeopleLists(user?.pubkey);
const addToList = useAddToPeopleList();
const removeFromList = useRemoveFromPeopleList();

const [createDialogOpen, setCreateDialogOpen] = useState(false);

const handleToggle = async (listId: string, currentlyChecked: boolean) => {
if (currentlyChecked) {
await removeFromList.mutateAsync({ listId, memberPubkey });
} else {
await addToList.mutateAsync({ listId, memberPubkey });
}
};

const isEmpty = !isLoading && (!userLists || userLists.length === 0);

return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add to a list</DialogTitle>
<DialogDescription>
Pick which of your people lists to add this person to.
</DialogDescription>
</DialogHeader>

{isLoading && (
<div className="space-y-2 py-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)}

{!isLoading && !isEmpty && (
<ScrollArea className="max-h-64 w-full rounded-md border p-4">
<div className="space-y-2">
{userLists!.map((list) => {
const isChecked = list.members.includes(memberPubkey);
return (
<div
key={list.id}
className="flex items-center space-x-3 p-2 rounded hover:bg-accent"
>
<Checkbox
id={list.id}
checked={isChecked}
onCheckedChange={() => handleToggle(list.id, isChecked)}
/>
<Label
htmlFor={list.id}
className="flex-1 cursor-pointer flex items-center gap-2"
>
<Users className="h-4 w-4 shrink-0" />
<span className="truncate">{list.name}</span>
</Label>
<span className="text-xs text-muted-foreground shrink-0">
{list.members.length} {list.members.length === 1 ? 'member' : 'members'}
</span>
</div>
);
})}
</div>
</ScrollArea>
)}

{isEmpty && (
<p className="text-sm text-muted-foreground text-center py-4">
You don't have any lists yet.
</p>
)}

<div className="pt-2">
<Button
variant="sticker"
className="w-full"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
Create new list
</Button>
</div>
</DialogContent>
</Dialog>

<CreatePeopleListDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
prefilledMembers={[memberPubkey]}
/>
</>
);
}
Loading
Loading