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.
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.
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).
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
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- 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
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.
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.
- 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
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.
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
- 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
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.
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.
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.