diff --git a/docs/superpowers/plans/2026-05-05-people-lists.md b/docs/superpowers/plans/2026-05-05-people-lists.md new file mode 100644 index 00000000..96fe63b4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-people-lists.md @@ -0,0 +1,1543 @@ +# People Lists Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add NIP-51 people-list (follow set, kind 30000) support with full CRUD, unified discovery, public list-detail surfaces, profile/sidebar/search/discovery integration, and saved-list (kind 30003) persistence. + +**Architecture:** Mirror the existing kind-30005 video-list infrastructure with a parallel `usePeopleLists` family. Extend two cross-cutting touchpoints (`LIST_KINDS` multi-relay set; `ListDetailPage` kinds query) so the detail route auto-dispatches by kind. Introduce a `Tabs` primitive on `ProfilePage` (none today) and a new Lists section in `AppSidebar` (none today). Cards on discovery surfaces are kind-polymorphic via a small `UnifiedListCard`. Stats aggregate via `POST /api/users/bulk` (≤200 members; show `—` above). + +**Tech Stack:** React 18, TypeScript, Vitest, Testing Library, TanStack Query 5, `@nostrify/react`, `@phosphor-icons/react`, Tailwind, shadcn/ui. + +**TDD step template — apply to every task in this plan, including the condensed ones.** Some later tasks abbreviate as "Test, implement, commit" for brevity. Expand them to this template literally: + +1. **Write the failing test.** Concrete `it(…)` block(s) covering the visible behavior. +2. **Run it to verify it fails.** `npx vitest run `. Expected: FAIL — record the failure message. +3. **Implement the minimum code to make it pass.** +4. **Run it to verify it passes.** Same command. Expected: PASS. +5. **Run any neighboring tests** that could regress (e.g. modifying `ProfilePage` → also run `ProfilePage.test.tsx`). +6. **Commit** with the message shown. + +If a task says "Step 1-3: test, implement, commit," it means the above six steps still apply — don't take it literally as 3 steps. + +**Required pre-reading:** +- `docs/superpowers/specs/2026-05-05-people-lists-design.md` (this plan implements that spec) +- `CLAUDE.md` (project conventions, Nostr essentials, brand rules) +- `divine-context` skill (cross-repo handbook) +- Existing patterns: `src/hooks/useVideoLists.ts`, `src/components/CreateListDialog.tsx`, `src/components/AddToListDialog.tsx`, `src/pages/ListDetailPage.tsx`, `src/lib/eventRouting.ts`, `src/components/NostrProvider.tsx` + +**Brand guardrails to obey** (tested by `tests/brand/*`): +- No `uppercase` Tailwind class — use `` for headings +- No `lucide-react` imports — use `@phosphor-icons/react` (`bold` weight default; `fill` for active states) +- No `bg-gradient-*` / `linear-gradient(` / `radial-gradient(` on layout surfaces +- Voice: casual-direct ("Nada. Try something different?" not "No results found") + +**Brand primitives to use** (don't roll your own): +- `` for `` — accent rotates per surface (green default, pink trending, violet classics) +- `` for all headings (throws in dev if className contains `uppercase`) +- ` + + + + + + + ); +} diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 33dae8ea..49403865 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -6,11 +6,33 @@ import { LOCALE_STORAGE_KEY } from '@/lib/i18n/config'; import { initializeI18n } from '@/lib/i18n'; import { AppSidebar } from './AppSidebar'; import type { CategoryWithConfig } from '@/hooks/useCategories'; - -const { mockNavigate, mockSetTheme, mockCategories } = vi.hoisted(() => ({ +import type { PeopleList } from '@/types/peopleList'; +import type { VideoList } from '@/hooks/useVideoLists'; + +const { + mockNavigate, + mockSetTheme, + mockCategories, + mockCurrentUser, + mockUnifiedListsReturn, + mockResolvedSavedListsReturn, +} = vi.hoisted(() => ({ mockNavigate: vi.fn(), mockSetTheme: vi.fn(), mockCategories: [] as CategoryWithConfig[], + mockCurrentUser: { user: null as { pubkey: string } | null }, + mockUnifiedListsReturn: { + people: [] as PeopleList[], + video: [] as VideoList[], + isLoading: false, + isError: false, + }, + mockResolvedSavedListsReturn: { + people: [] as PeopleList[], + video: [] as VideoList[], + isLoading: false, + isError: false, + }, })); vi.mock('@/hooks/useCategories', () => ({ @@ -22,7 +44,20 @@ vi.mock('@/hooks/useTheme', () => ({ })); vi.mock('@/hooks/useCurrentUser', () => ({ - useCurrentUser: () => ({ user: null }), + useCurrentUser: () => mockCurrentUser, +})); + +vi.mock('@/hooks/useUnifiedLists', () => ({ + useUnifiedLists: () => mockUnifiedListsReturn, +})); + +vi.mock('@/hooks/useResolvedSavedLists', () => ({ + useResolvedSavedLists: () => mockResolvedSavedListsReturn, +})); + +vi.mock('@/components/CreatePeopleListDialog', () => ({ + CreatePeopleListDialog: ({ open }: { open: boolean }) => + open ?
: null, })); vi.mock('@/hooks/useNotifications', () => ({ @@ -75,6 +110,15 @@ describe('AppSidebar', () => { mockNavigate.mockReset(); mockSetTheme.mockReset(); mockCategories.length = 0; + mockCurrentUser.user = null; + mockUnifiedListsReturn.people = []; + mockUnifiedListsReturn.video = []; + mockUnifiedListsReturn.isLoading = false; + mockUnifiedListsReturn.isError = false; + mockResolvedSavedListsReturn.people = []; + mockResolvedSavedListsReturn.video = []; + mockResolvedSavedListsReturn.isLoading = false; + mockResolvedSavedListsReturn.isError = false; }); afterEach(() => { @@ -197,4 +241,99 @@ describe('AppSidebar', () => { expect(screen.getByRole('button', { name: 'English' })).toBeVisible(); }); + + describe('Lists section', () => { + it('does not render the Lists section when user is not logged in', () => { + mockCurrentUser.user = null; + + render( + + + , + ); + + expect(screen.queryByText('Your lists')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /create new list/i })).not.toBeInTheDocument(); + }); + + it('renders the Lists section when user is logged in', () => { + mockCurrentUser.user = { pubkey: 'aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011' }; + + render( + + + , + ); + + expect(screen.getByText('Your lists')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /create new list/i })).toBeInTheDocument(); + }); + + it('renders authored lists from useUnifiedLists', () => { + mockCurrentUser.user = { pubkey: 'aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011' }; + mockUnifiedListsReturn.video = [ + { + id: 'vlist-1', + name: 'My Videos', + pubkey: 'aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011', + createdAt: 1700000000, + videoCoordinates: [], + public: true, + }, + ]; + mockUnifiedListsReturn.people = [ + { + id: 'plist-1', + name: 'Cool People', + pubkey: 'aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011', + createdAt: 1700000001, + members: [], + }, + ]; + + render( + + + , + ); + + expect(screen.getByText('My Videos')).toBeInTheDocument(); + expect(screen.getByText('Cool People')).toBeInTheDocument(); + }); + + it('renders the Saved subgroup only when useResolvedSavedLists returns non-empty lists', () => { + mockCurrentUser.user = { pubkey: 'aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011' }; + + // No saved lists initially + render( + + + , + ); + + expect(screen.queryByText('Saved')).not.toBeInTheDocument(); + }); + + it('shows saved lists section when there are saved lists', () => { + mockCurrentUser.user = { pubkey: 'aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011aabbccddeeff0011' }; + mockResolvedSavedListsReturn.people = [ + { + id: 'saved-plist-1', + name: 'Saved People List', + pubkey: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + createdAt: 1700000002, + members: [], + }, + ]; + + render( + + + , + ); + + expect(screen.getByText('Saved')).toBeInTheDocument(); + expect(screen.getByText('Saved People List')).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 70d5a98a..e96e56af 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -2,11 +2,16 @@ // ABOUTME: Shows main nav, login/signup, expandable Divine links section import { Link, useLocation } from 'react-router-dom'; -import { House as Home, Compass, MagnifyingGlass as Search, Bell, User, Sun, Moon, CaretDown as ChevronDown, Headphones, ChartBar as BarChart3, SquaresFour as LayoutGrid, Rss, ChatCircle as MessageCircle } from '@phosphor-icons/react'; +import { House as Home, Compass, MagnifyingGlass as Search, Bell, User, Sun, Moon, CaretDown as ChevronDown, Headphones, ChartBar as BarChart3, SquaresFour as LayoutGrid, Rss, ChatCircle as MessageCircle, Play, UsersThree } from '@phosphor-icons/react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useCategories } from '@/hooks/useCategories'; import { nip19 } from 'nostr-tools'; +import { useUnifiedLists } from '@/hooks/useUnifiedLists'; +import { useResolvedSavedLists } from '@/hooks/useResolvedSavedLists'; +import { SectionHeader } from '@/components/brand/SectionHeader'; +import { Button } from '@/components/ui/button'; +import { CreatePeopleListDialog } from '@/components/CreatePeopleListDialog'; import { Collapsible, @@ -75,7 +80,10 @@ export function AppSidebar({ className }: { className?: string }) { const [divineOpen, setDivineOpen] = useState(false); const [termsOpen, setTermsOpen] = useState(false); const [appStoreUrl, setAppStoreUrl] = useState(null); + const [createListDialogOpen, setCreateListDialogOpen] = useState(false); const { data: categories } = useCategories(); + const unifiedLists = useUnifiedLists(user?.pubkey); + const resolvedSavedLists = useResolvedSavedLists(); const classicVinesRecovered = platformStats?.vine_videos?.toLocaleString(); const isActive = (path: string) => location.pathname === path; @@ -251,6 +259,87 @@ export function AppSidebar({ className }: { className?: string }) { )} + {/* Lists - only shown when logged in */} + {user && ( +
+
+ Your lists +
+ + {/* Authored lists */} +
+ {unifiedLists.video.slice(0, 8).map((list) => ( + + ))} + {unifiedLists.people.slice(0, Math.max(0, 8 - unifiedLists.video.length)).map((list) => ( + + ))} + {(unifiedLists.video.length + unifiedLists.people.length > 8) && ( + + )} +
+ + {/* Saved lists subgroup — hidden when empty */} + {(resolvedSavedLists.people.length > 0 || resolvedSavedLists.video.length > 0) && ( +
+
Saved
+
+ {resolvedSavedLists.video.map((list) => ( + + ))} + {resolvedSavedLists.people.map((list) => ( + + ))} +
+
+ )} + + {/* Create new list CTA */} +
+ +
+
+ )} + {/* Categories */} {categories && categories.length > 0 && (
@@ -540,6 +629,11 @@ export function AppSidebar({ className }: { className?: string }) {
+ + ); } diff --git a/src/components/CreatePeopleListDialog.test.tsx b/src/components/CreatePeopleListDialog.test.tsx new file mode 100644 index 00000000..8eeebba1 --- /dev/null +++ b/src/components/CreatePeopleListDialog.test.tsx @@ -0,0 +1,126 @@ +// ABOUTME: Tests for CreatePeopleListDialog component +// ABOUTME: Covers validation, successful submit, close-on-success, and prefilled-members display + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CreatePeopleListDialog } from './CreatePeopleListDialog'; + +const { mockMutateAsync, mockUseToast } = vi.hoisted(() => ({ + mockMutateAsync: vi.fn(), + mockUseToast: vi.fn(), +})); + +vi.mock('@/hooks/useCreatePeopleList', () => ({ + useCreatePeopleList: () => ({ + mutateAsync: mockMutateAsync, + isPending: false, + }), +})); + +vi.mock('@/hooks/useToast', () => ({ + useToast: () => mockUseToast(), +})); + +// Minimal dialog stubs so the Dialog renders its children in tests +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ + open, + children, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: React.ReactNode; + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +const mockToast = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + mockUseToast.mockReturnValue({ toast: mockToast }); + mockMutateAsync.mockResolvedValue({ id: 'test-id', name: 'My List' }); +}); + +describe('CreatePeopleListDialog', () => { + it('shows a validation error when submitted with an empty name', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: /create list/i })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Name is required.', + ); + expect(mockMutateAsync).not.toHaveBeenCalled(); + }); + + it('calls mutateAsync with the correct payload on successful submit', async () => { + const user = userEvent.setup(); + const prefilledMembers = ['pubkey1', 'pubkey2']; + + render( + , + ); + + await user.type(screen.getByLabelText(/list name/i), 'Cool Loopers'); + await user.type(screen.getByLabelText(/description/i), 'My picks'); + await user.click(screen.getByRole('button', { name: /create list/i })); + + await waitFor(() => + expect(mockMutateAsync).toHaveBeenCalledWith({ + name: 'Cool Loopers', + description: 'My picks', + image: undefined, + members: prefilledMembers, + }), + ); + }); + + it('calls onOpenChange(false) after a successful submit', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + + render( + , + ); + + await user.type(screen.getByLabelText(/list name/i), 'Test List'); + await user.click(screen.getByRole('button', { name: /create list/i })); + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it('renders the prefilled-members count when prefilledMembers is provided', () => { + render( + , + ); + + expect(screen.getByText(/will include 1 person/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/CreatePeopleListDialog.tsx b/src/components/CreatePeopleListDialog.tsx new file mode 100644 index 00000000..27fbc329 --- /dev/null +++ b/src/components/CreatePeopleListDialog.tsx @@ -0,0 +1,216 @@ +// ABOUTME: Dialog component for creating new people lists (NIP-51 kind 30000) +// ABOUTME: Three fields: name (required), description (optional), image URL (optional) + +import { useState } from 'react'; +import { useCreatePeopleList } from '@/hooks/useCreatePeopleList'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { CircleNotch, Users } from '@phosphor-icons/react'; +import { useToast } from '@/hooks/useToast'; + +interface CreatePeopleListDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + prefilledMembers?: string[]; +} + +export function CreatePeopleListDialog({ + open, + onOpenChange, + prefilledMembers, +}: CreatePeopleListDialogProps) { + const { toast } = useToast(); + const createPeopleList = useCreatePeopleList(); + + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [image, setImage] = useState(''); + const [nameError, setNameError] = useState(''); + const [imageError, setImageError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const validateImage = (url: string): boolean => { + if (!url) return true; + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Reset errors + setNameError(''); + setImageError(''); + + // Validate name + if (!name.trim()) { + setNameError('Name is required.'); + return; + } + + // Validate image URL if provided + if (image && !validateImage(image)) { + setImageError('Enter a valid URL (e.g. https://example.com/image.jpg).'); + return; + } + + setIsSubmitting(true); + try { + await createPeopleList.mutateAsync({ + name: name.trim(), + description: description.trim() || undefined, + image: image.trim() || undefined, + members: prefilledMembers ?? [], + }); + + toast({ + title: 'List created.', + description: 'Now add some loopers.', + }); + + onOpenChange(false); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Something went wrong. Try again?'; + toast({ + title: 'Didn\'t make it.', + description: message, + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + const handleOpenChange = (next: boolean) => { + if (!isSubmitting) { + onOpenChange(next); + } + }; + + return ( + + + + Create a people list + + Curate a list of loopers to follow, share, or revisit. + + + +
+
+ + { + setName(e.target.value); + if (nameError) setNameError(''); + }} + disabled={isSubmitting} + aria-describedby={nameError ? 'people-list-name-error' : undefined} + /> + {nameError && ( + + )} +
+ +
+ +