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.
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)
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 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.
Add -webkit-touch-callout: none and -webkit-user-select: none inline
styles to the GameControls container. The existing Tailwind select-none
class (user-select: none) is not sufficient on iOS, where WKWebView
still triggers the long-press callout/highlight gesture on held buttons.
The popover emoji picker (both quick presets and full picker) was
publishing reactions internally without triggering haptic feedback.
Add impactLight() at the top of publishReaction() so every emoji
selection path gets tactile feedback.
Install @capacitor/haptics and add a centralized haptics utility
(src/lib/haptics.ts) that uses the native taptic engine on iOS/Android
and falls back to navigator.vibrate() on web.
Haptics added to:
- Switch component (covers 36+ toggle switches app-wide)
- PullToRefresh threshold (covers 15+ pages)
- MobileBottomNav tab taps
- ReactionButton (like/unlike, double-click heart)
- RepostMenu (repost/undo repost)
- ZapDialog button press + payment success (NWC and WebLN)
- FollowButton and ProfilePage follow toggle
- ComposeBox (post, voice message, and poll publish success)
- NoteMoreMenu (bookmark, pin, mute)
- VinesFeedPage reaction and repost buttons
- ProfileReactionButton and ExternalReactionButton
- NoteCard share button
- BlobbiRoomShell swipe navigation
Replaces raw navigator.vibrate() calls in GameControls and
SendAnimation with the new cross-platform haptics utility, fixing
haptic feedback on iOS where the Vibration API is not available.
Radix Dialog's DismissableLayer sets pointer-events: none on
document.body when the modal is open. Since the dropdowns are portaled
to document.body, they inherit this and silently swallow all mouse
events. Adding pointer-events-auto restores click delivery.
The autocomplete dropdowns are portaled to document.body to escape
overflow clipping, which places them outside the Radix Dialog DOM tree.
Clicks on them were treated as 'interact outside' the dialog, preventing
mouse selection of emoji and mention suggestions.
Add data-autocomplete-dropdown attribute to both dropdown containers and
check for it in handleInteractOutside to prevent modal dismissal.
Zap receipts embedded via nostr:nevent1 references were falling through
to the generic EmbeddedNoteCard, which rendered the raw JSON content of
the zap request. Add a dedicated EmbeddedZapCard that extracts and
displays the sender, amount, and message using the existing zap utility
functions. Forwards disableHoverCards to prevent nested hover cards.
- Add disableMediaEmbeds prop to NoteContent that suppresses images,
galleries, and video/audio inside embedded quotes while preserving
link preview cards and lightning invoices
- Render inline fallback links for nevent/naddr references when
disableNoteEmbeds is true, instead of returning null and leaving
invisible gaps in quoted text
- Restore tag-based title/description fallback for events with empty
content (articles, custom addressable kinds) so they don't render
blank cards
- Migrate EmbeddedProfileBadgesCard to useProfileUrl for consistent
profile link routing
- Fix stateful global regex bug (IMETA_MEDIA_URL_REGEX) causing every
other URL to be misclassified when used with .test() in loops; add
non-global IMETA_MEDIA_URL_TEST_REGEX for safe .test() calls
- Rewrite EmbeddedNoteCard to render content via NoteContent (same as
NoteCard) with a 260px height cap instead of reimplementing URL
parsing and content truncation
- Pass disableEmbeds to NoteContent inside quotes to prevent recursive
nostr:nevent/note references from spawning nested EmbeddedNote
components
- Add overflow-aware 'Read more' toggle inline with attachment chips;
fade gradient only renders when content actually overflows
- Add BlobbiStateCard rendering for kind 31124 in both EmbeddedNote
and EmbeddedNaddr
- Extract EmbeddedCardShell with shared clickable card wrapper and
author row, deduplicating ~150 lines across EmbeddedNoteCard,
EmbeddedNaddrCard, and EmbeddedBlobbiCard
- Fix ComposeBox media URL detection using the same regex fix
- Fix EmbeddedNaddr profile links to use useProfileUrl instead of
hardcoded npub paths
Video/audio/webxdc URLs were silently stripped from NoteContent's token
stream and rendered by parent components after NoteContent. When a quote
post's nostr: URI appeared at the end of the content, media was placed
after the quote embed instead of before it.
Render all media inline within NoteContent at their original content
position via a new media-embed token type. Remove the now-unused
NoteMedia component and the separate media rendering in NoteCard,
PostDetailPage, and ComposeBox.
Also:
- Append media-embed tokens for imeta-declared media not in content
(gated to text note kinds 1/11/1111 only)
- Sanitize imeta-sourced URLs via sanitizeUrl()
- Skip useAuthor query when no media-embed tokens exist
- Memoize author display name derivation
- Sanitize AI tool background_url with sanitizeUrl() to prevent CSS injection
- Replace 'as unknown as' and 'as Partial<Record>' type escapes with proper
ChatCompletionTool, ChatCompletionResponseMessage, and ChatCompletionToolCall
types in useShakespeare
- BlueskyWidget: throw on !res.ok so useQuery retry works; type response
- WikipediaWidget: add explicit isError state instead of masking as 'no article'
- Pass prev (profileEvent) to publishEvent on KIND_BLOBBONAUT_PROFILE mutations
in BlobbiWidget and BlobbiPage to preserve published_at
- Add profileEvent field to EnsureCanonicalResult interface
- useEncryptedSettings: fetch fresh event from relays before mutation instead
of reading from stale TanStack cache (cross-device safety)
- Sanitize imeta URLs at the parse layer in PhotoWidget (parseFirstPhoto)
- Sanitize all URLs from Nostr event tags in musicHelpers (parseMusicTrack,
parseMusicPlaylist): audio URL, artwork, video, playlist artwork
- Fix stale-read-then-write in handleSetAsCompanion (BlobbiWidget + BlobbiPage):
use ensureCanonicalBeforeAction to fetch fresh profile from relays instead of
reading profile.allTags from TanStack Query cache
- Pass prev to publishEvent for KIND_BLOBBI_STATE (addressable kind 31124) in
both BlobbiWidget and BlobbiPage handleRest to preserve published_at
- Fix usePublishStatus: fetch previous kind 30315 event before publishing to
preserve published_at per addressable event convention