Kind 3 (NIP-02 follow list), kind 30000 (NIP-51 follow set), and kind 39089
(follow pack) are all the same semantic thing — an event containing a list of
p-tagged pubkeys — but were being rendered three different ways, with kind 3
having no rendering at all, kind 30000 routing to a bespoke ListDetailPage, and
kind 39089 using its own FollowPackDetailContent.
Merge the three into a single PeopleListContent (feed card) and
PeopleListDetailContent (full detail). The detail component hosts every
feature from the predecessors: Follow All with existing p-tag preservation,
Save-as-copy for non-owners, owner-mode member removal for kind 30000, the
Feed/Members/Comments tabs, sidebar integration, and the share/copy-link
menu. For kind 3 the event has no title of its own, so we fall back to the
author's display name.
Additional refinements bundled in:
- Register kinds 3 and 30000 at every previously-missing rendering point:
KIND_HEADER_MAP, shellTitleForKind, CommentContext KIND_LABELS/ICONS,
extraKinds specific labels and icons, and ExternalContentHeader fallbacks.
Kind 3 and 30000 now share the packs feed toggle via extraFeedKinds.
- Add infinite scroll to the people-list Feed tab via useTabFeed +
IntersectionObserver sentinel, replacing the useStreamPosts 40-post cap.
- Add Comments tab alongside Feed and Members, powered by useComments
(NIP-22 kind 1111). Drop the redundant variant badge. Allow kind 39089
packs to be pinned to the sidebar.
- Trim redundant chrome: drop the member-count pill in the feed card,
drop the member-count line in the detail header, and stop pulling the
author's 'about' and 'banner' into kind 3 follow list views.
- Add a dedicated FollowListCommentContext branch so comments on kind 3
show '@Name's follow list' instead of 'a follow list'.
- Replace the three-dots DropdownMenu on the detail view with the shared
PostActionBar (reply/repost/react/zap/share/more), matching other
detail views.
- Promote EmbeddedPost from ReplyComposeModal into a shared component
that dispatches to EmbeddedNote / EmbeddedNaddr, with a new
EmbeddedPeopleListCard for kinds 3/30000/39089 so quote posts, reply
indicators, hover cards, and the More menu all render follow lists
correctly.
- Link the 'N following' count on profile pages to a naddr of the kind 3
event (routing to the new detail view) instead of a bespoke modal.
Delete FollowingListModal. Using naddr rather than nevent ensures the
link always resolves to the latest replaceable event.
Delete ListDetailPage, FollowPackDetailContent, and FollowingListModal
entirely. All three kinds now route through AddrPostDetailPage →
PeopleListDetailContent.
When the Ditto relay lacks engagement data for music event kinds
(36787, 34139), sort:hot and sort:top queries return nothing. Add a
chronological fallback in useMusicFeed and the Discover tab's inline
New Tracks query: if the sorted query returns zero events, retry
against the default relay pool without the search param so users
always see content.
Add sort and scope filter controls to all three music pages:
Discover tab:
- New Tracks section gets sort (Hot/Top/New) and scope (Global/Following)
controls. Global scope queries curated artists; Following scope queries
the user's follow list. Hot/Top use Ditto relay NIP-50 search extensions.
Tracks tab:
- Replace useFeed('global') with useMusicFeed hook that supports sort and
scope. Infinite scroll pagination preserved. Genre chips still work as
client-side filter on top of the sorted results.
Playlists tab:
- Replace one-shot useMusicPlaylists with useMusicFeed for infinite scroll
with sort and scope. Album/playlist type toggle preserved.
New shared components:
- MusicSortFilterBar: Pill-style sort (Hot/Top/New with icons) and scope
(Global/Following) controls. Following only shown when logged in.
- useMusicFeed: Infinite scroll hook that maps sort modes to Ditto relay
NIP-50 search extensions and restricts authors for Following scope.
Not enough curated artists yet to justify one-per-artist filtering,
which leaves the section too sparse. Restore chronological ordering
so the feed fills up. The genre chips below the header change is kept.
Genre filter chips now appear below the 'New Tracks' section title
instead of above it, making it clear they filter that section.
New Tracks section now shows at most one track per artist (most
recent from each), preventing a prolific artist from dominating
the entire section. This applies to both the 'All' default state
and genre-filtered states.
Playlist detail page now clamps the description to 3 lines with a
'Show more' button that expands to show the full text. Uses CSS
line-clamp with a scrollHeight check to only show the toggle when
the text actually overflows.
useAddrEvent only treated kinds in 10000-19999 as replaceable, so any
naddr with a kind outside that range got a '#d' filter applied. For
legacy replaceable kinds like 0 and 3, real events don't carry a 'd'
tag, so the query matched nothing even when the relay had the event.
Invert the check to only apply the '#d' filter for true addressable
events (30000-39999). Legacy replaceable kinds and 10000-19999 are
now queried by kind+author alone.
Regression-of: 9b5df28b
When a playlist has no artwork or its image fails to load, resolve
the first track from the playlist's a-tag refs and use its artwork
as a fallback cover image.
- Add usePlaylistCoverArt hook: lightweight single-track query that
only fires when the playlist's own artwork is missing. Returns the
playlist art if present, or the first track's artwork otherwise.
- MusicPlaylistCard: uses the hook for cover art with per-URL error
tracking so a broken playlist image triggers the fallback without
breaking a working track image.
- PlaylistDetail: derives cover art from already-resolved trackEvents
(no extra query needed since tracks are already loaded).
When an image URL from a Nostr event returns an error (404, stale
URL, server down), the img element now falls back to the existing
gradient/icon placeholder instead of showing a broken image.
Applied to MusicPlaylistCard, MusicTrackCard, MusicHeroCard,
MusicTrackRow, and MusicDetailContent (track hero, playlist hero,
and PlaylistTrackRow). Each uses a local imgError state that flips
on the img onError event to swap in the fallback.
The previous 'updated their Blobbi' wording felt mechanical for what is
really a care interaction (feeding, cleaning, playing, etc.). 'Cared for'
better reflects the user's intent.
Images inside overflow-x-auto scroll containers don't reliably
trigger the browser's lazy load IntersectionObserver, causing
playlist and track card artwork to not load in horizontal scroll
sections. Remove loading=lazy from MusicPlaylistCard and
MusicTrackCard since they render a small fixed number of cards
that should load immediately. MusicTrackRow (vertical list)
keeps lazy loading since it works correctly there.
Move Artists and Playlists above Genre chips + New Tracks on the
Discover page. Playlists now query the Ditto relay with sort:hot
so the most engaging playlists surface first.
New section order: Hero > Featured > Artists > Playlists > Genre
chips > New Tracks > CTA.
When sort:hot distinct:author returns fewer than 5 tracks (common
with limited engagement data), issue a second query for recent tracks
with distinct:author and merge them in, skipping authors already
present. Ensures the Featured section always has enough variety.
Rework the Music Discover tab so every section is gated through the
curator's lists, ensuring only high-quality content appears.
- Add useFeaturedMusicTracks hook: queries Ditto relay with sort:hot
and distinct:author for curated artists. The #1 hot track becomes
the hero; the rest populate the Featured horizontal scroll with no
artist repeats.
- Add useMusicCuratorFollows hook: fetches Heather's kind 3 follow
list to filter playlists to people she follows.
- Add authors param to useMusicPlaylists and useMusicTracksByGenre
so both can be restricted to specific pubkeys.
- Rewire MusicDiscoverTab: New Tracks and genre filtering now use
curated artists only; playlists use curator follows; section renamed
from 'Recently Added' to 'New Tracks'.
Update useCuratedMusicArtists to fetch the externally maintained
kind 30000 follow set from npub1nl8r463... instead of looking for
a d:music-artists list from the app curator pubkey. The list is
maintained on Listr and contains 56 curated music artist pubkeys.
Fallback pubkeys updated to a subset of the same list.
- Add usePlaylistTracks hook to resolve a-tag refs into ordered track events
- Render track list in PlaylistDetail with Play All button and per-track
playlist playback via playPlaylist()
- Support albums as playlists tagged with t:album, showing release date,
label, and Disc3 icon
- Add All/Playlists/Albums filter toggle to MusicPlaylistsTab
- Show Album badge on playlist cards in grid views
- Add KIND_HEADER_MAP entries for music tracks (36787) and playlists (34139)
- Fix shellTitleForKind to return 'Playlist Details' for kind 34139
- Document music kinds and album convention in NIP.md
The full emoji picker in QuickReactMenu had no background because
EmojiPicker sets its shadow DOM background to transparent, and the
wrapper div only had rounded-xl/shadow-xl without a background class.
Added bg-popover and border-border so the picker matches the quick-react
pill bar styling.
Closes#235
Replace the generic KindFeedPage-based music feed with a dedicated
music discovery page featuring:
- **Discover tab** (default): Hero card with featured track, horizontal
scroll of featured tracks from curated artists, genre chip filters,
recently added track rows, playlists section, artist showcase, and
"Share Your Music on Nostr" CTA card
- **Tracks tab**: Infinite-scroll list of all music tracks with genre
filtering, using useFeed for standard pagination
- **Playlists tab**: 2-column grid of kind 34139 playlist cards
- **Artists tab**: 3-column grid of artist profile cards, curated
artists shown first
Architecture decisions:
- Shared discovery components in src/components/discovery/ (SectionHeader,
TagChips, HorizontalScroll, ProfileCard, ContentCTACard) designed for
reuse by podcasts and other future content-type discovery pages
- Music-specific components in src/components/music/ (MusicHeroCard,
MusicTrackRow, MusicTrackCard, MusicPlaylistCard)
- Single base query (useMusicData) fetches kind 36787 tracks and derives
genres + artists client-side to avoid redundant relay requests
- Curated artist list via kind 30000 follow set (d:music-artists) from
curator pubkey, with hardcoded fallback of 9 verified Wavlake artists
- Global by default (no follows/global tab split for discovery)
- Full now-playing state indicators across all components via useAudioPlayer
- Complete loading skeletons and empty states for every section
New files:
- src/components/discovery/{SectionHeader,TagChips,HorizontalScroll,
ProfileCard,ContentCTACard}.tsx
- src/components/music/{MusicHeroCard,MusicTrackRow,MusicTrackCard,
MusicPlaylistCard,MusicDiscoverTab,MusicTracksTab,MusicPlaylistsTab,
MusicArtistsTab}.tsx
- src/hooks/{useCuratedMusicArtists,useMusicData,useMusicPlaylists}.ts
- src/pages/MusicPage.tsx
Modified: src/AppRouter.tsx (route update)
Orphaned: src/pages/MusicFeedPage.tsx (no longer imported)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
These activity-style detail pages previously rendered only a slim action
row, missing the stats summary (Reposts / Quotes / Likes / Zaps), the
client + full-date row, and the InteractionsModal affordance. Users had
no way to see or browse the interactions the event itself had received.
Extract the stats + date row into a shared JSX block and replace the
four inline action-button grids with PostActionBar, bringing these
detail views in line with the standard post layout while keeping their
compact emoji/icon headers.
Add migration logic so users with stale persisted evolution missions
(e.g. containing the removed create_post mission) get their mission
list rebuilt to match current definitions while preserving progress.
Declares kind 8 as a third sub-kind under the existing Badges
ExtraKindDef with its own 'showBadgeAwards' / 'feedIncludeBadgeAwards'
toggles. The home feed and profile feed both derive their kinds list
from getEnabledFeedKinds, so both pick up badge awards automatically.
The Badges page's follows feed is a hardcoded list, so kind 8 is added
there explicitly.
Defaults match existing badge settings: enabled in hardcodedConfig (new
users see them), conservative in InitialSyncGate and TestApp. The
ContentSettings UI auto-generates a new Badge Awards toggle row.
Removes the now-redundant KIND_SPECIFIC_LABELS/ICONS entries for kind 8
since the sub-kind carries that metadata.
Badge awards previously only appeared as notifications when you were the
recipient. Now they render as full feed cards — showcase image, badge
metadata, recipient row, and an Accept button for logged-in recipients —
so issuers can share awards and feeds can surface community recognition.
Extracts parseBadgeATag, unslugify, and AcceptBadgeButton out of
NotificationsPage.tsx into shared modules, adds a compact embedded card
for kind 8 nevent references, and wires the kind through NoteCard,
PostDetailPage, CommentContext, and extraKinds registries.
User-facing display strings now read from config.appName so forks can
rebrand without code changes, and localStorage keys are namespaced by
config.appId so forks running on the same origin don't clobber each
other's preferences. Module-level cache-key constants that previously
hardcoded 'ditto:' have been refactored into hook-scoped reads from
config.appId (via a new getStorageKey() helper). The helpContent FAQ
template now uses {appName} placeholders substituted at read-time
through getFAQCategories(appName)/getFAQItem(appName, id).
The HTTPS check was a leftover from when the client name was derived
from the hostname. Now that it comes from appConfig, the tag should
be added unconditionally.
The signup and onboarding profile steps rendered ProfileCard without
passing onAvatarShape, so emoji shape selections were silent no-ops and
never made it into the published kind 0 event.
The interactions tally mission was silently dropped because
trackEvolutionTally maps over the evolution[] array — if it's empty,
nothing gets incremented. This happened when evolution missions
weren't persisted to kind 11125 or weren't hydrated on page load.
Both useHatchTasks and useEvolveTasks now have a safety-net effect:
if the companion is in an active task process (incubating/evolving)
but evolution[] is empty, they re-populate from the static mission
definitions. This ensures tally tracking works immediately regardless
of hydration timing.
Lets users with a local-nsec login reveal, copy, and back up their secret
key from /settings/profile. Uses saveNsec() so iOS gets iCloud Keychain,
Android gets Credential Manager with a file fallback, and web gets a
.nsec.txt download plus an opportunistic PasswordCredential save.
Renders an explanatory message for NIP-07 extension and NIP-46 bunker
logins, where the key is not accessible from the app.
- Remove dead code: useSyncTaskCompletions, incrementInteractionTaskTags,
getInteractionCount, getEvolveInteractionCount, unused lookup maps
- Fix task progress showing 0/N on load: compute event-based task counts
directly from Nostr query results (authoritative) instead of relying
solely on the evolution mission store which may not be hydrated yet.
Use max(queryCount, missionCount) so progress displays immediately.
- Fix hydration race: useDailyMissions raw memo now waits for hydration
before creating fresh missions, preventing overwrite of persisted
evolution[] with empty array. Also preserve evolution missions across
daily resets during hydration.
- Fix session store miss: use ensureSessionStore in incubation/evolution
start so evolution missions are always populated even if the store
hasn't been hydrated yet.
- Extract duplicate findMission to shared findEvolutionMission in
evolution-missions.ts
- Document evolution[] field on kind 11125 in NIP.md
Addresses confusion on the key-save step during signup:
- Rename the primary button from 'Continue' to 'Save Key' with a
Download icon, so the label matches the action it performs.
- Change saveNsec() to return 'saved' | 'saved-to-file' | 'dismissed'
instead of throwing on native dismissal. Dismissing the iCloud
Keychain prompt is a legitimate user choice so the handler now
proceeds silently rather than blocking with a 'Save failed' toast.
- Add an in-flight guard on the Save Key button with a spinner and
'Saving…' label. The finally block guarantees the disabled state is
cleared, so users can never get stuck on an unresponsive button —
fixing the 'button became disabled after I dismissed the prompt'
complaint by construction.
- On de-Googled Android builds (GrapheneOS, /e/OS, etc.) the AndroidX
Credential Manager has no provider to delegate to, so the keychain
save fails immediately. Fall back to writing the key to the app's
Documents directory so the user always has a persistent backup, and
surface a toast telling them where the file is.
- iOS keeps its original behaviour: dismissing the iCloud Keychain
sheet is a deliberate user choice, no automatic fallback. The
Documents folder on iOS is accessible via the Files app without
authentication, so silently dropping a plaintext nsec there would
violate user intent.
- Use the app name (from config.appName) as the filename slug for any
.nsec.txt file written to disk. On Capacitor location.hostname is
always 'localhost', so passing the app name is the only way to get
a meaningful filename. Drop the redundant 'nostr-' prefix since the
'.nsec.txt' extension already identifies the file.
- Rewrite the description and title on the save step: 'Your secret
key' + a single paragraph explaining what the key is and why it
matters.
- When the user reveals the key via the eye toggle, show an amber
callout with sharing/screenshotting warnings and a 'Learn more' link
to the Managing Nostr keys blog post. The warning appears at the
moment risk is highest.
- Auto-select the full nsec on focus/click so users copying into a
password manager don't have to fight mobile selection handles.
- Use openUrl() for the external 'Learn more' link so it works
correctly inside Capacitor's WKWebView.
- Singularise the keygen step copy ('cryptographic key' / 'Generate
my key') to stay consistent with the save step which presents a
single secret key.
- Restore full interactive chat widget with ScrollArea, streaming messages,
input area, and conversation cache that was regressed in ec9b6c43
- Extract useShakespeareCredits hook so credits gating is DRY between the
widget and the full AI chat page
- Show Dork ASCII mascot consistently across all empty/logged-out states
instead of the generic Bot icon
- Add RateLimitError class with Retry-After header parsing
- Distinguish insufficient_quota 429 from rate-limit 429
- Friendly Dork-themed error banners for rate limiting and out-of-credits
- Clean no-credits empty state with directive CTA and Get Credits button
- Hide model selector, trash, and input when user has no credits
- Hide page title on mobile, align model selector right
- Simplify sidebar widget to Shakespeare CTA
- Sanitize event-sourced URLs before CSS url() interpolation in
ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
and Capacitor secure-storage SharedPreferences so wallet credentials
don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
to match what the CI job expects (closes M-2)