Commit Graph

3380 Commits

Author SHA1 Message Date
Chad Curtis 273469eda8 Merge branch 'fix/blobbi-progression-task-persistence' into 'main'
Move Blobbi progression missions from kind 11125 to kind 31124

Closes #239

See merge request soapbox-pub/ditto!193
2026-04-21 22:29:05 +00:00
Chad Curtis 97a219aa8c Merge branch 'fix/blobbi-sleep-eye-overlay-initial-render' into 'main'
Fix open-eye flash on first sleep transition

Closes #240

See merge request soapbox-pub/ditto!195
2026-04-21 22:28:31 +00:00
Chad Curtis 5dafdf85f7 Merge branch 'fix/blobbi-legacy-new-deduplication' into 'main'
Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates

Closes #247

See merge request soapbox-pub/ditto!198
2026-04-21 22:28:00 +00:00
filemon 7830269ea1 Collapse canonical Blobbi duplicates created by migration races
filterMigratedLegacyCompanions now runs a second pass that groups
canonical companions by their migrated_from tag. Within each group
only the newest event (highest created_at) is kept; the rest are
hidden from the collection UI. Canonical companions without the tag
are never grouped — they pass through untouched.

This closes the remaining duplicate-in-UI gap left intentionally by
the earlier legacy→canonical dedup work.
2026-04-21 18:37:38 -03:00
filemon 118b0c11ab Strengthen legacy->canonical equivalence with migrated_from and base_color
Replace the name-only equivalence rule with a tiered priority:

1. migrated_from exact match (canonical event's migrated_from tag equals
   the legacy d-tag) — strongest signal, written during migration and
   preserved across all subsequent Blobbi updates.

2. Same normalized name + same raw base_color tag — covers older canonical
   copies created before migrated_from existed, where both events have an
   explicit base_color tag that matches.

3. Same normalized name when the legacy event has no base_color tag —
   weakest fallback for genuinely old bare legacy events with no visual
   tags to compare.

All tiers still require the legacy d-tag to be absent from profile.has
(the migration-completion guard).

Audited that migrated_from survives all Blobbi lifecycle operations:
mergeBlobbiStateTagsForRepublish preserves it as an unknown tag,
validateAndRepairBlobbiTags passes it through (not in schema, not
deprecated), and stage transition cleanup does not touch it.
2026-04-21 17:52:24 -03:00
filemon 4ad0a9cfb4 Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates
Legacy Blobbi events (d=blobbi-{name}) persisted on relays after migration
to canonical format (d=blobbi-{hex}-{hex}), causing them to appear alongside
their canonical copies in the UI. Interacting with a still-visible legacy
Blobbi triggered another migration each time, creating unbounded duplicates.

Three changes:

1. Filter migrated legacy Blobbies from the rendered collection. A legacy
   Blobbi is hidden only when a canonical Blobbi with the same normalized
   name exists AND the legacy d-tag is no longer in profile.has (confirming
   migration already occurred).

2. Guard ensureCanonicalBlobbiBeforeAction against re-migration. Before
   creating a new canonical event, query all companions and look for an
   existing canonical equivalent by normalized name. If found, reuse it
   and fix up profile.has/current_companion instead of migrating again.

3. Store a migrated_from tag on newly migrated events for future stronger
   equivalence lookups (additive, not depended on by current dedup logic).
2026-04-21 17:32:50 -03:00
Chad Curtis c23af72da7 Fix lightbox swipe-to-dismiss flicker and locked controls race
The dismiss animation only translated the image strip, leaving the top
bar, nav buttons, dot indicators, and bottom bar stationary — visible
as a jarring flicker of controls. The backdrop also flashed back to full
opacity for one frame before the portal unmounted.

Wrap all visible content (everything except the backdrop) in a single
container that receives the translateY transform so the entire UI sweeps
away as one unit. Reorder the setTimeout callback so onClose fires
before clearing the animating lock, and add an unmount-cleanup effect as
a safety net against stuck controls.

