Keep previous attention alive during the 250ms route-reaction delay
instead of clearing it immediately. This prevents the gaze system from
falling to random mode (which could point toward the sidebar) while
waiting for the new page's DOM to mount.
- Split cancelReaction into cancelPendingTimeouts (timeouts only) and
full cancel (timeouts + attention); route changes use the former
- Add bypassCooldown option to triggerAttention so the delayed reaction
can override the kept-alive attention without being blocked by cooldown
- Stabilize triggerAttention via uiAttentionRef instead of stale closure
clearAttention() now resets lastAttentionTimeRef to 0 so that a
triggerAttention call immediately after a forced clear is not silently
rejected by the 1500ms cooldown guard.
Remove right-sidebar detection and multi-target chaining. The generic
reaction now fires a single attention target at the top-center of the
main content area for a random 2-6 seconds.
- Remove findRightSidebarPosition() and all sidebar scan logic
- Replace fixed 1200ms duration with random 2000-6000ms
- Compute target position at reaction start via live DOM query
and window.innerWidth/Height fallback (no stale closure)
- Cancel both pending timeouts and active attention on drag
- Remove viewport prop from hook options (no longer needed)
- Update docstrings to describe the simplified behavior
On page navigation the companion now briefly pauses and scans the
layout areas that changed. Center content is always scanned first,
followed by the right sidebar if a non-placeholder sidebar is
detected in the DOM.
Implementation:
- useRouteReaction.ts: thin orchestration hook that watches pathname,
determines changed areas, and chains triggerAttention calls via
setTimeout. Cancels on new route change, drag, or unmount.
- useBlobbiCompanion.ts: wires the new hook with existing
triggerAttention/clearAttention from useBlobbiAttention.
No changes to the attention system, state machine, gaze hook, motion
hook, or entry animation. The existing attending state and attend-ui
gaze mode handle all the visual behavior.
Includes an empty ROUTE_REACTIONS map for future per-route overrides.
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
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.
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
The previous 'updated their Blobbi' wording felt mechanical for what is
really a care interaction (feeding, cleaning, playing, etc.). 'Cared for'
better reflects the user's intent.
On Capacitor iOS, leaving user-scalable unrestricted let WKWebView's
scroll view pinch recognizer engage intermittently, then get stuck
disabled once it fired. Adding maximum-scale=1 and user-scalable=no
disables browser-driven pinch zoom consistently across web, iOS, and
Android.
The in-app lightbox (LightboxImage in ImageGallery.tsx) already
implements its own pinch-to-zoom with custom touch handlers and CSS
transforms, so it continues to work. Future components that want
pinch-zoom can follow the same pattern.
When a commit fixes a bug introduced by an identifiable prior commit,
the fix should record the offending short SHA in a Regression-of:
trailer at the bottom of the commit message body.
This is a standard Git trailer (parseable by git interpret-trailers)
that makes intra-release regression detection trivial: the release
skill can now read the trailer directly instead of hunting through
git log and git blame to figure out whether a 'Fixed' entry actually
describes a bug a shipped user ever saw.
- AGENTS.md: new 'Attributing Regressions' subsection under Using Git
with the convention, when-to-add/skip rules, and tracing tips.
- .agents/skills/release/SKILL.md: Step 5.2 now has a fast path that
reads Regression-of trailers via 'git log --format=%(trailers:...)',
with the existing manual git log/blame approach as fallback.
- CONTRIBUTING.md: brief mention in the Bug fixes section and a new
self-review checklist item pointing at AGENTS.md.
Adds a Changelog Quality Checklist to the release skill covering:
- Diffing code between tags (not just reading commit messages)
- Tracing every 'Fixed' entry to its origin commit
- The 'would a user on the previous version notice this?' test
- A worked example of the intra-release bug pattern
Removes the 'expanded emoji picker background' fix from the v2.9.0
changelog -- that bug was both introduced and fixed within the 2.9.0
release window, so no shipped user ever saw it.
The full emoji picker in QuickReactMenu had no background because
EmojiPicker sets its shadow DOM background to transparent, and the
wrapper div only had rounded-xl/shadow-xl without a background class.
Added bg-popover and border-border so the picker matches the quick-react
pill bar styling.
Closes#235
These activity-style detail pages previously rendered only a slim action
row, missing the stats summary (Reposts / Quotes / Likes / Zaps), the
client + full-date row, and the InteractionsModal affordance. Users had
no way to see or browse the interactions the event itself had received.
Extract the stats + date row into a shared JSX block and replace the
four inline action-button grids with PostActionBar, bringing these
detail views in line with the standard post layout while keeping their
compact emoji/icon headers.
Add migration logic so users with stale persisted evolution missions
(e.g. containing the removed create_post mission) get their mission
list rebuilt to match current definitions while preserving progress.
Declares kind 8 as a third sub-kind under the existing Badges
ExtraKindDef with its own 'showBadgeAwards' / 'feedIncludeBadgeAwards'
toggles. The home feed and profile feed both derive their kinds list
from getEnabledFeedKinds, so both pick up badge awards automatically.
The Badges page's follows feed is a hardcoded list, so kind 8 is added
there explicitly.
Defaults match existing badge settings: enabled in hardcodedConfig (new
users see them), conservative in InitialSyncGate and TestApp. The
ContentSettings UI auto-generates a new Badge Awards toggle row.
Removes the now-redundant KIND_SPECIFIC_LABELS/ICONS entries for kind 8
since the sub-kind carries that metadata.
Badge awards previously only appeared as notifications when you were the
recipient. Now they render as full feed cards — showcase image, badge
metadata, recipient row, and an Accept button for logged-in recipients —
so issuers can share awards and feeds can surface community recognition.
Extracts parseBadgeATag, unslugify, and AcceptBadgeButton out of
NotificationsPage.tsx into shared modules, adds a compact embedded card
for kind 8 nevent references, and wires the kind through NoteCard,
PostDetailPage, CommentContext, and extraKinds registries.
User-facing display strings now read from config.appName so forks can
rebrand without code changes, and localStorage keys are namespaced by
config.appId so forks running on the same origin don't clobber each
other's preferences. Module-level cache-key constants that previously
hardcoded 'ditto:' have been refactored into hook-scoped reads from
config.appId (via a new getStorageKey() helper). The helpContent FAQ
template now uses {appName} placeholders substituted at read-time
through getFAQCategories(appName)/getFAQItem(appName, id).
The HTTPS check was a leftover from when the client name was derived
from the hostname. Now that it comes from appConfig, the tag should
be added unconditionally.
The signup and onboarding profile steps rendered ProfileCard without
passing onAvatarShape, so emoji shape selections were silent no-ops and
never made it into the published kind 0 event.
The interactions tally mission was silently dropped because
trackEvolutionTally maps over the evolution[] array — if it's empty,
nothing gets incremented. This happened when evolution missions
weren't persisted to kind 11125 or weren't hydrated on page load.
Both useHatchTasks and useEvolveTasks now have a safety-net effect:
if the companion is in an active task process (incubating/evolving)
but evolution[] is empty, they re-populate from the static mission
definitions. This ensures tally tracking works immediately regardless
of hydration timing.
Lets users with a local-nsec login reveal, copy, and back up their secret
key from /settings/profile. Uses saveNsec() so iOS gets iCloud Keychain,
Android gets Credential Manager with a file fallback, and web gets a
.nsec.txt download plus an opportunistic PasswordCredential save.
Renders an explanatory message for NIP-07 extension and NIP-46 bunker
logins, where the key is not accessible from the app.
- Remove dead code: useSyncTaskCompletions, incrementInteractionTaskTags,
getInteractionCount, getEvolveInteractionCount, unused lookup maps
- Fix task progress showing 0/N on load: compute event-based task counts
directly from Nostr query results (authoritative) instead of relying
solely on the evolution mission store which may not be hydrated yet.
Use max(queryCount, missionCount) so progress displays immediately.
- Fix hydration race: useDailyMissions raw memo now waits for hydration
before creating fresh missions, preventing overwrite of persisted
evolution[] with empty array. Also preserve evolution missions across
daily resets during hydration.
- Fix session store miss: use ensureSessionStore in incubation/evolution
start so evolution missions are always populated even if the store
hasn't been hydrated yet.
- Extract duplicate findMission to shared findEvolutionMission in
evolution-missions.ts
- Document evolution[] field on kind 11125 in NIP.md
Addresses confusion on the key-save step during signup:
- Rename the primary button from 'Continue' to 'Save Key' with a
Download icon, so the label matches the action it performs.
- Change saveNsec() to return 'saved' | 'saved-to-file' | 'dismissed'
instead of throwing on native dismissal. Dismissing the iCloud
Keychain prompt is a legitimate user choice so the handler now
proceeds silently rather than blocking with a 'Save failed' toast.
- Add an in-flight guard on the Save Key button with a spinner and
'Saving…' label. The finally block guarantees the disabled state is
cleared, so users can never get stuck on an unresponsive button —
fixing the 'button became disabled after I dismissed the prompt'
complaint by construction.
- On de-Googled Android builds (GrapheneOS, /e/OS, etc.) the AndroidX
Credential Manager has no provider to delegate to, so the keychain
save fails immediately. Fall back to writing the key to the app's
Documents directory so the user always has a persistent backup, and
surface a toast telling them where the file is.
- iOS keeps its original behaviour: dismissing the iCloud Keychain
sheet is a deliberate user choice, no automatic fallback. The
Documents folder on iOS is accessible via the Files app without
authentication, so silently dropping a plaintext nsec there would
violate user intent.
- Use the app name (from config.appName) as the filename slug for any
.nsec.txt file written to disk. On Capacitor location.hostname is
always 'localhost', so passing the app name is the only way to get
a meaningful filename. Drop the redundant 'nostr-' prefix since the
'.nsec.txt' extension already identifies the file.
- Rewrite the description and title on the save step: 'Your secret
key' + a single paragraph explaining what the key is and why it
matters.
- When the user reveals the key via the eye toggle, show an amber
callout with sharing/screenshotting warnings and a 'Learn more' link
to the Managing Nostr keys blog post. The warning appears at the
moment risk is highest.
- Auto-select the full nsec on focus/click so users copying into a
password manager don't have to fight mobile selection handles.
- Use openUrl() for the external 'Learn more' link so it works
correctly inside Capacitor's WKWebView.
- Singularise the keygen step copy ('cryptographic key' / 'Generate
my key') to stay consistent with the save step which presents a
single secret key.
- Restore full interactive chat widget with ScrollArea, streaming messages,
input area, and conversation cache that was regressed in ec9b6c43
- Extract useShakespeareCredits hook so credits gating is DRY between the
widget and the full AI chat page
- Show Dork ASCII mascot consistently across all empty/logged-out states
instead of the generic Bot icon
- Add RateLimitError class with Retry-After header parsing
- Distinguish insufficient_quota 429 from rate-limit 429
- Friendly Dork-themed error banners for rate limiting and out-of-credits
- Clean no-credits empty state with directive CTA and Get Credits button
- Hide model selector, trash, and input when user has no credits
- Hide page title on mobile, align model selector right
- Simplify sidebar widget to Shakespeare CTA
- Sanitize event-sourced URLs before CSS url() interpolation in
ProfileCard banner and letter stationery background (closes H-1, H-2)
- Sanitize event-sourced font families at the parse layer and in letter
card/detail consumers that bypass resolveStationery (closes M-6)
- Export sanitizeCssString for broader reuse
- Route NWC wallet connection URIs and active pointer through a new
useSecureLocalStorage hook, storing in iOS Keychain / Android KeyStore
on native (closes M-1)
- Add removeItem to secureStorage
- Add Android backup/data-extraction rules that exclude WebView storage
and Capacitor secure-storage SharedPreferences so wallet credentials
don't leak via Google Auto Backup (closes M-5)
- Document that GOOGLE_PLAY_SERVICE_ACCOUNT_JSON must be base64-encoded
to match what the CI job expects (closes M-2)
`ThemeFontSchema.url` and `ThemeBackgroundSchema.url` previously accepted
any string, relying entirely on downstream `sanitizeUrl()` calls for
protocol enforcement. Tightening the schema to `z.url()` rejects
obviously malformed inputs up front and matches the approach already used
for the relay list (`BlossomServersEventSchema`). `sanitizeUrl()` remains
the authoritative guard for `https:` enforcement at render time.
The per-device ephemeral key used to sign nostr-push RPC events was
previously stored unconditionally in localStorage. On Capacitor builds
this bypassed the iOS Keychain / Android KeyStore wrapper that every
other persistent key in the app already uses.
Route the key through `secureStorage`, which keeps the native path
encrypted at rest and falls back to localStorage on web (where it was
before). Because the key is now loaded asynchronously, convert the
`NostrPushClient` constructor into a private constructor plus a public
`create()` factory, and restructure `usePushNotifications` bring-up to
await the client before registering the service worker.
The key is ephemeral and per-device, so compromise only reveals which
Nostr events this device subscribes to -- not the user's identity --
but matching the existing secure-storage contract closes an obvious
inconsistency.
The `picture` and `banner` fields parsed from a kind 31990 NIP-89 event's
JSON content were passed directly to `<img src>` attributes without any
scheme validation. Non-https URLs could leak the user's IP to arbitrary
hosts, and data: URIs could be used for fingerprinting.
The same event's `website` URL was already sanitized; apply the same
treatment to the image URLs for consistency. The app's CSP `img-src`
already blocks most of these at the browser level, so this is
defense-in-depth.
Previously the resolver accepted any string value from a domain's
.well-known/nostr.json `names` map and persisted it to IndexedDB. A
malicious or misconfigured NIP-05 server could return arbitrary data
(non-hex, wrong length, HTML, etc.) that would then be cached and
passed to downstream consumers as a pubkey.
Exploitation impact is limited because invalid hex simply fails to
match anywhere in the Nostr filter API, but hygiene and cache
integrity warrant rejecting malformed values outright. Enforce the
standard 64-char lowercase hex shape and evict any cached entry that
fails validation.
Previously the SandboxFrame iframe relied entirely on cross-origin
subdomain isolation (the HMAC-derived `<id>.sandbox.ditto.pub` origin)
for containment. That does give origin-keyed storage and postMessage
isolation, but it does not restrict top-frame navigation, pointer lock,
or other capabilities that a hostile nsite/webxdc app could abuse.
The highest-value protection here is blocking `allow-top-navigation`:
without it, a malicious nsite could do `window.top.location = evilUrl`
and redirect the entire Ditto tab to a phishing page that impersonates
the app. The user opened a preview expecting to stay inside Ditto, so
this is a realistic and impactful attack.
The policy grants the capabilities that real web apps legitimately use
(scripts, same-origin storage + Service Workers per iframe.diy's
architecture, forms, modals, popups that escape the sandbox, downloads)
while withholding the ones that are either attacks (top navigation) or
unused niche features (pointer lock, presentation API, orientation
lock).
Also Omit 'sandbox' from the spread props so consumers cannot
accidentally weaken the policy.
NIP-17 requires that clients verify `messageEvent.pubkey === sealEvent.pubkey`
before trusting a gift-wrapped direct message. Without this check, any
attacker can construct a rumor claiming to be from another user and
gift-wrap it to the victim -- the seal signature only authenticates the
seal author, not the (unsigned) inner rumor.
Ditto's primary sender display uses sealEvent.pubkey so the headline
impersonation case is mitigated in practice, but the inner event's fields
(including its pubkey) are passed whole to NoteContent for kind 15 file
attachments, which could leak into downstream zap/reply targeting. Add
the spec-mandated check to prevent any trust in the inner pubkey.