Introduce getBlobbiStatDisplayState() — a read-only helper that derives
UI segment counts, care states, and badge/pulse flags from internal
1–100 stats without changing any gameplay behaviour.
Egg is always 'protected' with full segments. Baby maps to 4 segments
(urgent/attention/okay/good). Adult maps to 10 segments with wider
threshold bands. Values are clamped to STAT_MIN–STAT_MAX.
Includes 38 unit tests covering all boundary values, clamping, and
flag correctness.
- Remove orphaned JSDoc and dead isShort code path in VideoContent
- Unify vine mute state into shared vineGlobalMute module so mute
preference carries between NoteCard feeds and VinesFeedPage
- Fix fetchNextPage race cascade by using a ref guard instead of
putting isFetchingNextPage in the useCallback dep array
- Batch event ingestion in useStreamPosts so the event map is sorted
and flushed to state once per page instead of per-event
- Don't permanently kill pagination on transient network errors
- Move sort modifiers into searchParts before the join for consistency
- Wrap full VideoGridCard/ShortThumb content in ContentWarningGuard
so metadata doesn't leak when policy is blur
Kind 22 (Short-form Portrait Video) was rendering through VideoContent
with a shrunken max-width and a redundant "Short" badge, duplicating
the vine experience poorly. Now kind 22 shares the VineMedia component
with kind 34236, rendering full-width with play/pause and mute toggle.
- Unified isVine to match both kind 22 and 34236
- Removed isShortVideo flag and the small/badged VideoContent path
- Added mute/unmute button to VineMedia with shared module-level state
- Mute preference persists across shorts as you scroll through a feed
- Removed dead parseImeta function (replaced by parseVideoImeta)
useStreamPosts previously fetched a single batch of 40 events with no
way to load more. Refactored to track the oldest event timestamp and
expose fetchNextPage/hasNextPage/isFetchingNextPage for cursor-based
pagination using the same NIP-50 search filter with an `until` param.
SearchPage now renders an IntersectionObserver sentinel below the post
list that triggers loading the next page when scrolled into view.
The autoplayVideos config controls inline video players in normal
feeds. Vines use snap-scroll where the active slide should always
autoplay — gating on this setting made vines not play at all when
the user had autoplay disabled.
The guide step effect only handled the forward transition (room →
item/action on entering the target room). Add the reverse: when the
step is item or action but the user has navigated away, revert to
the room step so the directional arrow resumes blinking.
VideosFeedPage and VinesFeedPage rendered their own card components
(VideoGridCard, ShortThumb, VineCard) without any content warning
checks, bypassing the ContentWarningGuard used elsewhere. Videos
with NIP-36 content-warning tags displayed without blur or filtering.
- Wrap VideoGridCard and ShortThumb thumbnails in ContentWarningGuard
- Add full-screen dark CW overlay to VineCard matching vine aesthetic
- Filter out CW events when contentWarningPolicy is "hide" in both pages
- Prevent video autoplay while CW overlay is shown in VineCard
Previously only low-status (warning/critical) stat icons were clickable
and the guide auto-cleared when the stat recovered to normal. Now every
stat icon fires onGuide on tap regardless of status, and the guide is
only dismissed by completion events (item used, sleep started, or a
different stat clicked).
Changes:
- StatsCrown: remove the status-gate from onClick and cursor-pointer
- StatsCrown: add z-10 so stat icons layer above the Blobbi visual
- Blobbi animation wrapper: pointer-events-none so it cannot intercept
taps on overlapping stat icons
- BlobbiDashboard: remove the effect that cleared the guide when the
stat was normal; drop unused getStatStatus import
- Update comments and prop docs to reflect the new behaviour
- Extract readSeedUint32() for raw 32-bit reads instead of misusing
deriveIndexFromSeed with max=0x100000000 (which was a no-op modulus).
deriveIndexFromSeed is now only used for bounded array indexing.
- Guard deriveAdultFormFromSeed against NaN from short/invalid seeds.
- Guard adjustSeedForAdultType against indexOf returning -1 for
unknown form values.
Cache getBoundingClientRect() result in OverstimulationBlockOverlay
to avoid four forced reflows on the same element per activation.
Add sanitizeSvgColor() guard in generateAngerRiseEffect() so that
config.color is validated before interpolation into SVG stop-color
attributes, preventing injection if a future caller passes untrusted
color strings.
Scroll to top before applying the zoom transform and restore the
saved scroll position after zoom-out completes. The transform on
#root creates a new containing block that breaks sticky positioning
of the mobile top/bottom nav bars when the user is scrolled down.
- useFillLevelUpdate: shared recipe fingerprint + imperative gradient
stop updates, extracted from BlobbiAdultSvgRenderer and
BlobbiBabySvgRenderer (removed ~60 duplicated lines from each)
- useReactionDrain: shared rAF-based level drain loop with throttled
React state push, extracted from useOverstimulationReaction and
useShakeReaction (removed ~170 duplicated lines from each)
Net reduction: ~285 lines across the 4 consumer files.
The previous approach put a ref on the pointer-events-auto wrapper div,
which is a full-width block element -- getBoundingClientRect returned
the page width, not Blobbi's position. Now we query the companion's
actual fixed-position container via [data-blobbi-companion] to get
the true visual bounding rect.
When Blobbi hits max overstimulation, the entire UI now zooms toward
Blobbi's face (transform on #root) while a radial shockwave expands
from the companion and a red vignette dims the screen edges. On
recovery the zoom eases back out and the vignette fades.
The overlay is portaled onto document.body so it stays at viewport
scale while #root is scaled. Body overflow is hidden during the zoom
to prevent scrollbar flash.
Removes the canvas-based crumble/debris system (crumbleEngine.ts) in
favor of this simpler CSS-driven approach.
Replace the invisible click shield with a dramatic visual sequence when
Blobbi reaches max overstimulation: a radial shockwave expands from
Blobbi, a dark backdrop covers the UI (Blobbi stands alone in the void),
and canvas debris particles rain down like rubble. On recovery the
backdrop fades and debris converges back.
Also fix the debug bypass in useShakeReaction (true || threshold) that
shipped nausea on every shake regardless of hunger stat, and remove the
toast notification replaced by the visual overlay.
Pass lookMode="follow-pointer" to BlobbiStateCard in PostDetailPage,
matching the feed card behavior so Blobbi eyes follow the cursor on the
detail view as well.
Remove the intermediate popover + "Guide me" button step. Tapping a
low-status stat icon now calls onGuide immediately, starting the room
navigation or item/action highlight with zero friction.
Deletes the StatIndicatorWithHelp component (~120 lines) and its
associated imports (Popover, STAT_HELP_TEXT, Navigation, React state/
ref/callback/effect hooks). All stat icons now render through the
same StatIndicator; low-status icons get cursor-pointer and onClick
on their positioning wrapper.
Low-stat indicators now glow/pulse at warning and critical levels across
BlobbiPage and widgets via a shared StatIndicator. On BlobbiPage, hovering
(desktop) or tapping (mobile) a low stat shows contextual help with a
Guide me button that visually leads the user to the correct room and
item/action through a sequential glow chain: stat popover → room nav
arrow → carousel arrow → target item/action.
Implementation details:
- Synchronized low-status icon animations via a shared CSS @property
clock on the StatsCrown parent, so all icons pulse in phase regardless
of mount timing
- Split warning (stat-glow, 2s) and critical (stat-glow-critical, 2s)
into separate keyframes with distinct visual weight
- Guide glow uses a quick-blink-and-pause rhythm (1.1s cycle) distinct
from the status pulse
- Stable popover hover zone shared between trigger and content with
paired open/close timers; onOpenAutoFocus prevented to avoid flicker
- Guide cleanup on item use, action execution, new guide start, or stat
recovery
- ItemCarousel index only clamped when actually out of bounds, preventing
guide visual instability from unrelated re-renders
- Energy modeled as a first-class action target (sleep) rather than a
special case
- companionNeedsCare() uses calculateProjectedDecay() instead of raw
persisted stats, consistent with all other low-status UI
- Add useWorldFeed hook combining infinite-scroll pagination with live
streaming and 'X new posts' buffer/flush pattern
- World feed queries all country-tagged events globally with a diversity
cap (max 4 posts per country per page)
- Live streaming via persistent relay subscription with scroll-aware
buffering and highlight animation on flush
- Rename Ditto tab to World across Feed, ContentSettings, and useFeedTab
- Migrate localStorage key from ditto:showDittoFeed to agora:showWorldFeed
Add dir="auto" to NoteContent, BioContent, and DM message bubbles
so the browser's Unicode Bidirectional Algorithm automatically detects
text direction from the first strong directional character.
The path started and ended at the same point (100,105), making it a
triangle instead of a quadrilateral. The bottom-right hexagon vertex
(140,130) was missing entirely. Changed to trace center → right-mid →
bottom-right vertex → inner bottom-right point.
Fixed in all four locations: both .svg source files and both inlined
constants in adult-svg-data.ts.
The Poll/Spoiler popover opened side="bottom" from the toolbar, which
is near the bottom edge of the dialog. Because the dialog container
uses overflow-hidden (needed for flex layout containment) and the
PortalContainerProvider portals content inside the dialog DOM, the
popover was clipped.
Switch to side="top" so the menu opens upward into the visible area
of the modal.
The app renders Blobbi SVGs from inlined string constants in
adult-svg-data.ts, not from the .svg source files. The previous commits
only fixed the source files. This syncs all three fixes into the
inlined CRYSTI_BASE and CRYSTI_SLEEPING constants:
- Add animateTransform groups to CRYSTI_BASE sparkle circles
- Fix self-intersecting pink facet path in both constants
- Match sleeping facet opacities to base values
The sleeping SVG had all six facet opacities reduced by 0.2 compared to
the base, making it look washed out. Only the eyes/mouth should differ
between states — the body colors should stay vibrant, consistent with
how bloomi handles its sleeping variant.
The base SVG sparkle circles were missing animateTransform tags, causing
them to render static instead of orbiting like the sleeping variant.
Wrapped them in two animated groups matching crysti-sleeping.
The top-left pink facet (crystiFacet2) had a self-intersecting path
(bowtie shape) that caused inconsistent fill rendering. Reordered the
vertices to trace a proper convex quadrilateral in both base and sleeping
SVGs.
Remove the 'Make it yours' theme strip from the landing hero and the
ThemeStep from the signup/onboarding flow. Add an Appearance settings
page at /settings/appearance with three options (System, Light, Dark)
defaulting to System.
When the total debit (amount + network fee) crosses $100 USD, flip the
primary action into a confirmation affordance instead of silently
sending. Normal sub-threshold amounts are unchanged — still one tap.
OnchainZapContent: first tap arms a destructive-variant button,
second tap actually sends. Editing the amount or fee speed re-arms.
SendBitcoinDialog ConfirmView: adds a neutral informational note
("Sending $X — double-check the recipient and amount.") and flips
the Confirm & Send button to the destructive variant, so the second
click carries visible weight without extra friction.
Threshold and helper (`isLargeAmount`) live in lib/bitcoin.ts with
6 new tests covering the boundary, price-unavailable, and negative
input cases.
BlobbiStateCard gains an optional lookMode prop (default: 'forward'),
threaded through to BlobbiStageVisual. Only the NoteCard feed call site
passes 'follow-pointer'; post detail, embedded notes, and embedded naddr
keep the default forward gaze.
The global pointer listener in useBlobbiEyes now also tracks touchstart
and touchmove so the effect works on mobile.
The amber "Network fee is ~N% of your zap" message was alarmist and
added no useful signal — the user already sees the exact fee in the
Fee line above and the final total when they submit.
Regression-of: bddfe4b8
- Add 13 regression tests for Taproot address derivation, pubkey
validation, npub→address, and mainnet address validation
- Validate pubkey hex format (/^[0-9a-fA-F]{64}$/) in
nostrPubkeyToBitcoinAddress to fail fast on malformed input
- Match tapInternalKey against the signer's x-only pubkey in
signPsbtLocal, per the BITCOIN-SIGNING.md spec ("inputs whose
tapInternalKey does not match the signer's key MUST be left
unchanged"). Throw if no owned inputs are found.
- Use >= DUST_LIMIT (not >) for change-output dust check, so a change
of exactly 546 sats is preserved rather than donated to fees
- Extract formatBTC() helper into lib/bitcoin.ts; remove duplicated
replace(/\.?0+$/, '') from WalletPage, SendBitcoinDialog, and
BitcoinContentHeader
- Register kind 8333 ("Bitcoin zap") in CommentContext KIND_LABELS,
CommentContext KIND_ICONS, NoteCard KIND_HEADER_MAP,
signerWithNudge KIND_LABELS, and shellTitleForKind
- Disambiguate sign_psbt errors in NConnectSignerBtc: only re-wrap as
"doesn't support sending Bitcoin" when the error message looks like
a capability failure (unknown method, not implemented, etc.);
propagate transient errors unchanged
- Show the recipient's derived Bitcoin address in OnchainZapContent
so users can verify the destination before signing
- Clear knownUnsupportedBunkers on logout so a fresh login with an
upgraded bunker isn't tainted by a previous session's rejection
- Reject self-zaps in verifyOnchainZap (sender == recipient)
- Update NIP.md to specify: change-output handling, amount-cap vs
discard semantics, self-zap rejection, mempool/confirmation policy,
and mainnet-only scope
- Delete unused useNsecAccess hook
User-facing strings about signer capability referenced 'signPsbt',
'sign_psbt', 'PSBT', 'nsec', 'NIP-07', and 'NIP-46' — implementation
details a normal user shouldn't have to parse. Each site now says
'your browser extension doesn't support sending Bitcoin' (or the
bunker / generic variants), and points users at the 'secret key'
login option by its friendly name.
Changed sites:
* OnchainZapContent — unsupported-capability panel
* useOnchainZap — pre-send capability error
* bitcoin-signers.ts — NBrowserSignerBtc and NConnectSignerBtc
error strings (these surface as toasts in SendBitcoinDialog)
* SendBitcoinDialog — 'Signing Not Available' panel and the
in-mutation guard error
isSignerCapabilityError still matches the new copy (they all contain
"doesn't support"), so the capability-detection flow that flips the
UI from 'unknown' to 'unsupported' continues to work.
Regression-of: 008f3979
Previously, when a user's signer couldn't sign PSBTs, the Bitcoin zap
flow only discovered this after the user pressed Zap — surfacing a
toast after an otherwise-normal submission. The zap button was offered
as if it would work, and failure felt like a bug rather than a
capability limit.
Now useBitcoinSigner returns a three-state `capability`:
* supported — nsec login, or extension with window.nostr.signPsbt
present.
* unsupported — extension without signPsbt, OR a bunker that has
already rejected sign_psbt once in this session.
* unknown — bunker login with no capability info yet (NIP-46
has no capability-discovery RPC). Attempt is allowed
and if it fails with a 'does not support' error, the
hook calls reportSignerUnsupported(pubkey) to flip
the capability to 'unsupported' for the rest of the
session. A DOM event broadcasts the change so
consumer hooks re-render without a shared store.
OnchainZapContent renders an explicit 'Bitcoin zaps aren't available'
panel whenever capability === 'unsupported', with copy tailored to the
login type (different hints for nsec/extension/bunker). Inside
useOnchainZap, capability errors no longer show the generic failure
toast — the UI replacement is the only feedback the user sees.
ZapDialog defaults to the Lightning tab when Bitcoin is unsupported
and Lightning is available, and auto-switches mid-session if a bunker
rejects sign_psbt while the dialog is open — so the user is never
stranded on an unusable tab.
The Bitcoin zap dialog was heavier than the Lightning one, with a Review
step, a confirm screen, a balance card, a dropdown for transaction
speed, and a success view — all before you could actually send a zap.
The Lightning flow is presets → optional comment → Zap. Now Bitcoin is
the same shape.
Changes:
* Drop the form→confirm→success wizard. Single screen, single button.
The 'Zap' button does the whole thing; success closes the dialog
via the existing onSuccess callback (the hook already shows a toast).
* Remove the always-visible balance card. Balance only appears when
the amount exceeds available funds (or funds are zero).
* Collapse transaction speed into a one-line fee readout like
'Fee ≈ $0.12 · ~30 min' that opens a popover of the 4 speed options
when clicked. No dropdown taking up vertical space by default.
* Drop the 'Paying to <address>' card, the 'transactions are final'
warning, and the dedicated confirm screen — all redundant for a
small zap flow.
* Button label now reads 'Zap $5 · 5,123 sats' so users see both the
fiat amount they chose and the sats they're committing, without
needing a separate confirm screen to see either.
Fee-dominated warning becomes a single line of amber text instead of a
destructive alert; errors become a single line of destructive text.
Result is roughly half the vertical space and one click instead of three
to send a zap.
8333 is the Bitcoin mainnet P2P port, creating a clean semantic parallel
with NIP-57: kind 9735 (Lightning's P2P port) for Lightning zaps, kind
8333 for on-chain zaps. 3043 was the first free kind the generator
returned and carried no meaning.