Regression-of: cc655891
2026-04-20 20:00:50 -05:00
Chad Curtis bfee3dfdf1 release: v2.10.3 2026-04-20 19:40:48 -05:00
Chad Curtis b29f7ec4d5 Fix white status bar text on light theme (iOS)
App.tsx had a useEffect that unconditionally set SystemBarsStyle.Dark
(white icons) on mount, overriding the theme-aware logic in main.tsx
that had already set the correct style. On light themes this produced
white-on-white status bar text.

Remove the hardcoded override entirely — main.tsx handles initial
detection and MutationObservers cover all subsequent theme changes.
2026-04-20 19:12:06 -05:00
Chad Curtis a42e5f085e Fix envelope cards hard to tap on mobile
The hover wobble animation was triggering on touch devices via the
sticky :hover pseudo-class, rotating the envelope while the user
was trying to tap it. Restrict the wobble to true pointer-hover
devices with @media (hover: hover) and (pointer: fine).

Also tighten the entrance animation: remove rotation so tap targets
stay stable, reduce duration from 0.4s to 0.3s, start closer to
final size (0.85 vs 0.6), and cap stagger delay at 300ms so later
envelopes settle before the user can scroll to them.
2026-04-20 19:05:31 -05:00
Chad Curtis cc655891d5 Add swipe-to-dismiss gesture to lightbox overlays
Users can now swipe up or down to dismiss the full-screen image
lightbox, matching the native mobile pattern of flicking an image
away instead of reaching for the X button. The image follows the
finger with opacity fade, and commits the dismiss after 15% of
viewport height. When zoomed in the gesture is disabled so it
doesn't conflict with panning.

Applies to both the main Lightbox (feeds, galleries, media collage)
and the ProfileImageLightbox (avatar/banner taps).
2026-04-20 19:01:03 -05:00
Chad Curtis 708c25d938 Clear inline wall compose box after posting from modal
When a user typed in the inline ComposeBox then tapped the FAB (which
covers the Post button), the modal opened with the same draft text.
After posting from the modal, the inline ComposeBox still showed the
old text because it was a separate React instance with its own state,
leading to accidental double-posts.

Bump a key on the inline ComposeBox after a successful modal post so
React remounts it, picking up the already-cleared localStorage draft.
2026-04-20 18:54:21 -05:00
Chad Curtis a7cd13228b Add autoplay videos setting (default off)
Adds a new autoplayVideos config field and a toggle in Settings > Content >
Video Playback. When enabled, videos auto-play muted in feeds, collage
thumbnails, profile sidebar tiles, the Vines feed, and the VideoPlayer
component. The preference syncs across devices via encrypted settings.

usePlayerControls now listens for the volumechange event to keep the
volume UI in sync when the video is programmatically muted for autoplay.
2026-04-20 12:26:58 -05:00
Chad Curtis 8b311bde81 Merge branch 'ditto-music-feed' into 'main'
feat: redesign Music page with curated discovery experience

Closes #233

See merge request soapbox-pub/ditto!189
2026-04-19 22:27:56 +00:00
Chad Curtis b4e42778fa Merge remote-tracking branch 'origin/main' into ditto-music-feed
# Conflicts:
#	src/components/NoteCard.tsx
2026-04-19 17:27:12 -05:00
Chad Curtis 986adeb901 Reset package-lock.json to origin/main 2026-04-19 17:25:26 -05:00
Mary Kate Fain 1ce9beeaf5 Remove stray Cloudflare 524 error page committed as '&1' 2026-04-19 17:19:23 -05:00
filemon 6f68153306 Fix sleeping Blobbi showing open eyes in Blobbies tab grid
The MoreTabContent grid rendered BlobbiStageVisual without a sleeping
recipe, so sleeping companions got the awake base SVG with open eyes.
The sleeping visual (closed clip-rects, closed-eye lines, Zzz) is
entirely recipe-driven — without the recipe the SVG renderer always
produces the awake appearance.

Pass buildSleepingRecipe() and recipeLabel='sleeping' for companions
whose state is 'sleeping' in the tab grid call site.
2026-04-18 22:19:42 -03:00
filemon 03d174e5cc Add one-shot eye-open animation on wake-up
When Blobbi transitions from sleeping to awake, the eyes now visibly
open over 400ms with an ease-in-out curve before the normal blink/gaze
loop resumes. The wake-up animation mirrors the sleep-entry animation:

