Skip to content

feat: NIP-51 people lists (kind 30000) with unified discovery#333

Open
rabble wants to merge 51 commits into
mainfrom
feat/people-lists
Open

feat: NIP-51 people lists (kind 30000) with unified discovery#333
rabble wants to merge 51 commits into
mainfrom
feat/people-lists

Conversation

@rabble
Copy link
Copy Markdown
Member

@rabble rabble commented May 5, 2026

Summary

Adds support for NIP-51 follow sets (kind 30000) — public people lists — alongside the existing video lists (kind 30005). Users can create, edit, delete people lists; add/remove people from any user-bearing surface; subscribe to others' lists via NIP-51 bookmark sets (kind 30003); and discover both kinds together via a unified Lists experience.

Mobile designs from Figma (UI-Design, file `rp1DsDEUuCaicW0lk6I2aZ`) — adapted via the existing < lg mobile / ≥ lg desktop responsive split.

Spec: docs/superpowers/specs/2026-05-05-people-lists-design.md
Plan: docs/superpowers/plans/2026-05-05-people-lists.md

Surfaces

  • Discovery: Lists tab on /discovery, Lists tab on /search, Lists tab on every Profile page (Videos | Lists), Lists section in AppSidebar (authored + saved).
  • Detail: /list/:pubkey/:listId auto-routes to people-list vs video-list rendering. Sub-routes: /members, /videos, owner-guarded /edit.
  • CRUD: CreatePeopleListDialog, EditPeopleListDialog, DeletePeopleListDialog, AddToPeopleListDialog.
  • Quick-add overflow items: ProfileHeader, VideoCard, UserListDialog rows.

Backend / data layer

  • 12 new hooks under src/hooks/usePeople* and src/hooks/useSaved*.
  • All mutations are optimistic with rollback on publish failure.
  • Stats aggregation via existing fetchBulkUsers REST endpoint, capped at 200 members; loops aggregate is deferred (bulk endpoint shape lacks total_loops — documented in spec).

Drive-by fixes called out in the spec review

  • useDeleteVideoList was missing ['k', '30005'] on its NIP-09 kind 5 deletion event — fixed for NIP-09 conformance (src/hooks/useVideoLists.ts:530).
  • LIST_KINDS in NostrProvider.tsx extended from [30000, 30001, 30005] to [30000, 30001, 30003, 30005] so the new saved-lists kind also goes to multi-relay.
  • ListDetailPage query widened from kinds: [30005] to kinds: [30000, 30005] and split into a dispatcher.
  • ProfilePage now has a Tabs primitive (none before this PR).
  • parseVideoList exported from useVideoLists.ts so useResolvedSavedLists can reuse it.

Brand & a11y

  • All new surfaces use brand primitives: <SectionHeader>, <Card variant="brand">, <Button variant="sticker">.
  • Icons from @phosphor-icons/react only.
  • Brand guardrail tests pass (no uppercase class, no lucide-react, no gradients).
  • 2-col mobile / 4-col desktop card grids; list-detail constrained to ~720px on desktop.

Explicit deferrals (in spec, out of v1)

  • Private/encrypted list members (NIP-04 encrypted content)
  • Collaborative people lists
  • Mute lists (kind 10000), pinned-notes (kind 10001)
  • Notifications when added to a list (silent for v1)
  • Bulk-import kind:3 contacts → people list
  • Loops aggregate in stats header (rendered as always)
  • Symmetric save-button on existing video-list detail page (people-list save fully wired)
  • Playwright visual baselines for new surfaces (best added in a follow-up with a real dev server)

Test plan

  • Verify CI green
  • As a logged-in user, create a people list from /lists
  • Open someone else's profile → overflow → "Add to list…" → add them to a list (and remove via toggle)
  • Visit /lists/<your-pubkey>/<list-id> and confirm Header (stats / description / avatar strip) + recent-videos grid render
  • Tap "View all" → /members route renders the full roster
  • As another user, save the list (Follow button on detail page); verify it shows up in your sidebar Saved subgroup
  • Discovery: visit /discovery Lists tab; verify both kinds appear with correct count badges (▶ vs 👥)
  • Search: type a query, switch to Lists tab; verify results
  • Delete a list as owner; verify it disappears from your authored list

Branch / commits

44 commits, mostly small TDD steps. The plan was executed via subagent-driven-development; each task includes failing-test → minimal-impl → tests-pass → commit. 874 tests passing across 166 files post-rebase.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 5, 2026

Deploying divine-web with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7afb2d2
Status: ✅  Deploy successful!
Preview URL: https://5b166c23.divine-web.pages.dev
Branch Preview URL: https://feat-people-lists.divine-web.pages.dev

