Commit Graph

4447 Commits

Author SHA1 Message Date
Alex Gleason cbfbca063e Validate theme font and background URLs at the schema layer
`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.
2026-04-16 13:47:16 -05:00
Alex Gleason f3393b2cc8 Store nostr-push device key in secure storage on native builds
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.
2026-04-16 13:47:16 -05:00
Alex Gleason 2eb643f422 Sanitize app-handler picture and banner URLs
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.
2026-04-16 13:47:15 -05:00
Alex Gleason e22dbbe85c Validate NIP-05 resolver returns a 64-char hex pubkey
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.
2026-04-16 13:47:15 -05:00
Alex Gleason e01ed039fb Add restrictive sandbox attribute to web sandbox iframe
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.
2026-04-16 13:47:13 -05:00
Chad Curtis 17cdb87723 Merge branch 'fix/scroll-restoration-on-back-navigation' into 'main'
Fix scroll position lost when navigating back from post detail page

Closes #217

See merge request soapbox-pub/ditto!161
2026-04-16 18:35:28 +00:00
Alex Gleason a55ff61669 Verify NIP-17 inner rumor pubkey matches seal pubkey
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.
2026-04-16 13:21:15 -05:00
Chad Curtis 5c215aeec5 Add debounced persistence for evolution mission progress
The in-memory session store doesn't survive page refresh. Add
usePersistEvolutionProgress hook that listens for evolution mission
changes and debounce-publishes (5s) to kind 11125 content JSON via
fetchFreshEvent + serializeProfileContent. Wired into BlobbiPage.
2026-04-16 13:11:41 -05:00
Chad Curtis 591ab57352 Fix lint: remove unused imports, wrap evolution in useMemo 2026-04-16 12:06:22 -05:00
Chad Curtis cb42b1b6a3 Move hatch/evolve task progress from kind 31124 tags to kind 11125 evolution[]
Migrate the hatch/evolve task system to use MissionsContent.evolution[]
on kind 11125 (Blobbonaut Profile) instead of task/task_completed tags
on kind 31124 (Blobbi State).

- Add evolution-missions.ts with static definitions for hatch and evolve
  task pools (TallyMission for interactions, EventMission for themes,
  color moments, posts, profile edits)
- Populate evolution[] in session store on incubation/evolution start;
  clear on stop
- Switch interaction tracking from incrementInteractionTaskTags (kind
  31124 tag manipulation) to trackEvolutionMissionTally (session store)
- Rewrite useHatchTasks/useEvolveTasks to read progress from evolution[]
  and backfill event IDs from retroactive Nostr queries
- Remove useSyncTaskCompletions and the task tag sync effect from
  BlobbiPage

WIP: type errors and barrel exports still need cleanup.
2026-04-16 11:56:16 -05:00
Chad Curtis 3039c46565 Merge branch 'feat/blobbi-retroactive-task-progression' into 'main'
Make hatch/evolve missions count retroactively from user history

Closes #222

See merge request soapbox-pub/ditto!185
2026-04-16 16:18:07 +00:00
Chad Curtis 2d74088b25 Add scroll-to-top feed refresh on Home re-tap and fix mobile tab hover artifact 2026-04-15 20:56:02 -05:00
filemon 2b9dd6ed6a Clean up route-reaction hook: fix stale click reuse, remove unused scaffolding, document stable callbacks 2026-04-15 20:22:22 -03:00
filemon 8ccc2c4a7a Merge branch 'main' into feat/improve-blobbi-companion-transitions 2026-04-15 19:37:32 -03:00
Alex Gleason 02b83be58e Prevent text selection on long-press of gamepad controls on iOS
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.
2026-04-14 15:49:16 -05:00
Alex Gleason 8c3371e968 Add native iOS notification polling with rich metadata and grouping
Implement background relay polling for iOS using BGTaskScheduler,
addressing Apple App Store rejection (Guideline 4.2 - Minimum Functionality).

- DittoNotificationPlugin: Capacitor plugin mirroring the Android interface,
  schedules BGAppRefreshTask whenever notifications are enabled (no settings
  change required — both push/persistent modes poll on iOS)
- NostrPoller: fetches notification events via URLSessionWebSocketTask,
  resolves author display names from kind 0 metadata (24h cache), verifies
  referenced event authorship for reactions/reposts/zaps
- Rich notifications with author names, content previews, zap amounts, and
  reaction emoji display
- iOS thread identifiers for native notification grouping per category+post
- Notification categories with summary formats
- Foreground notification display and tap-to-navigate handling
- Immediate poll on app foreground to catch up on missed notifications
- Hide Delivery Method picker on iOS (only meaningful on Android)
2026-04-14 14:58:49 -05:00
Alex Gleason 1a106545f7 Fix haptics: call isNativePlatform() at invocation time, log errors
The platform check was cached as a module-level constant, which could
evaluate before the Capacitor bridge was ready. Moved to per-call checks
matching the pattern used everywhere else in the codebase. Also replaced
silent .catch(() => {}) with console.warn so failures are visible in
Safari Web Inspector / Xcode console.
2026-04-14 14:08:32 -05:00
filemon 86c4594cdd Clean up self-review findings: remove dead exports, simplify query keys, align ceremony state flow
- Remove dead deprecated exports: isValidEvolvePost, EVOLVE_REQUIRED_POSTS,
  BLOBBI_EVOLVE_POST_PREFIX, isValidBlobbiPost, sanitizeToHashtag
- Remove corresponding barrel re-exports from actions/index.ts
- Simplify hatch/evolve query keys to ['...-tasks', pubkey] since
  retroactive queries no longer depend on stateStartedAt
- Drop stateStartedAt from enabled guards so retroactive queries
  aren't blocked when the timestamp is missing
- Align BlobbiHatchingCeremony hatch path: babies now start as
  'evolving' with state_started_at set, matching useBlobbiStageTransition
- Ceremony fakePreview for existing eggs preserves companion's actual state
2026-04-14 14:58:49 -03:00
filemon 6d157c0a65 Merge branch 'main' into feat/blobbi-retroactive-task-progression 2026-04-14 14:13:47 -03:00
filemon 43c75175f4 Auto-start incubation/evolution for new Blobbis
New eggs now start in 'incubating' state with state_started_at set at
adoption time, so hatch tasks begin tracking immediately.

Newly hatched babies now start in 'evolving' state with a fresh
state_started_at, so evolution tasks begin tracking immediately.

The evolving state is applied after validateAndRepairBlobbiTags (which
would otherwise repair task-process states to 'active' via cleanupTaskTags).

Existing/older Blobbis are unaffected -- no migration is performed.
Stop incubation/evolution actions continue to work as before.
2026-04-14 13:47:34 -03:00
Alex Gleason ffa1094f93 Add haptic feedback to Blobbi egg interactions
Hatching ceremony: escalating haptics on each crack click (light → medium
→ heavy → success notification on hatch). Egg tap-to-wiggle in feeds and
posts: light impact on each user-initiated tap. Auto-wiggle intervals are
excluded to avoid unwanted vibration.
2026-04-14 11:44:45 -05:00
Alex Gleason 12a4966b84 Add haptic feedback to emoji reaction selection in QuickReactMenu
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.
2026-04-14 11:29:22 -05:00
filemon b68ea276db Make hatch/evolve missions count retroactively from user history
Content-type missions (theme, color moment, post, profile edit) now query
the user's full Nostr history instead of filtering by state_started_at.
Only Blobbi-specific tasks (interactions, maintain_stats) still require
actions on the current Blobbi instance.

Egg incubation:
- create_theme, color_moment: retroactive (no since: filter)
- create_post: retroactive, simplified to any post with #blobbi tag
- interactions: still Blobbi-specific (7x care actions)

Baby evolution:
- create_themes, color_moments, edit_profile: retroactive
- create_posts task removed entirely
- interactions: still Blobbi-specific (21x care actions)
- maintain_stats: still Blobbi-specific (dynamic, all stats >= 80)
2026-04-14 13:27:16 -03:00
Alex Gleason cc702027b0 Add native haptic feedback to all key interactions
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.
2026-04-14 11:06:18 -05:00
Mary Kate Fain dcf77aac2a Add pointer-events-auto to autocomplete dropdowns
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.
2026-04-13 21:14:52 -05:00
Mary Kate Fain cdf3391aad Fix emoji/mention autocomplete dropdowns not clickable in compose modal
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.
2026-04-13 21:09:58 -05:00
Mary Kate Fain 5febdb2d7d Increase widget header icon size to match text-xl label 2026-04-13 20:54:08 -05:00
Mary Kate Fain 005f40b536 Increase widget header label to text-xl 2026-04-13 20:52:35 -05:00
Mary Kate Fain 01a6012a0a Simplify sidebar widgets: remove collapse, reposition drag handle, clean up borders 2026-04-13 20:48:36 -05:00
Chad Curtis c009eb4d5c Fix inline zap rendering: add EmbeddedZapCard for kind 9735
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.
2026-04-13 19:05:18 -05:00
Chad Curtis 9bdfa1a485 Merge branch 'improve/blobbi-stuck-behavior' into 'main'
update: reduce stuck recovery chance threshold from 30% to 10%

See merge request soapbox-pub/ditto!183
2026-04-13 23:48:56 +00:00
Chad Curtis 6742792e90 Fix embedded quote review issues
- 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
2026-04-13 18:15:31 -05:00
Chad Curtis 8f6d52a9f9 Fix embedded quote rendering: use NoteContent for DRY media/blobbi display
- 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
2026-04-13 18:03:09 -05:00
Chad Curtis 51a25919c7 Fix media rendering after quote posts in q-tag quotes
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
2026-04-13 17:10:16 -05:00
Chad Curtis 8b3b412b16 Persist poop cleanup XP to companion (debounced publish)
Each poop cleaned awards 5 XP to the companion's experience tag.
Multiple pickups are debounced into a single Nostr publish (1.5s
after the last cleanup) to avoid excessive relay traffic. Uses
ensureCanonicalBeforeAction for fresh-read safety.
2026-04-13 16:34:40 -05:00
Chad Curtis bbcefbb79e Fix review findings: wire up daily XP award, stale-read safety, poop toast, carousel index reset
- Wire useAwardDailyXp into BlobbiDashboard so daily mission XP is
  actually persisted (was exported but never called)
- Rewrite useAwardDailyXp to use fetchFreshEvent + prev pattern
  instead of reading from stale TanStack Query cache
- Remove misleading '+XP' toast from poop cleanup; delegate to parent
  via onPoopCleaned callback with honest 'Cleaned up!' message
- Fix room-config.ts comment to accurately describe room tag status
  (read on mount, not yet written back on room change)
- Make handleOpenShopFromAction navigate to kitchen room instead of
  silently closing the modal
- Reset ItemCarousel index to 0 when items array changes to prevent
  out-of-bounds access
- Derive KitchenBar foodEntries from foodItems memo instead of
  duplicating getLiveShopItems().filter(food)
- CareBar Treat button: memoize treat item, show its name as label,
  handle missing item gracefully
- Fix useItemCooldown: remove module-level side-effect subscription,
  use proper useSyncExternalStore subscribe contract
2026-04-13 16:29:51 -05:00
Chad Curtis 83f2f1de7e Merge origin/main into feat/blobbi-rooms-progression, fix lint warnings 2026-04-13 16:12:35 -05:00
filemon 3dd77c2fcc update: reduce stuck recovery chance threshold from 30% to 10% 2026-04-13 17:45:49 -03:00
filemon b4b94698b4 Merge branch 'main' into feat/improve-blobbi-companion-transitions 2026-04-13 17:27:15 -03:00
filemon 7fa751492b Use closest() for click-target detection, clamp Y to prevent downward gaze
Replace the manual parent-walk loop with closest('a, button,
[role="button"]') — simpler, handles role="button" elements, and
returns null cleanly when no clickable ancestor exists (so only the
raw pointer fallback is used).

Clamp the resolved click-origin Y to 55% of viewport height so Blobbi
looks across toward the sidebar rather than sharply downward when the
clicked item is near the bottom of the screen.
2026-04-13 17:25:24 -03:00
filemon 496dfd48e0 Use live element rect for click-origin glance position
Store the nearest clickable ancestor (<a>/<button>) on pointerdown
alongside the raw coordinates. At route-change time, re-read the
element's getBoundingClientRect() to get its current visible center,
which accounts for any scroll shifts between the click and the React
effect. Falls back to the raw pointer coordinates if the element has
been unmounted.
2026-04-13 17:20:07 -03:00
filemon bae285dd8f Blobbi glances at click origin before looking at main content on route change
Track the last pointerdown position in a ref. On route change, if a
recent click exists (<1s), glance at that position for ~700ms first,
then look at the center-top of the new page for 2-6s. Programmatic
navigation (no recent click) falls back to immediate center-top.

Single-file change in useRouteReaction.ts. No changes to attention,
gaze, or state hooks.
2026-04-13 17:13:33 -03:00
filemon d628619eca Fix Blobbi gaze drifting away during route-reaction attention
Guard makeDecision() so it bails out while attentionTarget is active.
Previously the decision loop could fire during the attending window,
transitioning Blobbi to walking/idle and breaking the attend-ui gaze
lock. The attention-end handler already resumes decisions when it
clears, so this guard is safe.
2026-04-13 16:52:54 -03:00
Mary Kate Fain 4ffa3119a7 Fix self-review findings: CSS injection, stale reads, type safety, error states
- 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)
2026-04-12 18:29:36 -05:00
Mary Kate Fain dbf7ed9bb2 Fix unsanitized Nostr URLs, stale-read mutations, and missing prev on addressable events
- 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
2026-04-12 17:57:28 -05:00
filemon edc4163852 Eliminate sidebar-look during route transitions by setting immediate center-top attention
On route change, immediately trigger a preliminary attention target at
viewport center-top before the 250ms DOM-mount delay. This ensures the
gaze system never falls to random or mouse-follow mode during the gap,
which previously caused Blobbi to drift leftward toward the sidebar.

The delayed reaction still fires at 250ms and replaces the preliminary
target with a precise DOM-measured position.
2026-04-12 19:36:57 -03:00
filemon 08cc77dbdc Fix Blobbi looking at sidebar during route transitions
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
2026-04-12 19:25:47 -03:00
Mary Kate Fain 8f5f33560e Fix AI chat widget: sticky input at bottom, remove redundant Full chat link
For fillHeight widgets, WidgetCard now renders content in a plain
fixed-height div instead of a ScrollArea, so the widget's internal
flex layout can properly fill the container with messages scrolling
above and input pinned at the bottom.

Remove the 'Full chat' link since the widget header already links
to /ai-chat.
2026-04-12 17:18:09 -05:00
Mary Kate Fain 41392d9299 Extract BlobbiAwayState into shared component
Move the 'out exploring' UI into src/blobbi/ui/BlobbiAwayState.tsx with
size presets ('md' for page, 'sm' for widget). Both BlobbiPage and
BlobbiWidget now import from the shared component instead of rendering
the away state inline.
2026-04-12 17:07:36 -05:00
Mary Kate Fain 4623438652 Extract DorkThinking into shared component, use in AI chat widget
Move the animated Dork face (the <[o_o]> thinking animation) into
src/components/DorkThinking.tsx with a className prop for sizing.
Both AIChatPage and AIChatWidget now import from the shared component.
The widget uses text-[10px] for a compact fit inside the chat bubble.
2026-04-12 16:50:53 -05:00