- runWakeUpAnimation() queries fresh DOM elements from containerRef
- Sets clip-rects to the closed position (using BLINK_CLOSED_AMOUNT)
- Animates from closed to open, then calls onComplete
- The normal awake animation loop only starts after completion
- This prevents two rAF loops from fighting over clip-rect attributes

The animation only fires on genuine sleeping→awake transitions via
wasSleepingRef, not on mount or refresh when Blobbi is already awake.
If cancelled mid-animation (e.g. quick sleep re-toggle), the onComplete
callback is still invoked so the hook does not get stuck.
2026-04-18 19:26:24 -03:00
Chad Curtis 243ce98dd4 Merge branch 'feat/inbox-relay-delivery' into main 2026-04-18 16:53:26 -05:00
Chad Curtis f14316f024 Send reply events to tagged users' inbox relays (NIP-65)
When publishing kind 1 or kind 1111 reply events, also deliver them to
the read (inbox) relays of p-tagged users. This follows the NIP-65
recommendation that clients send events to the read relays of each
tagged user so recipients are more likely to see replies.

The inbox delivery is fire-and-forget after the main publish succeeds,
so it does not slow down the UI or block the publish flow.
2026-04-18 16:42:48 -05:00
filemon 399a3586b2 Add one-shot eye-close animation on sleep entry
When Blobbi transitions from awake to sleeping, the eyes now visibly
close over 400ms with an ease-in-out curve before settling into the
stable sleeping state. The closed-eye lines fade in during the last
40% of the animation.

The animation is driven by a standalone rAF loop in runSleepEntryAnimation()
that queries fresh DOM elements from containerRef.current — never from
stale cached refs. A wasSleepingRef tracks the previous isSleeping value
so the animation only fires on genuine awake→sleep transitions, not on
mount or refresh when Blobbi is already sleeping.

At t=1 the DOM exactly matches the recipe's static closed state, so
there is no visual discontinuity when the animation completes.
2026-04-18 18:36:17 -03:00
filemon 3bba781f49 Fix open-eye flash on first sleep transition
Clear stale cached DOM refs in useBlobbiEyes when entering sleep.
The awake animation loop caches blink/gaze SVG elements, but
dangerouslySetInnerHTML replaces the entire SVG when the sleeping
recipe is applied. The old refs' open-eye clip geometry was being
used to querySelector into the new sleeping SVG and reset the
clip-paths back to the open position, causing both open eyes and
closed-eye lines to render simultaneously.

The sleeping recipe already sets clip rects to the closed position
in the SVG string, so no JS-side clip-path reset is needed.
Clearing the caches prevents stale operations and lets fresh
caching happen naturally when Blobbi wakes up.
2026-04-18 18:09:12 -03:00
Alex Gleason 91fe272bea release: v2.10.2 2026-04-18 09:19:01 -05:00
filemon 0618a1ca13 Skip redundant evolution persist when content is already up-to-date
The debounce hook was re-publishing a kind 31124 event with identical
evolution content 5s after every interaction, because the primary
write path already persisted the same data inline. Now compares the
serialized content against the fresh event before publishing and
skips when they match. The hook still fires for the one case where
it is genuinely needed: event-based backfill from Nostr queries.
2026-04-18 09:33:06 -03:00
filemon 3fe1256381 Harden per-Blobbi evolution persistence across interaction paths
Five targeted fixes to the evolution mission persistence flow:

1. Inline evolution content into interaction write paths: all three
   action hooks (direct action, inventory item, companion item use) now
   read the updated evolution from the session store and embed it into
   the 31124 content in the same publish. The debounce hook remains as
   a safety net for event-based backfill, not the primary persistence.

2. Scope ensuredRef per Blobbi: the 'ensure missions exist' guard in
   useHatchTasks and useEvolveTasks was a plain boolean ref that would
   not re-run when switching companions. Now keyed by pubkey:d.

3. Add companionD to query keys: hatch-tasks and evolve-tasks queries
   were keyed by pubkey only, causing stale cache reuse across Blobbis.