View logs

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 5, 2026

🚀 Preview Deployment

Property Value
Preview URL https://2ddc8daf.divine-web-fm8.pages.dev
Commit 7afb2d2
Branch feat/people-lists

@rabble rabble force-pushed the feat/people-lists branch from a2f1801 to bdde1e5 Compare May 5, 2026 12:01
rabble and others added 27 commits May 6, 2026 00:13
Approved design spec for adding NIP-51 follow sets (people lists)
alongside the existing video lists (kind 30005). Public-only in v1;
unified discovery surfaces; preserves the existing video-list infra.

Two prerequisite refactors are called out in the spec:
- ListDetailPage hardcoded kinds query → support kind 30000
- ProfilePage gets a Tabs primitive (Videos | Lists)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step-by-step implementation plan against the approved spec.
Eight chunks: foundation/refactors, read hooks, mutations,
saved-lists, dialogs, detail surfaces, discovery surfaces,
quick-add + verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop loops aggregate from v1 (bulk-users endpoint lacks total_loops);
  render '—' always; document deferral.
- Pin actual fetchBulkUsers signature and isFunnelcakeAvailable(apiUrl) arg.
- Correct SearchPage existing tabs (all|videos|users|hashtags); add lists
  as 5th tab with concrete grid-cols-5 + insertion line.
- Split Task 6.5 (ListDetailPage refactor) into 5 sub-tasks 6.5a-e.
- Add Task 4.1.5 useResolvedSavedLists for spec's stale-reference filter
  (sidebar Saved subgroup + /lists Saved tab).
- Pin Task 3.4 cache invalidation to include single-list key.
- Pin VideoCard menu location with grep first; concrete dropdown snippet.
- Add brand-primitives reminder block (Card variant, SectionHeader,
  Button sticker) and responsive reminder (lg breakpoint, mobile dark).
- Add canonical TDD step template (1-6) at top so condensed tasks expand
  consistently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add buildListMembersPath, buildListVideosPath, buildListEditPath, and
decodeListIdParam helpers to eventRouting.ts with 5 new TDD tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
React Query hook that fetches all kind-30000 events for a pubkey, dedupes by d-tag keeping latest created_at, and returns sorted PeopleList array.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
React Query hook keyed by ['people-list', pubkey, dTag] that fetches a
single kind-30000 event by d-tag, picks the latest if multiple are
returned, and parses via parsePeopleList (returns null when not found).
Disabled when either pubkey or dTag is empty. Four tests covering the
single-event, tie-breaking, empty-relay, and disabled cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Composition hook that wires usePeopleList → useBatchedAuthors so
callers get a flat [{pubkey, metadata}] array plus combined loading/error
flags in a single import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces usePeopleListMemberVideos(pubkey, dTag) — an infinite-scroll
hook that aggregates videos from all members of a NIP-51 people list.
Primary path queries the relay (kinds:[34236], authors: members) with
cursor-based `until` pagination; hard-caps total events at 500.  The
REST path (POST /api/videos/bulk with from_event kind 30000) is stubbed
as a TODO comment pending Funnelcake support.  4 vitest tests cover the
empty-members gate, single-page sort order, two-page pagination, and the
loading-state gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements useCreatePeopleList mutation: generates a UUID d-tag, publishes
kind 30000 with title/description/image/p tags (members deduped), and
optimistically prepends the new PeopleList to the query cache before
invalidating. Covered by 4 vitest tests (no-user throw, tag assertions,
optional-tag omission, cache prepend).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements mutation to update name, description, and image on an
existing kind 30000 people list. Reads current event from relay,
rebuilds tags preserving all p-tags, and republishes. Empty string
for description/image clears the tag; undefined preserves existing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…timistic updates

Both mutations are idempotent, read the current relay event before publishing,
and apply optimistic cache updates with snapshot-based rollback on error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements delete for kind 30000 people lists via NIP-09 deletion events.
Mirrors useDeleteVideoList pattern: publishes kind 5 with both 'a' and 'k' tags
for NIP-09 conformance, optimistically removes from cache, then invalidates
both ['people-lists', pubkey] and ['people-list', pubkey, listId] caches.

