Derive a bc1p... Taproot address directly from the user's Nostr public key
(both use secp256k1 x-only keys) and display balance via Blockstream API.
Includes QR code, copy-to-clipboard, balance with pending detection, and
a WALLET.md documenting the derivation algorithm. Sending is not yet
implemented.
openDatabase() now catches errors from idb's openDB() (which throws
synchronously when indexedDB is undefined) and returns null. All
consumers — profileCache, nip05Cache, dmMessageStore — check for null
and silently degrade to in-memory only.
The DM message store also stops re-throwing errors, which previously
could produce unhandled rejections in DMProvider.
Move the early return for null companion below all hooks so useMemo
calls are unconditional. The null/egg guard is now inside the recipe
useMemo, and isSleeping/isEgg use optional chaining.
Replace raw companion.stats with calculateProjectedDecay() output so
feed cards reflect the Blobbi's real current condition after time-based
stat decay, matching what the room view shows via useProjectedBlobbiState.
The pure calculateProjectedDecay() function is called once per render
inside useMemo (no setInterval per card), keeping feed rendering
lightweight while staying consistent with the room's decay math.
BlobbiStateCard now resolves the same status recipe used by the room
view (resolveStatusRecipe) from the on-chain stats, so feed Blobbis
show hunger, dirt, sleepiness, sadness, and sickness visuals.
A new attenuateRecipeForFeed() helper scales down body-effect particle
counts and removes flies to keep the smaller feed-card size readable.
Sleeping Blobbis get the buildSleepingRecipe() overlay, matching the
room behaviour.
Replace the local-shakespeare.dev preview domain with iframe.diy, which
provides a service-worker based sandbox. This brings the nsite preview
implementation in line with Shakespeare's approach.
Key changes:
- iframe.diy handshake: listen for 'ready', respond with 'init'
- Derive private HMAC-SHA256 subdomains via deriveIframeSubdomain('nsite', ...)
- Inject preview script into HTML responses for console forwarding,
SPA navigation tracking, and /index.html path normalization
- Remove sandbox attribute (iframe.diy manages its own sandboxing)
- Serve injected script from virtual /__injected__/preview.js path
The i-tag UUID used for webxdc coordination is attacker-controlled.
Using it directly as the iframe.diy subdomain lets a malicious event
author pick a subdomain that collides with another app's origin,
gaining access to its localStorage/IndexedDB.
Introduce a persistent random seed in localStorage (ditto:seed) and
derive the subdomain as base36(HMAC-SHA256(seed, prefix|identifier)).
The prefix (e.g. "webxdc") domain-separates different use-cases.
The subdomain is stable per device+app but unpredictable to event
authors.
Apply a strict CSP header to every response served from the .xdc archive
to enforce the webxdc offline sandbox. Permits same-origin, inline, eval,
wasm, data: and blob: but blocks all external network access.
Migrate the webxdc iframe runtime from webxdc.app to iframe.diy. Instead of
sending ZIP bytes to the iframe and having the SW unzip them, the parent now
unzips the .xdc archive and serves files via iframe.diy's fetch-proxy RPC.
A webxdc bridge script is served as a virtual /webxdc.js file, and a
<script> tag is injected into HTML responses via DOMParser to load it.
- Rewrite Webxdc.tsx to use iframe.diy's ready/init/fetch protocol
- Unzip .xdc archives on the parent side and serve via fetch RPC responses
- Serve webxdc bridge as virtual /webxdc.js via the fetch handler
- Inject <script src="/webxdc.js"> into HTML using DOMParser
Replace useInfiniteHotFeed (sort:hot via NIP-50 search) with standard
NIP-01 reverse-chronological pagination for the curated Ditto feed.
Latest ordering provides a natural time-based spread of content types,
working better with the diversity algorithm and giving a fresher feel.
Portal ProfileImageLightbox to document.body, matching the fix
already applied to the shared Lightbox component. Without the
portal, the lightbox was trapped inside the center column's z-0
stacking context from MainLayout, causing the right sidebar
(a sibling outside that context) to paint on top.
The final drain loop now tries all deferred items (not just the front),
and any items that still can't satisfy the gap constraint are dropped
rather than appended back-to-back. This prevents runs like 3 Blobbis
in a row that occurred when the graceful degradation path blindly
appended all leftover deferred items.
Process each page independently with gap state carrying forward from
the previous page's tail. Earlier pages never change when new pages
arrive, eliminating the visible re-render/jump. The proportional cap
now applies per-page instead of across the full flattened list.
Prevent the same content type from appearing within 3 positions of
itself and cap any single type at 20% of the feed. Uses a two-phase
algorithm: proportional cap first (trims excess from least-hot items),
then greedy gap-enforced interleave that keeps items as close to their
original hotness rank as possible. Only applies to the Ditto/landing
feed — follows, global, and other feeds are untouched.
Filter the Ditto tab and logged-out landing feed to only show content
from people followed by the curator npub (npub1jvnpg4c6ljadf5t6ry0w9q0rnm4mksde87kglkrc993z46c39axsgq89sc),
inclusive of the curator. Add Kind 20 (photos), 21/22 (videos),
34236 (divines), and 36787/34139 (music) to the curated feed kinds.
Replace outdated references to 'inventory items', 'consume',
'quantity', and 'storage decrement' across 14 files. Comments
now consistently describe items as reusable abilities sourced
from the shop catalog, not consumable inventory.
Items are now single-use abilities — tap item, press Use, effect
happens immediately. No confirmation dialogs or quantity selectors.
Changes:
- Remove BlobbiUseItemConfirmDialog and InventoryUseConfirmDialog
- Remove quantity state, selectors, and multi-use loops from modals
- Simplify mutation hooks to always apply item effects once
- Drop quantity parameter from UseItemFunction type signature
- Update all call sites through the full stack (BlobbiPage, context,
companion layer, companion item use hook)
Items are now treated as abilities/tools unlocked by stage, not
consumable inventory that must be purchased. All catalog items are
shown in the companion action menu regardless of inventory quantity.
Changes:
- Source items from shop catalog instead of user inventory storage
- Remove quantity validation and storage decrement on item use
- Remove quantity badges and 'in inventory' text from all modals
- Keep stage-based filtering (egg vs baby/adult restrictions)
- Cap quantity selector at 99 instead of inventory count
The previous useStreamPosts always injected 'protocol:nostr' into the
NIP-50 search string, which is a Ditto relay extension that filters for
native Nostr events. Without it, useTabFeed's queries return stale or
fewer results because the relay doesn't scope to the Nostr protocol.
Augment the resolved filter's search field with 'protocol:nostr' before
passing it to useTabFeed, matching the old behavior.
SavedFeedContent was using useStreamPosts which stores data in React
component state (useState). When navigating to a post detail page the
component unmounts and all state is destroyed, forcing a full re-fetch
on back navigation — losing the user's scroll position and content.
Replace useStreamPosts with useTabFeed (useInfiniteQuery) to match how
the Home, Ditto, and Global feeds work. TanStack Query caches all
fetched pages independently of component lifecycle (gcTime = 30 min),
so navigating back renders content instantly from cache, preserving
scroll position.
This also adds proper infinite scroll pagination and repost unwrapping
to custom saved feeds, which previously loaded a single batch.
Closes#217
ScrollToTop was calling window.scrollTo(0, 0) on every pathname change,
including back/forward (POP) navigation. This destroyed the browser's
native scroll restoration, forcing users back to the top of the feed.
Use useNavigationType() to only scroll to top on PUSH navigation (user
clicked a link), preserving scroll position on POP (back/forward).
Closes#217
- Switch autocomplete dropdowns from absolute to fixed positioning so they
aren't clipped by ancestor overflow containers (e.g. the compose modal's
overflow-y-auto wrapper)
- Add viewport-relative coordinate calculation using getBoundingClientRect
- Add flip logic to show dropdown above cursor when near viewport bottom
- Dismiss dropdown on scroll/resize since fixed position doesn't track
- Add font-emoji utility class to force emoji presentation for native
Unicode characters (star, fire, etc.) that may render as text glyphs
- Apply same fixes to MentionAutocomplete for consistency
Closes#216
Replaceable and addressable event headers now distinguish between
first publish and subsequent updates using the published_at tag:
- published_at == created_at → 'created' verb (e.g. 'created an emoji pack')
- published_at != created_at → 'updated' verb (e.g. 'updated an emoji pack')
- no published_at → 'shared' fallback for backward compatibility
Extend useNostrPublish with an optional `prev` property on the event
template. For replaceable and addressable kinds, the hook automatically
manages published_at:
- First publish (no prev): set published_at equal to created_at
- Update (prev provided): preserve published_at from the old event
- Old event lacks published_at: don't fabricate one
- Caller already set published_at in tags: leave it alone
Callers pass `prev` when they have the old event from fetchFreshEvent,
giving the hook everything it needs without extra network requests.
Updated all 11 call sites that publish replaceable or addressable events.
Documents the prev convention in AGENTS.md.
Two issues caused custom tab feeds (e.g. Magic Decks) to loop:
1. ProfileSavedFeedContent flattened pages without deduplication, so
events returned by multiple pages rendered as visible duplicates.
2. useTabFeed only stopped paginating when rawCount === 0. For
addressable events the relay keeps returning the same latest
versions, so rawCount never hit zero. Changed to rawCount < limit
(relay returned fewer than requested = exhausted).
Replace grouped-by-emoji layout with a flat list where each reaction
row shows an inline emoji badge (similar to the zap amount badge).
Add an emoji summary bar at the top when multiple emoji types are
present. This makes it immediately obvious who reacted with what.
The scroll-aware active indicator reporting and scroll listener logic was
duplicated between TabButton and SortableTabChip. Extract into a shared
useActiveTabIndicator hook in SubHeaderBar.
- Add pencil icon to SortableTabChip for editing existing custom tabs
- Wire onEdit to open ProfileTabEditModal with the existing tab data
- Clear the active arc underline when an active tab is removed (cleanup in useLayoutEffect)
- Round dnd-kit transform values to avoid sub-pixel rendering issues
SubHeaderBar: add left/right chevron scroll arrows on desktop when tabs
overflow, with gradient fade. Auto-scroll active tab into view and keep
arc hover/active indicators aligned during horizontal scroll.
ContentSettings: add Interest Tabs section with inline add/remove for
hashtags and geotags. Remove buttons always visible (mobile-friendly),
X icons use strokeWidth 4.
On desktop, overflowing feed tabs were completely inaccessible since the
scrollbar was hidden and there was no swipe gesture. Add left/right
chevron scroll buttons that appear only on desktop when tabs overflow,
with gradient fade indicators. Also auto-scrolls the active tab into
view when switching tabs, and keeps the arc hover/active indicators
aligned during horizontal scroll.
When a depth-collapsed 'Show X more replies' button was the last item
in a reply sequence, it lacked a bottom border separator. Added an
isLast prop to ExpandThreadButton that adds border-b when the button
terminates the visual sequence.
DashboardShell uses fixed positioning on mobile, placing it directly
over the body background image. Without the bg-background/85 class
that MainLayout's center column provides, the raw background image
showed through unthemed. Add the same 85% opacity background overlay
used consistently across the rest of the app.
All blobbi mutations now follow the read-modify-write pattern: fetch fresh
state from relays before mutating, then optimistically update the cache.
This prevents two classes of bugs:
1. Stale cache reads: mutations were reading from TanStack Query cache
(30s staleTime) instead of relays, causing newer events to be silently
overwritten with old stats when actions happened within the cache window.
2. Invalidation races: every mutation called invalidateCompanion() after
the optimistic update, which triggered a refetch from relays before the
just-published event had propagated, overwriting the optimistic data
with the pre-mutation state.
Changes:
- ensureCanonicalBlobbiBeforeAction now fetches fresh companion + profile
from relays (the read step) instead of using cached closure values
- useBlobbiCareActivity fetches fresh companion before streak updates
- Removed all invalidateCompanion()/invalidateProfile() calls after
optimistic updates across every action hook
- updateCompanionEvent now updates ALL blobbi-collection query caches
for the user, not just the specific d-tag list it was instantiated with,
keeping BlobbiPage and companion layer caches in sync
Settings (theme, sidebar, etc.) changed on one device were not applied
on other devices. Three root causes:
1. NostrSync seeded lastSyncedTimestamp to remoteSync on first load,
then the guard (remoteSync <= lastSyncedTimestamp) blocked the same
data from being applied. Settings were never applied on page reload.
2. The encrypted settings query had staleTime: Infinity and
refetchOnWindowFocus: false, so remote changes were never fetched.
3. useInitialSync was missing customTheme, corsProxy, faviconUrl, and
linkPreviewUrl fields.
To avoid gating every F5 behind a spinner, a lastSync timestamp is
now persisted to localStorage whenever settings are applied. On reload,
InitialSyncGate checks this: if present, render immediately from
localStorage and let NostrSync hot-swap remote changes in background.
If absent (new browser, cleared storage), show the spinner until
settings load.
Initial sync applied the theme mode (e.g. 'custom') from encrypted
settings but not the customTheme config (colors, fonts, background),
so the theme appeared broken on first login requiring manual setup
which also triggered an unwanted kind 16767 publish.
Set hasSubHeader on LetterComposePage so the MobileTopBar uses a flat
rect instead of the down-arc variant, preventing the 20px arc overhang
from painting over the LetterEditor picker panel.
Introduce a /follow/:npub deep link that auto-follows a user when
visited by a logged-in user, or presents an immersive business card
with a 'Follow on Ditto' CTA for logged-out visitors. The page applies
the target user's profile theme, renders their feed with infinite
scroll, and uses the same banner/avatar/arc styling as the main profile.
Add a FollowQRDialog that generates a themed QR code for the follow
URL. The QR colors are derived from the active theme: primary color
for modules (with contrast-safe darkening/lightening), and background
color for the QR background. Foreground text color is used when it is
colorful and offers significantly better contrast.
Surface the QR dialog from: own profile page (top-level button),
profile more menu, desktop sidebar account popover, and mobile drawer.