4. Filter persist hook by d-tag: usePersistEvolutionProgress now checks
   detail.d against companionD so it only reacts to evolution updates
   for the active companion.

5. Clear evolution in switch mode: when switching incubation from one
   Blobbi to another, the stopped Blobbi's 31124 content now has its
   evolution[] cleared, and its session store entry is removed.
2026-04-18 09:23:08 -03:00
filemon 1bce67d21d Move Blobbi progression missions from kind 11125 to kind 31124
Evolution/hatch mission progress was stored in the shared Blobbonaut
profile (kind 11125) content JSON, causing split-brain state between
the per-user profile and per-Blobbi events. After reload, progress
could disappear or get overwritten across Blobbis because the session
store was keyed by pubkey only and persisted via a debounced write to
the wrong event.

Now:
- Daily missions remain on kind 11125 (per-user, correct)
- Evolution missions live on kind 31124 content JSON (per-Blobbi)
- Session store split: daily keyed by pubkey, evolution by pubkey:d
- usePersistEvolutionProgress writes to 31124 instead of 11125
- useHatchTasks/useEvolveTasks read from companion.evolution
- serializeProfileContent strips legacy evolution from 11125
- Start/stop incubation/evolution seed/clear 31124 content directly
- Interaction tallies pass companion d-tag for per-Blobbi tracking
2026-04-18 08:59:19 -03:00
Chad Curtis 00fa9cad57 Merge branch 'fix/blobbi-progression-state-separation' into 'main'
Separate progression state from activity state to prevent evolution reset

Closes #238

See merge request soapbox-pub/ditto!192
2026-04-18 11:35:58 +00:00
filemon 9b9abaa855 Fix stale references to state_started_at in progression migration cleanup
- StartIncubationDialog: display progressionState instead of state in
  restart dialog text (was showing 'active' instead of 'incubating')
- blobbi-tag-schema: update deprecated tag replacedBy and category
  comment to reference progression_started_at instead of state_started_at
- blobbi.ts: update deprecation comments for incubation_time and
  start_incubation to reference progression_started_at
2026-04-18 08:10:56 -03:00
filemon 06b53dbc82 Fix stale comments and result types from progression-state migration
Update JSDoc comments in useBlobbiIncubation to reference
progression_state/progression_started_at instead of the deprecated
state/state_started_at. Rename stateStartedAt to progressionStartedAt
in StartIncubationResult and StartEvolutionResult interfaces.
2026-04-18 07:34:07 -03:00
filemon bf6788c141 Separate progression state from activity state to prevent evolution reset
Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently
destroying evolution progress. The root cause was using a single state
tag for two orthogonal concerns: activity (active/sleeping/hibernating)
and progression (incubating/evolving).

Introduce progression_state and progression_started_at as new tags
orthogonal to the activity state tag. Sleep, wake, and hibernation
changes now never touch progression. Parser auto-migrates legacy
events that stored progression in the state tag on read.
2026-04-18 07:03:52 -03:00
Chad Curtis 363e39d72c Harden against malformed untrusted data that crashes pages
- ExternalContentPage: new URL() on non-URL NIP-73 identifiers (isbn:,
  iso3166:) threw TypeError crashing the page; now URL is only created
  for url-type content and #-prefixed strings are used for other types
- ExternalContentPage & RelayPage: decodeURIComponent on malformed
  percent-encoded URL params threw URIError; now wrapped in try/catch
- useMastodonPost: new URL() on invalid URLs inside queryFn now returns
  null instead of letting the error propagate
- colorUtils/themeEvent: malformed hex color values from theme events
  produced NaN CSS variables; added isValidHex guard in parseColorTags
2026-04-18 04:50:34 -05:00
Chad Curtis 36373400f8 Fix crash on invalid blurhash strings from Nostr events
Validate blurhash hashes before passing them to react-blurhash's
<Blurhash> component, which throws when the encoded length doesn't
match the component count header. Malformed hashes from third-party
events (e.g. length 92 instead of 94) now gracefully fall back to a
skeleton placeholder instead of crashing the page.
2026-04-18 04:39:14 -05:00
Alex Gleason 54a49f1ece release: v2.10.1 2026-04-17 22:43:30 -05:00
Alex Gleason a2f2d9ff89 Add configurable shareOrigin to AppConfig
window.location.origin resolves to capacitor://localhost on iOS and
https://localhost on Android, which produces broken QR codes, broken
copy-link actions, and a broken remote-login callback URL on native
builds.