Tests: throws when no user, publishes kind-5 with both tags, cache removal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reads the user's kind 30003 'saved-lists' addressable event and parses
a tags into typed SavedListRef objects (kind 30000 | 30005). Drops
malformed refs (wrong kind, non-hex pubkey, missing colons). Disabled
when no current user; staleTime 60s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exports parseVideoList from useVideoLists.ts (was private) so the new
resolver can call it. useResolvedSavedLists composes useSavedLists() +
useQueries to fetch each saved ref; refs where the relay returns 0 events
or an unexpected kind (e.g. a deletion tombstone) are silently dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements optimistic-update mutations for saving/unsaving addressable
list refs (kind 30000/30005) in the user's kind 30003 saved-lists event.
Both hooks are idempotent, validate input, and rollback the
['saved-lists', pubkey] cache on publish failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Composition hook over usePeopleLists and useVideoLists. Returns both
lists with unified loading/error states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dialog for creating NIP-51 kind 30000 people lists with name, description, and image URL fields. Validates required name and URL format, shows prefilled-members count when provided, and calls useCreatePeopleList on submit with proper error/success toasts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors CreatePeopleListDialog for editing existing NIP-51 people lists.
Pre-populates name/description/image on open, calls useUpdatePeopleList,
shows "Saved. Looking sharp." on success, and closes the dialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Confirmation dialog for deleting people lists. Mirrors DeleteListDialog
pattern with destructive variant button, warning text, and mutation
integration via useDeletePeopleList hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Checkbox rows per list let users add/remove a person from any of their
people lists in a single click; empty state shows create CTA only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the public list-detail page header with back nav, sticker
action buttons (Follow/Following/Edit list), SectionHeader title,
stats row (members·videos·loops, — for null), description, and
avatar strip with View all link. 5 vitest tests, tsc clean, brand guardrails pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vertical member-row grid for people list detail pages (Figma #6): avatar +
Bricolage ExtraBold display_name + Inter sub-line (NIP-05 or truncated npub),
with per-row MinusCircle (editMode+owner) or DotsThree overflow action.
5 vitest tests all pass; tsc clean; brand guardrails pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rabble and others added 24 commits May 6, 2026 00:13
Wraps usePeopleListMemberVideos + parseVideoEvents into a VideoGrid with
useContentModeration isMuted filtering on author pubkeys. 4 vitest tests
cover render count, empty state, loading passthrough, and muted-author
exclusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the owner curate screen (Figma #8) with top bar navigation,
members grid in edit mode, and user search/add candidates section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move kind-30005 video grid rendering out of ListDetailPage into a new
VideoListContent component. ListDetailPage is now thin: it fetches the
event, parses with parseVideoList, and delegates rendering to
VideoListContent. No behavior change for kind-30005; kind-30000 body is
left empty pending Task 6.5b.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the relay returns a kind-30000 event, ListDetailPage now renders
PeopleListContent (header + videos-from-members grid) instead of the
kind-30005 VideoListContent path. Raw event is retained in query result
so kind dispatch happens at render time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ListMembersPage at /list/:pubkey/:listId/members, wiring
usePeopleList + PeopleListMembersGrid with a top bar (back arrow,
list name via SectionHeader, member count, owner add-button) and
loading/not-found states. Route registered in AppRouter under the
public group. Three vitest tests cover header, grid, and loading.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds ListVideosPage at /list/:pubkey/:listId/videos, wiring
usePeopleList + PeopleListVideosGrid with a top bar (back arrow,
list name via SectionHeader) and loading/not-found states. Route
registered in AppRouter under the public group alongside the members
sub-route. Two vitest tests cover header+grid and loading state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Wire up ListEditPage under the authenticated route group; non-owners
and logged-out visitors are immediately redirected to the detail page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-column mosaic media area (177:120 ratio) with avatar tiles, member
count scrim badge, SectionHeader title and optional description. Falls
back to pubkey-hashed colour swatches when no membersPreview is given.
Links to buildListPath. All 4 vitest tests pass; brand guardrails clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces VideoListCard (kind 30005 discovery card — thumbnail tile,
play-badge, title, description) and UnifiedListCard which dispatches to
PeopleListCard or VideoListCard based on the Nostr kind discriminant.
All 5 unit tests pass; brand and tsc clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces a Videos | Lists Tabs primitive to ProfilePage for the first
time. URL hash sync: #lists deep-links to the Lists tab on load; tab
changes update the hash via replace navigation. ProfileListsTab renders
a 2-col (mobile) / 4-col (desktop) grid of UnifiedListCard entries,
sorted by recency across both people (kind 30000) and video (kind 30005)
lists, with a sticker-variant create button gated to isOwn. Six new
tests cover the component and page-level tab behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new Lists tab to the Discovery page backed by useDiscoveryLists
hook (kinds 30000+30005, ranked by member/video count × 10 + recency).
Renders UnifiedListCard in a 2-col mobile / 4-col desktop grid.
Includes i18n keys for all 15 locales and full vitest coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a 5th tab (Lists) to the SearchPage that queries kind 30000
(people lists) and 30005 (video sets) via NIP-50 relay search with a
local-filter fallback. Introduces useSearchLists hook and renders
results through UnifiedListCard in a 2-col/4-col grid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a login-gated Lists section to AppSidebar showing authored lists
(video+people, capped at 8) and saved lists subgroup from
useUnifiedLists/useResolvedSavedLists, with a + Create new list CTA
that opens CreatePeopleListDialog.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace video-only ListsPage with two sub-tabs (Authored / Saved) that
display both PeopleLists and VideoLists via useUnifiedLists and
useResolvedSavedLists. Lists are flattened and sorted by createdAt desc,
rendered as a 2-col mobile / 4-col desktop UnifiedListCard grid.
Authored empty state shows the Create CTA; Saved empty state shows a
subdued message only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an 'Add to list...' DropdownMenuItem (UsersThree icon) to the
other-user profile overflow menu, wired to AddToPeopleListDialog.
Hidden on own profile. Covered by 3 new TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a context-menu item that opens AddToPeopleListDialog when a logged-in
user views a video by someone else, allowing one-tap addition to any people
list. Hidden when the video belongs to the current user.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each row in UserListDialog gets a DotsThree button that opens
AddToPeopleListDialog to add the person to any of the user's
people lists. Overflow is hidden when row pubkey matches current user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI fails caught by `tsc -p tsconfig.app.json --noEmit` (stricter than
the root tsconfig used locally):
- SectionHeader: extend `as` prop to accept "h1" (used by 4 callsites
  in detail header, edit mode, members/videos pages).
- PeopleListCard: AvatarTile/PlaceholderSwatch now accept a `style`
  prop so the mosaic can size tiles via inline styles.
- ProfileListsTab: narrow the discriminated union before passing to
  UnifiedListCard (kind 30000 vs 30005 branches).
- Test mocks: cast through `unknown` to satisfy UseQueryResult /
  UseMutationResult shape checks for the QueryClient v5 type.
- PeopleListMembersGrid.test: widen `MemberOverride` type to allow
  display_name + name overrides in test fixtures.

Build: bump workbox `maximumFileSizeToCacheInBytes` from 3MB to 4MB.
The new people-lists code pushed the main bundle from 2.95 MB to
3.15 MB, exceeding the prior limit. Code-splitting is the right
long-term fix; bumping the limit unblocks this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The project's eslint config sets `noInlineConfig: true` so disable
directives don't work. Replaced all `as any` mock casts with proper
`as unknown as ReturnType<typeof useFoo>` chains, and removed unused
imports (waitFor, fireEvent, useNavigate, PassThrough,
SearchListResult, MEMBERS_PREVIEW).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Other Nostr clients abuse kind 30000 for mute lists, block lists, and
dm-contact lists with reserved d-tags like "Block List", "mute", and
"dm-contacts". These were polluting /discovery/lists, search, and even
user profile list tabs as if they were curated follow sets.

- parsePeopleList rejects reserved d-tags (mute/block/dm-contacts/etc.)
  case-insensitively — applies everywhere people lists surface.
- useDiscoveryLists and useSearchLists additionally require a `title`
  tag, so events whose name would fall back to the d-tag don't appear
  on public discovery surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VideoListCard hardcoded /list/videos/<pubkey>/<id>, but the route is
registered as /list/:pubkey/:listId/videos (videos as a SUFFIX, not a
prefix), so clicking any video-list discovery card hit the 404 page.

Use buildListPath like PeopleListCard does — the kind-30005 detail view
already lives at /list/<pubkey>/<id> via ListDetailPage's kind dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty people lists and empty video lists were rendering as cards with
0-count badges and placeholder swatches — nothing to thumbnail and
nothing to engage with. Filter both surfaces to require at least one
member (kind 30000) or one video coordinate (kind 30005).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discovery > Lists previously rendered placeholder swatches because no
member profiles or video thumbnails were ever fetched. Now:

- New useDiscoveryListPreviews hook batch-fetches profiles for the first
  3 members of each people list (warming the useAuthor cache) and
  queries kind 34236 for the first video coordinate of each video list,
  extracting its imeta thumbnail.
- UnifiedListCard threads the previews helper into PeopleListCard's
  membersPreview prop and VideoListCard's new thumbnail prop.
- VideoListCard prefers the fetched thumbnail and falls back to
  list.image (the optional cover tag) before showing a placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The since: now - 7 days filter was a self-inflicted bug — addressable
events (kinds 30000/30005) republish only when edited, so a 7-day window
excluded virtually every real curated list. Pull a wide pool (limit
500), dedupe by pubkey:kind:dTag keeping the newest, then rank locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rabble rabble force-pushed the feat/people-lists branch from bdde1e5 to 7afb2d2 Compare May 5, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant