Add prev to 5 KIND_BLOBBI_STATE publish paths that already fetch a fresh
base event but were not passing it to useNostrPublish. Without prev, the
replaceable-event published_at ordering is unguarded and concurrent
writes (debounce persist vs action hook vs sleep/care activity) can cause
a relay to accept an older-content event over a newer one, resetting
hatch/evolve mission progress.
Affected paths:
- useBlobbiDirectAction (canonical.companion.event)
- useBlobbiUseInventoryItem (canonical.companion.event)
- useBlobbiItemUse standalone (companion.event from relay fetch)
- useBlobbiSleepToggle (companion.event from relay fetch)
- useBlobbiCareActivity (freshCompanion.event from relay fetch)
No content serialization, tag update, or mission definition changes.
Replace the hook-internal fetchFreshProfile useCallback with the shared
fetchFreshBlobbonautProfile helper, which has identical semantics (query
both kind ranges, prefer 11125 over 31125, sort by created_at desc) plus
an explicit 10s timeout signal.
This eliminates the only remaining duplication between the migration hook
and the shared helper introduced in the previous commit.
- Add usePersistDailyProgress hook: debounced persistence of daily mission
progress to kind 11125 content, with flush on visibility-hidden and unmount
- Fix hydration race: deterministic merge strategy prefers persisted missions
when they carry real progress; keeps local if persisted has zero progress
- Fix content preservation: all kind 11125 write paths now preserve existing
content instead of publishing content: '' which wiped daily mission data
- Fix stale-read-then-write: useBlobbiPurchaseItem, profile normalization,
and onboarding auto-fix now use fetchFreshEvent before publishing
- Add isLoading state to useDailyMissions for proper loading/empty exclusion
- Convert take_photo missions from event to tally tracking
- Extract trackInventoryDailyActions for DRY daily mission tracking
- Handle legacy EventMission → TallyMission conversion in trackTally
- Add dev-only Reset Daily Missions button in BlobbiDevEditor
The content: '' pattern originated in 251ea43e and several subsequent commits
that created kind 11125 write paths. It only became a data-loss bug when daily
mission persistence was introduced. No single regression-of commit is
identifiable — it is a systemic gap across multiple files.
When the home-feed Follows tab is empty, the 'Discover people to follow'
button links to /packs. That page is also a Follows/Global tabbed feed,
and it likewise defaulted to Follows — so a user who follows nobody
landed on another empty view.
Pre-seed the /packs feed tab to 'global' in sessionStorage before
navigating, so the link lands on a populated view.
Regression-of: 399df4da
The alt-tag fallback shipped in 9813a226 let several display paths fall
through to other tags (title, name, summary, description, d) or to the
raw content when alt was absent. For an unknown kind like attestr.xyz
(31871), that surfaced the opaque d-tag identifier
'e5272de9:289bce03a0b7:1777206698' as the preview and leaked raw content
into the hover-card reply indicator — both worse than the 'This event
kind is not supported' tombstone the feature was meant to produce.
Tighten the fallback everywhere an unknown kind might render:
- getEventFallbackText: only the NIP-31 alt tag; no title/name/d.
- CommentContext.getEventDisplayName: known kinds keep title/name/d,
unknown kinds consider only alt. getKindLabel returns 'an unsupported
event' instead of 'a post', so 'Commenting on ...' never implies the
root is a text note.
- EmbeddedNote tagMeta: alt only, no title/name/description fallback.
- ExternalContentHeader.AddressableEventPreview: drop d-tag fallback.
- EmbeddedNaddr: gate rich title/description/content rendering behind
a known-kind check; unknown kinds render UnknownKindContent instead
of extractMetadata, which was leaking plaintext content as the
description when the body wasn't JSON.
Regression-of: 9813a226
Previously, any kind not explicitly handled by NoteCard or
PostDetailContent fell through to the kind-1 text-note renderer, which
ran the URL/hashtag/nostr: tokenizer over arbitrary content — broken
for events whose content is JSON or empty.
Add an UnknownKindContent component that displays the NIP-31 'alt' tag
(falling back to title/name/summary/d) in a rounded card, or a dashed
'This event kind is not supported' tombstone when the event carries no
fallback text. Route to it from both dispatchers when the kind isn't
1, 11, or 1111.
Extend the same handling to embedded quote previews (EmbeddedNote,
EmbeddedNaddr, AddressableEventPreview) so reply-context hover cards,
compose previews, more-menu previews, notification references, and
inline nostr: mentions all display unknown kinds consistently instead
of feeding JSON or arbitrary content to the kind-1 tokenizer.
Detail page: render the kind 2473 / 30621 action header ("Alex Gleason
heard a bird", "… drew a constellation") alongside the existing
per-kind headers, using the same phrasing as KIND_HEADER_MAP.
Bird-detection card: add a Discuss link next to the Wikipedia link,
routing to /i/<wikidata-url> so comments on the species' NIP-73
identifier aggregate across detections.
Regression-of: c957041c
Surface a "View on Birdstar" external action that opens the constellation
on birdstar.app via its naddr1, which is more useful than the edge count
— users can actually interact with the figure there.
Regression-of: c957041c
Adds feed support for kind 2473 (bird-by-ear detections) and kind 30621
(user-drawn star figures) from Birdstar. Detections render as species
cards using the existing Wikidata + Wikipedia summary hooks; constellations
render as gnomonically-projected SVG star-maps backed by the Hipparcos
catalog from d3-celestial. The 1.1 MB catalog is code-split via lazy() so
it only loads when a constellation event is actually viewed.
Introduce external social interactions for Blobbi companions. Other users
can feed, play, clean, and medicate a Blobbi they don't own via kind 1124
events. The owner's canonical 31124 state absorbs these interactions
through a checkpoint-based consolidation system with auto-sync on page load.
Key additions:
- Kind 1124 event schema with validation, parsing, and deterministic sort
- Social projection pipeline (read-only stat overlay from pending interactions)
- Owner-side consolidation into canonical state with checkpoint advancement
- Auto-sync hook (useCanonicalSync) triggered when owner opens /blobbi
- Social permission toggle (open/closed tag on 31124)
- Interaction UI: popover with item carousel on feed cards, detail pages,
and the owner dashboard action bar
- Interaction reactions: facial expressions, sparkles, bubbles, floating
hearts with phase-based animation system
- Activity tab showing interaction history with caretaker attribution
- BlobbiSocialActions component with egg gating and cooldown logic
- NIP.md documentation for the new kind
- Extract useTypewriter hook to shared module (was duplicated in both
BlobbiHatchingCeremony and BlobbiEvolveCeremony)
- Extract hexToRgb, blendToWhite, buildRevealGradient to shared
ceremony-colors module (was duplicated across both ceremonies)
- Remove unused updateCompanionEvent prop and NostrEvent import from
BlobbiEvolveCeremony
- Add prefers-reduced-motion override for evolve ceremony animations
via data-evolve-ceremony attribute selector
Replicate the letter-by-letter reveal from the hatching ceremony. The
first line types out immediately when the adult appears, then the second
line starts typing once the first completes. Both lines show a blinking
cursor while typing.
The text was only appearing in a separate dialog phase at the very end,
barely readable before fadeout. Now it fades in 1.5s into the reveal
phase so users have plenty of time to read it alongside the adult.
The onComplete callback was an inline arrow in BlobbiPage, so every
parent re-render (triggered by the evolve mutation updating companion
data) created a new reference and restarted the entire useEffect timer
chain. Stabilize both onComplete and onEvolve behind refs so the
timeline runs exactly once regardless of parent re-renders.
Background 0.8s -> 0.15s, adult blobbi 0.5s -> 0.15s, glow 0.8s -> 0.2s,
shine 0.5s -> 0.15s, halo 0.8s -> 0.2s. Flash gap 0.8s -> 0.4s.
The adult and its backdrop now pop in almost immediately after the flash.
The gap between baby vanishing and adult appearing was ~1.8s (1.4s flash
+ 400ms delay). Now the reveal starts 0.8s after the flash and the adult
fades in immediately, cutting the dead time to under a second.
The Follows tab of the profile recovery dialog previously showed only
a generic Users icon, a follow count, and a timestamp — making every
historical snapshot look identical and giving users no way to
distinguish one version from another. Now each snapshot renders an
avatar stack of the followed profiles, which makes differences between
snapshots visible at a glance.
Changes along the way:
- Extract the avatar-stack block from PeopleListContent into a shared
PeopleAvatarStack component (sm/md/lg sizes, hover-to-pop-forward
with display-name tooltip, "+N more" overflow).
- Reverse `p` tags in kind 3 displays so the newest follows surface
first. Kind 3 grows by appending, so the natural order is
oldest-first — the same early follows would otherwise dominate every
preview. Implemented as a getDisplayPubkeys(event, pubkeys) helper
used at display sites only; mutations and filters keep the original
array.
- Compact the snapshot cards in ProfileRecoveryDialog by moving the
Restore button into the top-right slot that already hosted the
Current badge, and dropping the redundant "<icon> N follows" title
row from the Follows card since the avatars communicate the same
thing more clearly.
The hatching ceremony reveal phase used a hardcoded blue background
regardless of the blobbi's color. Now the background derives from the
baby's baseColor, blended toward white for a soft pastel, with a
vignette shadow overlay so the blobbi pops against same-hue backgrounds.
The baby-to-adult evolution transition previously had no animation at
all. A new BlobbiEvolveCeremony component provides an immersive
full-screen experience: energy particles spiral inward, the baby glows
and scales up, a flash fires the evolve mutation, and the adult form
is revealed with color-matched sparkles and radiant glow.
The feed toggle at Settings > Content > Social was labeled 'Follow Packs'
with kind badge [39089], hiding the fact that it also controls kind 3
follow lists and kind 30000 people sets (both bundled via extraFeedKinds).
Rename the label to 'People Lists', expand the description to name all
three variants, and rename the storage keys:
feedIncludePacks -> feedIncludePeopleLists
showPacks -> showPeopleLists
No migration. AppProvider shallow-merges stored feedSettings on top of
defaultConfig, so existing users (whose blob has the old keys but not
the new ones) fall through to the default 'true' on the new keys and
get people lists re-enabled. The old keys linger as dead fields in
localStorage and encrypted relay settings until the next write.
The sidebar, /packs route, and AddToListDialog still say 'Follow Packs'
where that narrower term is correct; only the feed-inclusion toggle,
whose scope covers all three kinds, was renamed.
useFeed already runs shouldHideFeedEvent to drop deprecated kind 30000
follow sets (reserved d-tags, empty lists), unlisted decks, hidden
treasures, and empty emoji packs. useProfileFeed didn't, so those events
reached NoteCard just to be filtered out there, costing wasted mounts
and diverging the two code paths.
rawCount still uses pre-filter counts so pagination-exhaustion detection
matches useFeed.
The questionnaire that used to drive this block was simplified away in
e88f9e5f, but the save handler kept writing a full hardcoded feedSettings
object. That clobbered App.tsx's defaultConfig with a narrower preset and,
on the phase === 'not-found' path, could overwrite a returning user's
tuned settings if the encrypted-settings fetch returned empty. It also
drifted every time a new setting was added to App.tsx.
Trust defaultConfig for fresh users and encrypted-settings sync for
returning users. The follow-list check and step routing (outro vs follows)
are what the callback is actually for.
Two fixes on the members-only filter UI:
1. Toggling the shield now updates feeds live, without a reload.
The previous implementation used `useLocalStorage` in two separate
components. Each call instantiates its own `useState`, so writes from
one didn't flow to the other's reader. `localStorage`'s `storage`
event only fires cross-tab, not in the tab that wrote — so same-tab
consumers stayed stale until a remount.
Replaced with a module-level singleton store subscribed via
`useSyncExternalStore`. All consumers share one source of truth;
toggling rerenders every subscriber in the same tab instantly. The
store still persists to localStorage and listens for cross-tab
`storage` events, so behaviour across tabs is unchanged.
2. Move the shield off the CommunityDetailPage tab row.
Placing the toggle inline with the TabsList made it sit on the
bottom-border stroke that belongs to the tabs, reading as if the
shield itself were an underlined tab. Moved it up one row, right-
justified on the "Founded by" label row. Visually cleaner and still
scopes the filter to the entire community (all content feeds under
the tabs, current and future), not any single tab.
Two NIP-alignment fixes:
Gap 1 — Report warnings now require `p` match (correctness).
Previously `CommunityContentWarning` looked up reports by event id only,
so any community member could publish a kind 1984 pairing a victim
event's id with their own pubkey on the `p` tag to force a warning
overlay onto an arbitrary event. Added `getApplicableReports` in
communityUtils mirroring `hasApplicableContentBan`, and use it to
require `report.targetPubkey === event.pubkey` before the warning
renders. Matches NIP.md §Reports — Content Warnings: "report warnings
MUST only attach to content when the target event's id matches the
report's `e` tag and the target event's pubkey matches the report's
`p` tag."
Gap 2 — Members-only filter toggle.
The NIP recommends canonical community feeds discard non-member
content by default. Added a shield-icon toggle that controls this as
a presentation-layer filter, defaulting on. When active, community
feeds (Activities feed, per-community Comments tab, and any future
community-scoped content surfaces) only show events authored by
chain-validated members. When off, everything scoped to the
community is shown regardless of authorship.
- `useMembersOnlyFilter` — localStorage-backed hook with cross-tab
sync; one preference shared across all community surfaces.
- `MembersOnlyToggle` — shield / shield-off icon button with tooltip
explaining current state.
- Filtering is applied post-query in the consumer pages, so toggling
is instant and doesn't invalidate the query cache.
- Community definition events (kind 34550) are never filtered — they
represent the community itself, not user-generated content.
- Toggle placement: in `CommunitiesPage` header (scopes the global
Activities feed); in `CommunityDetailPage` alongside the tabs
(scopes every content feed in that community, now and future).
- Empty-state copy hints at the filter when a list is empty only
because of it.
Drops the read-only calendar-events (kind 31922/31923) listing from
CommunityDetailPage. The feature was partial — events could be listed
but not created from the community context — and the moderation /
authorship model for community-scoped events needs its own design
pass. Keeping it half-shipped complicates the moderation foundation
this branch is establishing.
A proper community events implementation will land in its own MR with
clearer scope: creation, RSVP handling, moderation rules for
community-scoped NIP-52 events, and whether the activity feed should
surface them.
General (non-community) calendar event support is unaffected —
EventsFeedPage, CalendarEventContent, CalendarEventDetailPage, RSVP
hooks, and the feed dispatch all remain. The community activity feed
already did not include kind 31922/31923, so no change there.
Two fixes prompted by external review:
1. resolveCommunityModeration now takes the community A tag and filters
events by matching `A` tag as its first pass. The previous change
removed the A-tag existence check from parseCommunityReport on the
assumption that callers scope by relay `#A` filter; that was an
invariant of the current callers, not a property of the API. Moving
the check to the resolver restores the trust boundary at the public
API surface while keeping parseCommunityReport a pure single-event
parser. The activity feed's pre-grouping pass is dropped since the
resolver now handles per-community filtering itself.
2. Drop the `['community-members', aTag]` cache seeding from the
activity feed. The activity feed uses shared relay limits across
every subscribed community (500 awards and 500 reports total), so
per-community results can be truncated. Seeding the per-community
members cache with incomplete data would silently corrupt membership,
authority, and moderation state on community detail pages.
useCommunityMembers remains the authoritative per-community fetch.
- Extract community content warning's context subscription into the
wrapper itself so NoteCard's memo() boundary no longer depends on
moderation data. Refetches now re-render only the warning and the
three-dot menu, not the whole card.
- Rename useCommunityModeration -> useCommunityModerationForEvent and
return the full context value; PostDetailPage installs it as a
Provider, removing 7 manual communityContext prop passes. Unifies the
three previous paths for computing CommunityMenuContext down to one.
- Seed the per-community members cache from the activity feed so
opening a community detail page after the feed loads hits warm cache
instead of re-querying kind 8 awards and kind 1984 reports.
- Single-pass parse in resolveCommunityModeration (was parsing each
kind 1984 event twice across the ban and report passes).
- Drop the redundant A-tag existence check in parseCommunityReport;
callers scope events via the relay's #A filter.
- Scope ban/report cache invalidation with a predicate that only
matches activity feeds containing the affected community's A tag.
- Drop CommunityMembership.totalCount (was just members.length) and
consolidate scattered EMPTY_* sentinels into EMPTY_MEMBERSHIP and
EMPTY_RANK_MAP in communityUtils.
Rename memberMap -> rankMap to clarify it is a pre-moderation rank lookup
(includes banned members) and should not be used to list active members.
Extract canBanTarget(), getViewerAuthority(), isEventAllowedByModeration(),
CommunityMenuContext, and EMPTY_MODERATION into communityUtils as shared
primitives, eliminating duplicated logic across hooks and components.
Remove unused ApplyCommunityModerationOptions dead code.
Rework resolveCommunityModeration into a two-pass approach so that
members who are themselves banned cannot retain moderation authority:
Pass 1: collect valid ban candidates, sort by reporter rank ascending,
then apply them — skipping any candidate whose reporter was already
banned by a higher-ranked member earlier in the pass.
Pass 2: collect non-ban reports, skipping reporters who ended up in
the banned set from pass 1.
The NoteMoreMenu 'Remove post' and 'Ban' options were only visible on
the community detail page where CommunityModerationContext was provided.
Now they also appear in the activities feed and post detail page.
- Add useCommunityModeration hook for PostDetailPage (resolves community
context from event's A tag with lazy queries)
- Extend useCommunityActivityFeed to expose per-community memberMap and
moderation data (zero extra queries — reuses already-fetched data)
- Wrap each NoteCard in ActivitiesTab with CommunityModerationContext
- NoteCard itself is untouched — no performance impact on other feeds
- Eliminate double resolveMembership call by filtering banned members post-hoc
- Memoize community context derivation in NoteMoreMenu
- Hoist viewerMember lookup out of render loop in CommunityDetailPage
- Only mount BanConfirmDialog when viewer has ban authority
- Deduplicate NIP-56 report type definitions into canonical source