Add an optional shareOrigin field to AppConfig and a useShareOrigin
hook that falls back to window.location.origin when unset. Replace
all 13 call sites that build shareable URLs.

The origin can be configured three ways, in order of precedence:
user localStorage > ditto.json > VITE_SHARE_ORIGIN env var. Native
deployments can set VITE_SHARE_ORIGIN=https://ditto.pub at build time
so that shared URLs resolve correctly when opened on another device
(and get caught by DeepLinkHandler when opened on the same app via
Universal/App Links).

Regression-of: a12d5db5
2026-04-17 22:39:44 -05:00
Alex Gleason cb26238729 release: v2.10.0 2026-04-17 19:34:53 -05:00
Mary Kate Fain 800e0bbe47 Address self-review checklist findings
- Remove dead export useMusicTracksByGenre from useMusicData
- Sanitize metadata?.picture through sanitizeUrl() in ProfileCard,
  MusicHeroCard, MusicTrackCard, and MusicTrackRow
- Fix MusicDiscoverTab featured section where skeleton and loaded
  content could render simultaneously (make mutually exclusive)
- Add error state handling to all 4 music tab components
- Move genre filtering from client-side to relay-level #t filtering
  in MusicTracksTab via new genre param on useMusicFeed
2026-04-17 19:09:08 -05:00
Alex Gleason f4ae344b30 Unify follow list, follow set, and follow pack rendering
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.
2026-04-17 18:52:00 -05:00
Mary Kate Fain 61d3c261fe Default tracks and playlists tabs to 'new' sort 2026-04-17 18:15:04 -05:00
Mary Kate Fain ae32b62552 Show 3 playlist columns on desktop instead of 2 2026-04-17 18:03:46 -05:00
Mary Kate Fain 781aa2579b Fix empty results for hot/top sort on music tracks and playlists
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.
2026-04-17 17:55:58 -05:00
Mary Kate Fain 6999da3e45 feat: add Hot/Top/New sort and Global/Following scope filters to music pages
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.
2026-04-17 17:31:35 -05:00
Mary Kate Fain 2c5528774f revert: remove New Tracks artist deduplication
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.
2026-04-17 17:21:21 -05:00
Mary Kate Fain 78db2568e0 fix: move genre chips below New Tracks header and deduplicate by artist
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.
2026-04-17 17:16:50 -05:00
Mary Kate Fain 64db8b2ce0 feat: collapse long playlist descriptions with show more/less toggle
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.
2026-04-17 16:53:55 -05:00
Alex Gleason 8f6361f6fc Bump dompurify to 3.4.0 for security fixes
DOMPurify 3.4.0 is a security release that fixes multiple issues
including mXSS via re-contextualization and closing tags, prototype
pollution via CUSTOM_ELEMENT_HANDLING and USE_PROFILES, ADD_ATTR
predicates skipping URI validation, and ADD_TAGS/FORBID_TAGS
precedence bugs.

The project uses DOMPurify to sanitize user-supplied SVGs in
sanitizeSvg.ts and sanitizeBlobbiSvg.ts, so pulling in these fixes
hardens our SVG rendering path against hostile inputs.
2026-04-17 16:53:16 -05:00
Alex Gleason 85894b98f5 Add stronger language to commit rule in AGENTS.md 2026-04-17 16:50:14 -05:00
Alex Gleason 3b052d3eb6 Fix naddr lookup for legacy replaceable kinds (0, 3, etc.)
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
2026-04-17 16:47:09 -05:00
Mary Kate Fain ecb61d44a5 feat: fall back to first track's artwork for playlist covers
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).
2026-04-17 16:44:08 -05:00
Mary Kate Fain 5789b34b5a fix: add onError fallback for broken images in music components
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.
2026-04-17 16:39:21 -05:00