The Avatar component was initializing maskUrl as '' and loading it in a
useEffect. Since hasCustomShape was true immediately, rounded-full was
removed on the first render, but the mask wasn't applied until after the
effect fired — causing a visible square flash for one frame.
getAvatarMaskUrl is already synchronous (renders emoji to canvas, caches
the data-URL), so compute it inline during render instead of deferring
to an effect. The mask is now applied on the very first paint.
useLayoutOptions was calling store.setOptions() synchronously during
render, which triggered useSyncExternalStore listeners in MobileBottomNav
(and MainLayout) while Index was still rendering.
Move the store update into useLayoutEffect, which fires synchronously
after commit but before browser paint — same visual result without
violating React's setState-during-render rule.
Previously comments shared feedKey 'feedIncludePosts' with kind 1, and
generic reposts shared 'feedIncludeReposts' with kind 6. This made it
impossible to toggle them independently in settings.
Add feedIncludeComments and feedIncludeGenericReposts to FeedSettings
and wire them to their respective EXTRA_KINDS entries.
Multiple ExtraKindDef entries share the same feedKey (e.g. posts/comments
both use feedIncludePosts) and multiple subKinds share the same showKey
(e.g. both video sub-kinds use showVideos). Using these as React keys
caused 'duplicate key' warnings.
Use def.id (always unique) for ContentTypeRow keys and sub.feedKey
(unique per sub-kind) for SubKindRow keys.
Foundation for migrating the monolithic emotion system toward a composable
architecture where each visual area (eyes, mouth, eyebrows, body effects)
is handled independently.
New modules created:
- bodyEffects/ — types, generators (dirt marks, stink clouds, anger rise),
and applyBodyEffects() for applying body decorators independently of face
- mouth/ — types and re-exports of existing mouth detection/generation
- eyebrows/ — types and re-exports of existing eyebrow generation
Dirty emotion refactored:
- Removed face modifications (droopyMouth, eyebrows) from EMOTION_CONFIGS.dirty
- dirty is now a body-only decorator that adds dirt marks + stink clouds
without touching eyes, mouth, or eyebrows
- Hygiene stat now maps to 'boring' as the face emotion (same as happiness)
- Body effects (dirty) are resolved independently in resolveStatusEmotions()
and flow as a separate bodyEffects field through the entire pipeline:
resolveStatusEmotions → useStatusReaction → BlobbiStageVisual →
BlobbiAdultVisual/BlobbiBabyVisual → applyBodyEffects()
- Any face + dirty is now possible: boring+dirty, sleepy+dirty, dizzy+dirty
The existing emotion system (applyEmotion) still works unchanged for all
other emotions. The eyes/ module already existed. This is an incremental
step — no full migration yet.
Finish the two-layer emotion architecture so resolveStatusEmotions() is
the single source of truth and both the main BlobbiPage and the floating
companion use the same flow.
useStatusReaction:
- Now returns baseEmotion, overlayEmotion, triggeringBaseStat,
triggeringOverlayStat, isStatusReactionActive, currentSeverity,
isOverrideActive (replaces the old single currentEmotion).
- Internally calls resolveStatusEmotions() on every check cycle and
tracks base and overlay transitions independently with animation
safety per layer.
- Action overrides replace the overlay; the base persists underneath.
status-reactions.ts:
- Remove combineEmotions() (no longer needed).
- Deprecate resolveStatusReaction() with a JSDoc notice.
- resolveStatusEmotions() is now the primary API.
BlobbiStageVisual:
- Accepts a new baseEmotion prop and forwards it to BlobbiBabyVisual
and BlobbiAdultVisual.
BlobbiPage (main consumer):
- Destructures baseEmotion + overlayEmotion from the hook and passes
both through to BlobbiStageVisual.
Companion system:
- CompanionData now carries full BlobbiStats and state.
- BlobbiCompanionLayer runs its own useStatusReaction to drive the
companion's emotions from stats, including item-use action overrides.
- BlobbiCompanion and BlobbiCompanionVisual accept baseEmotion + emotion
props and forward them to the underlying visual components.
Allow users to browse and restore previous versions of their accepted
badges (profile badges) from relay history, matching the existing
mute list recovery pattern in /settings/content.
The lazy-loading skeleton was missing center column borders
(sidebar:border-l/r) and the right sidebar widget backgrounds
(bg-background/85 rounded-xl). Updated to mirror the real Outlet
wrapper classes and RightSidebar widget card styling with three
distinct skeleton sections (Trends, Hot Posts, New Accounts).
When a badge list overflows PREVIEW_LIMIT, show one fewer badge to
make room for the +N button on the same row instead of widowing it.
The loading skeleton now also includes a placeholder for the overflow
cell when applicable.
Shows placeholder skeletons matching the badge grid layout (48px
rounded squares + name bars) instead of a centered spinner while
badge definitions are being fetched.
Kind 10008/30008 profile badges events now render a compact card with
author info, a row of up to 6 badge thumbnails, and a badge count
when embedded in quotes or reply context. Works in both EmbeddedNote
(nevent references) and EmbeddedNaddr (naddr references).
- Added boring emotion (😑) - low-energy, unamused expression
- Added dirty emotion (💩) - hygiene-specific with dirt/stink visuals
- Maintains existing emotion order with new emotions near the top
The KIND_HEADER_MAP action header (e.g. 'updated their badges',
'created a badge', 'shared a photo') was only rendered in the normal
NoteCard layout. Now it also appears in the threaded layout, so parent
events shown as ancestors in reply threads display their kind context.
When viewing a NIP-22 comment (kind 1111) that references its parent
via an 'a' tag (addr coordinates) rather than an 'e' tag (event ID),
the parent event is now rendered as a full threaded NoteCard with a
connector line — matching how kind 1 reply threads display ancestors.
Previously these showed a compact AddressableEventPreview banner.
Now the parent badges list (or any other addr-referenced event) renders
inline in the thread, giving proper visual context for the comment.
Replaceable events have no d-tag, so useAddrEvent must omit the #d
filter for kinds in the 10000-19999 range. Without this, querying
for a kind 10008 profile badges event via naddr would include
'#d': [''] in the filter, which fails to match events without a d-tag.
Replaceable events should use naddr encoding (kind + pubkey + empty
identifier) rather than nevent (event ID), since they are identified by
their coordinates. This fixes kind 10008 profile badge events linking
to /nevent1... instead of /naddr1... from the feed.
PostDetailPage (used for nevent1 identifiers) was missing the kind
10008/30008 branch, so profile badge events fell through to the generic
PostDetailContent. AddrPostDetailPage already had this handling via
ProfileBadgesDetailView — now PostDetailPage shares the same code path
for both badge definitions (30009) and profile badges (10008/30008).
Build the tilt directly into BadgeThumbnail instead of a separate
wrapper. Use aggressive parameters (35deg max tilt, 1.15x scale,
perspective = size*3) so the effect is clearly visible on small
28-48px thumbnails. Add a perspective parameter to useCardTilt.
Remove old group-hover:scale-110 from all badge grid call sites
(BadgeShowcaseGrid, ProfileBadgesContent, ProfilePage,
ProfileHoverCard) since the tilt+scale is now built into the
thumbnail itself.
- Add 'boring' persistent face: low-energy, unamused expression (replaces sad as generic fallback)
- Droopy mouth with shallow curve, flat eyebrows
- Used for non-critical bad stats (health, happiness)
- Add 'dirty' persistent state: hygiene-specific visuals
- Includes dirt marks on lower body (3 curved scratch-like lines)
- Animated stink clouds floating upward
- Uses boring face as base + hygiene effects
- Refactor 'sleepy' to be an overlay animation
- KEY FIX: sleepy now animates the CURRENT mouth state instead of resetting to default smile
- When Blobbi is unwell (boring/dirty/dizzy face), sleepy animation preserves that base face
- Implementation: applySleepyMouthAnimation finds existing mouth path and animates from there
- Example: boring face + sleepy = boring expression with sleepy animation on top
- Update status-reactions.ts emotion mapping
- health: boring (not feeling good) → dizzy (critical)
- hygiene: dirty (poor hygiene visuals)
- happiness: boring (low energy, unamused)
- energy: sleepy (now an overlay, not base-replacing)
- Add base + overlay emotion architecture to visual components
- BlobbiAdultVisual and BlobbiBabyVisual now accept optional baseEmotion prop
- Emotions applied sequentially: base first, then overlay
- Preserves existing behavior when only one emotion provided
- Add resolveStatusEmotions() utility
- Separates base emotions from overlay emotions
- Returns StatusEmotionResult with baseEmotion and overlayEmotion
- Enables proper multi-stat handling (e.g., low health + low energy = boring face with sleepy overlay)
Architecture notes:
- Base emotions (boring, dirty, dizzy, sad, happy, etc.): replace face completely
- Overlay emotions (sleepy): animate on top without replacing base
- Critical fix: Blobbi no longer visually resets to happy during sleepy cycle when in bad state
Reuse useCardTilt for the badge image in BadgeContent feed cards, but
only respond to mouse/pen pointer events. Touch events are explicitly
ignored and touch-action is set back to auto so tapping through to the
badge detail view and normal scrolling are unaffected. Includes the
specular glare overlay masked to the badge image shape.
Update useCardTilt to handle touch inputs via PointerEvent. Touch
interactions use a press-and-drag gesture: the tilt follows the finger
while down, then holds for 600ms after release before smoothly
resetting. touch-action: none prevents the browser from intercepting
the gesture for scrolling. Mouse behavior is unchanged.
Update BadgeHero glare overlay to match: glare follows touch position
during the drag and fades after the same linger delay on release.
Remove the secondary Badge UI element showing '<Award icon> Badge' next to
the issuer name on the badge detail page. The context is already clear from
the page layout and hero image.
Remove the 'Name's Badges (N badges)' header line from ProfileBadgesContent
to reduce visual clutter. The badge grid and NoteCard's KIND_HEADER_MAP
already provide sufficient context.
- Add isPhoto detection and PhotoDetailContent in PostDetailPage so kind 20
events render their image gallery when viewed directly via nevent links
- Add parsePhotoUrls helper and ImageGallery import to PostDetailPage
- Add 'Photo' shell title for kind 20 loading state
- Add KIND_HEADER_MAP entry for kind 20 ('shared a photo') in NoteCard
- Add Photo attachment indicator in EmbeddedNote for kind 20 events in
quote posts and reply context
Refactored useStatusReaction hook to be more stateful and animation-aware:
- Track currently active reaction to avoid restarting same reaction
- Distinguish between persistent (sleepy, sad, dizzy, hungry) and one-shot reactions
- Persistent reactions loop continuously while condition remains active
- Only replace reactions when: type changes, higher priority interrupts, or one-shot completes
- Remove stats from useEffect dependencies to prevent reset on every recomputation
- Add animation cycle duration awareness to avoid mid-animation interruptions
- Use refs for stats/timing to maintain stable callback references
This ensures:
- Sleepy animation completes full cycle including slow eye opening
- Crying/sad reactions don't reset before tear cycle completes
- Dizzy animation doesn't keep resetting its visual motion
- Eyebrow/face reactions don't flicker from repeated reapplication
Profile badges should be a replaceable event (kind 10008), not an
addressable event (kind 30008) with a fixed d-tag. This follows the
same deprecation pattern used by NIP-51 lists.
All writes now publish kind 10008. All reads query both 10008 and
legacy 30008, picking whichever is newest, for backwards compatibility
during the transition period.
Extract shared fetchFreshEvent() utility that fetches the freshest
version of a replaceable/addressable event directly from relays before
every mutation. This prevents data loss when the TanStack Query cache
is stale (cross-device edits, rapid sequential operations).
Previously only useFollowActions and useMuteList had this safety
pattern. Now all list-type hooks use the same shared primitive:
useAcceptBadge, useRemoveBadge, useBookmarks, usePinnedNotes,
useInterests, and useUserLists.
Interests, custom emojis, and Blossom server list queries now batch
with profile/follow/mute queries instead of firing separate REQs,
reducing ~3 REQs on feed load.
The stream buffer count was reported raw without applying client-side
filters (search query, media type, replies, protocol, mute list, etc.),
so the pill would show e.g. '10 new posts' when only 3 matched the
active search criteria. Extract the filtering predicate into a shared
matchesFilters callback and derive the pill count from filtered buffer
contents instead of the raw streamBufferCount.
Both pages have a SubHeaderBar but were missing the ARC_OVERHANG_PX
spacer div that prevents content from sitting behind the arc background.
Every other page with tabs already includes this spacer.
Missing pull-to-refresh on Photos, Videos, Trends, Search, Bookmarks,
TagFeed (#t/#g), DomainFeed pages meant Android users had no way to
refresh content without navigating away.
- Create usePageRefresh hook that wraps queryClient.invalidateQueries
with a referentially-stable callback (ref-based) for PullToRefresh
- Wrap scrollable content in PullToRefresh on all affected pages
- Fix Feed.tsx: HashtagFeedContent, GeotagFeedContent, and
SavedFeedContent tabs now include PullToRefresh (were outside wrapper)
- Refactor Events, Books, Themes pages to use usePageRefresh for
consistency and reduced boilerplate
Clicks inside the portaled AlertDialog bubble through React's synthetic
event tree to the NoteCard article, triggering post detail navigation.
Adding stopPropagation on AlertDialogContent prevents any click inside
the delete confirmation from reaching the card handler.
MenuItem button clicks inside the Radix Dialog portal bubble through
React's synthetic event system to the parent article's handleCardClick,
causing navigation to post details when selecting menu actions like
delete. Adding stopPropagation prevents this.
Move the delete confirmation AlertDialog out of NoteMoreMenuContent
(where it was nested inside the more-menu Dialog) and into the parent
NoteMoreMenu component. The nested Radix dialogs caused overlapping
overlays and focus traps that left the page uninteractable after
confirming deletion. Now follows the same close-then-open pattern used
by Report, Mention, AddToList, and EventJson dialogs.
- Standardize data attributes: data-cx/cy → data-eye-cx/cy with legacy fallback
- Replace hardcoded eye selectors with EYE_CLASSES constants from eyes/types
- Remove unused side-specific clip rect class variants from EYE_CLASSES
- Fix sleepy animation: skip JS blink when SMIL animations present
- Fix companion reaction support: skip eye transforms when CSS animations active
- Update detection.ts to try new attribute format first, fall back to legacy
- Update useBlobbiEyes and useExternalEyeOffset to respect CSS animations
The shortcode label used text-muted-foreground making it hard to read.
Changed to inherit text-popover-foreground so it matches the theme's
normal text color. The selection/hover highlight now uses bg-secondary/60,
matching the subtle hover shade used by the NoteMoreMenu items.
Custom emojis were explicitly excluded from usage tracking, so they could
never accumulate enough count to appear in the quick-react popover. The
bar already handles displaying and filtering custom emojis correctly
(including removing stale ones from deleted packs), so the only missing
piece was the tracking call.
emoji-mart renders custom emoji <img> tags with only max-width/max-height
inline styles but no explicit width/height. SVG files that lack intrinsic
width/height attributes (even with a viewBox) collapse to 0x0 because
max-* constraints alone can't force dimensions on a dimensionless image.
Inject a CSS rule into emoji-mart's shadow DOM that gives custom emoji
images explicit 1em x 1em dimensions with object-fit: contain. The img
lives inside span.emoji-mart-emoji, not a button with data-emoji-set.