Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a106545f7 | |||
| ffa1094f93 | |||
| e890e913f5 | |||
| 12a4966b84 | |||
| cc702027b0 | |||
| 328c858e4e | |||
| dcf77aac2a | |||
| cdf3391aad | |||
| 787446b4ee | |||
| 5febdb2d7d | |||
| 005f40b536 | |||
| 01a6012a0a | |||
| c009eb4d5c | |||
| 9bdfa1a485 | |||
| 6742792e90 | |||
| 8f6d52a9f9 | |||
| 51a25919c7 | |||
| 1405b5e2c2 | |||
| 8b3b412b16 | |||
| bbcefbb79e | |||
| 83f2f1de7e | |||
| 3dd77c2fcc | |||
| b51b11063f | |||
| 4ffa3119a7 | |||
| dbf7ed9bb2 | |||
| 8f5f33560e | |||
| 41392d9299 | |||
| 4623438652 | |||
| 6948938768 | |||
| db9cdd04c5 | |||
| 528cf905fb | |||
| 2c08bcd94a | |||
| 9de3fa7112 | |||
| 28027cd7b2 | |||
| e54fad61ae | |||
| 31189801f8 | |||
| d579e91bbd | |||
| 27133d69f2 | |||
| 5e895e59ae | |||
| c5f9f8be6c | |||
| 1a58875418 | |||
| 8ee6388ab8 | |||
| 5878b8ad5f | |||
| ec4359f1aa | |||
| f217394012 | |||
| 32908f7b4f | |||
| bd333b9584 | |||
| 3ac1dc6b0a | |||
| 025ecd8645 | |||
| 0fca39a1bd | |||
| 6dc7fb7ade | |||
| d256acdef3 | |||
| 98e0273bdb | |||
| e26407d740 | |||
| b42f12ce77 | |||
| 7a10e4a406 | |||
| eda18d8b93 | |||
| 70809a8c7c | |||
| 5b15300f23 | |||
| 8585dd4833 | |||
| 12bda76526 | |||
| 07a9b956cb | |||
| 0e7f847de0 | |||
| 4998ea8f5d | |||
| 0cc81cd35f | |||
| ed09c8947d | |||
| 2e79d93806 | |||
| f05097087b |
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.6] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
|
||||
- Emoji shortcodes now render as color emoji instead of plain text glyphs
|
||||
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
|
||||
- Signing requests on Android are more reliable and no longer silently fail after switching apps
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.6.5"
|
||||
versionName "2.6.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-keyboard')
|
||||
implementation project(':capacitor-local-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
|
||||
@@ -8,13 +8,6 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
@@ -30,45 +23,6 @@ public class MainActivity extends BridgeActivity {
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// Workaround for @capacitor/keyboard plugin intermittently leaving
|
||||
// the CoordinatorLayout at a fixed pixel height on Android 15+
|
||||
// (API 35+) with edge-to-edge enforced.
|
||||
//
|
||||
// The Keyboard plugin's possiblyResizeChildOfContent() sets the
|
||||
// CoordinatorLayout's LayoutParams.height to a computed pixel value
|
||||
// when the keyboard appears. On keyboard dismiss, the animation
|
||||
// callback resets it to MATCH_PARENT. However, when insets change
|
||||
// without a keyboard animation (permission dialogs, config changes,
|
||||
// edge-to-edge recalculations), the plugin's rootView insets
|
||||
// listener fires with showingKeyboard=true and sets the height,
|
||||
// but no animation runs to reset it — leaving the WebView stuck
|
||||
// at roughly half height.
|
||||
//
|
||||
// Fix: set an OnApplyWindowInsetsListener on the CoordinatorLayout
|
||||
// itself. This fires AFTER the Keyboard plugin's listener on the
|
||||
// rootView (parent dispatches to children). When the IME is not
|
||||
// visible, we force the height back to MATCH_PARENT, overriding
|
||||
// any stale value the plugin may have set in the same dispatch.
|
||||
FrameLayout content = getWindow().getDecorView().findViewById(android.R.id.content);
|
||||
if (content != null && content.getChildCount() > 0) {
|
||||
View child = content.getChildAt(0);
|
||||
// Set the listener on the ContentFrameLayout (parent of the
|
||||
// CoordinatorLayout) so it fires after the Keyboard plugin's
|
||||
// rootView listener but before the SystemBars plugin's listener
|
||||
// on the CoordinatorLayout — avoiding overwriting either one.
|
||||
ViewCompat.setOnApplyWindowInsetsListener(content, (@NonNull View v, @NonNull WindowInsetsCompat insets) -> {
|
||||
boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime());
|
||||
if (!imeVisible) {
|
||||
ViewGroup.LayoutParams lp = child.getLayoutParams();
|
||||
if (lp.height >= 0) {
|
||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
child.requestLayout();
|
||||
}
|
||||
}
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
// Only start the foreground service if the user has opted into
|
||||
// "persistent" notification style. Default is "push" (no service).
|
||||
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
|
||||
@@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-haptics'
|
||||
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
|
||||
|
||||
include ':capacitor-keyboard'
|
||||
project(':capacitor-keyboard').projectDir = new File('../node_modules/@capacitor/keyboard/android')
|
||||
|
||||
|
||||
@@ -19,9 +19,6 @@ const config: CapacitorConfig = {
|
||||
scheme: 'Ditto'
|
||||
},
|
||||
plugins: {
|
||||
Keyboard: {
|
||||
resizeOnFullScreen: true,
|
||||
},
|
||||
SystemBars: {
|
||||
// Inject --safe-area-inset-* CSS variables on Android to work around
|
||||
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>Ditto — Your content. Your vibe. Your rules.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
MARKETING_VERSION = 2.6.6;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -343,7 +343,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.5;
|
||||
MARKETING_VERSION = 2.6.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -14,6 +14,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.2.0"),
|
||||
.package(name: "CapacitorApp", path: "../../../node_modules/@capacitor/app"),
|
||||
.package(name: "CapacitorFilesystem", path: "../../../node_modules/@capacitor/filesystem"),
|
||||
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
|
||||
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
|
||||
.package(name: "CapacitorLocalNotifications", path: "../../../node_modules/@capacitor/local-notifications"),
|
||||
.package(name: "CapacitorShare", path: "../../../node_modules/@capacitor/share"),
|
||||
@@ -28,6 +29,7 @@ let package = Package(
|
||||
.product(name: "Cordova", package: "capacitor-swift-pm"),
|
||||
.product(name: "CapacitorApp", package: "CapacitorApp"),
|
||||
.product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"),
|
||||
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
|
||||
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
|
||||
.product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"),
|
||||
.product(name: "CapacitorShare", package: "CapacitorShare"),
|
||||
|
||||
Generated
+25
-15
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.6.4",
|
||||
"version": "2.6.6",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
@@ -60,7 +61,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -109,7 +110,7 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
@@ -418,6 +419,15 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/haptics": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz",
|
||||
"integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.2.0.tgz",
|
||||
@@ -429,9 +439,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/keyboard": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.2.tgz",
|
||||
"integrity": "sha512-he6xKmTBp5AhVrWJeEi6RYkJ25FjLLdNruBU2wafpITk3Nb7UdzOj96x3K6etFuEj8/rtn9WXBTs1o2XA86A1A==",
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
|
||||
"integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
@@ -2584,9 +2594,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.0.tgz",
|
||||
"integrity": "sha512-IQf74SSusSIyhI9FkUQSUTsX20yeww5xHIUeexvxcWXEpVhYJYCwduK2yRB75NvYgXjcqYeDUGA2RvzBhDc/eA==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
|
||||
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
@@ -10087,12 +10097,12 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.462.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
|
||||
"integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
|
||||
"integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.6.5",
|
||||
"version": "2.6.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -18,7 +18,8 @@
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
"@capacitor/filesystem": "^8.1.2",
|
||||
"@capacitor/keyboard": "^8.0.2",
|
||||
"@capacitor/haptics": "^8.0.2",
|
||||
"@capacitor/keyboard": "^8.0.3",
|
||||
"@capacitor/local-notifications": "^8.0.1",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capgo/capacitor-autofill-save-password": "^8.0.22",
|
||||
@@ -67,7 +68,7 @@
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.0",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@plausible-analytics/tracker": "^0.4.4",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -116,7 +117,7 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"nostr-tools": "^2.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.ditto.app",
|
||||
"sha256_cert_fingerprints": ["7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71"]
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "pub.ditto.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"7C:05:A8:5A:07:0F:84:AE:43:DE:85:67:A4:5F:7F:FB:42:0A:05:05:27:CE:B6:8C:DA:AF:A5:E0:12:E0:9E:71",
|
||||
"E5:B1:A9:13:C9:37:35:3C:A5:E7:27:89:C0:9D:3D:0D:A5:4F:F5:26:88:06:BD:24:46:21:AB:61:6B:CC:C5:E5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.6] - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
- Emoji and mention autocomplete dropdowns no longer get clipped by the compose box
|
||||
- Emoji shortcodes now render as color emoji instead of plain text glyphs
|
||||
- Dialogs and input fields on Android are no longer obscured by the virtual keyboard
|
||||
- Signing requests on Android are more reliable and no longer silently fail after switching apps
|
||||
|
||||
## [2.6.5] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -151,6 +151,11 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
{ id: 'wikipedia' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Loader2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Coins,
|
||||
X,
|
||||
Eye,
|
||||
Scroll,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { formatCompactNumber, cn } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -46,14 +45,12 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { HatchTasksResult } from '../hooks/useHatchTasks';
|
||||
import type { EvolveTasksResult } from '../hooks/useEvolveTasks';
|
||||
import { TasksPanel } from './TasksPanel';
|
||||
import { DailyMissionsPanel } from './DailyMissionsPanel';
|
||||
import { useDailyMissions } from '../hooks/useDailyMissions';
|
||||
import { useClaimMissionReward } from '../hooks/useClaimMissionReward';
|
||||
import { useRerollMission } from '../hooks/useRerollMission';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -62,8 +59,6 @@ interface BlobbiMissionsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
hatchTasks: HatchTasksResult;
|
||||
evolveTasks: EvolveTasksResult;
|
||||
onOpenPostModal: () => void;
|
||||
@@ -146,16 +141,12 @@ function MissionTypeLegend() {
|
||||
// ─── Daily Missions Section ───────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsSectionProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
availableStages?: ('egg' | 'baby' | 'adult')[];
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function DailyMissionsSection({
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
availableStages,
|
||||
disabled,
|
||||
defaultOpen = true,
|
||||
@@ -164,23 +155,17 @@ function DailyMissionsSection({
|
||||
|
||||
const {
|
||||
missions,
|
||||
todayClaimedReward,
|
||||
totalPotentialReward,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
todayXp,
|
||||
allComplete,
|
||||
bonusUnlocked,
|
||||
bonusXp,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
} = useDailyMissions({ availableStages });
|
||||
|
||||
const { mutate: claimReward, isPending: isClaiming } = useClaimMissionReward(
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
);
|
||||
|
||||
const { mutate: rerollMission, isPending: isRerolling } = useRerollMission();
|
||||
|
||||
const claimableCount = missions.filter((m) => m.completed && !m.claimed).length;
|
||||
const completedCount = missions.filter((m) => m.complete).length;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -194,13 +179,12 @@ function DailyMissionsSection({
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Summary pill — always visible */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Coins className="size-3 shrink-0 text-amber-500 dark:text-amber-400" />
|
||||
<span className="tabular-nums">
|
||||
{formatCompactNumber(todayClaimedReward)} / {formatCompactNumber(totalPotentialReward)}
|
||||
{completedCount} / {missions.length}
|
||||
</span>
|
||||
{claimableCount > 0 && (
|
||||
{allComplete && (
|
||||
<span className="size-4 rounded-full bg-emerald-500 text-white text-[10px] font-bold flex items-center justify-center shrink-0">
|
||||
{claimableCount}
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -213,13 +197,11 @@ function DailyMissionsSection({
|
||||
<div className="pt-3">
|
||||
<DailyMissionsPanel
|
||||
missions={missions}
|
||||
onClaimReward={(id) => claimReward({ missionId: id })}
|
||||
onRerollMission={(id) => rerollMission({ missionId: id, availableStages })}
|
||||
todayCoins={todayClaimedReward}
|
||||
disabled={disabled || isClaiming || isRerolling}
|
||||
bonusAvailable={bonusAvailable}
|
||||
bonusClaimed={bonusClaimed}
|
||||
bonusReward={bonusReward}
|
||||
todayXp={todayXp}
|
||||
disabled={disabled || isRerolling}
|
||||
bonusUnlocked={bonusUnlocked}
|
||||
bonusXp={bonusXp}
|
||||
noMissionsAvailable={noMissionsAvailable}
|
||||
rerollsRemaining={rerollsRemaining}
|
||||
isRerolling={isRerolling}
|
||||
@@ -442,8 +424,6 @@ export function BlobbiMissionsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onOpenPostModal,
|
||||
@@ -527,8 +507,6 @@ export function BlobbiMissionsModal({
|
||||
|
||||
{/* 2. Daily Bounties */}
|
||||
<DailyMissionsSection
|
||||
profile={profile}
|
||||
updateProfileEvent={updateProfileEvent}
|
||||
availableStages={availableStages}
|
||||
disabled={isProcessBusy}
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* DailyMissionsPanel — card-grid layout for daily bounties.
|
||||
*
|
||||
* Each mission is a compact card in a 2-col grid.
|
||||
* Tapping a card expands it to show progress, claim button, and reroll.
|
||||
* Tapping a card expands it to show progress and reroll.
|
||||
* Only one card expanded at a time.
|
||||
* Completion is implicit (derived from progress vs target).
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Coins,
|
||||
Gift,
|
||||
Sparkles,
|
||||
Gift,
|
||||
Egg,
|
||||
Trophy,
|
||||
RefreshCw,
|
||||
@@ -24,13 +24,13 @@ import {
|
||||
Music,
|
||||
Pill,
|
||||
CircleDot,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
import type { DailyMission, DailyMissionAction } from '../lib/daily-missions';
|
||||
import { BONUS_MISSION_ID } from '../hooks/useClaimMissionReward';
|
||||
import type { DailyMissionAction } from '../lib/daily-missions';
|
||||
import type { DailyMissionView } from '../hooks/useDailyMissions';
|
||||
import {
|
||||
ExpandableMissionCard,
|
||||
MissionDescription,
|
||||
@@ -40,14 +40,12 @@ import {
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyMissionsPanelProps {
|
||||
missions: DailyMission[];
|
||||
onClaimReward: (missionId: string) => void;
|
||||
missions: DailyMissionView[];
|
||||
onRerollMission?: (missionId: string) => void;
|
||||
todayCoins: number;
|
||||
todayXp: number;
|
||||
disabled?: boolean;
|
||||
bonusAvailable?: boolean;
|
||||
bonusClaimed?: boolean;
|
||||
bonusReward?: number;
|
||||
bonusUnlocked?: boolean;
|
||||
bonusXp?: number;
|
||||
noMissionsAvailable?: boolean;
|
||||
rerollsRemaining?: number;
|
||||
isRerolling?: boolean;
|
||||
@@ -82,51 +80,34 @@ function DailyMissionIcon({ action }: { action: DailyMissionAction }) {
|
||||
// ─── Bonus Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface BonusCardProps {
|
||||
isAvailable: boolean;
|
||||
isClaimed: boolean;
|
||||
reward: number;
|
||||
onClaim: () => void;
|
||||
disabled?: boolean;
|
||||
isUnlocked: boolean;
|
||||
xp: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function BonusCard({ isAvailable, isClaimed, reward, onClaim, disabled, isExpanded, onToggle }: BonusCardProps) {
|
||||
const progress = isClaimed ? 1 : isAvailable ? 1 : 0;
|
||||
|
||||
function BonusCard({ isUnlocked, xp, isExpanded, onToggle }: BonusCardProps) {
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
id="bonus"
|
||||
category="daily"
|
||||
icon={<Trophy className="size-5" />}
|
||||
title="Daily Champion"
|
||||
completed={isClaimed}
|
||||
progress={progress}
|
||||
completed={isUnlocked}
|
||||
progress={isUnlocked ? 1 : 0}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<MissionDescription>
|
||||
{isAvailable || isClaimed
|
||||
? 'Bonus reward for completing all daily missions!'
|
||||
{isUnlocked
|
||||
? 'Bonus XP for completing all daily missions!'
|
||||
: 'Complete all missions to unlock this bonus'}
|
||||
</MissionDescription>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
+{formatCompactNumber(reward)}
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
<Zap className="size-3" />
|
||||
+{formatCompactNumber(xp)} XP
|
||||
</div>
|
||||
|
||||
{isAvailable && !isClaimed && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClaim}
|
||||
disabled={disabled}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white h-8 text-xs"
|
||||
>
|
||||
<Trophy className="size-3.5 mr-1.5" />
|
||||
Claim Bonus {formatCompactNumber(reward)} Coins
|
||||
</Button>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
}
|
||||
@@ -147,7 +128,7 @@ function NoMissionsState() {
|
||||
);
|
||||
}
|
||||
|
||||
function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
function AllCompleteState({ todayXp }: { todayXp: number }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-5 text-primary/60" />
|
||||
@@ -155,8 +136,8 @@ function AllClaimedState({ todayCoins }: { todayCoins: number }) {
|
||||
<p className="text-sm font-medium">All done for today</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Earned{' '}
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{formatCompactNumber(todayCoins)} coins
|
||||
<span className="font-medium text-violet-600 dark:text-violet-400">
|
||||
{formatCompactNumber(todayXp)} XP
|
||||
</span>{' '}
|
||||
— come back tomorrow!
|
||||
</p>
|
||||
@@ -187,13 +168,11 @@ function RerollCounter({ remaining }: { remaining: number }) {
|
||||
|
||||
export function DailyMissionsPanel({
|
||||
missions,
|
||||
onClaimReward,
|
||||
onRerollMission,
|
||||
todayCoins,
|
||||
todayXp,
|
||||
disabled,
|
||||
bonusAvailable = false,
|
||||
bonusClaimed = false,
|
||||
bonusReward = 50,
|
||||
bonusUnlocked = false,
|
||||
bonusXp = 50,
|
||||
noMissionsAvailable = false,
|
||||
rerollsRemaining = 0,
|
||||
isRerolling = false,
|
||||
@@ -202,10 +181,8 @@ export function DailyMissionsPanel({
|
||||
|
||||
if (noMissionsAvailable) return <NoMissionsState />;
|
||||
|
||||
const allRegularClaimed = missions.every((m) => m.claimed);
|
||||
const allDone = allRegularClaimed && bonusClaimed;
|
||||
|
||||
if (allDone) return <AllClaimedState todayCoins={todayCoins} />;
|
||||
const allComplete = missions.every((m) => m.complete);
|
||||
if (allComplete && bonusUnlocked) return <AllCompleteState todayXp={todayXp} />;
|
||||
|
||||
const canReroll = rerollsRemaining > 0 && !!onRerollMission;
|
||||
|
||||
@@ -220,9 +197,8 @@ export function DailyMissionsPanel({
|
||||
|
||||
{/* Regular mission cards */}
|
||||
{missions.map((mission) => {
|
||||
const progress = mission.requiredCount > 0 ? mission.currentCount / mission.requiredCount : 0;
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
const showReroll = onRerollMission && !mission.completed && !mission.claimed && canReroll;
|
||||
const progressFrac = mission.target > 0 ? mission.progress / mission.target : 0;
|
||||
const showReroll = onRerollMission && !mission.complete && canReroll;
|
||||
|
||||
return (
|
||||
<ExpandableMissionCard
|
||||
@@ -231,8 +207,8 @@ export function DailyMissionsPanel({
|
||||
category="daily"
|
||||
icon={<DailyMissionIcon action={mission.action} />}
|
||||
title={mission.title}
|
||||
completed={mission.claimed}
|
||||
progress={Math.min(progress, 1)}
|
||||
completed={mission.complete}
|
||||
progress={Math.min(progressFrac, 1)}
|
||||
isExpanded={expandedId === mission.id}
|
||||
onToggle={handleToggle}
|
||||
>
|
||||
@@ -240,19 +216,19 @@ export function DailyMissionsPanel({
|
||||
<MissionDescription>{mission.description}</MissionDescription>
|
||||
|
||||
{/* Progress */}
|
||||
{!mission.claimed && (
|
||||
{!mission.complete && (
|
||||
<MissionProgress
|
||||
current={mission.currentCount}
|
||||
required={mission.requiredCount}
|
||||
completed={mission.completed}
|
||||
current={mission.progress}
|
||||
required={mission.target}
|
||||
completed={mission.complete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reward + reroll row */}
|
||||
{/* XP + reroll row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<Coins className="size-3" />
|
||||
{formatCompactNumber(mission.reward)}
|
||||
<span className="inline-flex items-center gap-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
<Zap className="size-3" />
|
||||
{formatCompactNumber(mission.xp)} XP
|
||||
</span>
|
||||
|
||||
{showReroll && (
|
||||
@@ -277,7 +253,7 @@ export function DailyMissionsPanel({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{mission.claimed && (
|
||||
{mission.complete && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-medium text-primary">
|
||||
<Check className="size-2.5" />
|
||||
Done
|
||||
@@ -285,20 +261,12 @@ export function DailyMissionsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claim button */}
|
||||
{canClaim && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClaimReward(mission.id);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white h-8 text-xs"
|
||||
>
|
||||
<Gift className="size-3.5 mr-1.5" />
|
||||
Claim {formatCompactNumber(mission.reward)} Coins
|
||||
</Button>
|
||||
{/* Complete indicator */}
|
||||
{mission.complete && (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<Gift className="size-3.5" />
|
||||
+{formatCompactNumber(mission.xp)} XP earned
|
||||
</div>
|
||||
)}
|
||||
</ExpandableMissionCard>
|
||||
);
|
||||
@@ -306,11 +274,8 @@ export function DailyMissionsPanel({
|
||||
|
||||
{/* Bonus card */}
|
||||
<BonusCard
|
||||
isAvailable={bonusAvailable}
|
||||
isClaimed={bonusClaimed}
|
||||
reward={bonusReward}
|
||||
onClaim={() => onClaimReward(BONUS_MISSION_ID)}
|
||||
disabled={disabled}
|
||||
isUnlocked={bonusUnlocked}
|
||||
xp={bonusXp}
|
||||
isExpanded={expandedId === 'bonus'}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
|
||||
@@ -1,242 +1,121 @@
|
||||
/**
|
||||
* useClaimMissionReward - Hook for claiming daily mission rewards
|
||||
*
|
||||
* Handles:
|
||||
* - Persisting coin rewards to kind 11125 Blobbonaut profile
|
||||
* - Updating localStorage mission state
|
||||
* - Idempotent claiming (prevents double-credit)
|
||||
* - Optimistic cache updates
|
||||
* useAwardDailyXp - Award XP for completed daily missions
|
||||
*
|
||||
* Completion is implicit (derived from progress vs target).
|
||||
* This hook calculates the total XP earned today and persists
|
||||
* the updated XP total to kind 11125 tags.
|
||||
*
|
||||
* Uses fetchFreshEvent to avoid stale-read overwrites when
|
||||
* multiple mutations race (e.g. item use XP + daily XP).
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbonautTags,
|
||||
parseBlobbonautEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
} from '../lib/daily-missions';
|
||||
import { buildXpTagUpdates } from '@/blobbi/core/lib/progression';
|
||||
import { serializeProfileContent } from '@/blobbi/core/lib/missions';
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { totalDailyXp } from '../lib/daily-missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ClaimMissionRequest {
|
||||
missionId: string;
|
||||
export interface AwardDailyXpRequest {
|
||||
/** Current missions state to calculate XP from */
|
||||
missions: MissionsContent;
|
||||
}
|
||||
|
||||
/** Special ID for claiming the bonus mission */
|
||||
export const BONUS_MISSION_ID = 'bonus_daily_complete';
|
||||
|
||||
export interface ClaimMissionResult {
|
||||
missionId: string;
|
||||
coinsEarned: number;
|
||||
newTotalCoins: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useClaimMissionReward] Failed to write state:', error);
|
||||
}
|
||||
export interface AwardDailyXpResult {
|
||||
xpAwarded: number;
|
||||
newTotalXp: number;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to claim daily mission rewards.
|
||||
*
|
||||
* This hook persists coin rewards to the kind 11125 Blobbonaut profile event,
|
||||
* ensuring rewards are stored on-chain rather than just in localStorage.
|
||||
*
|
||||
* @param currentProfile - The current Blobbonaut profile (required for coin updates)
|
||||
* @param updateProfileEvent - Callback to update the profile in the query cache
|
||||
* Hook to award XP for completed daily missions.
|
||||
*
|
||||
* @param updateProfileEvent - Callback to update profile in query cache
|
||||
*/
|
||||
export function useClaimMissionReward(
|
||||
currentProfile: BlobbonautProfile | null,
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void
|
||||
export function useAwardDailyXp(
|
||||
updateProfileEvent: (event: import('@nostrify/nostrify').NostrEvent) => void,
|
||||
) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId }: ClaimMissionRequest): Promise<ClaimMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to claim rewards');
|
||||
}
|
||||
mutationFn: async ({ missions }: AwardDailyXpRequest): Promise<AwardDailyXpResult> => {
|
||||
if (!user?.pubkey) throw new Error('Must be logged in');
|
||||
|
||||
if (!currentProfile) {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
const xpToAward = totalDailyXp(missions);
|
||||
if (xpToAward <= 0) return { xpAwarded: 0, newTotalXp: 0 };
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins);
|
||||
}
|
||||
|
||||
// Handle bonus mission claim
|
||||
if (missionId === BONUS_MISSION_ID) {
|
||||
// Check if bonus is available
|
||||
if (!isBonusMissionAvailable(missionsState!)) {
|
||||
throw new Error('Bonus mission not available yet');
|
||||
}
|
||||
|
||||
// Check if already claimed
|
||||
if (isBonusMissionClaimed(missionsState!)) {
|
||||
throw new Error('Bonus reward already claimed');
|
||||
}
|
||||
|
||||
const coinsToAdd = BONUS_MISSION_DEFINITION.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
});
|
||||
|
||||
// Publish updated profile event
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
|
||||
// Update the query cache
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Update localStorage to mark bonus as claimed
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true, isBonus: true }
|
||||
}));
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle regular mission claim
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (!mission) {
|
||||
throw new Error('Mission not found');
|
||||
}
|
||||
|
||||
// Check if already claimed (idempotency check)
|
||||
if (mission.claimed) {
|
||||
throw new Error('Reward already claimed');
|
||||
}
|
||||
|
||||
// Check if mission is completed
|
||||
if (!mission.completed) {
|
||||
throw new Error('Mission not completed yet');
|
||||
}
|
||||
|
||||
const coinsToAdd = mission.reward;
|
||||
const newTotalCoins = currentProfile.coins + coinsToAdd;
|
||||
|
||||
// Build updated tags with new coin balance
|
||||
const updatedTags = updateBlobbonautTags(currentProfile.allTags, {
|
||||
coins: newTotalCoins.toString(),
|
||||
// Fetch fresh profile from relays to avoid stale-read overwrites
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [KIND_BLOBBONAUT_PROFILE],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
// Publish updated profile event to kind 11125
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
});
|
||||
const freshProfile = prev ? parseBlobbonautEvent(prev) : undefined;
|
||||
const currentXp = freshProfile?.xp ?? 0;
|
||||
const newTotalXp = currentXp + xpToAward;
|
||||
|
||||
// Update the query cache optimistically
|
||||
updateProfileEvent(event);
|
||||
|
||||
// Now update localStorage to mark mission as claimed
|
||||
const updatedMissions = missionsState!.missions.map(m =>
|
||||
m.id === missionId ? { ...m, claimed: true } : m
|
||||
// Update XP and level tags on the fresh event's tags
|
||||
const updatedTags = updateBlobbonautTags(
|
||||
prev?.tags ?? [],
|
||||
buildXpTagUpdates(newTotalXp),
|
||||
);
|
||||
|
||||
const updatedState: DailyMissionsState = {
|
||||
...missionsState!,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: missionsState!.totalCoinsEarned + coinsToAdd,
|
||||
};
|
||||
// Persist missions state to content field
|
||||
const content = serializeProfileContent(
|
||||
prev?.content ?? '',
|
||||
{ missions },
|
||||
);
|
||||
|
||||
writeMissionsState(updatedState);
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content,
|
||||
tags: updatedTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, claimed: true }
|
||||
}));
|
||||
updateProfileEvent(event);
|
||||
|
||||
return {
|
||||
missionId,
|
||||
coinsEarned: coinsToAdd,
|
||||
newTotalCoins,
|
||||
};
|
||||
return { xpAwarded: xpToAward, newTotalXp };
|
||||
},
|
||||
onSuccess: ({ coinsEarned }) => {
|
||||
// Invalidate profile query to ensure fresh data
|
||||
onSuccess: ({ xpAwarded }) => {
|
||||
if (user?.pubkey) {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
toast({
|
||||
title: 'Reward Claimed!',
|
||||
description: `You earned ${coinsEarned} coins.`,
|
||||
});
|
||||
if (xpAwarded > 0) {
|
||||
toast({
|
||||
title: 'XP Earned!',
|
||||
description: `You earned ${xpAwarded} XP from daily missions.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
// Don't show error for already claimed (user might have double-clicked)
|
||||
if (error.message === 'Reward already claimed' || error.message === 'Bonus reward already claimed') {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Failed to Claim Reward',
|
||||
title: 'Failed to Award XP',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy export name for backward compatibility during migration
|
||||
export const useClaimMissionReward = useAwardDailyXp;
|
||||
export type ClaimMissionRequest = AwardDailyXpRequest;
|
||||
export type ClaimMissionResult = AwardDailyXpResult;
|
||||
|
||||
@@ -1,201 +1,209 @@
|
||||
/**
|
||||
* useDailyMissions - Hook for managing Blobbi daily missions
|
||||
*
|
||||
* Provides:
|
||||
* - Daily mission state management with localStorage persistence
|
||||
* - Automatic daily reset
|
||||
* - Progress tracking functions
|
||||
* - Read-only access to mission state (claiming is handled by useClaimMissionReward)
|
||||
* - Stage-based filtering (only shows missions user can complete)
|
||||
* - Bonus mission tracking
|
||||
*
|
||||
* Note: Reward claiming should be done via useClaimMissionReward hook,
|
||||
* which persists coins to the kind 11125 Blobbonaut profile.
|
||||
* useDailyMissions - Hook for reading daily mission state
|
||||
*
|
||||
* Provides reactive access to the current day's missions.
|
||||
* Progress tracking is done via the tracker module (non-React).
|
||||
* Completion is implicit (derived from count/events vs target).
|
||||
* XP is awarded automatically when missions complete.
|
||||
*
|
||||
* State lives in a pubkey-scoped in-memory Map. On mount or account
|
||||
* switch, hydrates from kind 11125 content JSON if the session store
|
||||
* is empty. Completed missions are persisted by `useAwardDailyXp`;
|
||||
* intermediate progress resets on page refresh.
|
||||
*/
|
||||
|
||||
import { useMemo, useEffect, useState, useCallback } from 'react';
|
||||
import { useMemo, useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
|
||||
import { parseProfileContent } from '@/blobbi/core/lib/missions';
|
||||
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
areAllMissionsCompleted,
|
||||
areAllMissionsClaimed,
|
||||
getTotalPotentialReward,
|
||||
getTodayClaimedReward,
|
||||
isBonusMissionAvailable,
|
||||
isBonusMissionClaimed,
|
||||
BONUS_MISSION_DEFINITION,
|
||||
getRerollsRemaining,
|
||||
createDailyMissionsContent,
|
||||
areAllDailyComplete,
|
||||
totalDailyXp,
|
||||
getDefinition,
|
||||
MAX_DAILY_REROLLS,
|
||||
DAILY_BONUS_XP,
|
||||
} from '../lib/daily-missions';
|
||||
|
||||
import {
|
||||
readMissionsFromStorage,
|
||||
writeMissionsToStorage,
|
||||
hydrateFromPersisted,
|
||||
} from '../lib/daily-mission-tracker';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DailyMissionView {
|
||||
/** Mission ID (matches pool definition) */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Action type */
|
||||
action: DailyMissionAction;
|
||||
/** Required count */
|
||||
target: number;
|
||||
/** Current progress */
|
||||
progress: number;
|
||||
/** Whether mission is complete */
|
||||
complete: boolean;
|
||||
/** XP reward */
|
||||
xp: number;
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsOptions {
|
||||
/** Available Blobbi stages the user has (filters eligible missions) */
|
||||
availableStages?: BlobbiStage[];
|
||||
/**
|
||||
* Raw content string from the kind 11125 profile event.
|
||||
* Pass `profile.content` here. The hook parses it to extract
|
||||
* persisted missions and hydrates the session store on first load.
|
||||
*/
|
||||
profileContent?: string;
|
||||
}
|
||||
|
||||
export interface UseDailyMissionsResult {
|
||||
/** Current daily missions state */
|
||||
missions: DailyMission[];
|
||||
/** Whether all missions are completed */
|
||||
allCompleted: boolean;
|
||||
/** Whether all missions are claimed */
|
||||
allClaimed: boolean;
|
||||
/** Total potential reward for today (including bonus if available) */
|
||||
totalPotentialReward: number;
|
||||
/** Total claimed reward for today */
|
||||
todayClaimedReward: number;
|
||||
/** Lifetime total coins earned from daily missions */
|
||||
lifetimeCoinsEarned: number;
|
||||
/** Whether the bonus mission is available (all regular missions completed) */
|
||||
bonusAvailable: boolean;
|
||||
/** Whether the bonus mission has been claimed */
|
||||
bonusClaimed: boolean;
|
||||
/** Bonus mission reward amount */
|
||||
bonusReward: number;
|
||||
/** Whether user has no eligible missions (e.g., only eggs) */
|
||||
/** Today's daily missions with computed progress */
|
||||
missions: DailyMissionView[];
|
||||
/** The raw missions content (for persistence/mutation hooks) */
|
||||
raw: MissionsContent | undefined;
|
||||
/** Whether all daily missions are complete */
|
||||
allComplete: boolean;
|
||||
/** Total XP earned today (completed missions + bonus) */
|
||||
todayXp: number;
|
||||
/** Whether the daily bonus is unlocked (all missions complete) */
|
||||
bonusUnlocked: boolean;
|
||||
/** Bonus XP amount */
|
||||
bonusXp: number;
|
||||
/** Whether user has no eligible missions */
|
||||
noMissionsAvailable: boolean;
|
||||
/** Number of rerolls remaining for today */
|
||||
/** Rerolls remaining today */
|
||||
rerollsRemaining: number;
|
||||
/** Maximum rerolls allowed per day */
|
||||
/** Max rerolls per day */
|
||||
maxRerolls: number;
|
||||
/** Force refresh missions (for testing or manual reset) */
|
||||
/** Force refresh missions (testing) */
|
||||
forceReset: () => void;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useDailyMissions] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useDailyMissions(options: UseDailyMissionsOptions = {}): UseDailyMissionsResult {
|
||||
const { availableStages } = options;
|
||||
const { availableStages, profileContent } = options;
|
||||
const { user } = useCurrentUser();
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
// Read state directly from localStorage, with a version counter to trigger re-reads
|
||||
// Version counter to trigger re-reads from session store
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
// Read from localStorage on every render when version changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is intentionally used to force re-read
|
||||
const state = useMemo(() => readMissionsState(), [version]);
|
||||
|
||||
// Wrapper to write state and update version
|
||||
const setState = useCallback((newState: DailyMissionsState) => {
|
||||
writeMissionsState(newState);
|
||||
setVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
// Listen for external updates from mutations (reroll, claim, progress tracking)
|
||||
// This re-reads localStorage when other hooks modify it directly
|
||||
// Track whether we've hydrated for this pubkey
|
||||
const hydratedRef = useRef<string | null>(null);
|
||||
|
||||
// Hydrate session store from kind 11125 content on mount / account switch
|
||||
useEffect(() => {
|
||||
const handleExternalUpdate = () => {
|
||||
// Bump version to trigger a re-read from localStorage
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
if (!pubkey || !profileContent) return;
|
||||
if (hydratedRef.current === pubkey) return; // already hydrated this session
|
||||
|
||||
window.addEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
return () => window.removeEventListener('daily-missions-updated', handleExternalUpdate);
|
||||
// Check if session store already has data for this pubkey
|
||||
const existing = readMissionsFromStorage(pubkey);
|
||||
if (existing) {
|
||||
hydratedRef.current = pubkey;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse persisted missions from profile content
|
||||
const parsed = parseProfileContent(profileContent);
|
||||
if (parsed.missions && !needsDailyReset(parsed.missions)) {
|
||||
hydrateFromPersisted(parsed.missions, pubkey);
|
||||
hydratedRef.current = pubkey;
|
||||
setVersion((v) => v + 1);
|
||||
} else {
|
||||
hydratedRef.current = pubkey;
|
||||
}
|
||||
}, [pubkey, profileContent]);
|
||||
|
||||
// Listen for tracker events
|
||||
useEffect(() => {
|
||||
const handler = () => setVersion((v) => v + 1);
|
||||
window.addEventListener('daily-missions-updated', handler);
|
||||
return () => window.removeEventListener('daily-missions-updated', handler);
|
||||
}, []);
|
||||
|
||||
// Stable key for availableStages to use in dependencies
|
||||
// Stable stages key for deps
|
||||
const stagesKey = availableStages?.sort().join(',') ?? '';
|
||||
|
||||
// Ensure we have valid state for today
|
||||
const currentState = useMemo(() => {
|
||||
// Check if we need to reset for a new day
|
||||
if (needsDailyReset(state)) {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
// Persist the reset state (this will trigger version bump via setState)
|
||||
writeMissionsState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state && state.rerollsRemaining === undefined) {
|
||||
const migratedState = {
|
||||
...state,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
};
|
||||
writeMissionsState(migratedState);
|
||||
return migratedState;
|
||||
}
|
||||
|
||||
return state!;
|
||||
// Read and ensure current state
|
||||
const raw = useMemo((): MissionsContent | undefined => {
|
||||
const stored = readMissionsFromStorage(pubkey);
|
||||
|
||||
if (!needsDailyReset(stored)) return stored;
|
||||
|
||||
// Reset for new day, preserve evolution missions
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
stored?.evolution ?? [],
|
||||
pubkey,
|
||||
availableStages,
|
||||
);
|
||||
writeMissionsToStorage(fresh, pubkey);
|
||||
return fresh;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, pubkey, stagesKey]);
|
||||
}, [version, pubkey, stagesKey]);
|
||||
|
||||
// Force reset missions (for testing)
|
||||
const forceReset = () => {
|
||||
const previousCoins = state?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins, availableStages);
|
||||
setState(newState);
|
||||
};
|
||||
// Build view models
|
||||
const missions: DailyMissionView[] = useMemo(() => {
|
||||
if (!raw?.daily) return [];
|
||||
return raw.daily.map((m) => {
|
||||
const def = getDefinition(m.id);
|
||||
return {
|
||||
id: m.id,
|
||||
title: def?.title ?? m.id,
|
||||
description: def?.description ?? '',
|
||||
action: def?.action ?? 'interact',
|
||||
target: m.target,
|
||||
progress: missionProgress(m),
|
||||
complete: isMissionComplete(m),
|
||||
xp: def?.xp ?? 0,
|
||||
};
|
||||
});
|
||||
}, [raw]);
|
||||
|
||||
// Computed values
|
||||
const missions = currentState.missions;
|
||||
const allCompleted = areAllMissionsCompleted(currentState);
|
||||
const allClaimed = areAllMissionsClaimed(currentState);
|
||||
const bonusAvailable = isBonusMissionAvailable(currentState);
|
||||
const bonusClaimed = isBonusMissionClaimed(currentState);
|
||||
const bonusReward = BONUS_MISSION_DEFINITION.reward;
|
||||
const allComplete = raw ? areAllDailyComplete(raw) : false;
|
||||
const todayXp = raw ? totalDailyXp(raw) : 0;
|
||||
const bonusUnlocked = allComplete;
|
||||
const noMissionsAvailable = missions.length === 0;
|
||||
const rerollsRemaining = getRerollsRemaining(currentState);
|
||||
const maxRerolls = MAX_DAILY_REROLLS;
|
||||
|
||||
// Total potential includes bonus if regular missions exist
|
||||
const basePotentialReward = getTotalPotentialReward(currentState);
|
||||
const totalPotentialReward = missions.length > 0
|
||||
? basePotentialReward + bonusReward
|
||||
: 0;
|
||||
|
||||
// Today's claimed includes bonus if claimed
|
||||
const baseTodayClaimedReward = getTodayClaimedReward(currentState);
|
||||
const todayClaimedReward = baseTodayClaimedReward + (bonusClaimed ? bonusReward : 0);
|
||||
|
||||
const lifetimeCoinsEarned = currentState.totalCoinsEarned;
|
||||
const rerollsRemaining = raw?.rerolls ?? MAX_DAILY_REROLLS;
|
||||
|
||||
const forceReset = useCallback(() => {
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
raw?.evolution ?? [],
|
||||
pubkey,
|
||||
availableStages,
|
||||
);
|
||||
writeMissionsToStorage(fresh, pubkey);
|
||||
setVersion((v) => v + 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pubkey, stagesKey, raw?.evolution]);
|
||||
|
||||
return {
|
||||
missions,
|
||||
allCompleted,
|
||||
allClaimed,
|
||||
totalPotentialReward,
|
||||
todayClaimedReward,
|
||||
lifetimeCoinsEarned,
|
||||
bonusAvailable,
|
||||
bonusClaimed,
|
||||
bonusReward,
|
||||
raw,
|
||||
allComplete,
|
||||
todayXp,
|
||||
bonusUnlocked,
|
||||
bonusXp: DAILY_BONUS_XP,
|
||||
noMissionsAvailable,
|
||||
rerollsRemaining,
|
||||
maxRerolls,
|
||||
maxRerolls: MAX_DAILY_REROLLS,
|
||||
forceReset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* useItemCooldown — React hook for per-item cooldown state.
|
||||
*
|
||||
* Subscribes to the shared item-cooldown singleton so components
|
||||
* re-render when any item's cooldown starts or expires.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { isOnCooldown } = useItemCooldown();
|
||||
* <Button disabled={isOnCooldown(item.id)}>Use</Button>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import { isItemOnCooldown, subscribeCooldowns } from '../lib/item-cooldown';
|
||||
|
||||
/** Monotonic version counter bumped by the subscription callback. */
|
||||
let snapshotVersion = 0;
|
||||
|
||||
function subscribe(onStoreChange: () => void): () => void {
|
||||
// subscribeCooldowns returns an unsubscribe function.
|
||||
// The callback bumps the version AND notifies React.
|
||||
return subscribeCooldowns(() => {
|
||||
snapshotVersion++;
|
||||
onStoreChange();
|
||||
});
|
||||
}
|
||||
|
||||
function getSnapshot(): number {
|
||||
return snapshotVersion;
|
||||
}
|
||||
|
||||
export function useItemCooldown() {
|
||||
useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const isOnCooldown = useCallback((itemId: string): boolean => {
|
||||
return isItemOnCooldown(itemId);
|
||||
}, []);
|
||||
|
||||
return { isOnCooldown };
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* useRerollMission - Hook for rerolling daily missions
|
||||
*
|
||||
* Handles:
|
||||
* - Replacing a mission with a new one from the pool
|
||||
* - Tracking reroll usage (max 3 per day)
|
||||
* - Respecting stage-based mission filtering
|
||||
* - Persisting state to localStorage
|
||||
* useRerollMission - Replace a daily mission with a new one from the pool
|
||||
*
|
||||
* Updates the in-memory session store.
|
||||
*/
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -13,17 +9,12 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiStage } from '../lib/daily-missions';
|
||||
import { rerollMission, getDefinition } from '../lib/daily-missions';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMission,
|
||||
type BlobbiStage,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
rerollMission,
|
||||
canRerollMission,
|
||||
getRerollsRemaining,
|
||||
} from '../lib/daily-missions';
|
||||
readMissionsFromStorage,
|
||||
writeMissionsToStorage,
|
||||
} from '../lib/daily-mission-tracker';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,118 +25,51 @@ export interface RerollMissionRequest {
|
||||
|
||||
export interface RerollMissionResult {
|
||||
oldMissionId: string;
|
||||
newMission: DailyMission;
|
||||
newMissionId: string;
|
||||
rerollsRemaining: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
|
||||
function readMissionsState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
const state = JSON.parse(stored) as DailyMissionsState;
|
||||
|
||||
// Migration: ensure rerollsRemaining is set for old state
|
||||
if (state.rerollsRemaining === undefined) {
|
||||
state.rerollsRemaining = 3; // MAX_DAILY_REROLLS
|
||||
}
|
||||
|
||||
return state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMissionsState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[useRerollMission] Failed to write state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to reroll a daily mission.
|
||||
*
|
||||
* Replaces the specified mission with a new one from the pool,
|
||||
* respecting stage-based filtering and avoiding duplicates.
|
||||
*/
|
||||
export function useRerollMission() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ missionId, availableStages }: RerollMissionRequest): Promise<RerollMissionResult> => {
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to reroll missions');
|
||||
}
|
||||
if (!user?.pubkey) throw new Error('Must be logged in');
|
||||
|
||||
// Read current missions state from localStorage
|
||||
let missionsState = readMissionsState();
|
||||
|
||||
// Ensure we have valid state for today
|
||||
if (needsDailyReset(missionsState)) {
|
||||
const previousCoins = missionsState?.totalCoinsEarned ?? 0;
|
||||
missionsState = createDailyMissionsState(getTodayDateString(), user.pubkey, previousCoins, availableStages);
|
||||
}
|
||||
const current = readMissionsFromStorage(user.pubkey);
|
||||
if (!current) throw new Error('No missions state');
|
||||
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(missionsState!, missionId)) {
|
||||
const rerollsLeft = getRerollsRemaining(missionsState!);
|
||||
if (rerollsLeft <= 0) {
|
||||
throw new Error('No rerolls remaining today');
|
||||
}
|
||||
|
||||
const mission = missionsState!.missions.find(m => m.id === missionId);
|
||||
if (mission?.completed || mission?.claimed) {
|
||||
throw new Error('Cannot reroll completed or claimed missions');
|
||||
}
|
||||
|
||||
throw new Error('Cannot reroll this mission');
|
||||
}
|
||||
const updated = rerollMission(current, missionId, availableStages);
|
||||
if (!updated) throw new Error('Cannot reroll this mission');
|
||||
|
||||
// Perform the reroll
|
||||
const result = rerollMission(missionsState!, missionId, availableStages);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No replacement missions available. All alternative missions may already be in your daily list.');
|
||||
}
|
||||
writeMissionsToStorage(updated, user.pubkey);
|
||||
|
||||
// Persist the updated state
|
||||
writeMissionsState(result.state);
|
||||
|
||||
// Dispatch event for React components to re-render
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: {
|
||||
missionId,
|
||||
rerolled: true,
|
||||
newMissionId: result.newMission.id,
|
||||
}
|
||||
// Notify React
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', {
|
||||
detail: { missionId, rerolled: true },
|
||||
}));
|
||||
|
||||
// Find the new mission ID at the same index
|
||||
const oldIdx = current.daily.findIndex((m) => m.id === missionId);
|
||||
const newMissionId = updated.daily[oldIdx]?.id ?? missionId;
|
||||
|
||||
return {
|
||||
oldMissionId: missionId,
|
||||
newMission: result.newMission,
|
||||
rerollsRemaining: getRerollsRemaining(result.state),
|
||||
newMissionId,
|
||||
rerollsRemaining: updated.rerolls,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ newMission, rerollsRemaining }) => {
|
||||
const rerollText = rerollsRemaining === 1
|
||||
? '1 reroll left'
|
||||
: rerollsRemaining === 0
|
||||
? 'No rerolls left'
|
||||
: `${rerollsRemaining} rerolls left`;
|
||||
|
||||
onSuccess: ({ newMissionId, rerollsRemaining }) => {
|
||||
const def = getDefinition(newMissionId);
|
||||
const rerollText = rerollsRemaining === 0
|
||||
? 'No rerolls left'
|
||||
: `${rerollsRemaining} reroll${rerollsRemaining === 1 ? '' : 's'} left`;
|
||||
|
||||
toast({
|
||||
title: 'Mission Replaced',
|
||||
description: `New mission: ${newMission.title}. ${rerollText}.`,
|
||||
description: `New mission: ${def?.title ?? newMissionId}. ${rerollText}.`,
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
|
||||
@@ -155,20 +155,65 @@ export {
|
||||
|
||||
// Daily Missions
|
||||
export { useDailyMissions } from './hooks/useDailyMissions';
|
||||
export type { UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export type { ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
export type { DailyMissionView, UseDailyMissionsResult } from './hooks/useDailyMissions';
|
||||
export { useAwardDailyXp, useClaimMissionReward } from './hooks/useClaimMissionReward';
|
||||
export type { AwardDailyXpRequest, AwardDailyXpResult, ClaimMissionRequest, ClaimMissionResult } from './hooks/useClaimMissionReward';
|
||||
export { useRerollMission } from './hooks/useRerollMission';
|
||||
export type { RerollMissionRequest, RerollMissionResult } from './hooks/useRerollMission';
|
||||
export {
|
||||
trackDailyMissionProgress,
|
||||
trackDailyMissionEvent,
|
||||
trackMultipleDailyMissionActions,
|
||||
} from './lib/daily-mission-tracker';
|
||||
export type {
|
||||
DailyMission,
|
||||
DailyMissionAction,
|
||||
DailyMissionDefinition,
|
||||
DailyMissionsState,
|
||||
Mission,
|
||||
TallyMission,
|
||||
EventMission,
|
||||
MissionsContent,
|
||||
} from './lib/daily-missions';
|
||||
|
||||
// Progression
|
||||
export {
|
||||
xpToLevel,
|
||||
levelToXp,
|
||||
xpProgress,
|
||||
xpToNextLevel,
|
||||
getUnlocks,
|
||||
buildXpTagUpdates,
|
||||
MAX_LEVEL,
|
||||
} from '@/blobbi/core/lib/progression';
|
||||
export type { Unlocks } from '@/blobbi/core/lib/progression';
|
||||
|
||||
// Missions content model
|
||||
export {
|
||||
parseProfileContent,
|
||||
serializeProfileContent,
|
||||
isMissionComplete,
|
||||
isTallyMission,
|
||||
isEventMission,
|
||||
missionProgress,
|
||||
} from '@/blobbi/core/lib/missions';
|
||||
export type { ProfileContent } from '@/blobbi/core/lib/missions';
|
||||
|
||||
// Item cooldown
|
||||
export { isItemOnCooldown, setItemCooldown, subscribeCooldowns } from './lib/item-cooldown';
|
||||
export { ITEM_COOLDOWN_SUCCESS_MS, ITEM_COOLDOWN_FAILURE_MS } from './lib/item-cooldown';
|
||||
export { useItemCooldown } from './hooks/useItemCooldown';
|
||||
|
||||
// Action XP
|
||||
export {
|
||||
ACTION_XP,
|
||||
INVENTORY_ACTION_XP,
|
||||
DIRECT_ACTION_XP,
|
||||
POOP_CLEANUP_XP,
|
||||
calculateActionXP,
|
||||
calculateInventoryActionXP,
|
||||
applyXPGain,
|
||||
formatXPGain,
|
||||
} from './lib/blobbi-xp';
|
||||
|
||||
// Streak tracking
|
||||
export {
|
||||
calculateStreakUpdate,
|
||||
|
||||
@@ -44,6 +44,11 @@ export const ACTION_XP: Record<BlobbiAction, number> = {
|
||||
...DIRECT_ACTION_XP,
|
||||
};
|
||||
|
||||
/**
|
||||
* XP awarded for cleaning up poop.
|
||||
*/
|
||||
export const POOP_CLEANUP_XP = 5;
|
||||
|
||||
// ─── XP Calculation Utilities ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,109 +1,115 @@
|
||||
/**
|
||||
* Daily Mission Tracker - Standalone progress tracking utility
|
||||
*
|
||||
* This module provides a simple way to track daily mission progress
|
||||
* without requiring React hooks or context. It directly manipulates
|
||||
* localStorage for immediate persistence.
|
||||
*
|
||||
* This approach allows action hooks (which may be called outside of
|
||||
* the daily missions hook context) to record progress.
|
||||
*
|
||||
* Provides a way to record daily mission progress from anywhere
|
||||
* (hooks, event handlers, etc.) without requiring React context.
|
||||
*
|
||||
* Uses a pubkey-scoped in-memory Map. Kind 11125 content JSON is the
|
||||
* persistent source of truth. Completed missions are persisted by
|
||||
* `useAwardDailyXp`; intermediate progress resets on page refresh.
|
||||
*
|
||||
* Dispatches 'daily-missions-updated' CustomEvent so React hooks re-render.
|
||||
*/
|
||||
|
||||
import type { MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import type { DailyMissionAction } from './daily-missions';
|
||||
import {
|
||||
type DailyMissionsState,
|
||||
type DailyMissionAction,
|
||||
getTodayDateString,
|
||||
needsDailyReset,
|
||||
createDailyMissionsState,
|
||||
updateMissionProgress,
|
||||
createDailyMissionsContent,
|
||||
trackTally,
|
||||
trackEvent,
|
||||
} from './daily-missions';
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 'blobbi:daily-missions';
|
||||
|
||||
// ─── Storage Utilities ────────────────────────────────────────────────────────
|
||||
// ─── In-Memory Session Store ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the current daily missions state from localStorage
|
||||
* Pubkey-scoped session cache. Each logged-in user gets their own entry.
|
||||
* Cleared on page refresh (intentional — kind 11125 is the persistent store).
|
||||
*/
|
||||
function readState(): DailyMissionsState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const sessionStore = new Map<string, MissionsContent>();
|
||||
|
||||
function key(pubkey: string | undefined): string {
|
||||
return pubkey ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the daily missions state to localStorage
|
||||
*/
|
||||
function writeState(state: DailyMissionsState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('[DailyMissionTracker] Failed to write state:', error);
|
||||
}
|
||||
function ensureCurrent(pubkey?: string): MissionsContent {
|
||||
const current = sessionStore.get(key(pubkey));
|
||||
if (!needsDailyReset(current)) return current!;
|
||||
const fresh = createDailyMissionsContent(
|
||||
getTodayDateString(),
|
||||
current?.evolution ?? [],
|
||||
pubkey,
|
||||
);
|
||||
sessionStore.set(key(pubkey), fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid state for today, creating one if necessary
|
||||
*/
|
||||
function ensureCurrentState(pubkey?: string): DailyMissionsState {
|
||||
const current = readState();
|
||||
|
||||
if (needsDailyReset(current)) {
|
||||
const previousCoins = current?.totalCoinsEarned ?? 0;
|
||||
const newState = createDailyMissionsState(getTodayDateString(), pubkey, previousCoins);
|
||||
writeState(newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
return current!;
|
||||
function notify(detail?: Record<string, unknown>): void {
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail }));
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Record progress for a daily mission action.
|
||||
* This function can be called from anywhere (hooks, event handlers, etc.)
|
||||
* and will immediately persist to localStorage.
|
||||
*
|
||||
* @param action - The action type that was performed
|
||||
* @param count - Number of times the action was performed (default: 1)
|
||||
* @param pubkey - Optional user pubkey for personalized mission selection
|
||||
* Record a tally-based action (feed, clean, interact, etc.).
|
||||
*/
|
||||
export function trackDailyMissionProgress(
|
||||
action: DailyMissionAction,
|
||||
count: number = 1,
|
||||
pubkey?: string
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = ensureCurrentState(pubkey);
|
||||
const updated = updateMissionProgress(current, action, count);
|
||||
writeState(updated);
|
||||
|
||||
// Dispatch a custom event so React components can re-render if needed
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { action, count } }));
|
||||
const current = ensureCurrent(pubkey);
|
||||
const updated = trackTally(current, action, count);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ action, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to track multiple actions at once.
|
||||
* Useful when an action should count toward multiple missions.
|
||||
*
|
||||
* @param actions - Array of actions to track
|
||||
* @param pubkey - Optional user pubkey
|
||||
* Record an event-based action (take_photo, etc.) with its Nostr event ID.
|
||||
*/
|
||||
export function trackDailyMissionEvent(
|
||||
action: DailyMissionAction,
|
||||
eventId: string,
|
||||
pubkey?: string,
|
||||
): void {
|
||||
const current = ensureCurrent(pubkey);
|
||||
const updated = trackEvent(current, action, eventId);
|
||||
sessionStore.set(key(pubkey), updated);
|
||||
notify({ action, eventId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Track multiple tally actions at once.
|
||||
*/
|
||||
export function trackMultipleDailyMissionActions(
|
||||
actions: DailyMissionAction[],
|
||||
pubkey?: string
|
||||
pubkey?: string,
|
||||
): void {
|
||||
let current = ensureCurrentState(pubkey);
|
||||
|
||||
let current = ensureCurrent(pubkey);
|
||||
for (const action of actions) {
|
||||
current = updateMissionProgress(current, action, 1);
|
||||
current = trackTally(current, action, 1);
|
||||
}
|
||||
|
||||
writeState(current);
|
||||
window.dispatchEvent(new CustomEvent('daily-missions-updated', { detail: { actions } }));
|
||||
sessionStore.set(key(pubkey), current);
|
||||
notify({ actions });
|
||||
}
|
||||
|
||||
/** Read current session state for a pubkey. */
|
||||
export function readMissionsFromStorage(pubkey?: string): MissionsContent | undefined {
|
||||
return sessionStore.get(key(pubkey));
|
||||
}
|
||||
|
||||
/** Write state to session store for a pubkey. */
|
||||
export function writeMissionsToStorage(missions: MissionsContent, pubkey?: string): void {
|
||||
sessionStore.set(key(pubkey), missions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the session store from kind 11125 persisted data.
|
||||
* Called once on mount / account switch when the session store is empty.
|
||||
* No-op if the store already has data for this pubkey.
|
||||
*/
|
||||
export function hydrateFromPersisted(missions: MissionsContent, pubkey: string): void {
|
||||
if (sessionStore.has(pubkey)) return;
|
||||
sessionStore.set(pubkey, missions);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
/**
|
||||
* Daily Missions System for Blobbi
|
||||
*
|
||||
* This module defines the daily mission pool, selection logic, and types.
|
||||
* Daily missions are separate from hatch/evolve missions and provide
|
||||
* daily engagement loops with coin rewards.
|
||||
*
|
||||
* Defines the daily mission pool, selection logic, and state management.
|
||||
* Missions use the tally/event model from missions.ts:
|
||||
* - Tally missions: { id, target, count }
|
||||
* - Event missions: { id, target, events }
|
||||
* Completion is derived: count >= target or events.length >= target.
|
||||
* No explicit completed/claimed flags.
|
||||
*/
|
||||
|
||||
import type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
import { isTallyMission, isEventMission, isMissionComplete } from '@/blobbi/core/lib/missions';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mission action types that can trigger progress
|
||||
* Actions that can trigger daily mission progress.
|
||||
* Tally actions increment a counter. Event actions append an event ID.
|
||||
*/
|
||||
export type DailyMissionAction =
|
||||
| 'interact' // Any interaction (feed, clean, play, etc.)
|
||||
| 'feed' // Feeding action specifically
|
||||
| 'clean' // Cleaning action specifically
|
||||
| 'sing' // Sing direct action
|
||||
| 'play_music' // Play music direct action
|
||||
| 'sleep' // Put Blobbi to sleep
|
||||
| 'take_photo' // Take a photo of Blobbi
|
||||
| 'medicine'; // Give medicine to Blobbi
|
||||
export type DailyMissionAction =
|
||||
| 'interact' // Any care interaction (tally)
|
||||
| 'feed' // Feeding action (tally)
|
||||
| 'clean' // Cleaning action (tally)
|
||||
| 'sing' // Sing direct action (tally)
|
||||
| 'play_music' // Play music direct action (tally)
|
||||
| 'sleep' // Put Blobbi to sleep (tally)
|
||||
| 'take_photo' // Take a photo (event)
|
||||
| 'medicine'; // Give medicine (tally)
|
||||
|
||||
/**
|
||||
* Blobbi stage type for filtering missions
|
||||
*/
|
||||
/** Whether a mission action tracks events or tallies */
|
||||
export type MissionTrackingType = 'tally' | 'event';
|
||||
|
||||
/** Blobbi stage type for filtering missions */
|
||||
export type BlobbiStage = 'egg' | 'baby' | 'adult';
|
||||
|
||||
/**
|
||||
* Definition of a daily mission in the pool
|
||||
* Definition of a daily mission in the pool.
|
||||
* This is the static template -- not the runtime state.
|
||||
*/
|
||||
export interface DailyMissionDefinition {
|
||||
/** Unique identifier for this mission type */
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
/** Display title */
|
||||
title: string;
|
||||
@@ -39,277 +48,160 @@ export interface DailyMissionDefinition {
|
||||
/** Action that triggers progress */
|
||||
action: DailyMissionAction;
|
||||
/** Number of times the action must be performed */
|
||||
requiredCount: number;
|
||||
/** Coin reward for completing this mission */
|
||||
reward: number;
|
||||
/** Selection weight (higher = more likely to be selected) */
|
||||
target: number;
|
||||
/** Whether this mission tracks events or tallies */
|
||||
tracking: MissionTrackingType;
|
||||
/** XP reward for completing this mission */
|
||||
xp: number;
|
||||
/** Selection weight (higher = more likely) */
|
||||
weight: number;
|
||||
/** Required stages to show this mission (if empty/undefined, requires baby or adult) */
|
||||
/** Required stages to show this mission */
|
||||
requiredStages?: BlobbiStage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A daily mission instance with progress tracking
|
||||
*/
|
||||
export interface DailyMission extends DailyMissionDefinition {
|
||||
/** Current progress (how many times the action has been performed today) */
|
||||
currentCount: number;
|
||||
/** Whether the mission has been completed */
|
||||
completed: boolean;
|
||||
/** Whether the reward has been claimed */
|
||||
claimed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored state for daily missions (persisted in localStorage)
|
||||
*/
|
||||
export interface DailyMissionsState {
|
||||
/** The date string (YYYY-MM-DD) when these missions were generated */
|
||||
date: string;
|
||||
/** The selected missions for this day */
|
||||
missions: DailyMission[];
|
||||
/** Total coins earned from daily missions (lifetime) */
|
||||
totalCoinsEarned: number;
|
||||
/** Whether the bonus mission has been claimed today */
|
||||
bonusClaimed?: boolean;
|
||||
/** Number of rerolls remaining for today (resets daily, max 3) */
|
||||
rerollsRemaining?: number;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum number of mission rerolls allowed per day */
|
||||
export const MAX_DAILY_REROLLS = 3;
|
||||
|
||||
/** Number of daily missions selected each day */
|
||||
export const DAILY_MISSION_COUNT = 3;
|
||||
|
||||
/** XP bonus for completing all daily missions */
|
||||
export const DAILY_BONUS_XP = 50;
|
||||
|
||||
// ─── Mission Pool ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pool of available daily missions.
|
||||
* Weights determine selection frequency:
|
||||
* - High weight (10): Common missions (interact, feed, clean)
|
||||
* - Medium weight (6): Regular missions (sing, play music, sleep)
|
||||
* - Low weight (2): Uncommon missions (change shape)
|
||||
* - Rare weight (1): Rare missions (take photo)
|
||||
*/
|
||||
export const DAILY_MISSION_POOL: DailyMissionDefinition[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BABY/ADULT ONLY MISSIONS
|
||||
// These actions are NOT available for eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Interact Missions (Baby/Adult only) ───────────────────────────────────
|
||||
// ── Baby/Adult only ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'interact_3',
|
||||
title: 'Quick Care',
|
||||
id: 'interact_3', title: 'Quick Care',
|
||||
description: 'Interact with your Blobbi 3 times',
|
||||
action: 'interact',
|
||||
requiredCount: 3,
|
||||
reward: 30,
|
||||
weight: 10,
|
||||
action: 'interact', target: 3, tracking: 'tally', xp: 15, weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'interact_6',
|
||||
title: 'Attentive Caretaker',
|
||||
id: 'interact_6', title: 'Attentive Caretaker',
|
||||
description: 'Interact with your Blobbi 6 times',
|
||||
action: 'interact',
|
||||
requiredCount: 6,
|
||||
reward: 50,
|
||||
weight: 8,
|
||||
action: 'interact', target: 6, tracking: 'tally', xp: 30, weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Feed Missions (Baby/Adult only) ───────────────────────────────────────
|
||||
{
|
||||
id: 'feed_1',
|
||||
title: 'Snack Time',
|
||||
id: 'feed_1', title: 'Snack Time',
|
||||
description: 'Feed your Blobbi once',
|
||||
action: 'feed',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
action: 'feed', target: 1, tracking: 'tally', xp: 10, weight: 10,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_2',
|
||||
title: 'Hungry Blobbi',
|
||||
id: 'feed_2', title: 'Hungry Blobbi',
|
||||
description: 'Feed your Blobbi 2 times',
|
||||
action: 'feed',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 8,
|
||||
action: 'feed', target: 2, tracking: 'tally', xp: 20, weight: 8,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'feed_3',
|
||||
title: 'Feast Day',
|
||||
id: 'feed_3', title: 'Feast Day',
|
||||
description: 'Feed your Blobbi 3 times',
|
||||
action: 'feed',
|
||||
requiredCount: 3,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
action: 'feed', target: 3, tracking: 'tally', xp: 35, weight: 5,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sleep Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'sleep_1',
|
||||
title: 'Nap Time',
|
||||
id: 'sleep_1', title: 'Nap Time',
|
||||
description: 'Put your Blobbi to sleep',
|
||||
action: 'sleep',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Photo Missions (Baby/Adult only) ──────────────────────────────────────
|
||||
{
|
||||
id: 'take_photo_1',
|
||||
title: 'Snapshot',
|
||||
description: 'Take a polaroid photo of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 1,
|
||||
reward: 55,
|
||||
weight: 4,
|
||||
action: 'sleep', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_2',
|
||||
title: 'Photo Album',
|
||||
id: 'take_photo_1', title: 'Snapshot',
|
||||
description: 'Take a photo of your Blobbi',
|
||||
action: 'take_photo', target: 1, tracking: 'event', xp: 25, weight: 4,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'take_photo_2', title: 'Photo Album',
|
||||
description: 'Take 2 photos of your Blobbi',
|
||||
action: 'take_photo',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 2,
|
||||
action: 'take_photo', target: 2, tracking: 'event', xp: 40, weight: 2,
|
||||
requiredStages: ['baby', 'adult'],
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EGG + BABY + ADULT MISSIONS
|
||||
// These actions are available for ALL stages including eggs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Clean Missions (All stages) ───────────────────────────────────────────
|
||||
// ── All stages ───────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'clean_1',
|
||||
title: 'Quick Cleanup',
|
||||
id: 'clean_1', title: 'Quick Cleanup',
|
||||
description: 'Clean your Blobbi once',
|
||||
action: 'clean',
|
||||
requiredCount: 1,
|
||||
reward: 25,
|
||||
weight: 10,
|
||||
action: 'clean', target: 1, tracking: 'tally', xp: 10, weight: 10,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'clean_2',
|
||||
title: 'Squeaky Clean',
|
||||
id: 'clean_2', title: 'Squeaky Clean',
|
||||
description: 'Clean your Blobbi 2 times',
|
||||
action: 'clean',
|
||||
requiredCount: 2,
|
||||
reward: 45,
|
||||
weight: 6,
|
||||
action: 'clean', target: 2, tracking: 'tally', xp: 20, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Sing Missions (All stages) ────────────────────────────────────────────
|
||||
{
|
||||
id: 'sing_1',
|
||||
title: 'Sing Along',
|
||||
id: 'sing_1', title: 'Sing Along',
|
||||
description: 'Sing a song to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
action: 'sing', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'sing_2',
|
||||
title: 'Karaoke Session',
|
||||
id: 'sing_2', title: 'Karaoke Session',
|
||||
description: 'Sing 2 songs to your Blobbi',
|
||||
action: 'sing',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
action: 'sing', target: 2, tracking: 'tally', xp: 25, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Play Music Missions (All stages) ──────────────────────────────────────
|
||||
{
|
||||
id: 'play_music_1',
|
||||
title: 'DJ Time',
|
||||
id: 'play_music_1', title: 'DJ Time',
|
||||
description: 'Play a song for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 1,
|
||||
reward: 30,
|
||||
weight: 6,
|
||||
action: 'play_music', target: 1, tracking: 'tally', xp: 15, weight: 6,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'play_music_2',
|
||||
title: 'Music Marathon',
|
||||
id: 'play_music_2', title: 'Music Marathon',
|
||||
description: 'Play 2 songs for your Blobbi',
|
||||
action: 'play_music',
|
||||
requiredCount: 2,
|
||||
reward: 50,
|
||||
weight: 3,
|
||||
action: 'play_music', target: 2, tracking: 'tally', xp: 25, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
|
||||
// ─── Medicine Missions (All stages) ────────────────────────────────────────
|
||||
// Medicine rewards are higher since medicine costs coins to use
|
||||
{
|
||||
id: 'medicine_1',
|
||||
title: 'Health Check',
|
||||
id: 'medicine_1', title: 'Health Check',
|
||||
description: 'Give medicine to your Blobbi',
|
||||
action: 'medicine',
|
||||
requiredCount: 1,
|
||||
reward: 60,
|
||||
weight: 5,
|
||||
action: 'medicine', target: 1, tracking: 'tally', xp: 20, weight: 5,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
{
|
||||
id: 'medicine_2',
|
||||
title: 'Doctor Visit',
|
||||
id: 'medicine_2', title: 'Doctor Visit',
|
||||
description: 'Give medicine to your Blobbi 2 times',
|
||||
action: 'medicine',
|
||||
requiredCount: 2,
|
||||
reward: 70,
|
||||
weight: 3,
|
||||
action: 'medicine', target: 2, tracking: 'tally', xp: 35, weight: 3,
|
||||
requiredStages: ['egg', 'baby', 'adult'],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
// ─── Lookup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current date string in YYYY-MM-DD format (local timezone)
|
||||
*/
|
||||
const POOL_BY_ID = new Map(DAILY_MISSION_POOL.map((d) => [d.id, d]));
|
||||
|
||||
/** Look up a mission definition by ID */
|
||||
export function getDefinition(id: string): DailyMissionDefinition | undefined {
|
||||
return POOL_BY_ID.get(id);
|
||||
}
|
||||
|
||||
// ─── Date Utilities ──────────────────────────────────────────────────────────
|
||||
|
||||
/** YYYY-MM-DD in local timezone */
|
||||
export function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a seed number from a date string and optional user pubkey.
|
||||
* Used for deterministic daily mission selection.
|
||||
*/
|
||||
function generateDailySeed(dateString: string, pubkey?: string): number {
|
||||
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
/** Whether the missions content needs a daily reset */
|
||||
export function needsDailyReset(missions: MissionsContent | undefined): boolean {
|
||||
if (!missions) return true;
|
||||
return missions.date !== getTodayDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeded random number generator (Mulberry32)
|
||||
*/
|
||||
// ─── Selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Seeded PRNG (Mulberry32) */
|
||||
function seededRandom(seed: number): () => number {
|
||||
return function() {
|
||||
return function () {
|
||||
let t = seed += 0x6D2B79F5;
|
||||
t = Math.imul(t ^ t >>> 15, t | 1);
|
||||
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
||||
@@ -317,392 +209,245 @@ function seededRandom(seed: number): () => number {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mission is available for the given stages.
|
||||
* Missions with no requiredStages default to requiring baby or adult.
|
||||
*/
|
||||
function isMissionAvailableForStages(
|
||||
mission: DailyMissionDefinition,
|
||||
availableStages: BlobbiStage[]
|
||||
): boolean {
|
||||
const requiredStages = mission.requiredStages ?? ['baby', 'adult'];
|
||||
return requiredStages.some((stage) => availableStages.includes(stage));
|
||||
function generateDailySeed(dateString: string, pubkey?: string): number {
|
||||
const input = pubkey ? `${dateString}:${pubkey}` : dateString;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = ((hash << 5) - hash) + input.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function isMissionAvailableForStages(def: DailyMissionDefinition, stages: BlobbiStage[]): boolean {
|
||||
const required = def.requiredStages ?? ['baby', 'adult'];
|
||||
return required.some((s) => stages.includes(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select N missions from the pool using weighted random selection.
|
||||
* Uses a seeded random generator for deterministic daily selection.
|
||||
*
|
||||
* @param count - Number of missions to select
|
||||
* @param dateString - Date string for seeding (YYYY-MM-DD)
|
||||
* @param pubkey - Optional user pubkey for seeding
|
||||
* @param availableStages - Stages the user has available (filters eligible missions)
|
||||
* Select N missions deterministically from the pool.
|
||||
* Seeded by date + pubkey so the same user gets the same missions for a given day.
|
||||
*/
|
||||
export function selectDailyMissions(
|
||||
count: number,
|
||||
dateString: string,
|
||||
pubkey?: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
availableStages?: BlobbiStage[],
|
||||
): DailyMissionDefinition[] {
|
||||
const seed = generateDailySeed(dateString, pubkey);
|
||||
const random = seededRandom(seed);
|
||||
|
||||
// Filter pool by available stages (default to baby/adult if not specified)
|
||||
const stagesToCheck = availableStages ?? ['baby', 'adult'];
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) =>
|
||||
isMissionAvailableForStages(m, stagesToCheck)
|
||||
);
|
||||
|
||||
// If no missions are available for the user's stages, return empty
|
||||
if (eligibleMissions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create a copy of the eligible pool
|
||||
const available = [...eligibleMissions];
|
||||
const stages = availableStages ?? ['baby', 'adult'];
|
||||
const eligible = DAILY_MISSION_POOL.filter((m) => isMissionAvailableForStages(m, stages));
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
const random = seededRandom(generateDailySeed(dateString, pubkey));
|
||||
const available = [...eligible];
|
||||
const selected: DailyMissionDefinition[] = [];
|
||||
|
||||
|
||||
while (selected.length < count && available.length > 0) {
|
||||
// Calculate total weight of remaining missions
|
||||
const totalWeight = available.reduce((sum, m) => sum + m.weight, 0);
|
||||
|
||||
// Pick a random value in [0, totalWeight)
|
||||
let pick = random() * totalWeight;
|
||||
|
||||
// Find the mission that corresponds to this pick
|
||||
let selectedIndex = 0;
|
||||
let idx = 0;
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
pick -= available[i].weight;
|
||||
if (pick <= 0) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
if (pick <= 0) { idx = i; break; }
|
||||
}
|
||||
|
||||
// Add to selected and remove from available
|
||||
selected.push(available[selectedIndex]);
|
||||
available.splice(selectedIndex, 1);
|
||||
selected.push(available[idx]);
|
||||
available.splice(idx, 1);
|
||||
}
|
||||
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh DailyMission from a definition
|
||||
*/
|
||||
export function createMissionFromDefinition(def: DailyMissionDefinition): DailyMission {
|
||||
return {
|
||||
...def,
|
||||
currentCount: 0,
|
||||
completed: false,
|
||||
claimed: false,
|
||||
};
|
||||
// ─── Mission Instantiation ───────────────────────────────────────────────────
|
||||
|
||||
/** Create a fresh Mission from a definition */
|
||||
export function createMission(def: DailyMissionDefinition): Mission {
|
||||
if (def.tracking === 'event') {
|
||||
return { id: def.id, target: def.target, events: [] } satisfies EventMission;
|
||||
}
|
||||
return { id: def.id, target: def.target, count: 0 } satisfies TallyMission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the initial daily missions state for a new day
|
||||
*/
|
||||
export function createDailyMissionsState(
|
||||
/** Create a fresh MissionsContent for a new day, preserving evolution missions */
|
||||
export function createDailyMissionsContent(
|
||||
dateString: string,
|
||||
existingEvolution: Mission[],
|
||||
pubkey?: string,
|
||||
previousTotalCoins: number = 0,
|
||||
availableStages?: BlobbiStage[]
|
||||
): DailyMissionsState {
|
||||
const definitions = selectDailyMissions(3, dateString, pubkey, availableStages);
|
||||
availableStages?: BlobbiStage[],
|
||||
): MissionsContent {
|
||||
const defs = selectDailyMissions(DAILY_MISSION_COUNT, dateString, pubkey, availableStages);
|
||||
return {
|
||||
date: dateString,
|
||||
missions: definitions.map(createMissionFromDefinition),
|
||||
totalCoinsEarned: previousTotalCoins,
|
||||
rerollsRemaining: MAX_DAILY_REROLLS,
|
||||
daily: defs.map(createMission),
|
||||
evolution: existingEvolution,
|
||||
rerolls: MAX_DAILY_REROLLS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the daily missions need to be reset (new day)
|
||||
*/
|
||||
export function needsDailyReset(state: DailyMissionsState | null): boolean {
|
||||
if (!state) return true;
|
||||
return state.date !== getTodayDateString();
|
||||
}
|
||||
// ─── Progress Tracking ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update mission progress for a given action
|
||||
* Increment tally for all daily missions matching the given action.
|
||||
* Returns a new missions content (immutable).
|
||||
*/
|
||||
export function updateMissionProgress(
|
||||
state: DailyMissionsState,
|
||||
export function trackTally(
|
||||
missions: MissionsContent,
|
||||
action: DailyMissionAction,
|
||||
incrementBy: number = 1
|
||||
): DailyMissionsState {
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
// Skip if not the matching action or already completed
|
||||
if (mission.action !== action || mission.completed) {
|
||||
return mission;
|
||||
}
|
||||
|
||||
const newCount = Math.min(mission.currentCount + incrementBy, mission.requiredCount);
|
||||
const nowCompleted = newCount >= mission.requiredCount;
|
||||
|
||||
return {
|
||||
...mission,
|
||||
currentCount: newCount,
|
||||
completed: nowCompleted,
|
||||
};
|
||||
incrementBy: number = 1,
|
||||
): MissionsContent {
|
||||
const updated = missions.daily.map((m) => {
|
||||
const def = POOL_BY_ID.get(m.id);
|
||||
if (!def || def.action !== action) return m;
|
||||
if (!isTallyMission(m)) return m;
|
||||
if (m.count >= m.target) return m; // already complete
|
||||
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
};
|
||||
return { ...missions, daily: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim reward for a completed mission
|
||||
* Append an event ID to a daily mission.
|
||||
* Deduplicates by event ID. Returns new missions content.
|
||||
*/
|
||||
export function claimMissionReward(
|
||||
state: DailyMissionsState,
|
||||
missionId: string
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
let coinsEarned = 0;
|
||||
|
||||
const updatedMissions = state.missions.map((mission) => {
|
||||
if (mission.id !== missionId) return mission;
|
||||
|
||||
// Can only claim if completed and not yet claimed
|
||||
if (!mission.completed || mission.claimed) return mission;
|
||||
|
||||
coinsEarned = mission.reward;
|
||||
return {
|
||||
...mission,
|
||||
claimed: true,
|
||||
};
|
||||
export function trackEvent(
|
||||
missions: MissionsContent,
|
||||
action: DailyMissionAction,
|
||||
eventId: string,
|
||||
): MissionsContent {
|
||||
const updated = missions.daily.map((m) => {
|
||||
const def = POOL_BY_ID.get(m.id);
|
||||
if (!def || def.action !== action) return m;
|
||||
if (!isEventMission(m)) return m;
|
||||
if (m.events.length >= m.target) return m; // already complete
|
||||
if (m.events.includes(eventId)) return m; // dedup
|
||||
return { ...m, events: [...m.events, eventId] };
|
||||
});
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
totalCoinsEarned: state.totalCoinsEarned + coinsEarned,
|
||||
},
|
||||
coinsEarned,
|
||||
};
|
||||
return { ...missions, daily: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total potential reward for all daily missions
|
||||
* Track progress for an evolution mission by tally.
|
||||
*/
|
||||
export function getTotalPotentialReward(state: DailyMissionsState): number {
|
||||
return state.missions.reduce((sum, m) => sum + m.reward, 0);
|
||||
export function trackEvolutionTally(
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
incrementBy: number = 1,
|
||||
): MissionsContent {
|
||||
const updated = missions.evolution.map((m) => {
|
||||
if (m.id !== missionId) return m;
|
||||
if (!isTallyMission(m)) return m;
|
||||
if (m.count >= m.target) return m;
|
||||
return { ...m, count: Math.min(m.count + incrementBy, m.target) };
|
||||
});
|
||||
return { ...missions, evolution: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total claimed reward for today
|
||||
* Append an event ID to an evolution mission.
|
||||
*/
|
||||
export function getTodayClaimedReward(state: DailyMissionsState): number {
|
||||
return state.missions
|
||||
.filter((m) => m.claimed)
|
||||
.reduce((sum, m) => sum + m.reward, 0);
|
||||
export function trackEvolutionEvent(
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
eventId: string,
|
||||
): MissionsContent {
|
||||
const updated = missions.evolution.map((m) => {
|
||||
if (m.id !== missionId) return m;
|
||||
if (!isEventMission(m)) return m;
|
||||
if (m.events.length >= m.target) return m;
|
||||
if (m.events.includes(eventId)) return m;
|
||||
return { ...m, events: [...m.events, eventId] };
|
||||
});
|
||||
return { ...missions, evolution: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are completed
|
||||
*/
|
||||
export function areAllMissionsCompleted(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.completed);
|
||||
// ─── Completion Queries ──────────────────────────────────────────────────────
|
||||
|
||||
/** Whether all daily missions are complete */
|
||||
export function areAllDailyComplete(missions: MissionsContent): boolean {
|
||||
return missions.daily.length > 0 && missions.daily.every(isMissionComplete);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all daily missions are claimed
|
||||
*/
|
||||
export function areAllMissionsClaimed(state: DailyMissionsState): boolean {
|
||||
return state.missions.every((m) => m.claimed);
|
||||
/** Whether all evolution missions are complete */
|
||||
export function areAllEvolutionComplete(missions: MissionsContent): boolean {
|
||||
return missions.evolution.length > 0 && missions.evolution.every(isMissionComplete);
|
||||
}
|
||||
|
||||
// ─── Bonus Mission ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The bonus mission that becomes available after completing all regular missions.
|
||||
* This is a special mission that rewards extra coins for daily completion.
|
||||
*/
|
||||
export const BONUS_MISSION_DEFINITION: DailyMissionDefinition = {
|
||||
id: 'bonus_daily_complete',
|
||||
title: 'Daily Champion',
|
||||
description: 'Complete all daily missions to claim this bonus reward',
|
||||
action: 'interact', // Not actually used - bonus is auto-completed
|
||||
requiredCount: 1,
|
||||
reward: 80,
|
||||
weight: 0, // Not part of random selection
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the bonus mission is available (all regular missions completed)
|
||||
*/
|
||||
export function isBonusMissionAvailable(state: DailyMissionsState): boolean {
|
||||
// Bonus is available if there are regular missions and all are completed
|
||||
return state.missions.length > 0 && areAllMissionsCompleted(state);
|
||||
/** Total XP available from today's daily missions (including bonus if all complete) */
|
||||
export function totalDailyXp(missions: MissionsContent): number {
|
||||
const base = missions.daily.reduce((sum, m) => {
|
||||
const def = POOL_BY_ID.get(m.id);
|
||||
return sum + (def && isMissionComplete(m) ? def.xp : 0);
|
||||
}, 0);
|
||||
const bonus = areAllDailyComplete(missions) ? DAILY_BONUS_XP : 0;
|
||||
return base + bonus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the bonus mission has been claimed today
|
||||
*/
|
||||
export function isBonusMissionClaimed(state: DailyMissionsState): boolean {
|
||||
return state.bonusClaimed ?? false;
|
||||
/** XP earned by a specific daily mission (0 if incomplete or unknown) */
|
||||
export function missionXp(missionId: string, mission: Mission): number {
|
||||
const def = POOL_BY_ID.get(missionId);
|
||||
if (!def || !isMissionComplete(mission)) return 0;
|
||||
return def.xp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the bonus mission reward
|
||||
*/
|
||||
export function claimBonusMissionReward(
|
||||
state: DailyMissionsState
|
||||
): { state: DailyMissionsState; coinsEarned: number } {
|
||||
// Can only claim if bonus is available and not yet claimed
|
||||
if (!isBonusMissionAvailable(state) || isBonusMissionClaimed(state)) {
|
||||
return { state, coinsEarned: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
bonusClaimed: true,
|
||||
totalCoinsEarned: state.totalCoinsEarned + BONUS_MISSION_DEFINITION.reward,
|
||||
},
|
||||
coinsEarned: BONUS_MISSION_DEFINITION.reward,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mission Reroll ───────────────────────────────────────────────────────────
|
||||
// ─── Reroll ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the number of rerolls remaining for today.
|
||||
* Returns MAX_DAILY_REROLLS if not set (for backward compatibility with old state).
|
||||
*/
|
||||
export function getRerollsRemaining(state: DailyMissionsState): number {
|
||||
// If rerollsRemaining is not set (old state), default to max
|
||||
if (state.rerollsRemaining === undefined || state.rerollsRemaining === null) {
|
||||
return MAX_DAILY_REROLLS;
|
||||
}
|
||||
return state.rerollsRemaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user can reroll a mission
|
||||
*/
|
||||
export function canRerollMission(state: DailyMissionsState, missionId: string): boolean {
|
||||
const rerollsRemaining = getRerollsRemaining(state);
|
||||
if (rerollsRemaining <= 0) return false;
|
||||
|
||||
// Find the mission
|
||||
const mission = state.missions.find((m) => m.id === missionId);
|
||||
if (!mission) return false;
|
||||
|
||||
// Cannot reroll completed or claimed missions
|
||||
if (mission.completed || mission.claimed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a replacement mission that:
|
||||
* - Is not already in the current mission list
|
||||
* - Is not the mission being replaced (avoid immediately giving back the same)
|
||||
* - Respects the user's available stages
|
||||
*
|
||||
* Uses weighted random selection from eligible missions.
|
||||
* Select a replacement mission not already in the current set.
|
||||
* Uses Math.random (rerolls should feel random, not deterministic).
|
||||
*/
|
||||
export function selectReplacementMission(
|
||||
currentMissions: DailyMission[],
|
||||
missionToReplace: DailyMission,
|
||||
availableStages?: BlobbiStage[]
|
||||
currentMissions: Mission[],
|
||||
missionToReplaceId: string,
|
||||
availableStages?: BlobbiStage[],
|
||||
): DailyMissionDefinition | null {
|
||||
// Default to baby/adult if no stages provided (most common case)
|
||||
const stagesToCheck = availableStages && availableStages.length > 0
|
||||
? availableStages
|
||||
: ['baby', 'adult'] as BlobbiStage[];
|
||||
|
||||
// Get IDs of missions that cannot be selected (current active missions)
|
||||
const excludedIds = new Set<string>();
|
||||
|
||||
// Exclude all current missions EXCEPT the one being replaced
|
||||
for (const m of currentMissions) {
|
||||
if (m.id !== missionToReplace.id) {
|
||||
excludedIds.add(m.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter pool to eligible missions
|
||||
const eligibleMissions = DAILY_MISSION_POOL.filter((m) => {
|
||||
// Must not be an already-active mission (except the one being replaced)
|
||||
if (excludedIds.has(m.id)) return false;
|
||||
// Must not be the same mission being replaced
|
||||
if (m.id === missionToReplace.id) return false;
|
||||
// Must be available for user's stages
|
||||
if (!isMissionAvailableForStages(m, stagesToCheck)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no eligible missions, return null
|
||||
if (eligibleMissions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use Math.random() for non-deterministic selection (rerolls should feel random)
|
||||
const totalWeight = eligibleMissions.reduce((sum, m) => sum + m.weight, 0);
|
||||
const stages = availableStages ?? ['baby', 'adult'];
|
||||
const excludedIds = new Set(currentMissions.map((m) => m.id));
|
||||
|
||||
const eligible = DAILY_MISSION_POOL.filter((m) =>
|
||||
m.id !== missionToReplaceId &&
|
||||
!excludedIds.has(m.id) &&
|
||||
isMissionAvailableForStages(m, stages),
|
||||
);
|
||||
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
const totalWeight = eligible.reduce((sum, m) => sum + m.weight, 0);
|
||||
let pick = Math.random() * totalWeight;
|
||||
|
||||
for (const mission of eligibleMissions) {
|
||||
pick -= mission.weight;
|
||||
if (pick <= 0) {
|
||||
return mission;
|
||||
}
|
||||
for (const def of eligible) {
|
||||
pick -= def.weight;
|
||||
if (pick <= 0) return def;
|
||||
}
|
||||
|
||||
// Fallback to first eligible (shouldn't happen)
|
||||
return eligibleMissions[0];
|
||||
return eligible[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reroll a mission, replacing it with a new one from the pool.
|
||||
* Returns the updated state and the new mission, or null if reroll failed.
|
||||
* Reroll a daily mission. Returns updated missions content or null if not possible.
|
||||
*/
|
||||
export function rerollMission(
|
||||
state: DailyMissionsState,
|
||||
missions: MissionsContent,
|
||||
missionId: string,
|
||||
availableStages?: BlobbiStage[]
|
||||
): { state: DailyMissionsState; newMission: DailyMission } | null {
|
||||
// Check if reroll is allowed
|
||||
if (!canRerollMission(state, missionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the mission index
|
||||
const missionIndex = state.missions.findIndex((m) => m.id === missionId);
|
||||
if (missionIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldMission = state.missions[missionIndex];
|
||||
|
||||
// Select a replacement
|
||||
const replacement = selectReplacementMission(state.missions, oldMission, availableStages);
|
||||
if (!replacement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create the new mission instance
|
||||
const newMission = createMissionFromDefinition(replacement);
|
||||
|
||||
// Update the missions array
|
||||
const updatedMissions = [...state.missions];
|
||||
updatedMissions[missionIndex] = newMission;
|
||||
|
||||
// Decrement rerolls remaining
|
||||
const newRerollsRemaining = getRerollsRemaining(state) - 1;
|
||||
|
||||
availableStages?: BlobbiStage[],
|
||||
): MissionsContent | null {
|
||||
if (missions.rerolls <= 0) return null;
|
||||
|
||||
const idx = missions.daily.findIndex((m) => m.id === missionId);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const existing = missions.daily[idx];
|
||||
if (isMissionComplete(existing)) return null; // can't reroll completed
|
||||
|
||||
const replacement = selectReplacementMission(missions.daily, missionId, availableStages);
|
||||
if (!replacement) return null;
|
||||
|
||||
const updatedDaily = [...missions.daily];
|
||||
updatedDaily[idx] = createMission(replacement);
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
missions: updatedMissions,
|
||||
rerollsRemaining: newRerollsRemaining,
|
||||
},
|
||||
newMission,
|
||||
...missions,
|
||||
daily: updatedDaily,
|
||||
rerolls: missions.rerolls - 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export mission utilities for convenience
|
||||
export { isTallyMission, isEventMission, isMissionComplete, missionProgress } from '@/blobbi/core/lib/missions';
|
||||
export type { Mission, TallyMission, EventMission, MissionsContent } from '@/blobbi/core/lib/missions';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Centralized item-use cooldown tracking.
|
||||
*
|
||||
* Module-level singleton shared by every item-use path
|
||||
* (dashboard, companion layer, shop modal, falling items).
|
||||
*
|
||||
* Keyed by item type ID (e.g. "food_apple"), not instance IDs.
|
||||
* Separate durations for success (short) and failure (longer).
|
||||
* Built-in subscriber system for React via useSyncExternalStore.
|
||||
*/
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Cooldown after a successful item use (ms). */
|
||||
export const ITEM_COOLDOWN_SUCCESS_MS = 400;
|
||||
|
||||
/** Cooldown after a failed item use (ms). */
|
||||
export const ITEM_COOLDOWN_FAILURE_MS = 2000;
|
||||
|
||||
// ─── Singleton State ──────────────────────────────────────────────────────────
|
||||
|
||||
interface CooldownEntry {
|
||||
expiresAt: number;
|
||||
timerId: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const cooldowns = new Map<string, CooldownEntry>();
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
subscribers.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Check whether an item is currently on cooldown. */
|
||||
export function isItemOnCooldown(itemId: string): boolean {
|
||||
const entry = cooldowns.get(itemId);
|
||||
if (!entry) return false;
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
clearTimeout(entry.timerId);
|
||||
cooldowns.delete(itemId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Put an item on cooldown. Notifies subscribers on start and expiry. */
|
||||
export function setItemCooldown(itemId: string, success: boolean): void {
|
||||
const prev = cooldowns.get(itemId);
|
||||
if (prev) clearTimeout(prev.timerId);
|
||||
|
||||
const ms = success ? ITEM_COOLDOWN_SUCCESS_MS : ITEM_COOLDOWN_FAILURE_MS;
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
cooldowns.delete(itemId);
|
||||
notify();
|
||||
}, ms);
|
||||
|
||||
cooldowns.set(itemId, { expiresAt: Date.now() + ms, timerId });
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Subscribe to cooldown state changes. Returns unsubscribe function. */
|
||||
export function subscribeCooldowns(callback: () => void): () => void {
|
||||
subscribers.add(callback);
|
||||
return () => { subscribers.delete(callback); };
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export const DEFAULT_COMPANION_CONFIG: CompanionConfig = {
|
||||
pause2Duration: 100, // Short pause before falling
|
||||
|
||||
// Truly stuck behavior
|
||||
trulyStuckChance: 0.30, // 30% chance to be truly stuck (needs user drag)
|
||||
trulyStuckChance: 0.10, // 10% chance to be truly stuck (needs user drag)
|
||||
|
||||
fallDuration: 450, // Fall after getting loose
|
||||
landingDuration: 200, // Brief squash on landing
|
||||
|
||||
@@ -79,6 +79,11 @@ export interface EnsureCanonicalResult {
|
||||
* to avoid restoring stale/legacy values after migration.
|
||||
*/
|
||||
profileAllTags: string[][];
|
||||
/**
|
||||
* The previous profile event, for passing as `prev` to publishEvent
|
||||
* to preserve `published_at` on replaceable events.
|
||||
*/
|
||||
profileEvent: NostrEvent;
|
||||
/**
|
||||
* The latest profile storage to use.
|
||||
* Use this as the base for storage modifications.
|
||||
@@ -347,6 +352,7 @@ export function useBlobbiMigration() {
|
||||
allTags: migrationResult.event.tags,
|
||||
content: migrationResult.event.content,
|
||||
profileAllTags: migrationResult.profileTags,
|
||||
profileEvent: migrationResult.profileEvent,
|
||||
profileStorage: migrationResult.profileStorage,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +364,7 @@ export function useBlobbiMigration() {
|
||||
allTags: companion.allTags,
|
||||
content: companion.event.content,
|
||||
profileAllTags: profile.allTags,
|
||||
profileEvent: profile.event,
|
||||
profileStorage: profile.storage,
|
||||
};
|
||||
}, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]);
|
||||
|
||||
@@ -316,8 +316,16 @@ export interface BlobbonautProfile {
|
||||
coins: number;
|
||||
/** Petting level (interaction counter) */
|
||||
pettingLevel: number;
|
||||
/** Player lifetime XP (source of truth for progression) */
|
||||
xp: number;
|
||||
/** Player level (derived from xp, stored as queryable mirror) */
|
||||
level: number;
|
||||
/** Current room the player is in (persisted for cross-session continuity) */
|
||||
room: string | undefined;
|
||||
/** Purchased items storage */
|
||||
storage: StorageItem[];
|
||||
/** Raw content string for missions JSON */
|
||||
content: string;
|
||||
/** All tags preserved for republishing */
|
||||
allTags: string[][];
|
||||
}
|
||||
@@ -982,7 +990,11 @@ export function parseBlobbonautEvent(event: NostrEvent): BlobbonautProfile | und
|
||||
has: getTagValues(tags, 'has'),
|
||||
coins: parseNumericTag(tags, 'coins') ?? 0,
|
||||
pettingLevel: pettingLevelValue,
|
||||
xp: parseNumericTag(tags, 'xp') ?? 0,
|
||||
level: parseNumericTag(tags, 'level') ?? 1,
|
||||
room: getTagValue(tags, 'room') ?? undefined,
|
||||
storage: parseStorageTags(tags),
|
||||
content: event.content,
|
||||
allTags: tags,
|
||||
};
|
||||
}
|
||||
@@ -1140,6 +1152,10 @@ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([
|
||||
*/
|
||||
export const MANAGED_BLOBBONAUT_PROFILE_TAG_NAMES = new Set([
|
||||
'd', 'b', 'name', 'current_companion', 'blobbi_onboarding_done', 'onboarding_done', 'has', 'storage',
|
||||
// Progression tags
|
||||
'xp', 'level',
|
||||
// Room persistence
|
||||
'room',
|
||||
// Legacy player progress tags (preserved for compatibility)
|
||||
'coins', 'petting_level', 'pettingLevel', 'lifetime_blobbis', 'lifetimeBlobbis',
|
||||
'starter_blobbi', 'starterBlobbi', 'favorite_blobbi', 'favoriteBlobbi',
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Missions Content Model
|
||||
*
|
||||
* Defines the JSON shape stored in the kind 11125 content field.
|
||||
* Two mission categories:
|
||||
* - daily: reset each day, tally-based or event-based
|
||||
* - evolution: persist across sessions until stage transition completes
|
||||
*
|
||||
* Tally missions track a `count` (no event IDs).
|
||||
* Event missions track an `events` array of Nostr event IDs.
|
||||
* Completion is derived: count >= target or events.length >= target.
|
||||
*/
|
||||
|
||||
// ─── Mission Entry Types ─────────────────────────────────────────────────────
|
||||
|
||||
/** A mission tracked by a simple counter (feed, clean, interact, etc.) */
|
||||
export interface TallyMission {
|
||||
id: string;
|
||||
target: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** A mission tracked by Nostr event IDs (post, photo, theme, etc.) */
|
||||
export interface EventMission {
|
||||
id: string;
|
||||
target: number;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
/** Union of both mission shapes */
|
||||
export type Mission = TallyMission | EventMission;
|
||||
|
||||
/** Type guard: mission tracks events */
|
||||
export function isEventMission(m: Mission): m is EventMission {
|
||||
return 'events' in m;
|
||||
}
|
||||
|
||||
/** Type guard: mission tracks a tally */
|
||||
export function isTallyMission(m: Mission): m is TallyMission {
|
||||
return 'count' in m;
|
||||
}
|
||||
|
||||
/** Check if a mission is complete */
|
||||
export function isMissionComplete(m: Mission): boolean {
|
||||
if (isEventMission(m)) return m.events.length >= m.target;
|
||||
return m.count >= m.target;
|
||||
}
|
||||
|
||||
/** Get current progress numerator */
|
||||
export function missionProgress(m: Mission): number {
|
||||
if (isEventMission(m)) return m.events.length;
|
||||
return m.count;
|
||||
}
|
||||
|
||||
// ─── Content Shape ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The full missions object stored in kind 11125 content JSON */
|
||||
export interface MissionsContent {
|
||||
date: string; // YYYY-MM-DD for daily reset detection
|
||||
daily: Mission[]; // 3 daily missions, reset each day
|
||||
evolution: Mission[]; // active evolution missions, cleared on stage transition
|
||||
rerolls: number; // daily rerolls remaining (resets with date)
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level content JSON for kind 11125.
|
||||
* Currently only `missions`. Future keys can be added alongside.
|
||||
*/
|
||||
export interface ProfileContent {
|
||||
missions?: MissionsContent;
|
||||
}
|
||||
|
||||
// ─── Parse / Serialize ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the kind 11125 content field into a typed ProfileContent.
|
||||
* Returns an empty object for empty/invalid content. Never throws.
|
||||
*/
|
||||
export function parseProfileContent(content: string): ProfileContent {
|
||||
if (!content || !content.trim()) return {};
|
||||
try {
|
||||
const raw = JSON.parse(content);
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {};
|
||||
const result: ProfileContent = {};
|
||||
if (raw.missions && typeof raw.missions === 'object') {
|
||||
result.missions = parseMissionsContent(raw.missions);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize ProfileContent back to a JSON string for publishing.
|
||||
* Preserves any unknown top-level keys from the existing content.
|
||||
*/
|
||||
export function serializeProfileContent(
|
||||
existingContent: string,
|
||||
updates: Partial<ProfileContent>,
|
||||
): string {
|
||||
let base: Record<string, unknown> = {};
|
||||
if (existingContent && existingContent.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(existingContent);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
base = parsed;
|
||||
}
|
||||
} catch {
|
||||
// corrupt content -- start fresh but don't lose updates
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ ...base, ...updates });
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function parseMissionsContent(raw: Record<string, unknown>): MissionsContent | undefined {
|
||||
if (typeof raw.date !== 'string') return undefined;
|
||||
return {
|
||||
date: raw.date,
|
||||
daily: parseMissionArray(raw.daily),
|
||||
evolution: parseMissionArray(raw.evolution),
|
||||
rerolls: typeof raw.rerolls === 'number' ? Math.max(0, Math.floor(raw.rerolls)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMissionArray(raw: unknown): Mission[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const result: Mission[] = [];
|
||||
for (const entry of raw) {
|
||||
const m = parseSingleMission(entry);
|
||||
if (m) result.push(m);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSingleMission(raw: unknown): Mission | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
if (typeof obj.id !== 'string' || typeof obj.target !== 'number') return undefined;
|
||||
|
||||
// Event-based mission
|
||||
if (Array.isArray(obj.events)) {
|
||||
return {
|
||||
id: obj.id,
|
||||
target: Math.max(1, Math.floor(obj.target)),
|
||||
events: obj.events.filter((e): e is string => typeof e === 'string'),
|
||||
};
|
||||
}
|
||||
|
||||
// Tally-based mission
|
||||
if (typeof obj.count === 'number') {
|
||||
return {
|
||||
id: obj.id,
|
||||
target: Math.max(1, Math.floor(obj.target)),
|
||||
count: Math.max(0, Math.floor(obj.count)),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Progression System
|
||||
*
|
||||
* Player-level XP and leveling. XP lives on kind 11125 as tags.
|
||||
* Level is derived from XP. Unlocks are derived from level.
|
||||
* No nested objects, no JSON content, no multi-game maps.
|
||||
*/
|
||||
|
||||
// ─── XP Thresholds ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cumulative XP required to reach each level.
|
||||
* Index 0 = level 1 (0 XP), index 1 = level 2 (100 XP), etc.
|
||||
* Levels beyond the table cap at the last entry.
|
||||
*/
|
||||
const XP_THRESHOLDS: readonly number[] = [
|
||||
0, // Level 1
|
||||
100, // Level 2
|
||||
250, // Level 3
|
||||
500, // Level 4
|
||||
850, // Level 5
|
||||
1300, // Level 6
|
||||
1900, // Level 7
|
||||
2650, // Level 8
|
||||
3600, // Level 9
|
||||
4800, // Level 10
|
||||
6300, // Level 11
|
||||
8100, // Level 12
|
||||
10200, // Level 13
|
||||
12700, // Level 14
|
||||
15600, // Level 15
|
||||
19000, // Level 16
|
||||
23000, // Level 17
|
||||
27600, // Level 18
|
||||
33000, // Level 19
|
||||
39200, // Level 20
|
||||
];
|
||||
|
||||
export const MAX_LEVEL = XP_THRESHOLDS.length;
|
||||
|
||||
// ─── Level Calculation ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive level from cumulative XP.
|
||||
* Walks the threshold table to find the highest level the XP qualifies for.
|
||||
*/
|
||||
export function xpToLevel(xp: number): number {
|
||||
const safeXp = Math.max(0, Math.floor(xp));
|
||||
for (let i = XP_THRESHOLDS.length - 1; i >= 0; i--) {
|
||||
if (safeXp >= XP_THRESHOLDS[i]) {
|
||||
return i + 1; // levels are 1-indexed
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cumulative XP required to reach a given level.
|
||||
*/
|
||||
export function levelToXp(level: number): number {
|
||||
const idx = Math.max(0, Math.min(level - 1, XP_THRESHOLDS.length - 1));
|
||||
return XP_THRESHOLDS[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress within the current level as a fraction [0, 1].
|
||||
* Returns 1 at max level.
|
||||
*/
|
||||
export function xpProgress(xp: number): number {
|
||||
const level = xpToLevel(xp);
|
||||
if (level >= MAX_LEVEL) return 1;
|
||||
const currentThreshold = XP_THRESHOLDS[level - 1];
|
||||
const nextThreshold = XP_THRESHOLDS[level];
|
||||
const range = nextThreshold - currentThreshold;
|
||||
if (range <= 0) return 1;
|
||||
return Math.min(1, (xp - currentThreshold) / range);
|
||||
}
|
||||
|
||||
/**
|
||||
* XP remaining to reach the next level. 0 at max level.
|
||||
*/
|
||||
export function xpToNextLevel(xp: number): number {
|
||||
const level = xpToLevel(xp);
|
||||
if (level >= MAX_LEVEL) return 0;
|
||||
return XP_THRESHOLDS[level] - xp;
|
||||
}
|
||||
|
||||
// ─── Unlocks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Unlocks {
|
||||
/** Maximum number of Blobbis the player can own */
|
||||
maxBlobbis: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive unlocks from level. Pure function, no stored state.
|
||||
*/
|
||||
export function getUnlocks(level: number): Unlocks {
|
||||
let maxBlobbis = 1;
|
||||
if (level >= 5) maxBlobbis = 2;
|
||||
if (level >= 10) maxBlobbis = 3;
|
||||
if (level >= 15) maxBlobbis = 4;
|
||||
if (level >= 20) maxBlobbis = 5;
|
||||
return { maxBlobbis };
|
||||
}
|
||||
|
||||
// ─── Tag Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build XP and level tag updates for kind 11125.
|
||||
* Level is always derived from XP -- never set independently.
|
||||
*/
|
||||
export function buildXpTagUpdates(xp: number): Record<string, string> {
|
||||
return {
|
||||
xp: Math.max(0, Math.floor(xp)).toString(),
|
||||
level: xpToLevel(xp).toString(),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { EggVisualBlobbi } from '../types/egg.types';
|
||||
import { isValidBaseColor, isValidSecondaryColor } from '../lib/blobbi-egg-validation';
|
||||
import { SpecialMarkRenderer, SpecialMarkFallback } from './SpecialMarkRenderer';
|
||||
@@ -184,10 +185,12 @@ export const EggGraphic: React.FC<EggGraphicProps> = ({
|
||||
// Tour interactive steps: forward click to tour controller
|
||||
if (onTourEggClick && (tourVisualState === 'glowing_waiting_click' || tourVisualState === 'crack_stage_1' || tourVisualState === 'crack_stage_2' || tourVisualState === 'crack_stage_3')) {
|
||||
setIsTapWiggling(true);
|
||||
impactLight();
|
||||
onTourEggClick();
|
||||
return;
|
||||
}
|
||||
if (isTapWiggling || cracking) return; // Don't re-trigger during animation or cracking
|
||||
impactLight();
|
||||
setIsTapWiggling(true);
|
||||
}, [isTapWiggling, cracking, onTourEggClick, tourVisualState]);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { impactLight, impactMedium, impactHeavy, notificationSuccess } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
@@ -412,15 +413,19 @@ export function BlobbiHatchingCeremony({
|
||||
const handleEggClick = useCallback(() => {
|
||||
if (phase === 'egg') {
|
||||
triggerShake('animate-egg-onboard-shake-light');
|
||||
impactLight();
|
||||
setPhase('crack_1');
|
||||
} else if (phase === 'crack_1') {
|
||||
triggerShake('animate-egg-onboard-shake-medium');
|
||||
impactMedium();
|
||||
setPhase('crack_2');
|
||||
} else if (phase === 'crack_2') {
|
||||
triggerShake('animate-egg-onboard-shake-heavy');
|
||||
impactHeavy();
|
||||
setPhase('crack_3');
|
||||
} else if (phase === 'crack_3') {
|
||||
// Final click -> hatch!
|
||||
notificationSuccess();
|
||||
setPhase('hatching');
|
||||
setShowFlash(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
|
||||
*
|
||||
* Renders: stats crown (arced) + Blobbi visual + name.
|
||||
* Does NOT clip or constrain — fills available flex space.
|
||||
* Top padding accounts for the floating room header overlay.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
|
||||
Footprints, Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BlobbiReactionState } from '@/blobbi/actions';
|
||||
import type { BlobbiRoomId } from '../lib/room-config';
|
||||
import { ROOM_META, DEFAULT_ROOM_ORDER, getRoomIndex } from '../lib/room-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Stat colour maps ─────────────────────────────────────────────────────────
|
||||
|
||||
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
|
||||
hunger: 'orange',
|
||||
happiness: 'yellow',
|
||||
health: 'green',
|
||||
hygiene: 'blue',
|
||||
energy: 'violet',
|
||||
};
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
orange: 'text-orange-500', yellow: 'text-yellow-500', green: 'text-green-500',
|
||||
blue: 'text-blue-500', violet: 'text-violet-500',
|
||||
};
|
||||
|
||||
const STAT_BG_COLORS: Record<string, string> = {
|
||||
orange: 'bg-orange-500/10', yellow: 'bg-yellow-500/10', green: 'bg-green-500/10',
|
||||
blue: 'bg-blue-500/10', violet: 'bg-violet-500/10',
|
||||
};
|
||||
|
||||
const STAT_RING_HEX: Record<string, string> = {
|
||||
orange: '#f97316', yellow: '#eab308', green: '#22c55e',
|
||||
blue: '#3b82f6', violet: '#8b5cf6',
|
||||
};
|
||||
|
||||
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
||||
hunger: Utensils, happiness: Gamepad2, health: Heart, hygiene: Droplets, energy: Zap,
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiRoomHeroProps {
|
||||
companion: BlobbiCompanion;
|
||||
currentStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
isSleeping: boolean;
|
||||
isEgg: boolean;
|
||||
statusRecipe: BlobbiVisualRecipe | undefined;
|
||||
statusRecipeLabel: string | undefined;
|
||||
effectiveEmotion: BlobbiEmotion;
|
||||
hasDevOverride: boolean;
|
||||
blobbiReaction: BlobbiReactionState;
|
||||
isActiveFloatingCompanion: boolean;
|
||||
isUpdatingCompanion: boolean;
|
||||
handleSetAsCompanion: () => Promise<void>;
|
||||
heroRef: React.RefObject<HTMLDivElement | null>;
|
||||
heroWidth: number;
|
||||
/** Current room (for indicator below name) */
|
||||
roomId: BlobbiRoomId;
|
||||
/** Room order for dot indicators */
|
||||
roomOrder?: BlobbiRoomId[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRoomHero({
|
||||
companion,
|
||||
currentStats,
|
||||
isSleeping,
|
||||
isEgg,
|
||||
statusRecipe,
|
||||
statusRecipeLabel,
|
||||
effectiveEmotion,
|
||||
hasDevOverride,
|
||||
blobbiReaction,
|
||||
isActiveFloatingCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
heroRef,
|
||||
heroWidth,
|
||||
roomId,
|
||||
roomOrder = DEFAULT_ROOM_ORDER,
|
||||
className,
|
||||
}: BlobbiRoomHeroProps) {
|
||||
const roomMeta = ROOM_META[roomId];
|
||||
const roomIndex = getRoomIndex(roomId, roomOrder);
|
||||
if (isActiveFloatingCompanion) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
|
||||
<Footprints className="size-12 text-muted-foreground/30" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{companion.name} is out exploring right now.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSetAsCompanion}
|
||||
disabled={isUpdatingCompanion}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
isUpdatingCompanion && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
|
||||
>
|
||||
{isUpdatingCompanion ? <Loader2 className="size-4 animate-spin" /> : <Footprints className="size-4" />}
|
||||
<span>Bring {companion.name} home</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />
|
||||
|
||||
<div
|
||||
className="relative transition-all duration-500"
|
||||
style={!isSleeping ? {
|
||||
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
|
||||
} : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
reaction={blobbiReaction}
|
||||
recipe={hasDevOverride ? undefined : statusRecipe}
|
||||
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
|
||||
emotion={effectiveEmotion}
|
||||
className={isEgg
|
||||
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
|
||||
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEgg && (
|
||||
<h2
|
||||
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
|
||||
style={{ color: companion.visualTraits.baseColor }}
|
||||
>
|
||||
{companion.name}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Room indicator */}
|
||||
<div className="flex flex-col items-center mt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<roomMeta.icon className="size-3.5 sm:size-4 text-foreground/50" />
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground/60">{roomMeta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{roomOrder.map((id, i) => (
|
||||
<div
|
||||
key={id}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-300',
|
||||
i === roomIndex ? 'w-4 h-1 bg-primary' : 'w-1 h-1 bg-muted-foreground/20',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stats Crown ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsCrown({
|
||||
companion,
|
||||
currentStats,
|
||||
heroWidth,
|
||||
}: {
|
||||
companion: BlobbiCompanion;
|
||||
currentStats: BlobbiRoomHeroProps['currentStats'];
|
||||
heroWidth: number;
|
||||
}) {
|
||||
const allStats = useMemo(() =>
|
||||
getVisibleStats(companion.stage).map(stat => ({
|
||||
stat,
|
||||
value: currentStats[stat] ?? 100,
|
||||
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
|
||||
color: STAT_COLOR_MAP[stat],
|
||||
})),
|
||||
[companion.stage, currentStats]);
|
||||
|
||||
if (allStats.length === 0) return null;
|
||||
|
||||
const count = allStats.length;
|
||||
const isSmall = heroWidth < 400;
|
||||
const arcSpread = isSmall
|
||||
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
|
||||
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
|
||||
const arcHalf = arcSpread / 2;
|
||||
const angles = count === 1
|
||||
? [0]
|
||||
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
|
||||
{allStats.map((s, i) => {
|
||||
const angleDeg = angles[i];
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
|
||||
const x = Math.sin(angleRad) * radius;
|
||||
const y = Math.cos(angleRad) * radius - radius;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.stat}
|
||||
className="absolute transition-all duration-500"
|
||||
style={{
|
||||
transform: 'translate(-50%, 0)',
|
||||
left: `calc(50% + ${x.toFixed(1)}px)`,
|
||||
bottom: `${y.toFixed(1)}px`,
|
||||
}}
|
||||
>
|
||||
<StatIndicator stat={s.stat} value={s.value} color={s.color} status={s.status} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
function StatIndicator({
|
||||
stat,
|
||||
value,
|
||||
color,
|
||||
status = 'normal',
|
||||
}: {
|
||||
stat: string;
|
||||
value: number | undefined;
|
||||
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
status?: 'normal' | 'warning' | 'critical';
|
||||
}) {
|
||||
const displayValue = value ?? 0;
|
||||
const isLow = status === 'warning' || status === 'critical';
|
||||
const ringHex = STAT_RING_HEX[color];
|
||||
const IconComponent = STAT_ICON_MAP[stat];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
|
||||
STAT_BG_COLORS[color],
|
||||
status === 'critical' && 'animate-pulse',
|
||||
)}>
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
|
||||
stroke={ringHex}
|
||||
strokeDasharray={`${displayValue * 0.94} 100`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="relative">
|
||||
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
|
||||
{isLow && (
|
||||
<AlertTriangle
|
||||
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* BlobbiRoomShell — Outer layout for room-based navigation.
|
||||
*
|
||||
* Manages: room navigation (arrows + dots), sleep overlay, poop state.
|
||||
* Renders children in a flex column with the hero above and children below.
|
||||
* The parent decides what bottom bar to render based on the active room.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef as useReactRef, type CSSProperties } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
|
||||
import {
|
||||
type BlobbiRoomId,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
} from '../lib/room-config';
|
||||
import {
|
||||
generateInitialPoops,
|
||||
removePoop,
|
||||
type PoopInstance,
|
||||
} from '../lib/poop-system';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoopState {
|
||||
poops: PoopInstance[];
|
||||
shovelMode: boolean;
|
||||
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onRemovePoop: (poopId: string) => void;
|
||||
}
|
||||
|
||||
interface BlobbiRoomShellProps {
|
||||
/** Current active room */
|
||||
roomId: BlobbiRoomId;
|
||||
/** Called when user navigates to a different room */
|
||||
onChangeRoom: (roomId: BlobbiRoomId) => void;
|
||||
/** Whether the Blobbi is sleeping (darkens the room) */
|
||||
isSleeping: boolean;
|
||||
/** Hero element (BlobbiRoomHero) rendered in the flex-1 area */
|
||||
hero: React.ReactNode;
|
||||
/** Bottom bar content (per-room actions + carousel) */
|
||||
children: React.ReactNode;
|
||||
/** Optional content between hero and bottom bar (inline music/sing) */
|
||||
middleSlot?: React.ReactNode;
|
||||
/** Room order (defaults to DEFAULT_ROOM_ORDER) */
|
||||
roomOrder?: BlobbiRoomId[];
|
||||
/** Poop generation params */
|
||||
hunger: number;
|
||||
lastFeedTimestamp: number | undefined;
|
||||
/** Expose poop state to children via render prop or context */
|
||||
poopStateRef?: React.MutableRefObject<PoopState | null>;
|
||||
/** Called when a poop is cleaned. Parent handles toast/XP persistence. */
|
||||
onPoopCleaned?: () => void;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Minimum horizontal swipe distance (px) to trigger room change */
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
|
||||
export function BlobbiRoomShell({
|
||||
roomId,
|
||||
onChangeRoom,
|
||||
isSleeping,
|
||||
hero,
|
||||
children,
|
||||
middleSlot,
|
||||
roomOrder = DEFAULT_ROOM_ORDER,
|
||||
hunger,
|
||||
lastFeedTimestamp,
|
||||
poopStateRef,
|
||||
onPoopCleaned,
|
||||
}: BlobbiRoomShellProps) {
|
||||
const goLeft = useCallback(() => {
|
||||
onChangeRoom(getPreviousRoom(roomId, roomOrder));
|
||||
}, [roomId, roomOrder, onChangeRoom]);
|
||||
|
||||
const goRight = useCallback(() => {
|
||||
onChangeRoom(getNextRoom(roomId, roomOrder));
|
||||
}, [roomId, roomOrder, onChangeRoom]);
|
||||
|
||||
const leftDest = ROOM_META[getPreviousRoom(roomId, roomOrder)];
|
||||
const rightDest = ROOM_META[getNextRoom(roomId, roomOrder)];
|
||||
|
||||
// ─── Touch swipe ───
|
||||
const touchStartX = useReactRef<number | null>(null);
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
}, [touchStartX]);
|
||||
|
||||
const onTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD) return;
|
||||
impactLight();
|
||||
if (dx > 0) goLeft();
|
||||
else goRight();
|
||||
}, [touchStartX, goLeft, goRight]);
|
||||
|
||||
// ─── Poop system (ephemeral) ───
|
||||
const [poops, setPoops] = useState<PoopInstance[]>([]);
|
||||
const [shovelMode, setShovelMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPoops(generateInitialPoops(hunger, lastFeedTimestamp));
|
||||
// Only on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onRemovePoop = useCallback((poopId: string) => {
|
||||
setPoops(prev => {
|
||||
const { remaining } = removePoop(prev, poopId);
|
||||
if (remaining.length < prev.length) {
|
||||
onPoopCleaned?.();
|
||||
}
|
||||
if (remaining.length === 0) setShovelMode(false);
|
||||
return remaining;
|
||||
});
|
||||
}, [onPoopCleaned]);
|
||||
|
||||
const poopState: PoopState = useMemo(() => ({
|
||||
poops, shovelMode, setShovelMode, onRemovePoop,
|
||||
}), [poops, shovelMode, onRemovePoop]);
|
||||
|
||||
if (poopStateRef) poopStateRef.current = poopState;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 relative"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Room content */}
|
||||
<div className="flex-1 min-h-0 flex flex-col relative">
|
||||
{hero}
|
||||
{middleSlot}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Sleep overlay */}
|
||||
{isSleeping && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<button
|
||||
onClick={goLeft}
|
||||
className={cn(
|
||||
'group absolute left-1 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center justify-center',
|
||||
'size-10 sm:size-12 rounded-full',
|
||||
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
'cursor-pointer select-none',
|
||||
)}
|
||||
aria-label={`Go to ${leftDest.label}`}
|
||||
>
|
||||
<ChevronLeft
|
||||
className="size-7 sm:size-8 shrink-0"
|
||||
strokeWidth={4}
|
||||
style={{ animation: 'room-arrow-nudge-left 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goRight}
|
||||
className={cn(
|
||||
'group absolute right-1 top-1/2 -translate-y-1/2 z-40',
|
||||
'flex items-center justify-center',
|
||||
'size-10 sm:size-12 rounded-full',
|
||||
'text-muted-foreground/30 hover:text-foreground/60 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
'cursor-pointer select-none',
|
||||
)}
|
||||
aria-label={`Go to ${rightDest.label}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className="size-7 sm:size-8 shrink-0"
|
||||
strokeWidth={4}
|
||||
style={{ animation: 'room-arrow-nudge-right 2.5s ease-in-out infinite' } as CSSProperties}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* ItemCarousel — Single-focus carousel for room items.
|
||||
*
|
||||
* Fixed-size slots prevent layout reflow on item switch.
|
||||
* Mobile: focused item only. Desktop: prev/next previews.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CarouselEntry {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface ItemCarouselProps {
|
||||
items: CarouselEntry[];
|
||||
onUse: (id: string) => void;
|
||||
activeItemId?: string | null;
|
||||
disabled?: boolean;
|
||||
onFocusChange?: (entry: CarouselEntry) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ItemCarousel({
|
||||
items,
|
||||
onUse,
|
||||
activeItemId,
|
||||
disabled,
|
||||
onFocusChange,
|
||||
className,
|
||||
}: ItemCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const count = items.length;
|
||||
|
||||
// Reset index when items change to avoid out-of-bounds access
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i - 1 + count) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i + 1) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
|
||||
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = items[index];
|
||||
const prevItem = items[(index - 1 + count) % count];
|
||||
const nextItem = items[(index + 1) % count];
|
||||
const isThisActive = activeItemId === current.id;
|
||||
const showPreviews = count >= 3;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{prevItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onUse(current.id)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
|
||||
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
|
||||
'transition-colors duration-200',
|
||||
'hover:bg-accent/20 active:scale-95',
|
||||
isThisActive && 'bg-accent/40',
|
||||
disabled && !isThisActive && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl sm:text-5xl leading-none">{current.icon}</span>
|
||||
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
|
||||
{current.label}
|
||||
</span>
|
||||
{isThisActive && <Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />}
|
||||
</button>
|
||||
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{nextItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* RoomActionButton — Unified circular action button for room bottom bars.
|
||||
*
|
||||
* Responsive: size-14/size-20 circle, size-7/size-9 icons.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RoomActionButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
color: string;
|
||||
glowHex: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoomActionButton({
|
||||
icon,
|
||||
label,
|
||||
color,
|
||||
glowHex,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
badge,
|
||||
className,
|
||||
}: RoomActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
|
||||
'hover:-translate-y-1 hover:scale-110 active:scale-95',
|
||||
disabled && 'opacity-50 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 className="size-7 sm:size-9 animate-spin" /> : icon}
|
||||
</div>
|
||||
{badge && <div className="absolute -top-0.5 -right-0.5">{badge}</div>}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// src/blobbi/rooms/index.ts — barrel export
|
||||
|
||||
export {
|
||||
type BlobbiRoomId,
|
||||
type BlobbiRoomMeta,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
DEFAULT_INITIAL_ROOM,
|
||||
isValidRoomId,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from './lib/room-config';
|
||||
|
||||
export { ROOM_BOTTOM_BAR_CLASS } from './lib/room-layout';
|
||||
|
||||
export {
|
||||
type PoopInstance,
|
||||
XP_PER_POOP,
|
||||
generateInitialPoops,
|
||||
getPoopsInRoom,
|
||||
removePoop,
|
||||
hasAnyPoop,
|
||||
} from './lib/poop-system';
|
||||
|
||||
export { RoomActionButton } from './components/RoomActionButton';
|
||||
export { ItemCarousel, type CarouselEntry } from './components/ItemCarousel';
|
||||
export { BlobbiRoomHero, type BlobbiRoomHeroProps } from './components/BlobbiRoomHero';
|
||||
export { BlobbiRoomShell, type PoopState } from './components/BlobbiRoomShell';
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Ephemeral poop system.
|
||||
*
|
||||
* Generated on page mount based on hunger + time since last feed.
|
||||
* No persistence -- purely local React state.
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from './room-config';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoopInstance {
|
||||
id: string;
|
||||
room: BlobbiRoomId;
|
||||
source: 'overfeed' | 'time';
|
||||
createdAt: number;
|
||||
position: { bottom: number; left: number };
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OVERFEED_THRESHOLD = 95;
|
||||
const HOURS_PER_POOP = 2;
|
||||
export const XP_PER_POOP = 5;
|
||||
|
||||
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'rest'];
|
||||
|
||||
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
|
||||
{ bottom: 22, left: 8 },
|
||||
{ bottom: 18, left: 78 },
|
||||
{ bottom: 28, left: 14 },
|
||||
{ bottom: 25, left: 82 },
|
||||
{ bottom: 15, left: 20 },
|
||||
{ bottom: 20, left: 72 },
|
||||
];
|
||||
|
||||
// ─── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
let _idCounter = 0;
|
||||
function nextPoopId(): string {
|
||||
return `poop_${++_idCounter}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function pickPosition(index: number): { bottom: number; left: number } {
|
||||
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
|
||||
}
|
||||
|
||||
export function generateInitialPoops(
|
||||
hunger: number,
|
||||
lastFeedTimestamp: number | undefined,
|
||||
): PoopInstance[] {
|
||||
const poops: PoopInstance[] = [];
|
||||
const now = Date.now();
|
||||
let posIndex = 0;
|
||||
|
||||
if (hunger >= OVERFEED_THRESHOLD) {
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room: 'kitchen',
|
||||
source: 'overfeed',
|
||||
createdAt: now,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
|
||||
if (lastFeedTimestamp) {
|
||||
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
|
||||
const count = Math.min(Math.floor(hoursSinceFeed / HOURS_PER_POOP), 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room,
|
||||
source: 'time',
|
||||
createdAt: now - i * 1000,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return poops;
|
||||
}
|
||||
|
||||
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
|
||||
return poops.filter(p => p.room === room);
|
||||
}
|
||||
|
||||
export function removePoop(
|
||||
poops: PoopInstance[],
|
||||
poopId: string,
|
||||
): { remaining: PoopInstance[]; xpReward: number } {
|
||||
const remaining = poops.filter(p => p.id !== poopId);
|
||||
return {
|
||||
remaining,
|
||||
xpReward: remaining.length < poops.length ? XP_PER_POOP : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAnyPoop(poops: PoopInstance[]): boolean {
|
||||
return poops.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Blobbi Room System — IDs, metadata, ordering, navigation.
|
||||
*
|
||||
* Room order is data, not control flow, so it can be customised per-user later.
|
||||
* The kind 11125 profile has a `room` tag for cross-session continuity.
|
||||
* Currently read on mount but not yet written back on room change (session-local only).
|
||||
*/
|
||||
|
||||
import { Home, Refrigerator, Cross, Moon, Shirt, type LucideIcon } from 'lucide-react';
|
||||
|
||||
// ─── Room IDs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type BlobbiRoomId = 'home' | 'kitchen' | 'care' | 'rest' | 'closet';
|
||||
|
||||
// ─── Metadata ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiRoomMeta {
|
||||
id: BlobbiRoomId;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
|
||||
home: {
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
description: 'Main living room',
|
||||
icon: Home,
|
||||
},
|
||||
kitchen: {
|
||||
id: 'kitchen',
|
||||
label: 'Kitchen',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: Refrigerator,
|
||||
},
|
||||
care: {
|
||||
id: 'care',
|
||||
label: 'Care Room',
|
||||
description: 'Hygiene, care, and medicine',
|
||||
icon: Cross,
|
||||
},
|
||||
rest: {
|
||||
id: 'rest',
|
||||
label: 'Bedroom',
|
||||
description: 'Rest and recharge',
|
||||
icon: Moon,
|
||||
},
|
||||
closet: {
|
||||
id: 'closet',
|
||||
label: 'Closet',
|
||||
description: 'Wardrobe and accessories',
|
||||
icon: Shirt,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Default Order ────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
|
||||
'care',
|
||||
'kitchen',
|
||||
'home',
|
||||
'rest',
|
||||
// 'closet', — re-enable when wardrobe is ready
|
||||
];
|
||||
|
||||
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
|
||||
|
||||
/** Validate a string as a room ID (for parsing persisted values) */
|
||||
export function isValidRoomId(value: string | undefined): value is BlobbiRoomId {
|
||||
return !!value && value in ROOM_META;
|
||||
}
|
||||
|
||||
// ─── Navigation ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function getNextRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[0];
|
||||
return order[(idx + 1) % order.length];
|
||||
}
|
||||
|
||||
export function getPreviousRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[order.length - 1];
|
||||
return order[(idx - 1 + order.length) % order.length];
|
||||
}
|
||||
|
||||
export function getRoomIndex(
|
||||
room: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): number {
|
||||
return order.indexOf(room);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Shared layout constants for Blobbi room components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSS class for the bottom action bar in every room.
|
||||
*
|
||||
* On mobile (max-sidebar), adds extra bottom padding to clear the
|
||||
* fixed bottom navigation bar. On desktop (sidebar:), uses normal padding.
|
||||
*/
|
||||
export const ROOM_BOTTOM_BAR_CLASS =
|
||||
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Footprints, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
sm: {
|
||||
wrapper: 'flex flex-col items-center gap-3 py-6',
|
||||
icon: 'size-10 text-muted-foreground/30',
|
||||
name: 'text-sm font-semibold',
|
||||
description: 'text-xs text-muted-foreground',
|
||||
button: 'flex items-center gap-2 px-4 py-2 rounded-full text-white text-xs font-semibold transition-all hover:-translate-y-0.5 hover:scale-105 active:scale-95',
|
||||
buttonIcon: 'size-3.5',
|
||||
buttonLabel: (_name: string) => 'Bring home',
|
||||
descriptionText: (_name: string) => 'Out exploring with you',
|
||||
},
|
||||
md: {
|
||||
wrapper: 'flex flex-col items-center justify-center gap-6 text-center',
|
||||
icon: 'size-16 text-muted-foreground/30',
|
||||
name: '', // not shown separately in md — name is inline in description
|
||||
description: 'text-muted-foreground text-sm',
|
||||
button: 'flex items-center justify-center gap-2.5 px-8 py-3.5 rounded-full text-white font-semibold transition-all duration-300 ease-out hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
buttonIcon: 'size-5',
|
||||
buttonLabel: (name: string) => `Bring ${name} home`,
|
||||
descriptionText: (name: string) => `${name} is out exploring right now.`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface BlobbiAwayStateProps {
|
||||
/** The Blobbi's name. */
|
||||
name: string;
|
||||
/** Visual size preset. 'md' for full page, 'sm' for widget. */
|
||||
size?: 'sm' | 'md';
|
||||
/** Whether the companion update is in progress. */
|
||||
isUpdating: boolean;
|
||||
/** Callback to bring the Blobbi home (unset as floating companion). */
|
||||
onBringHome: () => void;
|
||||
}
|
||||
|
||||
/** Shared "out exploring" state shown when a Blobbi is the active floating companion. */
|
||||
export function BlobbiAwayState({ name, size = 'md', isUpdating, onBringHome }: BlobbiAwayStateProps) {
|
||||
const preset = SIZE_PRESETS[size];
|
||||
|
||||
return (
|
||||
<div className={preset.wrapper}>
|
||||
<Footprints className={preset.icon} />
|
||||
{size === 'sm' && <span className={preset.name}>{name}</span>}
|
||||
<p className={preset.description}>{preset.descriptionText(name)}</p>
|
||||
<button
|
||||
onClick={onBringHome}
|
||||
disabled={isUpdating}
|
||||
className={cn(preset.button, isUpdating && 'opacity-50 pointer-events-none')}
|
||||
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
|
||||
>
|
||||
{isUpdating
|
||||
? <Loader2 className={cn(preset.buttonIcon, 'animate-spin')} />
|
||||
: <Footprints className={preset.buttonIcon} />}
|
||||
<span>{preset.buttonLabel(name)}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { AlertTriangle, Utensils, Gamepad2, Heart, Droplets, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
orange: 'text-orange-500',
|
||||
yellow: 'text-yellow-500',
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
violet: 'text-violet-500',
|
||||
};
|
||||
|
||||
const STAT_BG_COLORS: Record<string, string> = {
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
green: 'bg-green-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
violet: 'bg-violet-500/10',
|
||||
};
|
||||
|
||||
const STAT_RING_HEX: Record<string, string> = {
|
||||
orange: '#f97316',
|
||||
yellow: '#eab308',
|
||||
green: '#22c55e',
|
||||
blue: '#3b82f6',
|
||||
violet: '#8b5cf6',
|
||||
};
|
||||
|
||||
/** Lucide icon component for each stat. */
|
||||
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
||||
hunger: Utensils,
|
||||
happiness: Gamepad2,
|
||||
health: Heart,
|
||||
hygiene: Droplets,
|
||||
energy: Zap,
|
||||
};
|
||||
|
||||
// ── Size presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
sm: {
|
||||
container: 'size-9',
|
||||
icon: 'size-3.5',
|
||||
strokeWidth: 3,
|
||||
alertSize: 'size-2.5',
|
||||
alertPos: '-top-1 -right-1.5',
|
||||
},
|
||||
md: {
|
||||
container: 'size-[4.5rem] sm:size-20',
|
||||
icon: 'size-6 sm:size-7',
|
||||
strokeWidth: 2.5,
|
||||
alertSize: 'size-3.5',
|
||||
alertPos: '-top-1.5 -right-2',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StatIndicatorProps {
|
||||
stat: string;
|
||||
value: number | undefined;
|
||||
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
status?: 'normal' | 'warning' | 'critical';
|
||||
/** Visual size preset. Default: 'md'. */
|
||||
size?: 'sm' | 'md';
|
||||
/** When provided, renders as a clickable button. */
|
||||
onClick?: () => void;
|
||||
/** Disable the button (only relevant when onClick is set). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function StatIndicator({
|
||||
stat,
|
||||
value,
|
||||
color,
|
||||
status = 'normal',
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled,
|
||||
}: StatIndicatorProps) {
|
||||
const displayValue = value ?? 0;
|
||||
const isLow = status === 'warning' || status === 'critical';
|
||||
const ringHex = STAT_RING_HEX[color];
|
||||
const IconComponent = STAT_ICON_MAP[stat];
|
||||
const preset = SIZE_PRESETS[size];
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{/* Progress ring */}
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth={preset.strokeWidth} className="text-muted/15" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15" fill="none" strokeWidth={preset.strokeWidth} strokeLinecap="round"
|
||||
stroke={ringHex}
|
||||
strokeDasharray={`${displayValue * 0.94} 100`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
{/* Icon with warning badge */}
|
||||
<div className="relative">
|
||||
{IconComponent && <IconComponent className={cn(preset.icon, STAT_COLORS[color])} strokeWidth={2.5} />}
|
||||
{isLow && (
|
||||
<AlertTriangle
|
||||
className={cn('absolute', preset.alertPos, preset.alertSize, status === 'critical' ? 'text-red-500' : 'text-amber-500')}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClass = cn(
|
||||
'relative rounded-full flex items-center justify-center',
|
||||
preset.container,
|
||||
STAT_BG_COLORS[color],
|
||||
status === 'critical' && 'animate-pulse',
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
baseClass,
|
||||
'transition-transform hover:scale-110 active:scale-95',
|
||||
disabled && 'opacity-40 pointer-events-none',
|
||||
)}
|
||||
aria-label={`${stat} ${displayValue}%`}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClass}>{inner}</div>;
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ChevronRight,
|
||||
Egg,
|
||||
Sparkles,
|
||||
Coins,
|
||||
CircleDot,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -29,7 +28,7 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { HatchTask } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import type { DailyMission } from '@/blobbi/actions/lib/daily-missions';
|
||||
import type { DailyMissionView } from '@/blobbi/actions/hooks/useDailyMissions';
|
||||
|
||||
// ─── Card Item Types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,8 +48,8 @@ interface DailyCardItem {
|
||||
description: string;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
reward: number;
|
||||
claimed: boolean;
|
||||
xp: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
type CardItem = TaskCardItem | DailyCardItem;
|
||||
@@ -65,7 +64,7 @@ interface MissionSurfaceCardProps {
|
||||
/** Process type for badge label */
|
||||
processType: 'hatch' | 'evolve' | null;
|
||||
/** Daily missions */
|
||||
dailyMissions: DailyMission[];
|
||||
dailyMissions: DailyMissionView[];
|
||||
/** Called when user taps "View all" */
|
||||
onViewAll: () => void;
|
||||
/** Called when user dismisses the card */
|
||||
@@ -97,22 +96,22 @@ function buildTaskCards(
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDailyCards(missions: DailyMission[]): DailyCardItem[] {
|
||||
// Show unclaimed missions first, then claimed ones
|
||||
const unclaimed = missions.filter((m) => !m.claimed);
|
||||
const toShow = unclaimed.length > 0 ? unclaimed : [];
|
||||
function buildDailyCards(missions: DailyMissionView[]): DailyCardItem[] {
|
||||
// Show incomplete missions first
|
||||
const incomplete = missions.filter((m) => !m.complete);
|
||||
const toShow = incomplete.length > 0 ? incomplete : [];
|
||||
|
||||
return toShow.map((m) => ({
|
||||
kind: 'daily',
|
||||
badge: 'Daily',
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
progress: m.requiredCount > 0
|
||||
? Math.min(100, Math.round((m.currentCount / m.requiredCount) * 100))
|
||||
progress: m.target > 0
|
||||
? Math.min(100, Math.round((m.progress / m.target) * 100))
|
||||
: 0,
|
||||
progressLabel: `${m.currentCount}/${m.requiredCount}`,
|
||||
reward: m.reward,
|
||||
claimed: m.claimed,
|
||||
progressLabel: `${m.progress}/${m.target}`,
|
||||
xp: m.xp,
|
||||
complete: m.complete,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -279,10 +278,9 @@ export function MissionSurfaceCard({
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
|
||||
{card.progressLabel}
|
||||
</span>
|
||||
{card.kind === 'daily' && !card.claimed && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-amber-600 dark:text-amber-400 font-medium shrink-0">
|
||||
<Coins className="size-2.5" />
|
||||
{card.reward}
|
||||
{card.kind === 'daily' && !card.complete && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-violet-600 dark:text-violet-400 font-medium shrink-0">
|
||||
{card.xp} XP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { NoteMedia } from '@/components/NoteMedia';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { usePostComment } from '@/hooks/usePostComment';
|
||||
@@ -34,11 +34,11 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||||
|
||||
/** Lazy-loaded EmojiPicker — keeps emoji-mart + its data out of the main bundle. */
|
||||
const LazyEmojiPicker = lazy(() => import('@/components/EmojiPicker').then(m => ({ default: m.EmojiPicker })));
|
||||
import { parseImetaMap } from '@/lib/imeta';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useInsertText } from '@/hooks/useInsertText';
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder';
|
||||
@@ -347,7 +347,7 @@ export function ComposeBox({
|
||||
const url = match[0];
|
||||
// Skip media URLs that render inline
|
||||
// Note: SVGs not excluded - LinkPreview checks content-type and handles both cases
|
||||
if (!IMETA_MEDIA_URL_REGEX.test(url)) {
|
||||
if (!IMETA_MEDIA_URL_TEST_REGEX.test(url)) {
|
||||
embeds.push({ type: 'link', value: url, index: match.index! });
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,7 @@ export function ComposeBox({
|
||||
}
|
||||
}
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -973,6 +974,7 @@ export function ComposeBox({
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : quotedEvent ? 'Your quote has been published.' : 'Your note has been published.' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -1016,6 +1018,7 @@ export function ComposeBox({
|
||||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
notificationSuccess();
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -1116,31 +1119,13 @@ export function ComposeBox({
|
||||
</div>
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (() => {
|
||||
const imetaMap = parseImetaMap(mockEvent.tags);
|
||||
const videos = extractVideoUrls(mockEvent.content);
|
||||
const imetaAudios = Array.from(imetaMap.values())
|
||||
.filter((e) => e.mime?.startsWith('audio/'))
|
||||
.map((e) => e.url);
|
||||
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
|
||||
const webxdcApps = Array.from(imetaMap.values()).filter(
|
||||
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
|
||||
);
|
||||
return (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={mockEvent}
|
||||
/>
|
||||
mockEvent && (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Poll options + settings — shown below the normal textarea/preview */}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DORK_ANIMATION = [
|
||||
'<[o_o]>',
|
||||
'>[-_-]<',
|
||||
'<[0_0]>',
|
||||
'>[-_-]<',
|
||||
];
|
||||
|
||||
/** Animated Dork face shown while the AI is thinking. */
|
||||
export function DorkThinking({ className }: { className?: string }) {
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % DORK_ANIMATION.length);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pre className={cn('font-mono text-muted-foreground leading-none', className)}>{DORK_ANIMATION[frame]}</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EmbeddedCardShellProps {
|
||||
/** Author pubkey — used for the author row. */
|
||||
pubkey: string;
|
||||
/** Timestamp of the event (unix seconds). */
|
||||
createdAt: number;
|
||||
/** The NIP-19 identifier to navigate to on click. */
|
||||
navigateTo: string;
|
||||
className?: string;
|
||||
/** When true, ProfileHoverCards inside the card are disabled. */
|
||||
disableHoverCards?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared clickable card shell with an author row used by all embedded
|
||||
* note / naddr preview cards. Handles the outer border, hover style,
|
||||
* click / keyboard navigation, avatar, display name, and timestamp.
|
||||
*
|
||||
* Pass card-specific content (text preview, blobbi visual, badge row, etc.)
|
||||
* as `children`.
|
||||
*/
|
||||
export function EmbeddedCardShell({
|
||||
pubkey,
|
||||
createdAt,
|
||||
navigateTo,
|
||||
className,
|
||||
disableHoverCards,
|
||||
children,
|
||||
}: EmbeddedCardShellProps) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:bg-secondary/40 transition-colors cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/${navigateTo}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/${navigateTo}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
|
||||
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. */
|
||||
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Award, Image, MessageSquareOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
const BlobbiStateCard = lazy(() => import('@/components/BlobbiStateCard').then(m => ({ default: m.BlobbiStateCard })));
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface EmbeddedNaddrProps {
|
||||
/** The decoded naddr coordinates. */
|
||||
@@ -87,6 +90,11 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa
|
||||
return <EmbeddedProfileBadgesCard event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Blobbi state events render the pet visual inline
|
||||
if (event.kind === 31124) {
|
||||
return <EmbeddedBlobbiCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
@@ -194,6 +202,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
const badgeRefs = useMemo(() => parseProfileBadges(event), [event]);
|
||||
|
||||
@@ -265,7 +274,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to={`/${nip19.npubEncode(event.pubkey)}`}
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -277,7 +286,7 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
</Avatar>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/${nip19.npubEncode(event.pubkey)}`}
|
||||
to={profileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -325,13 +334,6 @@ export function EmbeddedProfileBadgesCard({ event, className }: { event: NostrEv
|
||||
}
|
||||
|
||||
function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
const naddrId = useMemo(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
|
||||
@@ -353,114 +355,65 @@ function EmbeddedNaddrCard({ event, className, disableHoverCards }: { event: Nos
|
||||
}, [event.kind]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group block rounded-2xl border border-border overflow-hidden',
|
||||
'hover:bg-secondary/40 transition-colors cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/${naddrId}`);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(`/${naddrId}`);
|
||||
}
|
||||
}}
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={naddrId}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
{/* Text content */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
</>
|
||||
)}
|
||||
{/* Description */}
|
||||
{truncatedDesc && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{truncatedDesc}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
{/* Kind label and attachment indicators */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kindMeta && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
|
||||
{kindMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{truncatedDesc && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{truncatedDesc}
|
||||
</p>
|
||||
{image && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
Image
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Kind label and attachment indicators */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kindMeta && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
|
||||
{kindMeta.label}
|
||||
</span>
|
||||
)}
|
||||
{image && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
Image
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
|
||||
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
/** Embedded card for kind 31124 Blobbi state events — renders the pet visual inline. */
|
||||
function EmbeddedBlobbiCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const naddrId = useMemo(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
|
||||
}, [event]);
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={naddrId}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<BlobbiStateCard event={event} />
|
||||
</Suspense>
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+289
-289
@@ -1,74 +1,39 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { lazy, type ReactNode, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff, Zap } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
import { VanishCardCompact } from '@/components/VanishEventContent';
|
||||
import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent';
|
||||
import { EncryptedLetterCompact } from '@/components/EncryptedLetterContent';
|
||||
import { EmbeddedProfileBadgesCard } from '@/components/EmbeddedNaddr';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useEvent } from '@/hooks/useEvent';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
import { extractZapAmount, extractZapSender, extractZapMessage } from '@/hooks/useEventInteractions';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { LinkPreview } from '@/components/LinkPreview';
|
||||
import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
|
||||
import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls';
|
||||
import { getKindLabel, getKindIcon } from '@/lib/extraKinds';
|
||||
|
||||
const BlobbiStateCard = lazy(() => import('@/components/BlobbiStateCard').then(m => ({ default: m.BlobbiStateCard })));
|
||||
|
||||
/** NIP-62 Request to Vanish. */
|
||||
const VANISH_KIND = 62;
|
||||
|
||||
/** Bech32 charset used by NIP-19 identifiers. */
|
||||
const B32 = '023456789acdefghjklmnpqrstuvwxyz';
|
||||
|
||||
/** Regex that matches nostr:npub1… and nostr:nprofile1… inside text. */
|
||||
const MENTION_REGEX = new RegExp(`nostr:(npub1|nprofile1)[${B32}]+`, 'g');
|
||||
|
||||
/** A parsed segment of embedded-note text. */
|
||||
type EmbedSegment =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'mention'; pubkey: string; npub: string };
|
||||
|
||||
/** Split text into plain strings and mention segments. */
|
||||
function parseEmbedSegments(text: string): EmbedSegment[] {
|
||||
const segments: EmbedSegment[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
MENTION_REGEX.lastIndex = 0;
|
||||
while ((m = MENTION_REGEX.exec(text)) !== null) {
|
||||
if (m.index > last) {
|
||||
segments.push({ type: 'text', value: text.slice(last, m.index) });
|
||||
}
|
||||
try {
|
||||
const bech32 = m[0].slice('nostr:'.length);
|
||||
const decoded = nip19.decode(bech32);
|
||||
const pubkey = decoded.type === 'npub'
|
||||
? (decoded.data as string)
|
||||
: (decoded.data as { pubkey: string }).pubkey;
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
segments.push({ type: 'mention', pubkey, npub });
|
||||
} catch {
|
||||
// If decode fails, keep as plain text
|
||||
segments.push({ type: 'text', value: m[0] });
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
|
||||
if (last < text.length) {
|
||||
segments.push({ type: 'text', value: text.slice(last) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
/** Max-height (px) for the content area before it gets clipped. */
|
||||
const EMBED_MAX_HEIGHT = 260;
|
||||
|
||||
interface EmbeddedNoteProps {
|
||||
/** Hex event ID to fetch and display. */
|
||||
@@ -82,9 +47,6 @@ interface EmbeddedNoteProps {
|
||||
disableHoverCards?: boolean;
|
||||
}
|
||||
|
||||
/** Maximum characters of note content to show in the embedded preview. */
|
||||
const MAX_CONTENT_LENGTH = 280;
|
||||
|
||||
/** Inline embedded note card – similar to a link preview but for Nostr events. */
|
||||
export function EmbeddedNote({ eventId, relays, authorHint, className, disableHoverCards }: EmbeddedNoteProps) {
|
||||
const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint);
|
||||
@@ -117,99 +79,32 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo
|
||||
return <EmbeddedProfileBadgesCard event={event} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 9735 zap receipts get a compact zap card instead of rendering raw JSON
|
||||
if (event.kind === 9735) {
|
||||
return <EmbeddedZapCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNoteCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
/** The actual card once the event has been fetched. */
|
||||
function EmbeddedNoteCard({
|
||||
event,
|
||||
className,
|
||||
disableHoverCards,
|
||||
}: {
|
||||
event: { id: string; kind: number; pubkey: string; content: string; created_at: number; tags: string[][] };
|
||||
className?: string;
|
||||
disableHoverCards?: boolean;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
/** Compact inline card for kind 9735 zap receipts. */
|
||||
function EmbeddedZapCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const neventId = useMemo(
|
||||
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
|
||||
[event.id, event.pubkey],
|
||||
);
|
||||
|
||||
// Extract the first non-media URL for a link preview card
|
||||
const firstLinkUrl = useMemo(() => {
|
||||
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
|
||||
return allUrls.find((u) => !IMETA_MEDIA_URL_REGEX.test(u)) ?? null;
|
||||
}, [event.content]);
|
||||
const senderPubkey = useMemo(() => extractZapSender(event), [event]);
|
||||
const amountSats = useMemo(() => Math.floor(extractZapAmount(event) / 1000), [event]);
|
||||
const message = useMemo(() => extractZapMessage(event), [event]);
|
||||
|
||||
// Truncate long content, stripping media URLs, the previewed link, and nested nostr event references
|
||||
const truncatedContent = useMemo(() => {
|
||||
let text = event.content
|
||||
// Strip media URLs (same extensions as NoteContent's MEDIA_URL_REGEX)
|
||||
.replace(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'), '')
|
||||
// Strip embedded event references (nevent / note) so they don't nest
|
||||
.replace(/nostr:(nevent1|note1)[023456789acdefghjklmnpqrstuvwxyz]+/g, '');
|
||||
// Strip the URL that will be shown as a link preview card
|
||||
if (firstLinkUrl) {
|
||||
text = text.replace(firstLinkUrl, '');
|
||||
}
|
||||
const cleaned = text
|
||||
// Collapse leftover whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
if (cleaned.length <= MAX_CONTENT_LENGTH) return cleaned;
|
||||
return cleaned.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…';
|
||||
}, [event.content, firstLinkUrl]);
|
||||
|
||||
// For non-text kinds with empty content, extract title/description from tags
|
||||
const tagMeta = useMemo(() => {
|
||||
if (truncatedContent) return undefined;
|
||||
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const title = getTag('title') || getTag('name') || getTag('d');
|
||||
const description = getTag('summary') || getTag('description');
|
||||
|
||||
// Build a kind label line for context (e.g. "nsite")
|
||||
const kindLabel = getKindLabel(event.kind);
|
||||
const KindIcon = getKindIcon(event.kind);
|
||||
|
||||
if (!title && !description && !kindLabel) return undefined;
|
||||
return { title, description, kindLabel, KindIcon };
|
||||
}, [truncatedContent, event.tags, event.kind]);
|
||||
|
||||
// Detect stripped attachments to show indicator chips
|
||||
const isPhoto = event.kind === 20;
|
||||
const attachments = useMemo(() => {
|
||||
// Kind 20 (NIP-68 photo events): count images from imeta tags instead of content
|
||||
if (isPhoto) {
|
||||
const photoCount = event.tags.filter(([n]) => n === 'imeta').length;
|
||||
return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount };
|
||||
}
|
||||
const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length;
|
||||
const vids = extractVideoUrls(event.content).length;
|
||||
const auds = extractAudioUrls(event.content).length;
|
||||
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
|
||||
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
|
||||
const nonMediaLinks = allUrls.filter((u) => !IMETA_MEDIA_URL_REGEX.test(u)).length;
|
||||
// Subtract 1 if we're showing a link preview card for the first URL
|
||||
const links = firstLinkUrl ? nonMediaLinks - 1 : nonMediaLinks;
|
||||
return { imgs, vids, auds, apps, links, photos: 0 };
|
||||
}, [event.content, event.tags, isPhoto, firstLinkUrl]);
|
||||
|
||||
// NIP-36 content-warning check
|
||||
const cwTag = event.tags.find(([name]) => name === 'content-warning');
|
||||
const hasCW = !!cwTag;
|
||||
|
||||
// If policy is "hide", don't render the embedded note at all
|
||||
if (hasCW && config.contentWarningPolicy === 'hide') {
|
||||
return null;
|
||||
}
|
||||
const sender = useAuthor(senderPubkey || undefined);
|
||||
const senderMeta = sender.data?.metadata;
|
||||
const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone');
|
||||
const senderShape = getAvatarShape(senderMeta);
|
||||
const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -232,178 +127,273 @@ function EmbeddedNoteCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Note content */}
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Avatar shape={avatarShape} className="size-5">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
<div className="px-3 py-2.5 flex items-center gap-2.5 min-w-0">
|
||||
{/* Zap icon */}
|
||||
<div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10 shrink-0">
|
||||
<Zap className="size-4 text-amber-500 fill-amber-500" />
|
||||
</div>
|
||||
|
||||
<MaybeProfileHoverCard pubkey={event.pubkey} disabled={disableHoverCards}>
|
||||
{/* Sender avatar */}
|
||||
{senderPubkey && (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link to={senderProfileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={senderShape} className="size-5">
|
||||
<AvatarImage src={senderMeta?.picture} alt={senderName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{senderName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</MaybeHoverCard>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{senderPubkey ? (
|
||||
<MaybeHoverCard pubkey={senderPubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
to={senderProfileUrl}
|
||||
className="text-sm font-semibold truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
{sender.data?.event ? (
|
||||
<EmojifiedText tags={sender.data.event.tags}>{senderName}</EmojifiedText>
|
||||
) : senderName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
</>
|
||||
</MaybeHoverCard>
|
||||
) : (
|
||||
<span className="text-sm font-semibold truncate">Someone</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">zapped</span>
|
||||
{amountSats > 0 && (
|
||||
<span className="text-sm font-semibold text-amber-500 shrink-0">
|
||||
{formatNumber(amountSats)} {amountSats === 1 ? 'sat' : 'sats'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{message && (
|
||||
<p className="text-xs text-muted-foreground italic mt-0.5 line-clamp-2">
|
||||
“{message}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
· {timeAgo(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content warning notice or text preview or tag-based metadata */}
|
||||
{hasCW && config.contentWarningPolicy === 'blur' ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}”</> : ''}
|
||||
</p>
|
||||
) : truncatedContent ? (
|
||||
<EmbedContentPreview text={truncatedContent} disableHoverCards={disableHoverCards} />
|
||||
) : tagMeta ? (
|
||||
<>
|
||||
{tagMeta.title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
|
||||
)}
|
||||
{tagMeta.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
|
||||
)}
|
||||
{tagMeta.kindLabel && (
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{tagMeta.KindIcon && <tagMeta.KindIcon className="size-3 shrink-0" />}
|
||||
{tagMeta.kindLabel}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Link preview card for the first non-media URL */}
|
||||
{!hasCW && firstLinkUrl && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<LinkPreview url={firstLinkUrl} className="mt-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment indicators for stripped media/links */}
|
||||
{!hasCW && (attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{attachments.photos > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.imgs > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.vids > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Film className="size-3" />
|
||||
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.auds > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Music className="size-3" />
|
||||
{attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.apps > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Blocks className="size-3" />
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
{attachments.links > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<ExternalLink className="size-3" />
|
||||
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders embedded-note text with @mentions resolved inline. */
|
||||
function EmbedContentPreview({ text, disableHoverCards }: { text: string; disableHoverCards?: boolean }) {
|
||||
const segments = useMemo(() => parseEmbedSegments(text), [text]);
|
||||
/** The actual card once the event has been fetched. */
|
||||
function EmbeddedNoteCard({
|
||||
event,
|
||||
className,
|
||||
disableHoverCards,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
disableHoverCards?: boolean;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
|
||||
return (
|
||||
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-hidden line-clamp-3">
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
return <span key={i}>{seg.value}</span>;
|
||||
}
|
||||
return <EmbedMention key={i} pubkey={seg.pubkey} npub={seg.npub} disableHoverCards={disableHoverCards} />;
|
||||
})}
|
||||
</p>
|
||||
const neventId = useMemo(
|
||||
() => nip19.neventEncode({ id: event.id, author: event.pubkey }),
|
||||
[event.id, event.pubkey],
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline @mention inside an embedded note preview. */
|
||||
function EmbedMention({ pubkey, disableHoverCards }: { pubkey: string; npub: string; disableHoverCards?: boolean }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const hasRealName = !!author.data?.metadata?.name;
|
||||
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, author.data?.metadata);
|
||||
const [contentOverflows, setContentOverflows] = useState(false);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<MaybeProfileHoverCard pubkey={pubkey} disabled={disableHoverCards}>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className={cn(
|
||||
'font-medium hover:underline',
|
||||
hasRealName ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</MaybeProfileHoverCard>
|
||||
);
|
||||
}
|
||||
const isBlobbiState = event.kind === 31124;
|
||||
const isPhoto = event.kind === 20;
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */
|
||||
function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
// Attachment counts for indicator chips
|
||||
const attachments = useMemo(() => {
|
||||
if (isBlobbiState) return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: 0 };
|
||||
if (isPhoto) {
|
||||
const photoCount = event.tags.filter(([n]) => n === 'imeta').length;
|
||||
return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount };
|
||||
}
|
||||
const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length;
|
||||
const vids = extractVideoUrls(event.content).length;
|
||||
const auds = extractAudioUrls(event.content).length;
|
||||
const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length;
|
||||
const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || [];
|
||||
const links = allUrls.filter((u) => !IMETA_MEDIA_URL_TEST_REGEX.test(u)).length;
|
||||
return { imgs, vids, auds, apps, links, photos: 0 };
|
||||
}, [event.content, event.tags, isPhoto, isBlobbiState]);
|
||||
|
||||
// Kind label for non-text-note kinds
|
||||
const kindMeta = useMemo(() => {
|
||||
const label = getKindLabel(event.kind);
|
||||
if (!label) return undefined;
|
||||
return { label, Icon: getKindIcon(event.kind) };
|
||||
}, [event.kind]);
|
||||
|
||||
// Tag-based fallback metadata for events with empty content (articles, custom kinds, etc.)
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const tagMeta = useMemo(() => {
|
||||
if (hasContent) return undefined;
|
||||
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const title = getTag('title') || getTag('name') || getTag('d');
|
||||
const description = getTag('summary') || getTag('description');
|
||||
if (!title && !description) return undefined;
|
||||
return { title, description };
|
||||
}, [hasContent, event.tags]);
|
||||
|
||||
// NIP-36 content-warning check
|
||||
const cwTag = event.tags.find(([name]) => name === 'content-warning');
|
||||
const hasCW = !!cwTag;
|
||||
|
||||
// If policy is "hide", don't render the embedded note at all
|
||||
if (hasCW && config.contentWarningPolicy === 'hide') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChips = !hasCW && (
|
||||
attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 ||
|
||||
attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0 || kindMeta
|
||||
);
|
||||
const hasFooter = hasChips || contentOverflows;
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={neventId}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
{/* Content — rendered identically to a normal NoteCard, just height-capped */}
|
||||
{hasCW && config.contentWarningPolicy === 'blur' ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}”</> : ''}
|
||||
</p>
|
||||
) : isBlobbiState ? (
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<BlobbiStateCard event={event} />
|
||||
</Suspense>
|
||||
) : tagMeta ? (
|
||||
<>
|
||||
{tagMeta.title && (
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-2">{tagMeta.title}</p>
|
||||
)}
|
||||
{tagMeta.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">{tagMeta.description}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmbedTruncatedContent event={event} expanded={contentExpanded} onOverflowChange={setContentOverflows} />
|
||||
)}
|
||||
|
||||
{/* Attachment / kind indicator chips + Read more toggle */}
|
||||
{hasFooter && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{kindMeta && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
{kindMeta.Icon && <kindMeta.Icon className="size-3 shrink-0" />}
|
||||
{kindMeta.label}
|
||||
</span>
|
||||
)}
|
||||
{attachments.photos > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.imgs > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Image className="size-3" />
|
||||
{attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.vids > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Film className="size-3" />
|
||||
{attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.auds > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Music className="size-3" />
|
||||
{attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'}
|
||||
</span>
|
||||
)}
|
||||
{attachments.apps > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Blocks className="size-3" />
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
{attachments.links > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<ExternalLink className="size-3" />
|
||||
{attachments.links > 1 ? `${attachments.links} links` : 'Link'}
|
||||
</span>
|
||||
)}
|
||||
{contentOverflows && (
|
||||
<button
|
||||
className="ml-auto text-xs text-primary hover:underline shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setContentExpanded((v) => !v);
|
||||
}}
|
||||
>
|
||||
{contentExpanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Truncated content area with overflow detection. Toggle is rendered externally. */
|
||||
function EmbedTruncatedContent({ event, expanded, onOverflowChange }: {
|
||||
event: NostrEvent;
|
||||
expanded: boolean;
|
||||
onOverflowChange: (overflows: boolean) => void;
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const doesOverflow = el.scrollHeight > EMBED_MAX_HEIGHT;
|
||||
setOverflows(doesOverflow);
|
||||
onOverflowChange(doesOverflow);
|
||||
}, [onOverflowChange]);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [measure]);
|
||||
|
||||
// Re-measure after images load
|
||||
useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const imgs = el.querySelectorAll('img');
|
||||
if (imgs.length === 0) return;
|
||||
imgs.forEach((img) => img.addEventListener('load', measure, { once: true }));
|
||||
return () => imgs.forEach((img) => img.removeEventListener('load', measure));
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="relative overflow-hidden"
|
||||
style={!expanded && overflows ? { maxHeight: EMBED_MAX_HEIGHT } : undefined}
|
||||
>
|
||||
<NoteContent event={event} className="text-sm leading-relaxed" disableMediaEmbeds disableNoteEmbeds />
|
||||
{!expanded && overflows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -489,6 +479,16 @@ function EmbeddedNoteTombstone({ eventId, relays, authorHint, className }: { eve
|
||||
);
|
||||
}
|
||||
|
||||
/** Conditionally wraps children in a ProfileHoverCard. */
|
||||
function MaybeHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) {
|
||||
if (disabled) return <>{children}</>;
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
{children}
|
||||
</ProfileHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
|
||||
|
||||
@@ -366,7 +366,8 @@ export function EmojiShortcodeAutocomplete({
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
data-autocomplete-dropdown
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150 pointer-events-auto"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[280px] overflow-y-auto py-1">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useExternalReactionCount,
|
||||
} from '@/hooks/useExternalReactions';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExternalContent } from '@/lib/externalContent';
|
||||
|
||||
@@ -88,6 +89,7 @@ export function ExternalReactionButton({ content, iconSize = 'size-5', count, cl
|
||||
// Publish kind 17 reaction
|
||||
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
const tags: string[][] = [
|
||||
['k', getExternalKTag(content)],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FollowButtonProps {
|
||||
@@ -39,9 +40,11 @@ export function FollowButton({ pubkey, className, size = 'sm' }: FollowButtonPro
|
||||
try {
|
||||
if (isFollowing) {
|
||||
await unfollow(pubkey);
|
||||
impactMedium();
|
||||
toast({ title: 'Unfollowed' });
|
||||
} else {
|
||||
await follow(pubkey);
|
||||
impactMedium();
|
||||
toast({ title: 'Followed' });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { WebxdcHandle } from '@/components/Webxdc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -28,9 +29,9 @@ type GameButton = keyof typeof KEY_MAP;
|
||||
/** Buttons that trigger haptic feedback on press. */
|
||||
const HAPTIC_BUTTONS = new Set<GameButton>(['a', 'b']);
|
||||
|
||||
/** Trigger a short vibration if the Vibration API is available. */
|
||||
/** Trigger a short vibration via the native haptic engine. */
|
||||
function haptic() {
|
||||
navigator.vibrate?.(25);
|
||||
impactLight();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { Suspense, useState, useMemo, useCallback, useRef, lazy } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
@@ -12,6 +12,8 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const WidgetSidebar = lazy(() => import('@/components/WidgetSidebar').then((m) => ({ default: m.WidgetSidebar })));
|
||||
|
||||
/** Skeleton shown in the content area while a lazy page chunk is loading. */
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
@@ -104,8 +106,8 @@ function MainLayoutInner() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right sidebar — render page-provided sidebar, or an empty placeholder to preserve layout width */}
|
||||
{rightSidebar ?? <div className="w-[300px] shrink-0 hidden xl:block" />}
|
||||
{/* Right sidebar — render page-provided sidebar, or the widget sidebar */}
|
||||
{rightSidebar ?? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -262,7 +262,8 @@ export function MentionAutocomplete({
|
||||
const dropdown = (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
data-autocomplete-dropdown
|
||||
className="fixed z-[300] w-[280px] rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150 pointer-events-auto"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
>
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto py-1">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Bell, Home, Search, User } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectionChanged } from '@/lib/haptics';
|
||||
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
@@ -37,6 +38,7 @@ export function MobileBottomNav() {
|
||||
|
||||
const handleSearchClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
selectionChanged();
|
||||
setSearchOpen((v) => !v);
|
||||
}, []);
|
||||
|
||||
@@ -65,7 +67,7 @@ export function MobileBottomNav() {
|
||||
{/* Home */}
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
(location.pathname === '/' || location.pathname === homePath) ? 'text-primary' : 'text-muted-foreground',
|
||||
@@ -91,7 +93,7 @@ export function MobileBottomNav() {
|
||||
{user && (
|
||||
<Link
|
||||
to="/notifications"
|
||||
onClick={() => setSearchOpen(false)}
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
location.pathname === '/notifications' ? 'text-primary' : 'text-muted-foreground',
|
||||
@@ -111,7 +113,7 @@ export function MobileBottomNav() {
|
||||
{user ? (
|
||||
<Link
|
||||
to={profileUrl}
|
||||
onClick={() => setSearchOpen(false)}
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-0.5 flex-1 py-2 transition-colors',
|
||||
isOnProfile ? 'text-primary' : 'text-muted-foreground',
|
||||
|
||||
@@ -58,7 +58,6 @@ import { LiveStreamPlayer } from "@/components/LiveStreamPlayer";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { Nip05Badge } from "@/components/Nip05Badge";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NoteMedia } from "@/components/NoteMedia";
|
||||
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
|
||||
import { PatchCard } from "@/components/PatchCard";
|
||||
import { PollContent } from "@/components/PollContent";
|
||||
@@ -97,12 +96,11 @@ import { extractZapAmount, extractZapSender, extractZapMessage } from "@/hooks/u
|
||||
import { getContentWarning } from "@/lib/contentWarning";
|
||||
import { genUserName } from "@/lib/genUserName";
|
||||
import { getDisplayName } from "@/lib/getDisplayName";
|
||||
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
|
||||
import { extractAudioUrls, extractVideoUrls } from "@/lib/mediaUrls";
|
||||
import { usePollVoteLabel } from "@/hooks/usePollVoteLabel";
|
||||
import { getParentEventHints, isReplyEvent } from "@/lib/nostrEvents";
|
||||
import { isSingleImagePost } from "@/lib/noteContent";
|
||||
import { shareOrCopy } from "@/lib/share";
|
||||
import { impactLight } from "@/lib/haptics";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { formatNumber } from "@/lib/formatNumber";
|
||||
import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
@@ -469,35 +467,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isProfile &&
|
||||
!isBlobbiState;
|
||||
|
||||
// Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia
|
||||
const videos = useMemo(
|
||||
() => (isTextNote ? extractVideoUrls(event.content) : []),
|
||||
[event.content, isTextNote],
|
||||
);
|
||||
const audios = useMemo(() => {
|
||||
if (!isTextNote) return [];
|
||||
// Prefer imeta-declared audio over URL scraping
|
||||
const imetaAudios = Array.from(parseImetaMap(event.tags).values())
|
||||
.filter((e) => e.mime?.startsWith("audio/"))
|
||||
.map((e) => e.url);
|
||||
if (imetaAudios.length > 0) return imetaAudios;
|
||||
return extractAudioUrls(event.content);
|
||||
}, [event.content, event.tags, isTextNote]);
|
||||
const imetaMap = useMemo(
|
||||
() =>
|
||||
isTextNote ? parseImetaMap(event.tags) : new Map<string, ImetaEntry>(),
|
||||
[event.tags, isTextNote],
|
||||
);
|
||||
|
||||
// Extract webxdc attachments from imeta tags
|
||||
const webxdcApps = useMemo(() => {
|
||||
if (!isTextNote) return [];
|
||||
return Array.from(imetaMap.values()).filter(
|
||||
(entry) =>
|
||||
entry.mime === "application/x-webxdc" ||
|
||||
entry.mime === "application/vnd.webxdc+zip",
|
||||
);
|
||||
}, [imetaMap, isTextNote]);
|
||||
const isComment = event.kind === 1111;
|
||||
const isReply = isTextNote && !isComment && isReplyEvent(event);
|
||||
|
||||
@@ -695,10 +664,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
) : (
|
||||
<TruncatedNoteContent
|
||||
event={event}
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
/>
|
||||
)}
|
||||
</ContentWarningGuard>
|
||||
@@ -836,6 +801,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
title="Share"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
impactLight();
|
||||
const url = `${window.location.origin}/${encodedId}`;
|
||||
const result = await shareOrCopy(url);
|
||||
if (result === "copied") toast({ title: "Link copied to clipboard" });
|
||||
@@ -1168,19 +1134,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
const MAX_HEIGHT = 400; // px — posts taller than this get truncated
|
||||
|
||||
/** Truncates long text note content with a "Read more" fade + button.
|
||||
* Media attachments are also hidden behind the truncation and revealed on expand. */
|
||||
* Media attachments render inline within NoteContent at their original content position. */
|
||||
function TruncatedNoteContent({
|
||||
event,
|
||||
videos,
|
||||
audios = [],
|
||||
imetaMap,
|
||||
webxdcApps = [],
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
videos: string[];
|
||||
audios?: string[];
|
||||
imetaMap: Map<string, ImetaEntry>;
|
||||
webxdcApps?: ImetaEntry[];
|
||||
}) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
@@ -1212,8 +1170,6 @@ function TruncatedNoteContent({
|
||||
imgs.forEach((img) => img.removeEventListener("load", measure));
|
||||
}, [measure]);
|
||||
|
||||
const showMedia = !overflows || expanded;
|
||||
|
||||
return (
|
||||
<div className="mt-2 break-words overflow-hidden">
|
||||
<div
|
||||
@@ -1241,15 +1197,6 @@ function TruncatedNoteContent({
|
||||
{expanded ? "Show less" : "Read more"}
|
||||
</button>
|
||||
)}
|
||||
{showMedia && (
|
||||
<NoteMedia
|
||||
videos={videos}
|
||||
audios={audios}
|
||||
imetaMap={imetaMap}
|
||||
webxdcApps={webxdcApps}
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+126
-20
@@ -4,11 +4,16 @@ import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { Lightbox, ImageGallery } from '@/components/ImageGallery';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
@@ -17,6 +22,8 @@ import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||||
import { COUNTRIES } from '@/lib/countries';
|
||||
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
|
||||
import { parseImetaMap } from '@/lib/imeta';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
|
||||
@@ -27,6 +34,13 @@ interface NoteContentProps {
|
||||
disableEmbeds?: boolean;
|
||||
/** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */
|
||||
hideEmbedImages?: boolean;
|
||||
/** When true, nested nostr:nevent/note/naddr embeds render as inline links instead of cards.
|
||||
* Used inside embedded quote cards to prevent unbounded recursive nesting. */
|
||||
disableNoteEmbeds?: boolean;
|
||||
/** When true, images, galleries, and video/audio players are suppressed (rendered as
|
||||
* whitespace) while link preview cards and other non-media embeds are preserved.
|
||||
* Used inside embedded quote cards to keep them lightweight. */
|
||||
disableMediaEmbeds?: boolean;
|
||||
}
|
||||
|
||||
/** Regex matching `:shortcode:` patterns in text. */
|
||||
@@ -170,6 +184,7 @@ type ContentToken =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'image-embed'; url: string }
|
||||
| { type: 'image-gallery'; urls: string[] }
|
||||
| { type: 'media-embed'; url: string }
|
||||
| { type: 'link-embed'; url: string }
|
||||
| { type: 'inline-link'; url: string }
|
||||
| { type: 'mention'; pubkey: string }
|
||||
@@ -233,6 +248,8 @@ export function NoteContent({
|
||||
className,
|
||||
disableEmbeds = false,
|
||||
hideEmbedImages = false,
|
||||
disableNoteEmbeds = false,
|
||||
disableMediaEmbeds = false,
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
@@ -302,7 +319,7 @@ export function NoteContent({
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-image media URLs — rendered as embedded media by the parent.
|
||||
// Non-image media URLs (video, audio, webxdc) — render inline at their position.
|
||||
if (EMBED_MEDIA_URL_REGEX.test(url)) {
|
||||
if (result.length > 0) {
|
||||
const prev = result[result.length - 1];
|
||||
@@ -310,8 +327,9 @@ export function NoteContent({
|
||||
prev.value = prev.value.replace(/\s+$/, '');
|
||||
}
|
||||
}
|
||||
result.push({ type: 'media-embed', url });
|
||||
lastIndex = index + fullMatch.length;
|
||||
// Also strip leading whitespace that follows the skipped URL
|
||||
// Strip leading whitespace that follows the media URL
|
||||
const remaining = text.substring(lastIndex);
|
||||
const leadingWs = remaining.match(/^\s+/);
|
||||
if (leadingWs) {
|
||||
@@ -411,11 +429,42 @@ export function NoteContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Append media-embed tokens for imeta-declared media URLs not found in the content.
|
||||
// Some clients attach audio/video/webxdc via imeta tags without including the URL in
|
||||
// the content string. Without this, those attachments would be silently dropped.
|
||||
// Only scan for text note kinds — other kinds (DMs, calendar events, etc.) may use
|
||||
// imeta tags for different purposes.
|
||||
const TEXT_NOTE_KINDS = new Set([1, 11, 1111]);
|
||||
if (TEXT_NOTE_KINDS.has(event.kind)) {
|
||||
const contentMediaUrls = new Set(
|
||||
result.filter((t): t is { type: 'media-embed'; url: string } => t.type === 'media-embed').map((t) => t.url),
|
||||
);
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
let rawUrl: string | undefined;
|
||||
let mime: string | undefined;
|
||||
for (let j = 1; j < tag.length; j++) {
|
||||
const sp = tag[j].indexOf(' ');
|
||||
if (sp === -1) continue;
|
||||
const key = tag[j].slice(0, sp);
|
||||
if (key === 'url') rawUrl = tag[j].slice(sp + 1);
|
||||
else if (key === 'm') mime = tag[j].slice(sp + 1);
|
||||
}
|
||||
const url = sanitizeUrl(rawUrl);
|
||||
if (!url || contentMediaUrls.has(url)) continue;
|
||||
const isEmbeddableMedia = mime?.startsWith('audio/') || mime?.startsWith('video/')
|
||||
|| mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip';
|
||||
if (isEmbeddableMedia) {
|
||||
result.push({ type: 'media-embed', url });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse excessive whitespace around block-level tokens (link-preview, youtube-embed)
|
||||
// Preserve formatting but prevent too much stacking with the card's own spacing.
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const token = result[i];
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
const isBlock = token.type === 'image-embed' || token.type === 'media-embed' || token.type === 'link-embed' || token.type === 'nevent-embed'
|
||||
|| (token.type === 'naddr-embed' && !token.url) || token.type === 'lightning-invoice';
|
||||
|
||||
if (isBlock) {
|
||||
@@ -469,21 +518,17 @@ export function NoteContent({
|
||||
return map;
|
||||
}, [event.tags, viewerEmojis]);
|
||||
|
||||
// Parse imeta tags for dim/blurhash to pass to ImageGallery
|
||||
const imetaMap = useMemo(() => {
|
||||
const map = new Map<string, { dim?: string; blurhash?: string }>();
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
const parts: Record<string, string> = {};
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const p = tag[i];
|
||||
const sp = p.indexOf(' ');
|
||||
if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1);
|
||||
}
|
||||
if (parts.url) map.set(parts.url, { dim: parts.dim, blurhash: parts.blurhash });
|
||||
}
|
||||
return map;
|
||||
}, [event.tags]);
|
||||
// Parse imeta tags — used by ImageGallery (dim/blurhash) and inline media embeds
|
||||
const imetaMap = useMemo(() => parseImetaMap(event.tags), [event.tags]);
|
||||
|
||||
// Only fetch author data when there are media embeds that need it (video artist, audio avatar)
|
||||
const hasMedia = tokens.some((t) => t.type === 'media-embed');
|
||||
const author = useAuthor(hasMedia ? event.pubkey : undefined);
|
||||
const authorMetadata = author.data?.metadata;
|
||||
const authorDisplayName = useMemo(
|
||||
() => getDisplayName(authorMetadata, event.pubkey) ?? genUserName(event.pubkey),
|
||||
[authorMetadata, event.pubkey],
|
||||
);
|
||||
|
||||
// Group consecutive image-embed tokens (≥2) into image-gallery tokens
|
||||
const groupedTokens = useMemo(() => {
|
||||
@@ -554,7 +599,7 @@ export function NoteContent({
|
||||
case 'text':
|
||||
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
|
||||
case 'image-embed': {
|
||||
if (disableEmbeds) {
|
||||
if (disableEmbeds || disableMediaEmbeds) {
|
||||
// In preview contexts (e.g. triple-dot menu), replace image URLs
|
||||
// with a newline so text flow is preserved without showing raw URLs.
|
||||
return <span key={i}>{'\n'}</span>;
|
||||
@@ -569,7 +614,7 @@ export function NoteContent({
|
||||
);
|
||||
}
|
||||
case 'image-gallery': {
|
||||
if (disableEmbeds) {
|
||||
if (disableEmbeds || disableMediaEmbeds) {
|
||||
return <span key={i}>{token.urls.map(() => '\n').join('')}</span>;
|
||||
}
|
||||
const galleryStartIndex = tokenImageIndex.get(i) ?? 0;
|
||||
@@ -621,9 +666,70 @@ export function NoteContent({
|
||||
{token.url}
|
||||
</a>
|
||||
);
|
||||
case 'media-embed': {
|
||||
if (disableEmbeds || disableMediaEmbeds) {
|
||||
return <span key={i}>{'\n'}</span>;
|
||||
}
|
||||
const imeta = imetaMap.get(token.url);
|
||||
const mime = imeta?.mime ?? '';
|
||||
const isWebxdc = mime === 'application/x-webxdc' || mime === 'application/vnd.webxdc+zip' || token.url.endsWith('.xdc');
|
||||
const isAudio = mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac|opus)(\?[^\s]*)?$/i.test(token.url);
|
||||
if (isWebxdc && imeta) {
|
||||
return <WebxdcEmbed key={i} url={token.url} uuid={imeta.webxdc} name={imeta.summary} icon={imeta.thumbnail} />;
|
||||
}
|
||||
if (isAudio) {
|
||||
return (
|
||||
<AudioVisualizer
|
||||
key={i}
|
||||
src={token.url}
|
||||
mime={imeta?.mime}
|
||||
avatarUrl={authorMetadata?.picture}
|
||||
avatarFallback={authorDisplayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(authorMetadata)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Default: video
|
||||
return (
|
||||
<VideoPlayer
|
||||
key={i}
|
||||
src={token.url}
|
||||
poster={imeta?.thumbnail}
|
||||
dim={imeta?.dim}
|
||||
blurhash={imeta?.blurhash}
|
||||
artist={authorDisplayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'nevent-embed':
|
||||
if (disableNoteEmbeds) {
|
||||
const neventId = nip19.neventEncode({ id: token.eventId, ...(token.author ? { author: token.author } : {}), ...(token.relays?.length ? { relays: token.relays } : {}) });
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/${neventId}`}
|
||||
className="text-primary hover:underline break-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{neventId.slice(0, 16)}…
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <EmbeddedNote key={i} eventId={token.eventId} relays={token.relays} authorHint={token.author} className="my-2.5" />;
|
||||
case 'naddr-embed':
|
||||
if (disableNoteEmbeds) {
|
||||
const naddrId = nip19.naddrEncode({ kind: token.addr.kind, pubkey: token.addr.pubkey, identifier: token.addr.identifier });
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={`/${naddrId}`}
|
||||
className="text-primary hover:underline break-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{naddrId.slice(0, 16)}…
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={i}>
|
||||
{token.url && (
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import type { ImetaEntry } from '@/lib/imeta';
|
||||
|
||||
/** Media content for kind 1 text notes — renders videos, audio, and webxdc apps. */
|
||||
export function NoteMedia({
|
||||
videos,
|
||||
audios = [],
|
||||
imetaMap,
|
||||
webxdcApps = [],
|
||||
event,
|
||||
}: {
|
||||
videos: string[];
|
||||
audios?: string[];
|
||||
imetaMap: Map<string, ImetaEntry>;
|
||||
webxdcApps?: ImetaEntry[];
|
||||
event: NostrEvent;
|
||||
}) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
|
||||
|
||||
if (videos.length === 0 && audios.length === 0 && webxdcApps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Videos — each rendered with play/pause overlay */}
|
||||
{videos.map((url, i) => (
|
||||
<VideoPlayer key={`v-${i}`} src={url} poster={imetaMap.get(url)?.thumbnail} dim={imetaMap.get(url)?.dim} blurhash={imetaMap.get(url)?.blurhash} artist={displayName} />
|
||||
))}
|
||||
|
||||
{/* Audio — rendered as visualizer with avatar */}
|
||||
{audios.map((url, i) => {
|
||||
const mime = imetaMap.get(url)?.mime;
|
||||
return (
|
||||
<AudioVisualizer
|
||||
key={`a-${i}`}
|
||||
src={url}
|
||||
mime={mime}
|
||||
avatarUrl={metadata?.picture}
|
||||
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
|
||||
avatarShape={getAvatarShape(metadata)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Webxdc apps */}
|
||||
{webxdcApps.map((app) => (
|
||||
<WebxdcEmbed key={app.url} url={app.url} uuid={app.webxdc} name={app.summary} icon={app.thumbnail} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -343,6 +344,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
};
|
||||
|
||||
const handleBookmark = () => {
|
||||
impactLight();
|
||||
toggleBookmark.mutate(event.id);
|
||||
close();
|
||||
};
|
||||
@@ -359,6 +361,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
};
|
||||
|
||||
const handleTogglePin = () => {
|
||||
impactLight();
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: pinned ? 'Unpinned from profile' : 'Pinned to profile' });
|
||||
@@ -371,6 +374,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o
|
||||
};
|
||||
|
||||
const handleMuteConversation = () => {
|
||||
impactLight();
|
||||
const rootTag = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
||||
const threadId = rootTag?.[1] ?? event.id;
|
||||
addMute.mutate(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface ProfileReactionButtonProps {
|
||||
@@ -33,6 +34,7 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
|
||||
|
||||
const handleReact = useCallback((emoji: string, emojiTag?: string[]) => {
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
trackEmojiUsage(emoji);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
|
||||
const THRESHOLD = 80; // raw px before triggering
|
||||
const MAX_PULL = 120; // max visual distance (after damping)
|
||||
@@ -74,6 +75,7 @@ export function PullToRefresh({ onRefresh, children, className }: PullToRefreshP
|
||||
const reached = currentPull.current >= THRESHOLD * RESISTANCE;
|
||||
|
||||
if (reached) {
|
||||
impactMedium();
|
||||
busy.current = true;
|
||||
currentPull.current = 40;
|
||||
setPullDistance(40);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import type { ResolvedEmoji } from '@/lib/customEmoji';
|
||||
|
||||
@@ -81,6 +82,7 @@ export function QuickReactMenu({
|
||||
/** Publish a reaction with a native Unicode emoji string. */
|
||||
const publishReaction = useCallback((emoji: string, emojiTag?: [string, string, string]) => {
|
||||
if (!user) return;
|
||||
impactLight();
|
||||
|
||||
// Close the entire popover
|
||||
onClose?.();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserReaction } from '@/hooks/useUserReaction';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
|
||||
@@ -138,6 +139,7 @@ export function ReactionButton({
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
if (hasReacted) {
|
||||
impactLight();
|
||||
handleUnreact(e);
|
||||
return;
|
||||
}
|
||||
@@ -148,6 +150,7 @@ export function ReactionButton({
|
||||
e.stopPropagation();
|
||||
if (!user) return;
|
||||
if (hasReacted) return;
|
||||
impactLight();
|
||||
setMenuOpen(false);
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', eventId]);
|
||||
queryClient.setQueryData(['user-reaction', eventId], { content: '❤️' });
|
||||
|
||||
@@ -86,6 +86,15 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
const handleInteractOutside = useCallback((e: Event) => {
|
||||
if (isNestedDialogInteraction(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// The emoji/mention autocomplete dropdowns are portaled to document.body
|
||||
// (outside the Dialog DOM tree) to escape overflow clipping. Clicks on
|
||||
// them fire as "interact outside" the dialog — prevent dismissal so the
|
||||
// user can select an emoji or mention with the mouse.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('[data-autocomplete-dropdown]')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [isNestedDialogInteraction]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Quote, Undo2 } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
@@ -37,6 +38,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
toast({ title: 'Please log in to repost', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache immediately
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
|
||||
@@ -98,6 +100,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
|
||||
const handleUnrepost = () => {
|
||||
if (!user || !repostEventId) return;
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
|
||||
|
||||
@@ -36,61 +36,9 @@ function useIsXl(): boolean {
|
||||
return isXl;
|
||||
}
|
||||
|
||||
const SPARK_W = 50;
|
||||
const SPARK_H = 28;
|
||||
const SPARK_MARGIN = 2;
|
||||
const SPARK_DIVISOR = 0.25;
|
||||
|
||||
/** Maps an array of values to {x, y} SVG coordinates, filling the full chart area. */
|
||||
function dataToPoints(data: number[]): { x: number; y: number }[] {
|
||||
const len = data.length;
|
||||
if (len === 0) return [];
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const vfactor = (SPARK_H - SPARK_MARGIN * 2) / ((max - min) || 2);
|
||||
const hfactor = (SPARK_W - SPARK_MARGIN * 2) / ((len > 1 ? len - 1 : 1));
|
||||
return data.map((d, i) => ({
|
||||
x: i * hfactor + SPARK_MARGIN,
|
||||
y: (max === min ? 1 : (max - d)) * vfactor + SPARK_MARGIN,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Builds a smooth cubic-bezier SVG path string from {x,y} points. */
|
||||
function pointsToCurvePath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return '';
|
||||
const cmds: (string | number)[] = [];
|
||||
let prev: { x: number; y: number } | undefined;
|
||||
for (const p of points) {
|
||||
if (!prev) {
|
||||
cmds.push(p.x, p.y);
|
||||
} else {
|
||||
const len = (p.x - prev.x) * SPARK_DIVISOR;
|
||||
cmds.push('C', prev.x + len, prev.y, p.x - len, p.y, p.x, p.y);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
return 'M' + cmds.join(' ');
|
||||
}
|
||||
|
||||
/** Small sparkline SVG using a smooth cubic-bezier curve. */
|
||||
export function TrendSparkline({ data }: { data: number[] }) {
|
||||
const d = useMemo(() => pointsToCurvePath(dataToPoints(data)), [data]);
|
||||
|
||||
if (!d) return null;
|
||||
|
||||
return (
|
||||
<svg width={SPARK_W} height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} className="text-primary/60">
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
// Re-export TrendSparkline from its dedicated module for backwards compatibility.
|
||||
import { TrendSparkline } from '@/components/TrendSparkline';
|
||||
export { TrendSparkline };
|
||||
|
||||
export function RightSidebar() {
|
||||
const isXl = useIsXl();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const SPARK_W = 50;
|
||||
const SPARK_H = 28;
|
||||
const SPARK_MARGIN = 2;
|
||||
const SPARK_DIVISOR = 0.25;
|
||||
|
||||
/** Maps an array of values to {x, y} SVG coordinates, filling the full chart area. */
|
||||
function dataToPoints(data: number[]): { x: number; y: number }[] {
|
||||
const len = data.length;
|
||||
if (len === 0) return [];
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const vfactor = (SPARK_H - SPARK_MARGIN * 2) / ((max - min) || 2);
|
||||
const hfactor = (SPARK_W - SPARK_MARGIN * 2) / ((len > 1 ? len - 1 : 1));
|
||||
return data.map((d, i) => ({
|
||||
x: i * hfactor + SPARK_MARGIN,
|
||||
y: (max === min ? 1 : (max - d)) * vfactor + SPARK_MARGIN,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Builds a smooth cubic-bezier SVG path string from {x,y} points. */
|
||||
function pointsToCurvePath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return '';
|
||||
const cmds: (string | number)[] = [];
|
||||
let prev: { x: number; y: number } | undefined;
|
||||
for (const p of points) {
|
||||
if (!prev) {
|
||||
cmds.push(p.x, p.y);
|
||||
} else {
|
||||
const len = (p.x - prev.x) * SPARK_DIVISOR;
|
||||
cmds.push('C', prev.x + len, prev.y, p.x - len, p.y, p.x, p.y);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
return 'M' + cmds.join(' ');
|
||||
}
|
||||
|
||||
/** Small sparkline SVG using a smooth cubic-bezier curve. */
|
||||
export function TrendSparkline({ data }: { data: number[] }) {
|
||||
const d = useMemo(() => pointsToCurvePath(dataToPoints(data)), [data]);
|
||||
|
||||
if (!d) return null;
|
||||
|
||||
return (
|
||||
<svg width={SPARK_W} height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} className="text-primary/60">
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { GripVertical, X } from 'lucide-react';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
|
||||
interface WidgetCardProps {
|
||||
definition: WidgetDefinition;
|
||||
config: WidgetConfig;
|
||||
onRemove: () => void;
|
||||
onHeightChange: (height: number) => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Wrapper for each widget in the sidebar — header, height control. */
|
||||
export function WidgetCard({
|
||||
definition,
|
||||
config,
|
||||
onRemove,
|
||||
onHeightChange,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
children,
|
||||
}: WidgetCardProps) {
|
||||
const configHeight = config.height ?? definition.defaultHeight;
|
||||
const Icon = definition.icon;
|
||||
|
||||
// Local height for smooth resize — only commits to config on pointer up.
|
||||
const [liveHeight, setLiveHeight] = useState(configHeight);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const liveHeightRef = useRef(liveHeight);
|
||||
|
||||
// Sync local height when config changes externally (e.g. cross-device sync).
|
||||
useEffect(() => {
|
||||
if (!resizing) {
|
||||
setLiveHeight(configHeight);
|
||||
}
|
||||
}, [configHeight, resizing]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
setResizing(true);
|
||||
const startY = e.clientY;
|
||||
const startHeight = liveHeightRef.current;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const newHeight = Math.max(
|
||||
definition.minHeight,
|
||||
Math.min(definition.maxHeight, startHeight + (ev.clientY - startY)),
|
||||
);
|
||||
liveHeightRef.current = newHeight;
|
||||
setLiveHeight(newHeight);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizing(false);
|
||||
onHeightChange(liveHeightRef.current);
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
}, [definition.minHeight, definition.maxHeight, onHeightChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background/85 rounded-xl overflow-hidden transition-shadow',
|
||||
isDragging && 'shadow-lg ring-1 ring-primary/20',
|
||||
resizing && 'select-none',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
{/* Icon + label */}
|
||||
{definition.href ? (
|
||||
<Link to={definition.href} className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-primary transition-colors">
|
||||
<Icon className="size-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xl font-semibold truncate">{definition.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Icon className="size-5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xl font-semibold flex-1 truncate">{definition.label}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove widget"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
className="p-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
||||
{...dragHandleProps}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{definition.fillHeight ? (
|
||||
<div style={{ height: liveHeight }} className={cn('p-2', !resizing && 'transition-[height] duration-200')}>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea style={{ maxHeight: liveHeight }} className={cn(!resizing && 'transition-[max-height] duration-200')}>
|
||||
<div className="p-2">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onPointerDown={handleResizeStart}
|
||||
className="h-1.5 cursor-ns-resize flex items-center justify-center hover:bg-secondary/60 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check, Plus } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { WIDGET_DEFINITIONS, WIDGET_CATEGORIES } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WidgetPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentWidgets: WidgetConfig[];
|
||||
onAdd: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
/** Dialog for adding/removing widgets from the sidebar. */
|
||||
export function WidgetPickerDialog({ open, onOpenChange, currentWidgets, onAdd, onRemove }: WidgetPickerDialogProps) {
|
||||
const activeIds = useMemo(() => new Set(currentWidgets.map((w) => w.id)), [currentWidgets]);
|
||||
|
||||
// Group widgets by category
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, typeof WIDGET_DEFINITIONS> = {};
|
||||
for (const w of WIDGET_DEFINITIONS) {
|
||||
(groups[w.category] ??= []).push(w);
|
||||
}
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Widget</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-5 pr-2">
|
||||
{Object.entries(grouped).map(([category, widgets]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">
|
||||
{WIDGET_CATEGORIES[category] ?? category}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{widgets.map((widget) => {
|
||||
const isActive = activeIds.has(widget.id);
|
||||
const Icon = widget.icon;
|
||||
return (
|
||||
<button
|
||||
key={widget.id}
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
onRemove(widget.id);
|
||||
} else {
|
||||
onAdd(widget.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full px-3 py-2.5 rounded-xl transition-colors text-left',
|
||||
isActive
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-secondary/60',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'size-9 rounded-lg flex items-center justify-center shrink-0',
|
||||
isActive ? 'bg-primary/20 text-primary' : 'bg-secondary text-muted-foreground',
|
||||
)}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{widget.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{widget.description}</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'size-6 rounded-full flex items-center justify-center shrink-0 transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'border border-border text-muted-foreground/50',
|
||||
)}>
|
||||
{isActive ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useMemo, useState, lazy, Suspense, memo } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { WidgetCard } from '@/components/WidgetCard';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { LinkFooter } from '@/components/LinkFooter';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getWidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
import type { WidgetConfig } from '@/contexts/AppContext';
|
||||
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
|
||||
|
||||
// ── Lazy-loaded widget components ────────────────────────────────────────────
|
||||
|
||||
const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
|
||||
const HotPostsWidget = lazy(() => import('@/components/widgets/HotPostsWidget').then((m) => ({ default: m.HotPostsWidget })));
|
||||
const BlobbiWidget = lazy(() => import('@/components/widgets/BlobbiWidget').then((m) => ({ default: m.BlobbiWidget })));
|
||||
const StatusWidget = lazy(() => import('@/components/widgets/StatusWidget').then((m) => ({ default: m.StatusWidget })));
|
||||
const AIChatWidget = lazy(() => import('@/components/widgets/AIChatWidget').then((m) => ({ default: m.AIChatWidget })));
|
||||
const WikipediaWidget = lazy(() => import('@/components/widgets/WikipediaWidget').then((m) => ({ default: m.WikipediaWidget })));
|
||||
const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
|
||||
const PhotoWidget = lazy(() => import('@/components/widgets/PhotoWidget').then((m) => ({ default: m.PhotoWidget })));
|
||||
const MusicWidget = lazy(() => import('@/components/widgets/MusicWidget').then((m) => ({ default: m.MusicWidget })));
|
||||
const FeedWidget = lazy(() => import('@/components/widgets/FeedWidget').then((m) => ({ default: m.FeedWidget })));
|
||||
|
||||
const WidgetPickerDialog = lazy(() => import('@/components/WidgetPickerDialog').then((m) => ({ default: m.WidgetPickerDialog })));
|
||||
|
||||
// ── Widget content resolver ──────────────────────────────────────────────────
|
||||
|
||||
function WidgetContent({ id }: { id: string }) {
|
||||
switch (id) {
|
||||
case 'trends':
|
||||
return <TrendingWidget />;
|
||||
case 'hot-posts':
|
||||
return <HotPostsWidget />;
|
||||
case 'blobbi':
|
||||
return <BlobbiWidget />;
|
||||
case 'status':
|
||||
return <StatusWidget />;
|
||||
case 'ai-chat':
|
||||
return <AIChatWidget />;
|
||||
case 'wikipedia':
|
||||
return <WikipediaWidget />;
|
||||
case 'bluesky':
|
||||
return <BlueskyWidget />;
|
||||
case 'feed:photos':
|
||||
return <PhotoWidget />;
|
||||
case 'feed:music':
|
||||
return <MusicWidget />;
|
||||
case 'feed:articles':
|
||||
return <FeedWidget kinds={[30023]} feedPath="/articles" feedLabel="View all articles" />;
|
||||
case 'feed:events':
|
||||
return <FeedWidget kinds={[31922, 31923]} feedPath="/events" feedLabel="View all events" />;
|
||||
|
||||
default:
|
||||
return <p className="text-xs text-muted-foreground p-1">Unknown widget.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback while a widget component is loading. */
|
||||
function WidgetSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-4/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact fallback shown when a widget crashes. */
|
||||
function WidgetErrorFallback({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-4 px-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{name} failed to load.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Reload page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sortable widget wrapper ──────────────────────────────────────────────────
|
||||
|
||||
interface SortableWidgetProps {
|
||||
config: WidgetConfig;
|
||||
definition: WidgetDefinition;
|
||||
onRemove: (id: string) => void;
|
||||
onHeightChange: (id: string, height: number) => void;
|
||||
}
|
||||
|
||||
const SortableWidget = memo(function SortableWidget({ config, definition, onRemove, onHeightChange }: SortableWidgetProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: config.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<WidgetCard
|
||||
definition={definition}
|
||||
config={config}
|
||||
onRemove={() => onRemove(config.id)}
|
||||
onHeightChange={(h) => onHeightChange(config.id, h)}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={listeners}
|
||||
>
|
||||
<ErrorBoundary fallback={<WidgetErrorFallback name={definition.label} />} reportToSentry>
|
||||
<Suspense fallback={<WidgetSkeleton />}>
|
||||
<WidgetContent id={config.id} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</WidgetCard>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Main sidebar ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_WIDGETS: WidgetConfig[] = [];
|
||||
|
||||
export function WidgetSidebar() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const widgets = config.sidebarWidgets ?? EMPTY_WIDGETS;
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// Filter out widgets with unknown definitions
|
||||
const validWidgets = useMemo(
|
||||
() => widgets.filter((w) => getWidgetDefinition(w.id)),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const updateWidgets = useCallback((updater: (current: WidgetConfig[]) => WidgetConfig[]) => {
|
||||
updateConfig((c) => ({
|
||||
...c,
|
||||
sidebarWidgets: updater(c.sidebarWidgets ?? widgets),
|
||||
}));
|
||||
}, [updateConfig, widgets]);
|
||||
|
||||
const removeWidget = useCallback((id: string) => {
|
||||
updateWidgets((ws) => ws.filter((w) => w.id !== id));
|
||||
}, [updateWidgets]);
|
||||
|
||||
const changeHeight = useCallback((id: string, height: number) => {
|
||||
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, height } : w));
|
||||
}, [updateWidgets]);
|
||||
|
||||
const addWidget = useCallback((id: string) => {
|
||||
updateWidgets((ws) => {
|
||||
if (ws.some((w) => w.id === id)) return ws;
|
||||
return [...ws, { id }];
|
||||
});
|
||||
}, [updateWidgets]);
|
||||
|
||||
// Drag-and-drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const sortableIds = useMemo(() => validWidgets.map((w) => w.id), [validWidgets]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
updateWidgets((ws) => {
|
||||
const oldIndex = ws.findIndex((w) => w.id === active.id);
|
||||
const newIndex = ws.findIndex((w) => w.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return ws;
|
||||
return arrayMove(ws, oldIndex, newIndex);
|
||||
});
|
||||
}, [updateWidgets]);
|
||||
|
||||
return (
|
||||
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 flex-1">
|
||||
{validWidgets.map((w) => {
|
||||
const def = getWidgetDefinition(w.id);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<SortableWidget
|
||||
key={w.id}
|
||||
config={w}
|
||||
definition={def}
|
||||
onRemove={removeWidget}
|
||||
onHeightChange={changeHeight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add widget button */}
|
||||
<button
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex items-center justify-center gap-1.5 w-full py-2.5 rounded-xl bg-background/85 text-muted-foreground hover:text-foreground hover:bg-background transition-colors text-xs"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add widget
|
||||
</button>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<div className="mt-3">
|
||||
<LinkFooter />
|
||||
</div>
|
||||
|
||||
{/* Widget picker dialog */}
|
||||
<Suspense fallback={null}>
|
||||
{pickerOpen && (
|
||||
<WidgetPickerDialog
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
currentWidgets={widgets}
|
||||
onAdd={addWidget}
|
||||
onRemove={removeWidget}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react';
|
||||
import { Zap, Copy, Check, ExternalLink, Sparkle, Sparkles, Star, Rocket, X, Smile } from 'lucide-react';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -368,6 +369,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
}, [open, setInvoice]);
|
||||
|
||||
const handleZap = () => {
|
||||
impactMedium();
|
||||
const finalAmount = typeof amount === 'string' ? parseInt(amount, 10) : amount;
|
||||
zap(finalAmount, comment);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
generateNostrConnectURI,
|
||||
type NostrConnectParams,
|
||||
} from '@/hooks/useLoginActions';
|
||||
import { androidResume } from '@/lib/androidResume';
|
||||
import { getNsecCredential } from '@/lib/credentialManager';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
@@ -79,22 +78,18 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
}, [login, config.appName]);
|
||||
|
||||
// Start listening for connection (async) - runs after params are set.
|
||||
//
|
||||
// On Android, switching to Amber freezes the WebSocket so the NIP-46
|
||||
// response is silently dropped. When Ditto returns to the foreground we
|
||||
// abort the stale subscription and start a fresh one — the relay still
|
||||
// has the response event so `limit: 1` picks it up immediately.
|
||||
useEffect(() => {
|
||||
if (!nostrConnectParams || isWaitingForConnect) return;
|
||||
|
||||
let cancelled = false;
|
||||
let stopWatching: (() => void) | undefined;
|
||||
|
||||
const attemptConnect = async (signal: AbortSignal) => {
|
||||
const startListening = async () => {
|
||||
setIsWaitingForConnect(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
await login.nostrconnect(nostrConnectParams, signal);
|
||||
await login.nostrconnect(nostrConnectParams, abortControllerRef.current.signal);
|
||||
if (!cancelled) {
|
||||
stopWatching?.();
|
||||
onLogin();
|
||||
onClose();
|
||||
}
|
||||
@@ -102,42 +97,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
if (cancelled) return;
|
||||
// AbortError means we intentionally aborted (dialog closed or retry)
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = async () => {
|
||||
setIsWaitingForConnect(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// On Android, watch for foreground resume and retry the subscription.
|
||||
({ destroy: stopWatching } = androidResume({
|
||||
threshold: 0,
|
||||
onResume: () => {
|
||||
if (cancelled) return;
|
||||
console.log('[LoginDialog] foreground resume — retrying nostrconnect');
|
||||
// Abort the current (stale) subscription
|
||||
abortControllerRef.current?.abort();
|
||||
// Start a fresh subscription
|
||||
abortControllerRef.current = new AbortController();
|
||||
attemptConnect(abortControllerRef.current.signal).catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Nostrconnect retry failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await attemptConnect(abortControllerRef.current.signal);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Nostrconnect failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
console.error('Nostrconnect failed:', error);
|
||||
setConnectError(error instanceof Error ? error.message : String(error));
|
||||
setIsWaitingForConnect(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,7 +107,6 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopWatching?.();
|
||||
};
|
||||
}, [nostrConnectParams, login, onLogin, onClose, isWaitingForConnect]);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
|
||||
import { impactMedium } from '@/lib/haptics';
|
||||
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,8 +102,8 @@ function generateParticles(count: number, primaryHex: string): ConfettiParticle[
|
||||
}));
|
||||
}
|
||||
|
||||
function haptic(pattern: number | number[] = 30) {
|
||||
try { navigator?.vibrate?.(pattern); } catch { /* unsupported */ }
|
||||
function haptic() {
|
||||
impactMedium();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -182,7 +183,7 @@ export function SendAnimation({
|
||||
|
||||
if (impactT > 0 && !sealHapticFired.current) {
|
||||
sealHapticFired.current = true;
|
||||
haptic([15, 30, 50]);
|
||||
haptic();
|
||||
}
|
||||
|
||||
// Fly
|
||||
|
||||
@@ -2,16 +2,21 @@ import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { selectionChanged } from "@/lib/haptics"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, onCheckedChange, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
selectionChanged();
|
||||
onCheckedChange?.(checked);
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Send, Bot } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { DorkThinking } from '@/components/DorkThinking';
|
||||
import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Module-level cache so conversation survives collapse/expand (which unmounts
|
||||
* the component). Keyed by user pubkey. Intentionally not persisted to
|
||||
* localStorage — sidebar chat is ephemeral.
|
||||
*/
|
||||
const conversationCache = new Map<string, ChatMessage[]>();
|
||||
|
||||
/** Compact AI chat widget for the sidebar. */
|
||||
export function AIChatWidget() {
|
||||
const { user } = useCurrentUser();
|
||||
const { sendStreamingMessage, getAvailableModels, isLoading, isAuthenticated } = useShakespeare();
|
||||
|
||||
// Fetch available models and select the cheapest as default
|
||||
const { data: defaultModelId } = useQuery({
|
||||
queryKey: ['shakespeare-default-model'],
|
||||
queryFn: async () => {
|
||||
const response = await getAvailableModels();
|
||||
const sorted = response.data.sort((a, b) => {
|
||||
const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion);
|
||||
const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion);
|
||||
return costA - costB;
|
||||
});
|
||||
return sorted[0]?.id ?? '';
|
||||
},
|
||||
staleTime: 10 * 60_000,
|
||||
enabled: !!user,
|
||||
});
|
||||
const cacheKey = user?.pubkey ?? '';
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => conversationCache.get(cacheKey) ?? []);
|
||||
const [input, setInput] = useState('');
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// Write back to cache whenever messages change.
|
||||
useEffect(() => {
|
||||
if (cacheKey) {
|
||||
conversationCache.set(cacheKey, messages);
|
||||
}
|
||||
}, [messages, cacheKey]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||||
if (viewport) {
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = { role: 'user', content: text };
|
||||
const newMessages = [...messages, userMessage];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
setStreamingContent('');
|
||||
|
||||
try {
|
||||
let accumulated = '';
|
||||
await sendStreamingMessage(
|
||||
newMessages,
|
||||
defaultModelId || 'shakespeare',
|
||||
(chunk) => {
|
||||
accumulated += chunk;
|
||||
setStreamingContent(accumulated);
|
||||
},
|
||||
);
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: accumulated }]);
|
||||
setStreamingContent('');
|
||||
} catch {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }]);
|
||||
setStreamingContent('');
|
||||
}
|
||||
}, [input, isLoading, messages, sendStreamingMessage, defaultModelId]);
|
||||
|
||||
if (!user || !isAuthenticated) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
|
||||
<Bot className="size-8 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">Log in to chat with AI</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Messages area */}
|
||||
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
|
||||
<div className="space-y-3 p-2">
|
||||
{messages.length === 0 && !streamingContent && (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Bot className="size-6 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">Ask me anything...</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} />
|
||||
))}
|
||||
{streamingContent && (
|
||||
<MessageBubble message={{ role: 'assistant', content: streamingContent }} />
|
||||
)}
|
||||
{isLoading && !streamingContent && (
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="bg-secondary rounded-xl rounded-tl-sm px-3 py-2">
|
||||
<DorkThinking className="text-[10px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-border p-2 space-y-1.5">
|
||||
<div className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Message..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none text-sm bg-secondary/50 rounded-lg px-2.5 py-1.5 border-0 outline-none focus:ring-1 focus:ring-primary/30 placeholder:text-muted-foreground/60 min-h-[32px] max-h-[80px]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading || !defaultModelId}
|
||||
className="shrink-0 p-1.5 rounded-lg text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
const isUser = message.role === 'user';
|
||||
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
|
||||
|
||||
return (
|
||||
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap break-words',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground rounded-br-sm'
|
||||
: 'bg-secondary text-foreground rounded-bl-sm',
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Egg, Footprints, Loader2 } from 'lucide-react';
|
||||
|
||||
import { BlobbiAwayState } from '@/blobbi/ui/BlobbiAwayState';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { StatIndicator } from '@/blobbi/ui/StatIndicator';
|
||||
import { useProjectedBlobbiState } from '@/blobbi/core/hooks/useProjectedBlobbiState';
|
||||
import { useStatusReaction } from '@/blobbi/ui/hooks/useStatusReaction';
|
||||
import { useBlobbisCollection } from '@/blobbi/core/hooks/useBlobbisCollection';
|
||||
import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompanionData';
|
||||
import { useBlobbiMigration } from '@/blobbi/core/hooks/useBlobbiMigration';
|
||||
import { useBlobbiUseInventoryItem } from '@/blobbi/actions/hooks/useBlobbiUseInventoryItem';
|
||||
import { isActionVisibleForStage, type InventoryAction, type BlobbiAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags } from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiStats } from '@/blobbi/core/types/blobbi';
|
||||
|
||||
/** Stat-to-action mapping: each stat has an associated quick action + default item. */
|
||||
const STAT_ACTION_MAP: Record<string, { itemId: string; action: InventoryAction } | 'sleep'> = {
|
||||
hunger: { itemId: 'food_apple', action: 'feed' },
|
||||
happiness: { itemId: 'toy_ball', action: 'play' },
|
||||
health: { itemId: 'med_vitamins', action: 'medicine' },
|
||||
hygiene: { itemId: 'hyg_soap', action: 'clean' },
|
||||
energy: 'sleep',
|
||||
};
|
||||
|
||||
/** Stat-to-color mapping matching the BlobbiPage convention. */
|
||||
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
|
||||
hunger: 'orange',
|
||||
happiness: 'yellow',
|
||||
health: 'green',
|
||||
hygiene: 'blue',
|
||||
energy: 'violet',
|
||||
};
|
||||
|
||||
/** Blobbi action name for stage visibility checks. */
|
||||
const STAT_ACTION_NAME: Record<string, BlobbiAction> = {
|
||||
hunger: 'feed',
|
||||
happiness: 'play',
|
||||
health: 'medicine',
|
||||
hygiene: 'clean',
|
||||
};
|
||||
|
||||
/** localStorage key helper matching BlobbiPage pattern. */
|
||||
function getSelectedBlobbiKey(pubkey: string): string {
|
||||
return `blobbi:selected:d:${pubkey.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/** Mini Blobbi widget with live stats and quick actions. */
|
||||
export function BlobbiWidget() {
|
||||
const { user } = useCurrentUser();
|
||||
const { companions, companionsByD, isLoading, updateCompanionEvent } = useBlobbisCollection();
|
||||
const { profile, updateProfileEvent, invalidate: invalidateProfile } = useBlobbonautProfile();
|
||||
const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Match BlobbiPage's selection logic: localStorage > profile.has > first companion
|
||||
const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none';
|
||||
const [storedSelectedD, setStoredSelectedD] = useLocalStorage<string | null>(localStorageKey, null);
|
||||
|
||||
const companion = useMemo<BlobbiCompanion | null>(() => {
|
||||
if (!companions || companions.length === 0) return null;
|
||||
if (storedSelectedD && companionsByD[storedSelectedD]) return companionsByD[storedSelectedD];
|
||||
if (profile) {
|
||||
for (const d of profile.has) {
|
||||
if (companionsByD[d]) return companionsByD[d];
|
||||
}
|
||||
}
|
||||
return companions[0];
|
||||
}, [companions, companionsByD, storedSelectedD, profile]);
|
||||
|
||||
// Zero-arg wrapper for ensureCanonical (same pattern as BlobbiPage)
|
||||
const ensureCanonicalBeforeAction = useCallback(async () => {
|
||||
if (!companion || !profile) return null;
|
||||
return ensureCanonicalBlobbiBeforeAction({
|
||||
companion,
|
||||
profile,
|
||||
updateProfileEvent,
|
||||
updateCompanionEvent,
|
||||
updateStoredSelectedD: setStoredSelectedD,
|
||||
});
|
||||
}, [companion, profile, ensureCanonicalBlobbiBeforeAction, updateProfileEvent, updateCompanionEvent, setStoredSelectedD]);
|
||||
|
||||
// Wire up item action hook
|
||||
const { mutateAsync: executeUseItem, isPending: isUsingItem } = useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
});
|
||||
|
||||
// Sleep/wake handler (same pattern as BlobbiPage)
|
||||
const [isSleepPending, setIsSleepPending] = useState(false);
|
||||
const handleRest = useCallback(async () => {
|
||||
if (!user?.pubkey || !companion) return;
|
||||
const isCurrentlySleeping = companion.state === 'sleeping';
|
||||
const newState = isCurrentlySleeping ? 'active' : 'sleeping';
|
||||
setIsSleepPending(true);
|
||||
try {
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const decayResult = applyBlobbiDecay({
|
||||
stage: canonical.companion.stage,
|
||||
state: canonical.companion.state,
|
||||
stats: canonical.companion.stats,
|
||||
lastDecayAt: canonical.companion.lastDecayAt,
|
||||
now,
|
||||
});
|
||||
|
||||
const nowStr = now.toString();
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
const newTags = updateBlobbiTags(canonical.allTags, {
|
||||
state: newState,
|
||||
hunger: decayResult.stats.hunger.toString(),
|
||||
happiness: decayResult.stats.happiness.toString(),
|
||||
health: decayResult.stats.health.toString(),
|
||||
hygiene: decayResult.stats.hygiene.toString(),
|
||||
energy: decayResult.stats.energy.toString(),
|
||||
...streakUpdates,
|
||||
last_interaction: nowStr,
|
||||
last_decay_at: nowStr,
|
||||
});
|
||||
|
||||
const prev = canonical.companion.event;
|
||||
const event = await publishEvent({ kind: KIND_BLOBBI_STATE, content: canonical.content, tags: newTags, prev });
|
||||
updateCompanionEvent(event);
|
||||
toast({ title: isCurrentlySleeping ? 'Woke up!' : 'Resting...' });
|
||||
} catch {
|
||||
toast({ title: 'Action failed', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSleepPending(false);
|
||||
}
|
||||
}, [user?.pubkey, companion, ensureCanonicalBeforeAction, publishEvent, updateCompanionEvent]);
|
||||
|
||||
// Companion toggle handler (same logic as BlobbiPage)
|
||||
const [isUpdatingCompanion, setIsUpdatingCompanion] = useState(false);
|
||||
const isCurrentCompanion = companion ? profile?.currentCompanion === companion.d : false;
|
||||
const { companion: activeCompanion } = useBlobbiCompanionData();
|
||||
const isActiveFloatingCompanion = companion ? activeCompanion?.d === companion.d : false;
|
||||
|
||||
const handleSetAsCompanion = useCallback(async () => {
|
||||
if (!profile || !companion) return;
|
||||
setIsUpdatingCompanion(true);
|
||||
try {
|
||||
// Fetch fresh profile data from relays to avoid stale-read-then-write
|
||||
const canonical = await ensureCanonicalBeforeAction();
|
||||
if (!canonical) return;
|
||||
|
||||
let updatedTags: string[][];
|
||||
if (isCurrentCompanion) {
|
||||
updatedTags = updateBlobbonautTags(canonical.profileAllTags, {})
|
||||
.filter(tag => tag[0] !== 'current_companion');
|
||||
} else {
|
||||
const tagsWithoutCompanion = canonical.profileAllTags.filter(tag => tag[0] !== 'current_companion');
|
||||
updatedTags = updateBlobbonautTags(tagsWithoutCompanion, {
|
||||
current_companion: companion.d,
|
||||
});
|
||||
}
|
||||
const prev = canonical.profileEvent;
|
||||
const event = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: updatedTags,
|
||||
prev,
|
||||
});
|
||||
updateProfileEvent(event);
|
||||
invalidateProfile();
|
||||
toast({
|
||||
title: isCurrentCompanion ? 'Companion unset' : 'Companion set!',
|
||||
description: isCurrentCompanion
|
||||
? `${companion.name} is no longer your companion`
|
||||
: `${companion.name} is now your companion`,
|
||||
});
|
||||
} catch {
|
||||
toast({ title: 'Failed to update companion', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsUpdatingCompanion(false);
|
||||
}
|
||||
}, [profile, companion, isCurrentCompanion, ensureCanonicalBeforeAction, publishEvent, updateProfileEvent, invalidateProfile]);
|
||||
|
||||
const isActionPending = isUsingItem || isSleepPending;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link to="/blobbi" className="flex flex-col items-center gap-2 py-4 hover:bg-secondary/40 rounded-lg transition-colors">
|
||||
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Egg className="size-8 text-primary" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Log in to hatch your Blobbi</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<Skeleton className="size-24 rounded-full" />
|
||||
<div className="flex items-center justify-center gap-1.5 pt-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="size-9 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!companion) {
|
||||
return (
|
||||
<Link to="/blobbi" className="flex flex-col items-center gap-2 py-4 hover:bg-secondary/40 rounded-lg transition-colors">
|
||||
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
||||
<Egg className="size-8 text-primary" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">Hatch your Blobbi</span>
|
||||
<span className="text-xs text-muted-foreground">Get your virtual pet companion</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlobbiWidgetContent
|
||||
companion={companion}
|
||||
onUseItem={executeUseItem}
|
||||
onRest={handleRest}
|
||||
isActionPending={isActionPending}
|
||||
isCurrentCompanion={isCurrentCompanion}
|
||||
isActiveFloatingCompanion={isActiveFloatingCompanion}
|
||||
isUpdatingCompanion={isUpdatingCompanion}
|
||||
onToggleCompanion={handleSetAsCompanion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlobbiWidgetContentProps {
|
||||
companion: BlobbiCompanion;
|
||||
onUseItem: (req: { itemId: string; action: InventoryAction }) => Promise<unknown>;
|
||||
onRest: () => Promise<void>;
|
||||
isActionPending: boolean;
|
||||
isCurrentCompanion: boolean;
|
||||
isActiveFloatingCompanion: boolean;
|
||||
isUpdatingCompanion: boolean;
|
||||
onToggleCompanion: () => Promise<void>;
|
||||
}
|
||||
|
||||
function BlobbiWidgetContent({ companion, onUseItem, onRest, isActionPending, isCurrentCompanion, isActiveFloatingCompanion, isUpdatingCompanion, onToggleCompanion }: BlobbiWidgetContentProps) {
|
||||
const projected = useProjectedBlobbiState(companion);
|
||||
const defaultStats: BlobbiStats = { hunger: 100, happiness: 100, health: 100, hygiene: 100, energy: 100 };
|
||||
const stats = projected?.stats ?? defaultStats;
|
||||
const { recipe, recipeLabel } = useStatusReaction({
|
||||
stats,
|
||||
enabled: companion.state !== 'sleeping',
|
||||
});
|
||||
|
||||
const stage = companion.stage;
|
||||
|
||||
// Get the stat keys relevant for this stage (eggs see fewer stats)
|
||||
const relevantStats = getVisibleStats(stage);
|
||||
|
||||
// Filter stats by stage-appropriate actions
|
||||
const visibleStats = relevantStats.filter((stat) => {
|
||||
// Energy maps to sleep, which eggs can't do
|
||||
if (stat === 'energy') return stage !== 'egg';
|
||||
const actionName = STAT_ACTION_NAME[stat];
|
||||
if (!actionName) return false;
|
||||
return isActionVisibleForStage(stage, actionName);
|
||||
});
|
||||
|
||||
const handleStatClick = useCallback(async (stat: keyof BlobbiStats) => {
|
||||
const mapping = STAT_ACTION_MAP[stat];
|
||||
if (!mapping) return;
|
||||
if (mapping === 'sleep') {
|
||||
await onRest();
|
||||
} else {
|
||||
try {
|
||||
await onUseItem(mapping);
|
||||
} catch {
|
||||
// Error already toasted by the mutation hook
|
||||
}
|
||||
}
|
||||
}, [onUseItem, onRest]);
|
||||
|
||||
// When this Blobbi is the active floating companion, show "out exploring" state
|
||||
if (isActiveFloatingCompanion) {
|
||||
return (
|
||||
<BlobbiAwayState
|
||||
name={companion.name}
|
||||
size="sm"
|
||||
isUpdating={isUpdatingCompanion}
|
||||
onBringHome={onToggleCompanion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center gap-3 py-3">
|
||||
{/* Take along button — top right */}
|
||||
<button
|
||||
onClick={onToggleCompanion}
|
||||
disabled={isUpdatingCompanion || isActionPending}
|
||||
className={cn(
|
||||
'absolute top-2 right-1 size-7 rounded-full flex items-center justify-center transition-colors',
|
||||
isCurrentCompanion
|
||||
? 'text-emerald-500 bg-emerald-500/10 hover:bg-emerald-500/20'
|
||||
: 'text-violet-500 bg-violet-500/10 hover:bg-violet-500/20',
|
||||
(isUpdatingCompanion || isActionPending) && 'opacity-40 pointer-events-none',
|
||||
)}
|
||||
title={isCurrentCompanion ? 'With you' : 'Take along'}
|
||||
>
|
||||
{isUpdatingCompanion
|
||||
? <Loader2 className="size-3.5 animate-spin" />
|
||||
: <Footprints className="size-3.5" />}
|
||||
</button>
|
||||
|
||||
{/* Pet visual — links to full page */}
|
||||
<Link to="/blobbi" className="relative hover:scale-105 transition-transform">
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated
|
||||
lookMode="follow-pointer"
|
||||
recipe={recipe}
|
||||
recipeLabel={recipeLabel}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Name */}
|
||||
<Link to="/blobbi" className="text-sm font-semibold hover:text-primary transition-colors">
|
||||
{companion.name}
|
||||
</Link>
|
||||
|
||||
{/* Unified stat wheels — each is both a status indicator and an action button */}
|
||||
<div className="flex items-center justify-center gap-1.5 px-2">
|
||||
{visibleStats.map((stat) => (
|
||||
<StatIndicator
|
||||
key={stat}
|
||||
stat={stat}
|
||||
value={stats[stat]}
|
||||
color={STAT_COLOR_MAP[stat]}
|
||||
status={getStatStatus(stage, stat, stats[stat] ?? 100)}
|
||||
size="sm"
|
||||
onClick={() => handleStatClick(stat)}
|
||||
disabled={isActionPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageCircle, Repeat2, Heart } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { BlueskyPost, GetFeedResponse } from '@/hooks/useBlueskyTrending';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
|
||||
const BSKY_PUBLIC_API = 'https://api.bsky.app/xrpc';
|
||||
const DISCOVER_FEED_URI = 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot';
|
||||
const WIDGET_LIMIT = 5;
|
||||
|
||||
/**
|
||||
* Dedicated query for the widget — fetches a single page of 5 posts.
|
||||
* Uses a separate query key from the full BlueskyPage infinite query
|
||||
* so they don't share cached pages.
|
||||
*/
|
||||
function useBlueskyWidgetPosts() {
|
||||
return useQuery({
|
||||
queryKey: ['bluesky-widget'],
|
||||
queryFn: async ({ signal }) => {
|
||||
const params = new URLSearchParams({
|
||||
feed: DISCOVER_FEED_URI,
|
||||
limit: String(WIDGET_LIMIT),
|
||||
});
|
||||
const res = await fetch(`${BSKY_PUBLIC_API}/app.bsky.feed.getFeed?${params}`, {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Bluesky API error: ${res.status}`);
|
||||
const data: GetFeedResponse = await res.json();
|
||||
if (!data.feed) return [];
|
||||
return data.feed.map((item) => item.post);
|
||||
},
|
||||
staleTime: 15 * 60_000, // 15 minutes
|
||||
gcTime: 60 * 60_000, // 1 hour
|
||||
retry: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Bluesky trending posts widget for the sidebar. */
|
||||
export function BlueskyWidget() {
|
||||
const { data: posts, isLoading, isError } = useBlueskyWidgetPosts();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-muted-foreground p-1">Failed to load Bluesky posts.</p>;
|
||||
}
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No trending posts right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{posts.map((post) => (
|
||||
<BlueskyPostCard key={post.cid} post={post} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/bluesky" className="text-xs text-primary hover:underline">View more on Bluesky</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueskyPostCard({ post }: { post: BlueskyPost }) {
|
||||
const text = post.record.text;
|
||||
const snippet = text.length > 120 ? text.slice(0, 120) + '...' : text;
|
||||
const webUrl = `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split('/').pop()}`;
|
||||
const internalUrl = `/i/${encodeURIComponent(webUrl)}`;
|
||||
|
||||
const timeAgo = useMemo(() => {
|
||||
const diff = Date.now() - new Date(post.indexedAt).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}, [post.indexedAt]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={internalUrl}
|
||||
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
{/* Author line */}
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
{post.author.avatar ? (
|
||||
<img src={post.author.avatar} alt="" className="size-4 rounded-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="size-4 rounded-full bg-sky-500 flex items-center justify-center text-white text-[8px] font-bold">
|
||||
{(post.author.displayName ?? post.author.handle).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-semibold truncate">{post.author.displayName ?? post.author.handle}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2 mb-1">{snippet}</p>
|
||||
|
||||
{/* Engagement */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span className="flex items-center gap-0.5"><MessageCircle className="size-2.5" />{formatNumber(post.replyCount)}</span>
|
||||
<span className="flex items-center gap-0.5"><Repeat2 className="size-2.5" />{formatNumber(post.repostCount)}</span>
|
||||
<span className="flex items-center gap-0.5"><Heart className="size-2.5" />{formatNumber(post.likeCount)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Generic compact feed widget that queries Nostr events by kind and renders
|
||||
* them as a compact list. Used for Photos, Music, Articles, Events, Books widgets.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
|
||||
interface FeedWidgetProps {
|
||||
/** Event kind(s) to fetch. */
|
||||
kinds: number[];
|
||||
/** Link to the full feed page. */
|
||||
feedPath: string;
|
||||
/** Label for "View all" link. */
|
||||
feedLabel: string;
|
||||
/** Number of items to show. */
|
||||
limit?: number;
|
||||
/** Empty state message. */
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/** Compact feed widget showing recent events for given kind(s). */
|
||||
export function FeedWidget({ kinds, feedPath, feedLabel, limit = 5, emptyMessage = 'No content yet.' }: FeedWidgetProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { data: curatorFollows } = useCuratorFollowList();
|
||||
const followPubkeys = followData?.pubkeys;
|
||||
|
||||
const kindsKey = kinds.join(',');
|
||||
|
||||
// Scope to followed authors when logged in, curator's follow list otherwise.
|
||||
const authors = user && followPubkeys?.length ? followPubkeys : curatorFollows;
|
||||
const authorsKey = user ? 'follows' : 'curator';
|
||||
|
||||
const { data: events, isLoading } = useQuery({
|
||||
queryKey: ['widget-feed', kindsKey, authorsKey, limit],
|
||||
queryFn: async () => {
|
||||
return nostr.query([{ kinds, limit, ...(authors ? { authors } : {}) }]);
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
// Don't run until the appropriate follow list is resolved
|
||||
enabled: user ? followPubkeys !== undefined : curatorFollows !== undefined,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => (events ?? []).slice(0, limit), [events, limit]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{filtered.map((event) => (
|
||||
<CompactEventCard key={event.id} event={event} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to={feedPath} className="text-xs text-primary hover:underline">{feedLabel}</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal event card for sidebar widgets. */
|
||||
function CompactEventCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
|
||||
// Try to get a title from tags (articles, events, etc.)
|
||||
const title = event.tags.find(([t]) => t === 'title')?.[1];
|
||||
|
||||
// Build a snippet from content
|
||||
const snippet = useMemo(() => {
|
||||
if (title) return title;
|
||||
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
|
||||
if (clean.length > 100) return clean.slice(0, 100) + '...';
|
||||
return clean || '(media)';
|
||||
}, [event.content, title]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${encodedId}`}
|
||||
className="block hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">{displayName}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useSortedPosts } from '@/hooks/useTrending';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
|
||||
/** Hot posts widget for the right sidebar. */
|
||||
export function HotPostsWidget() {
|
||||
const { data: rawPosts, isLoading } = useSortedPosts('hot', 5);
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
const posts = useMemo(() => {
|
||||
if (!rawPosts || muteItems.length === 0) return rawPosts;
|
||||
return rawPosts.filter((e) => !isEventMuted(e, muteItems));
|
||||
}, [rawPosts, muteItems]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!posts || posts.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No hot posts right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{posts.slice(0, 5).map((event) => (
|
||||
<HotPostCard key={event.id} event={event} />
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/trends" className="text-xs text-primary hover:underline">View all on Trends</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact hot post card for the sidebar widget. */
|
||||
function HotPostCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`);
|
||||
|
||||
const snippet = useMemo(() => {
|
||||
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
|
||||
if (clean.length > 100) return clean.slice(0, 100) + '\u2026';
|
||||
return clean || '(media)';
|
||||
}, [event.content]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={openPost}
|
||||
onAuxClick={onAuxClick}
|
||||
className="block w-full text-left hover:bg-secondary/40 px-2 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Play, Pause, Music, Clock } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { parseMusicTrack, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Rich music widget showing the latest track with playback controls. */
|
||||
export function MusicWidget() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { data: curatorFollows } = useCuratorFollowList();
|
||||
|
||||
const followPubkeys = followData?.pubkeys;
|
||||
const authors = user && followPubkeys?.length ? followPubkeys : curatorFollows;
|
||||
const authorsKey = user ? 'follows' : 'curator';
|
||||
|
||||
const { data: event, isLoading } = useQuery<NostrEvent | null>({
|
||||
queryKey: ['widget-music', authorsKey],
|
||||
queryFn: async () => {
|
||||
const events = await nostr.query([{ kinds: [36787], limit: 1, ...(authors ? { authors } : {}) }]);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: user ? followPubkeys !== undefined : curatorFollows !== undefined,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<Skeleton className="w-full aspect-square rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No music yet.</p>;
|
||||
}
|
||||
|
||||
return <MusicCard event={event} />;
|
||||
}
|
||||
|
||||
function MusicCard({ event }: { event: NostrEvent }) {
|
||||
const player = useAudioPlayer();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
|
||||
const parsed = useMemo(() => parseMusicTrack(event), [event]);
|
||||
const encodedId = useMemo(() => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
|
||||
}, [event]);
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
const isNowPlaying = player.currentTrack?.id === event.id;
|
||||
const dur = parsed.duration ? formatTime(parsed.duration) : undefined;
|
||||
|
||||
const handlePlay = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isNowPlaying && player.isPlaying) {
|
||||
player.pause();
|
||||
} else if (isNowPlaying) {
|
||||
player.resume();
|
||||
} else {
|
||||
const track = toAudioTrack(event, parsed);
|
||||
track.artwork ??= metadata?.picture;
|
||||
player.playTrack(track);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Artwork with play overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-lg overflow-hidden cursor-pointer',
|
||||
isNowPlaying ? 'ring-2 ring-primary' : '',
|
||||
)}
|
||||
onClick={handlePlay}
|
||||
>
|
||||
{parsed.artwork ? (
|
||||
<img
|
||||
src={parsed.artwork}
|
||||
alt={parsed.title}
|
||||
className="w-full aspect-square object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Music className="size-12 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 hover:bg-black/15 transition-colors">
|
||||
<div
|
||||
className={cn(
|
||||
'size-12 rounded-full flex items-center justify-center transition-colors',
|
||||
isNowPlaying && player.isPlaying
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-primary/15 text-primary hover:bg-primary/25 backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
{isNowPlaying && player.isPlaying
|
||||
? <Pause className="size-5" fill="currentColor" />
|
||||
: <Play className="size-5 ml-0.5" fill="currentColor" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<Link to={`/${encodedId}`} className="block px-0.5 space-y-1 group">
|
||||
<p className="text-sm font-semibold leading-snug truncate group-hover:text-primary transition-colors">
|
||||
{parsed.title}
|
||||
</p>
|
||||
{parsed.artist && (
|
||||
<p className="text-xs text-muted-foreground truncate">{parsed.artist}</p>
|
||||
)}
|
||||
{dur && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3 shrink-0" />
|
||||
<span>{dur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-1.5 pt-0.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCuratorFollowList } from '@/hooks/useCuratorFollowList';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Parse the first imeta image URL from a kind 20 photo event. Sanitizes the URL at the parse layer. */
|
||||
function parseFirstPhoto(tags: string[][]): { url: string; alt?: string } | undefined {
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
const parts: Record<string, string> = {};
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const p = tag[i];
|
||||
const sp = p.indexOf(' ');
|
||||
if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1);
|
||||
}
|
||||
const url = sanitizeUrl(parts.url);
|
||||
if (url) return { url, alt: parts.alt };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Rich photo widget showing the latest photo from follows. */
|
||||
export function PhotoWidget() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { data: curatorFollows } = useCuratorFollowList();
|
||||
|
||||
const followPubkeys = followData?.pubkeys;
|
||||
const authors = user && followPubkeys?.length ? followPubkeys : curatorFollows;
|
||||
const authorsKey = user ? 'follows' : 'curator';
|
||||
|
||||
const { data: event, isLoading } = useQuery<NostrEvent | null>({
|
||||
queryKey: ['widget-photo', authorsKey],
|
||||
queryFn: async () => {
|
||||
const events = await nostr.query([{ kinds: [20], limit: 1, ...(authors ? { authors } : {}) }]);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: user ? followPubkeys !== undefined : curatorFollows !== undefined,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-1">
|
||||
<Skeleton className="w-full aspect-[4/3] rounded-lg" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No photos yet.</p>;
|
||||
}
|
||||
|
||||
return <PhotoCard event={event} />;
|
||||
}
|
||||
|
||||
function PhotoCard({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
|
||||
|
||||
const photo = useMemo(() => parseFirstPhoto(event.tags), [event.tags]);
|
||||
const caption = event.content?.trim();
|
||||
|
||||
if (!photo) return null;
|
||||
|
||||
return (
|
||||
<Link to={`/${encodedId}`} className="block group">
|
||||
{/* Photo */}
|
||||
<div className="rounded-lg overflow-hidden bg-secondary/30">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.alt ?? caption ?? 'Photo'}
|
||||
className="w-full object-cover max-h-[220px] group-hover:scale-[1.02] transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Author + caption */}
|
||||
<div className="mt-2 px-0.5 space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
{caption && (
|
||||
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{caption}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserStatus } from '@/hooks/useUserStatus';
|
||||
import { usePublishStatus } from '@/hooks/usePublishStatus';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
/** Compact status widget — shows current NIP-38 status, click to edit inline. */
|
||||
export function StatusWidget() {
|
||||
const { user } = useCurrentUser();
|
||||
const userStatus = useUserStatus(user?.pubkey);
|
||||
const publishStatus = usePublishStatus();
|
||||
const { toast } = useToast();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground px-1">Log in to set a status.</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="space-y-2 px-1">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value.slice(0, 80))}
|
||||
placeholder="What are you up to?"
|
||||
className="h-8 text-base md:text-sm"
|
||||
maxLength={80}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const text = draft.trim();
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
}).catch(() => {
|
||||
toast({ title: 'Failed to update status', variant: 'destructive' });
|
||||
});
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const text = draft.trim();
|
||||
publishStatus.mutateAsync({ status: text }).then(() => {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
toast({ title: text ? 'Status updated' : 'Status cleared' });
|
||||
}).catch(() => {
|
||||
toast({ title: 'Failed to update status', variant: 'destructive' });
|
||||
});
|
||||
}}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
|
||||
</button>
|
||||
{userStatus.status && (
|
||||
<button
|
||||
onClick={() => {
|
||||
publishStatus.mutateAsync({ status: '' }).then(() => {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
toast({ title: 'Status cleared' });
|
||||
}).catch(() => {
|
||||
toast({ title: 'Failed to clear status', variant: 'destructive' });
|
||||
});
|
||||
}}
|
||||
disabled={publishStatus.isPending}
|
||||
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setEditing(false); setDraft(''); }}
|
||||
className="text-xs text-muted-foreground hover:underline ml-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(true);
|
||||
setDraft(userStatus.status ?? '');
|
||||
}}
|
||||
className="flex items-center w-full px-1 py-1 text-sm hover:bg-secondary/40 rounded-lg transition-colors text-left"
|
||||
>
|
||||
{userStatus.status ? (
|
||||
<span className="truncate text-muted-foreground italic text-xs">{userStatus.status}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">Click to set a status...</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TrendSparkline } from '@/components/TrendSparkline';
|
||||
import { useTrendingTags, useTagSparklines } from '@/hooks/useTrending';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
|
||||
/** Compact trending tags widget for the right sidebar. */
|
||||
export function TrendingWidget() {
|
||||
const { data: trendingTagsResult, isLoading: tagsLoading } = useTrendingTags(true);
|
||||
|
||||
const trendingTags = trendingTagsResult?.tags;
|
||||
const labelCreatedAt = trendingTagsResult?.labelCreatedAt ?? 0;
|
||||
|
||||
const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]);
|
||||
const { data: sparklineData, isLoading: sparklinesLoading } = useTagSparklines(visibleTags, labelCreatedAt, visibleTags.length > 0);
|
||||
|
||||
if (tagsLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trendingTags || trendingTags.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No trends available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{trendingTags.slice(0, 5).map((item) => (
|
||||
<Link
|
||||
key={item.tag}
|
||||
to={`/t/${item.tag}`}
|
||||
className="flex items-center justify-between group hover:bg-secondary/40 px-2 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="font-bold text-sm">#{item.tag}</div>
|
||||
{item.accounts > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="text-primary font-semibold">{formatNumber(item.accounts)}</span> people talking
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{sparklinesLoading ? (
|
||||
<Skeleton className="h-[35px] w-[50px] rounded" />
|
||||
) : (
|
||||
<TrendSparkline data={sparklineData?.get(item.tag) ?? []} />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-1 px-2">
|
||||
<Link to="/trends" className="text-xs text-primary hover:underline">View all trends</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Star, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { useWikipediaFeatured } from '@/hooks/useWikipediaFeatured';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
/** Wikipedia widget showing today's featured article. */
|
||||
export function WikipediaWidget() {
|
||||
const { data: feed, isLoading, isError } = useWikipediaFeatured();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 p-1">
|
||||
<Skeleton className="w-full aspect-[16/9] rounded-lg" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-4/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <p className="text-sm text-muted-foreground p-1">Failed to load featured article.</p>;
|
||||
}
|
||||
|
||||
const tfa = feed?.tfa;
|
||||
if (!tfa) {
|
||||
return <p className="text-sm text-muted-foreground p-1">No featured article today.</p>;
|
||||
}
|
||||
|
||||
const imageUrl = tfa.originalimage?.source ?? tfa.thumbnail?.source;
|
||||
const excerpt = tfa.extract.length > 200 ? tfa.extract.slice(0, 200) + '...' : tfa.extract;
|
||||
const articleUrl = tfa.content_urls.desktop.page;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openUrl(articleUrl)}
|
||||
className="block w-full text-left group"
|
||||
>
|
||||
{/* Image */}
|
||||
{imageUrl && (
|
||||
<div className="relative aspect-[16/9] rounded-lg overflow-hidden bg-gradient-to-br from-amber-500/10 to-orange-500/10 mb-2">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={tfa.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-1 px-0.5">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<Star className="size-3 text-amber-500 shrink-0 mt-0.5" />
|
||||
<h3 className="text-sm font-bold leading-snug group-hover:text-primary transition-colors line-clamp-2">
|
||||
{tfa.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{excerpt}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70 pt-0.5">
|
||||
<ExternalLink className="size-2.5" />
|
||||
<span>Wikipedia</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +245,16 @@ export interface AppConfig {
|
||||
curatorPubkey?: string;
|
||||
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
|
||||
sandboxDomain: string;
|
||||
/** Ordered list of right sidebar widget configs. Each entry is a widget type ID with optional display settings. */
|
||||
sidebarWidgets: WidgetConfig[];
|
||||
}
|
||||
|
||||
/** Configuration for a single widget in the right sidebar. */
|
||||
export interface WidgetConfig {
|
||||
/** Widget type identifier (e.g. "trends", "blobbi", "wikipedia", "bluesky"). */
|
||||
id: string;
|
||||
/** User-configured height in pixels. Overrides the widget's default height. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface AppContextType {
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface BlueskyPost {
|
||||
indexedAt: string;
|
||||
}
|
||||
|
||||
interface GetFeedResponse {
|
||||
export interface GetFeedResponse {
|
||||
feed: Array<{
|
||||
post: BlueskyPost;
|
||||
feedContext?: string;
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed } from '@/contexts/AppContext';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed, WidgetConfig } from '@/contexts/AppContext';
|
||||
import type { ThemeConfig } from '@/themes';
|
||||
import type { ContentFilter } from './useContentFilters';
|
||||
import type { LetterPreferences } from '@/lib/letterTypes';
|
||||
@@ -78,6 +79,8 @@ export interface EncryptedSettings {
|
||||
lastSync?: number;
|
||||
/** Ordered list of sidebar item IDs (built-in + extra-kind) */
|
||||
sidebarOrder?: string[];
|
||||
/** Ordered list of right sidebar widget configs. */
|
||||
sidebarWidgets?: WidgetConfig[];
|
||||
/** Sidebar item ID to display on the homepage ("/") */
|
||||
homePage?: string;
|
||||
/** Whether the Global feed tab is shown */
|
||||
@@ -188,8 +191,30 @@ export function useEncryptedSettings() {
|
||||
if (!user) throw new Error('User not logged in');
|
||||
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
|
||||
|
||||
// Use the latest pending settings if available, otherwise fall back to cache.
|
||||
const currentSettings = pendingSettings.current ?? settings.data ?? {};
|
||||
// Use the latest pending settings if available (rapid successive mutations).
|
||||
// Otherwise, fetch fresh from relays to avoid cross-device stale reads.
|
||||
let currentSettings: EncryptedSettings;
|
||||
if (pendingSettings.current) {
|
||||
currentSettings = pendingSettings.current;
|
||||
} else {
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
kinds: [30078],
|
||||
authors: [user.pubkey],
|
||||
'#d': [`${config.appId}/metadata`],
|
||||
});
|
||||
if (freshEvent?.content) {
|
||||
try {
|
||||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, freshEvent.content);
|
||||
const json = JSON.parse(decrypted);
|
||||
const result = EncryptedSettingsSchema.safeParse(json);
|
||||
currentSettings = result.success ? (result.data as EncryptedSettings) : (json ?? {}) as EncryptedSettings;
|
||||
} catch {
|
||||
currentSettings = settings.data ?? {};
|
||||
}
|
||||
} else {
|
||||
currentSettings = settings.data ?? {};
|
||||
}
|
||||
}
|
||||
const updatedSettings: EncryptedSettings = {
|
||||
...currentSettings,
|
||||
...patch,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
interface PublishStatusParams {
|
||||
/** The status text. Empty string clears the status. */
|
||||
@@ -18,11 +20,21 @@ interface PublishStatusParams {
|
||||
*/
|
||||
export function usePublishStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: createEvent } = useNostrPublish();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ status, url }: PublishStatusParams) => {
|
||||
if (!user?.pubkey) return;
|
||||
|
||||
// Fetch the previous event to preserve published_at (addressable event convention)
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30315],
|
||||
authors: [user.pubkey],
|
||||
'#d': ['general'],
|
||||
});
|
||||
|
||||
const tags: string[][] = [['d', 'general']];
|
||||
if (url) tags.push(['r', url]);
|
||||
|
||||
@@ -30,6 +42,7 @@ export function usePublishStatus() {
|
||||
kind: 30315,
|
||||
content: status,
|
||||
tags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -14,12 +14,36 @@ export interface ChatMessage {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Tool function definition for chat completions. */
|
||||
export interface ChatCompletionTool {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** A tool call returned by the model. */
|
||||
export interface ChatCompletionToolCall {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
tools?: ChatCompletionTool[];
|
||||
}
|
||||
|
||||
/** A message in a chat completion response, possibly including tool calls. */
|
||||
export interface ChatCompletionResponseMessage {
|
||||
role: 'assistant';
|
||||
content?: string;
|
||||
tool_calls?: ChatCompletionToolCall[];
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
@@ -29,7 +53,7 @@ export interface ChatCompletionResponse {
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: ChatMessage;
|
||||
message: ChatCompletionResponseMessage;
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { nip57 } from 'nostr-tools';
|
||||
import type { Event } from 'nostr-tools';
|
||||
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
|
||||
/**
|
||||
* Hook for sending zaps to an event author.
|
||||
@@ -160,6 +161,7 @@ export function useZaps(
|
||||
// Clear states immediately on success
|
||||
setIsZapping(false);
|
||||
setInvoice(null);
|
||||
notificationSuccess();
|
||||
|
||||
toast({
|
||||
title: 'Zap successful!',
|
||||
@@ -204,6 +206,7 @@ export function useZaps(
|
||||
// Clear states immediately on success
|
||||
setIsZapping(false);
|
||||
setInvoice(null);
|
||||
notificationSuccess();
|
||||
|
||||
toast({
|
||||
title: 'Zap successful!',
|
||||
|
||||
@@ -705,3 +705,14 @@
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Room navigation arrow nudge — subtle horizontal pulse */
|
||||
@keyframes room-arrow-nudge-left {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(-3px); }
|
||||
}
|
||||
|
||||
@keyframes room-arrow-nudge-right {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(3px); }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Detects when a web app returns to the foreground after being backgrounded,
|
||||
* primarily to work around Android's WebSocket zombie connection problem.
|
||||
*
|
||||
* Android aggressively throttles backgrounded tabs, causing WebSocket connections
|
||||
* to silently miss events without triggering close/error handlers. This utility
|
||||
* detects the resume and reports how long the app was in the background, so
|
||||
* callers can force reconnection or re-query missed data.
|
||||
*
|
||||
* Framework-agnostic — no React dependency. Can be used in libraries.
|
||||
*/
|
||||
|
||||
export interface AndroidResumeOptions {
|
||||
/** Minimum background duration (ms) before triggering. Default: 0 */
|
||||
threshold?: number;
|
||||
/** Called when the app returns to foreground after exceeding the threshold. */
|
||||
onResume?: (backgroundDurationMs: number) => void;
|
||||
/**
|
||||
* If true, only activates on Android user agents.
|
||||
* Set to false to test on desktop. Default: true
|
||||
*/
|
||||
androidOnly?: boolean;
|
||||
}
|
||||
|
||||
function isAndroid(): boolean {
|
||||
return typeof navigator !== 'undefined' && /android/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function androidResume(options: AndroidResumeOptions = {}): { destroy: () => void } {
|
||||
const { threshold = 0, onResume, androidOnly = true } = options;
|
||||
const noop = { destroy: () => {} };
|
||||
|
||||
// No-op in non-browser environments (e.g. Node.js, Deno without DOM).
|
||||
if (typeof document === 'undefined') return noop;
|
||||
|
||||
if (androidOnly && !isAndroid()) return noop;
|
||||
|
||||
let hiddenAt: number | null = null;
|
||||
|
||||
const handler = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hiddenAt = Date.now();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
if (hiddenAt === null) return;
|
||||
const duration = Date.now() - hiddenAt;
|
||||
hiddenAt = null;
|
||||
if (duration >= threshold) {
|
||||
onResume?.(duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handler);
|
||||
return {
|
||||
destroy: () => {
|
||||
document.removeEventListener('visibilitychange', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
/**
|
||||
* Centralized haptic feedback utility.
|
||||
*
|
||||
* On native (iOS/Android) it uses @capacitor/haptics for true taptic engine
|
||||
* feedback. On web it falls back to navigator.vibrate() which works on
|
||||
* Android browsers but is a silent no-op elsewhere.
|
||||
*/
|
||||
|
||||
type ImpactStyle = 'Heavy' | 'Medium' | 'Light';
|
||||
type NotificationType = 'Success' | 'Warning' | 'Error';
|
||||
|
||||
// Lazy-loaded Haptics plugin — only imported on native to avoid bundling
|
||||
// the plugin in web builds where it isn't useful.
|
||||
let hapticsPromise: Promise<typeof import('@capacitor/haptics')> | null = null;
|
||||
|
||||
function getHaptics() {
|
||||
if (!hapticsPromise) {
|
||||
hapticsPromise = import('@capacitor/haptics');
|
||||
}
|
||||
return hapticsPromise;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function nativeImpact(style: ImpactStyle) {
|
||||
const { Haptics, ImpactStyle } = await getHaptics();
|
||||
await Haptics.impact({ style: ImpactStyle[style] });
|
||||
}
|
||||
|
||||
async function nativeNotification(type: NotificationType) {
|
||||
const { Haptics, NotificationType } = await getHaptics();
|
||||
await Haptics.notification({ type: NotificationType[type] });
|
||||
}
|
||||
|
||||
async function nativeSelectionChanged() {
|
||||
const { Haptics } = await getHaptics();
|
||||
await Haptics.selectionChanged();
|
||||
}
|
||||
|
||||
function vibrate(ms: number) {
|
||||
try {
|
||||
navigator.vibrate?.(ms);
|
||||
} catch {
|
||||
/* Vibration API not available */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
function warnHapticError(label: string, err: unknown) {
|
||||
console.warn(`[haptics] ${label} failed:`, err);
|
||||
}
|
||||
|
||||
/** Light tap — reactions, reposts, bookmarks, share. */
|
||||
export function impactLight(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeImpact('Light').catch((e) => warnHapticError('impactLight', e));
|
||||
} else {
|
||||
vibrate(10);
|
||||
}
|
||||
}
|
||||
|
||||
/** Medium tap — zap button press, pull-to-refresh threshold, follow. */
|
||||
export function impactMedium(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeImpact('Medium').catch((e) => warnHapticError('impactMedium', e));
|
||||
} else {
|
||||
vibrate(20);
|
||||
}
|
||||
}
|
||||
|
||||
/** Heavy tap — game button press, letter seal. */
|
||||
export function impactHeavy(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeImpact('Heavy').catch((e) => warnHapticError('impactHeavy', e));
|
||||
} else {
|
||||
vibrate(30);
|
||||
}
|
||||
}
|
||||
|
||||
/** Success notification — zap payment success, post published. */
|
||||
export function notificationSuccess(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeNotification('Success').catch((e) => warnHapticError('notificationSuccess', e));
|
||||
} else {
|
||||
vibrate(15);
|
||||
}
|
||||
}
|
||||
|
||||
/** Warning notification. */
|
||||
export function notificationWarning(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeNotification('Warning').catch((e) => warnHapticError('notificationWarning', e));
|
||||
} else {
|
||||
vibrate(20);
|
||||
}
|
||||
}
|
||||
|
||||
/** Error notification. */
|
||||
export function notificationError(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeNotification('Error').catch((e) => warnHapticError('notificationError', e));
|
||||
} else {
|
||||
vibrate(30);
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection changed — toggle switches, tab taps, picker changes. */
|
||||
export function selectionChanged(): void {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
nativeSelectionChanged().catch((e) => warnHapticError('selectionChanged', e));
|
||||
} else {
|
||||
vibrate(5);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,19 @@ export const IMETA_MEDIA_URL_REGEX = new RegExp(
|
||||
'gi',
|
||||
);
|
||||
|
||||
/**
|
||||
* Non-global variant of IMETA_MEDIA_URL_REGEX, safe for `.test()` calls.
|
||||
*
|
||||
* IMPORTANT: Never use the global (`g`) IMETA_MEDIA_URL_REGEX with `.test()` —
|
||||
* the global flag makes `lastIndex` stateful, so repeated `.test()` calls
|
||||
* (e.g. inside `.find()` or `.filter()`) will alternate between matching and
|
||||
* not matching, causing every other URL to be misclassified.
|
||||
*/
|
||||
export const IMETA_MEDIA_URL_TEST_REGEX = new RegExp(
|
||||
IMETA_MEDIA_URL_REGEX.source,
|
||||
'i',
|
||||
);
|
||||
|
||||
/** Infers a MIME type from a file extension string (lowercase). */
|
||||
export function mimeFromExt(ext: string): string {
|
||||
switch (ext) {
|
||||
@@ -68,6 +81,7 @@ export function mimeFromExt(ext: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Extracts all video URLs from a string. */
|
||||
export function extractVideoUrls(content: string): string[] {
|
||||
return content.match(new RegExp(VIDEO_URL_REGEX.source, 'gi')) ?? [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { AudioTrack } from '@/contexts/audioPlayerContextDef';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Gets a tag value by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -49,7 +50,7 @@ export function parseMusicTrack(event: NostrEvent): ParsedMusicTrack | null {
|
||||
const imeta = parseImeta(event.tags);
|
||||
|
||||
// Audio URL: try imeta first, then standalone url tag, then content if it looks like a URL
|
||||
const url = imeta.url ?? getTag(event.tags, 'url') ?? getTag(event.tags, 'media');
|
||||
const url = sanitizeUrl(imeta.url) ?? sanitizeUrl(getTag(event.tags, 'url')) ?? sanitizeUrl(getTag(event.tags, 'media'));
|
||||
if (!url) return null;
|
||||
|
||||
const durationStr = imeta.duration ?? getTag(event.tags, 'duration');
|
||||
@@ -59,10 +60,10 @@ export function parseMusicTrack(event: NostrEvent): ParsedMusicTrack | null {
|
||||
title,
|
||||
artist,
|
||||
url,
|
||||
artwork: imeta.thumbnail ?? getTag(event.tags, 'image') ?? getTag(event.tags, 'thumb'),
|
||||
artwork: sanitizeUrl(imeta.thumbnail) ?? sanitizeUrl(getTag(event.tags, 'image')) ?? sanitizeUrl(getTag(event.tags, 'thumb')),
|
||||
album,
|
||||
duration: duration && isFinite(duration) ? duration : undefined,
|
||||
videoUrl: getTag(event.tags, 'video'),
|
||||
videoUrl: sanitizeUrl(getTag(event.tags, 'video')),
|
||||
format: imeta.format ?? getTag(event.tags, 'm'),
|
||||
};
|
||||
}
|
||||
@@ -78,7 +79,7 @@ export interface ParsedMusicPlaylist {
|
||||
export function parseMusicPlaylist(event: NostrEvent): ParsedMusicPlaylist | null {
|
||||
const title = getTag(event.tags, 'title') ?? getTag(event.tags, 'd') ?? 'Untitled Playlist';
|
||||
const description = event.content || '';
|
||||
const artwork = getTag(event.tags, 'image') ?? getTag(event.tags, 'thumb');
|
||||
const artwork = sanitizeUrl(getTag(event.tags, 'image')) ?? sanitizeUrl(getTag(event.tags, 'thumb'));
|
||||
|
||||
// Track references are stored as 'a' or 'e' tags
|
||||
const trackRefs = event.tags
|
||||
|
||||
@@ -247,6 +247,10 @@ export const AppConfigSchema = z.object({
|
||||
imageQuality: z.enum(['compressed', 'original']),
|
||||
curatorPubkey: z.string().regex(/^[0-9a-f]{64}$/i).optional(),
|
||||
sandboxDomain: z.string().optional(),
|
||||
sidebarWidgets: z.array(z.object({
|
||||
id: z.string(),
|
||||
height: z.number().optional(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
// ─── DittoConfigSchema (build-time ditto.json) ───────────────────────
|
||||
@@ -316,6 +320,10 @@ export const EncryptedSettingsSchema = z.looseObject({
|
||||
}).optional(),
|
||||
lastSync: z.number().optional(),
|
||||
sidebarOrder: z.array(z.string()).optional(),
|
||||
sidebarWidgets: z.array(z.object({
|
||||
id: z.string(),
|
||||
height: z.number().optional(),
|
||||
})).optional(),
|
||||
homePage: z.string().optional(),
|
||||
showGlobalFeed: z.boolean().optional(),
|
||||
showCommunityFeed: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Flame,
|
||||
Egg,
|
||||
SmilePlus,
|
||||
Bot,
|
||||
Camera,
|
||||
Music,
|
||||
CalendarDays,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
|
||||
import { BlueskyIcon } from '@/components/icons/BlueskyIcon';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type IconComponent = ComponentType<{ className?: string }>;
|
||||
|
||||
/** Metadata for a widget type that can be added to the right sidebar. */
|
||||
export interface WidgetDefinition {
|
||||
/** Unique identifier matching WidgetConfig.id */
|
||||
id: string;
|
||||
/** Display label shown in the widget header and picker. */
|
||||
label: string;
|
||||
/** Short description for the widget picker. */
|
||||
description: string;
|
||||
/** Icon component for the widget header and picker. */
|
||||
icon: IconComponent;
|
||||
/** Default height in pixels. */
|
||||
defaultHeight: number;
|
||||
/** Minimum height in pixels. */
|
||||
minHeight: number;
|
||||
/** Maximum height in pixels. */
|
||||
maxHeight: number;
|
||||
/** Category for grouping in the picker. */
|
||||
category: 'personal' | 'content' | 'discovery';
|
||||
/** Optional internal route the header links to. */
|
||||
href?: string;
|
||||
/** When true, the widget uses a fixed height instead of max-height, allowing internal flex layouts to fill the container. */
|
||||
fillHeight?: boolean;
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** All available widget definitions. */
|
||||
export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
|
||||
// Discovery
|
||||
{
|
||||
id: 'trends',
|
||||
label: 'Trending',
|
||||
description: 'Top trending hashtags with sparkline charts',
|
||||
icon: TrendingUp,
|
||||
defaultHeight: 320,
|
||||
minHeight: 200,
|
||||
maxHeight: 600,
|
||||
category: 'discovery',
|
||||
href: '/trends',
|
||||
},
|
||||
{
|
||||
id: 'hot-posts',
|
||||
label: 'Hot Posts',
|
||||
description: 'Top posts from the Hot feed',
|
||||
icon: Flame,
|
||||
defaultHeight: 350,
|
||||
minHeight: 200,
|
||||
maxHeight: 600,
|
||||
category: 'discovery',
|
||||
href: '/trends',
|
||||
},
|
||||
{
|
||||
id: 'wikipedia',
|
||||
label: 'Wikipedia',
|
||||
description: "Today's featured article from Wikipedia",
|
||||
icon: WikipediaIcon,
|
||||
defaultHeight: 350,
|
||||
minHeight: 200,
|
||||
maxHeight: 600,
|
||||
category: 'discovery',
|
||||
href: '/wikipedia',
|
||||
},
|
||||
{
|
||||
id: 'bluesky',
|
||||
label: 'Bluesky',
|
||||
description: 'Trending posts from Bluesky',
|
||||
icon: BlueskyIcon,
|
||||
defaultHeight: 400,
|
||||
minHeight: 250,
|
||||
maxHeight: 700,
|
||||
category: 'discovery',
|
||||
href: '/bluesky',
|
||||
},
|
||||
|
||||
// Personal
|
||||
{
|
||||
id: 'blobbi',
|
||||
label: 'Blobbi',
|
||||
description: 'Your virtual pet companion',
|
||||
icon: Egg,
|
||||
defaultHeight: 350,
|
||||
minHeight: 200,
|
||||
maxHeight: 500,
|
||||
category: 'personal',
|
||||
href: '/blobbi',
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
label: 'Status',
|
||||
description: 'Your current status, editable inline',
|
||||
icon: SmilePlus,
|
||||
defaultHeight: 80,
|
||||
minHeight: 60,
|
||||
maxHeight: 120,
|
||||
category: 'personal',
|
||||
href: '/profile',
|
||||
},
|
||||
{
|
||||
id: 'ai-chat',
|
||||
label: 'AI Chat',
|
||||
description: 'Chat with Shakespeare AI',
|
||||
icon: Bot,
|
||||
defaultHeight: 400,
|
||||
minHeight: 250,
|
||||
maxHeight: 700,
|
||||
category: 'personal',
|
||||
href: '/ai-chat',
|
||||
fillHeight: true,
|
||||
},
|
||||
|
||||
// Content feeds
|
||||
{
|
||||
id: 'feed:photos',
|
||||
label: 'Photos',
|
||||
description: 'Recent photos from your feed',
|
||||
icon: Camera,
|
||||
defaultHeight: 400,
|
||||
minHeight: 250,
|
||||
maxHeight: 700,
|
||||
category: 'content',
|
||||
href: '/photos',
|
||||
},
|
||||
{
|
||||
id: 'feed:music',
|
||||
label: 'Music',
|
||||
description: 'Music tracks from your feed',
|
||||
icon: Music,
|
||||
defaultHeight: 350,
|
||||
minHeight: 250,
|
||||
maxHeight: 700,
|
||||
category: 'content',
|
||||
href: '/music',
|
||||
},
|
||||
{
|
||||
id: 'feed:articles',
|
||||
label: 'Articles',
|
||||
description: 'Long-form articles from your feed',
|
||||
icon: ScrollText,
|
||||
defaultHeight: 350,
|
||||
minHeight: 250,
|
||||
maxHeight: 700,
|
||||
category: 'content',
|
||||
href: '/articles',
|
||||
},
|
||||
{
|
||||
id: 'feed:events',
|
||||
label: 'Events',
|
||||
description: 'Upcoming calendar events',
|
||||
icon: CalendarDays,
|
||||
defaultHeight: 300,
|
||||
minHeight: 200,
|
||||
maxHeight: 600,
|
||||
category: 'content',
|
||||
href: '/events',
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
/** Pre-built Map for O(1) widget definition lookup. */
|
||||
const WIDGET_MAP = new Map(WIDGET_DEFINITIONS.map((w) => [w.id, w]));
|
||||
|
||||
/** Lookup a widget definition by ID. */
|
||||
export function getWidgetDefinition(id: string): WidgetDefinition | undefined {
|
||||
return WIDGET_MAP.get(id);
|
||||
}
|
||||
|
||||
/** Category labels for display in the picker. */
|
||||
export const WIDGET_CATEGORIES: Record<string, string> = {
|
||||
personal: 'Personal',
|
||||
content: 'Content',
|
||||
discovery: 'Discovery',
|
||||
};
|
||||
+50
-102
@@ -1,7 +1,6 @@
|
||||
import type { NostrEvent, NostrSigner } from '@nostrify/types';
|
||||
import { createElement } from 'react';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { androidResume } from '@/lib/androidResume';
|
||||
import { NudgeToastContent } from '@/components/SignerToastContent';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -16,9 +15,6 @@ const NUDGE_DELAY_MS = 4_000;
|
||||
const HARD_TIMEOUT_MS = 45_000;
|
||||
|
||||
|
||||
/** Max number of automatic retries on Android foreground resume. */
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -70,9 +66,8 @@ function labelForOp(kind: number | undefined, opType: OpType): string {
|
||||
|
||||
const CANCEL = Symbol('cancel');
|
||||
const TIMEOUT = Symbol('timeout');
|
||||
const RESUME = Symbol('resume');
|
||||
|
||||
type Signal = typeof CANCEL | typeof TIMEOUT | typeof RESUME;
|
||||
type Signal = typeof CANCEL | typeof TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast deduplication — prevent a storm of identical nudge toasts
|
||||
@@ -98,10 +93,9 @@ function showNudgeToast(opts: {
|
||||
kind: number | undefined;
|
||||
opType: OpType;
|
||||
isBunkerConnected: (() => boolean) | undefined;
|
||||
afterForegroundResume: boolean;
|
||||
onCancel: () => void;
|
||||
}): { dismiss: () => void } {
|
||||
const { kind, opType, isBunkerConnected, afterForegroundResume, onCancel } = opts;
|
||||
const { kind, opType, isBunkerConnected, onCancel } = opts;
|
||||
const android = isAndroid();
|
||||
const relayOk = isBunkerConnected ? isBunkerConnected() : true;
|
||||
const subject = labelForOp(kind, opType);
|
||||
@@ -120,9 +114,6 @@ function showNudgeToast(opts: {
|
||||
if (!relayOk) {
|
||||
title = 'Signer relay unreachable';
|
||||
descriptionText = 'Check your connection and try again.';
|
||||
} else if (android && afterForegroundResume) {
|
||||
title = `Approve ${subject} — try again`;
|
||||
descriptionText = 'Use the button below. Switching apps manually can interrupt the connection.';
|
||||
} else if (android) {
|
||||
title = `Approve ${subject}`;
|
||||
descriptionText = 'Set to auto-approve for a smoother experience.';
|
||||
@@ -184,11 +175,6 @@ interface RunResult<T> {
|
||||
* Runs `op` with:
|
||||
* - A nudge toast after NUDGE_DELAY_MS if still pending.
|
||||
* - A hard timeout at HARD_TIMEOUT_MS.
|
||||
* - On Android, automatic retry when the app returns to the foreground
|
||||
* (WebSocket connections are frozen while backgrounded, so NIP-46 responses
|
||||
* are missed).
|
||||
*
|
||||
* Uses an iterative retry loop instead of recursion.
|
||||
*/
|
||||
async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<RunResult<T>> {
|
||||
const { kind, opType, isBunkerConnected } = opts;
|
||||
@@ -200,96 +186,61 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
|
||||
| { tag: 'signal'; signal: Signal };
|
||||
|
||||
let nudgeFired = false;
|
||||
let afterForegroundResume = false;
|
||||
|
||||
// Previous op promises that are still in-flight. On Android foreground
|
||||
// resume we issue a fresh `op()` but keep racing previous ones so a late
|
||||
// response from an earlier attempt is still accepted (avoids duplicate
|
||||
// signer prompts).
|
||||
const pendingOps: Promise<Outcome>[] = [];
|
||||
// Signal channels — each resolves with a sentinel when its condition fires.
|
||||
const cancelSignal = deferred<typeof CANCEL>();
|
||||
const timeoutSignal = deferred<typeof TIMEOUT>();
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
// Signal channels — each resolves with a sentinel when its condition fires.
|
||||
const cancelSignal = deferred<typeof CANCEL>();
|
||||
const timeoutSignal = deferred<typeof TIMEOUT>();
|
||||
const resumeSignal = deferred<typeof RESUME>();
|
||||
|
||||
// --- Nudge timer ---
|
||||
let dismissNudge: (() => void) | undefined;
|
||||
const delay = NUDGE_DELAY_MS;
|
||||
const nudgeTimer = setTimeout(() => {
|
||||
nudgeFired = true;
|
||||
const handle = showNudgeToast({
|
||||
kind, opType, isBunkerConnected, afterForegroundResume,
|
||||
onCancel: () => cancelSignal.resolve(CANCEL),
|
||||
});
|
||||
dismissNudge = handle.dismiss;
|
||||
}, delay);
|
||||
|
||||
// --- Hard timeout ---
|
||||
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
|
||||
|
||||
// --- Android foreground resume watcher ---
|
||||
const { destroy: stopWatching } = androidResume({
|
||||
threshold: 0,
|
||||
onResume: () => {
|
||||
toast({ title: 'Checking for signer response\u2026', duration: 4000 });
|
||||
resumeSignal.resolve(RESUME);
|
||||
},
|
||||
// --- Nudge timer ---
|
||||
let dismissNudge: (() => void) | undefined;
|
||||
const nudgeTimer = setTimeout(() => {
|
||||
nudgeFired = true;
|
||||
const handle = showNudgeToast({
|
||||
kind, opType, isBunkerConnected,
|
||||
onCancel: () => cancelSignal.resolve(CANCEL),
|
||||
});
|
||||
dismissNudge = handle.dismiss;
|
||||
}, NUDGE_DELAY_MS);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(nudgeTimer);
|
||||
clearTimeout(hardTimer);
|
||||
stopWatching();
|
||||
dismissNudge?.();
|
||||
}
|
||||
// --- Hard timeout ---
|
||||
const hardTimer = setTimeout(() => timeoutSignal.resolve(TIMEOUT), HARD_TIMEOUT_MS);
|
||||
|
||||
// Start a new op and add it to the pending set.
|
||||
const newOp: Promise<Outcome> = op().then(
|
||||
(value): Outcome => ({ tag: 'value', value }),
|
||||
(error): Outcome => ({ tag: 'error', error }),
|
||||
);
|
||||
pendingOps.push(newOp);
|
||||
|
||||
const signalOutcome: Promise<Outcome> = Promise.race([
|
||||
cancelSignal.promise,
|
||||
timeoutSignal.promise,
|
||||
resumeSignal.promise,
|
||||
]).then((signal): Outcome => ({ tag: 'signal', signal }));
|
||||
|
||||
// Race all pending ops (current + any still in-flight from prior
|
||||
// attempts) against the signal channels.
|
||||
const outcome = await Promise.race([...pendingOps, signalOutcome]);
|
||||
cleanup();
|
||||
|
||||
// --- Handle outcome ---
|
||||
|
||||
if (outcome.tag === 'value') {
|
||||
if (nudgeFired) showSuccessToast(opType);
|
||||
return { value: outcome.value, nudgeFired };
|
||||
}
|
||||
|
||||
if (outcome.tag === 'error') {
|
||||
throw outcome.error;
|
||||
}
|
||||
|
||||
// outcome.tag === 'signal'
|
||||
switch (outcome.signal) {
|
||||
case CANCEL:
|
||||
throw new Error('Signing cancelled by user');
|
||||
|
||||
case TIMEOUT:
|
||||
throw new Error('Signer timed out');
|
||||
|
||||
case RESUME:
|
||||
afterForegroundResume = true;
|
||||
console.log('[signerWithNudge] retrying after foreground resume');
|
||||
continue;
|
||||
}
|
||||
function cleanup() {
|
||||
clearTimeout(nudgeTimer);
|
||||
clearTimeout(hardTimer);
|
||||
dismissNudge?.();
|
||||
}
|
||||
|
||||
throw new Error('Signer timed out after retries');
|
||||
const opOutcome: Promise<Outcome> = op().then(
|
||||
(value): Outcome => ({ tag: 'value', value }),
|
||||
(error): Outcome => ({ tag: 'error', error }),
|
||||
);
|
||||
|
||||
const signalOutcome: Promise<Outcome> = Promise.race([
|
||||
cancelSignal.promise,
|
||||
timeoutSignal.promise,
|
||||
]).then((signal): Outcome => ({ tag: 'signal', signal }));
|
||||
|
||||
const outcome = await Promise.race([opOutcome, signalOutcome]);
|
||||
cleanup();
|
||||
|
||||
if (outcome.tag === 'value') {
|
||||
if (nudgeFired) showSuccessToast(opType);
|
||||
return { value: outcome.value, nudgeFired };
|
||||
}
|
||||
|
||||
if (outcome.tag === 'error') {
|
||||
throw outcome.error;
|
||||
}
|
||||
|
||||
// outcome.tag === 'signal'
|
||||
switch (outcome.signal) {
|
||||
case CANCEL:
|
||||
throw new Error('Signing cancelled by user');
|
||||
|
||||
case TIMEOUT:
|
||||
throw new Error('Signer timed out');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -301,9 +252,6 @@ async function runWithNudge<T>(op: () => Promise<T>, opts: RunOpts): Promise<Run
|
||||
*
|
||||
* - Shows a nudge toast after 4s if a signing or encryption op is still
|
||||
* pending, so the user knows to check their signer app.
|
||||
* - On Android, automatically retries when the app returns to the foreground,
|
||||
* recovering from missed NIP-46 responses dropped while the WebSocket was
|
||||
* frozen in the background.
|
||||
* - When a nip44 encrypt is immediately followed by a signEvent (e.g. saving
|
||||
* encrypted settings), shows a phase-transition toast so the user knows to
|
||||
* approve the second request.
|
||||
|
||||
+7
-3
@@ -21,12 +21,16 @@ import '@fontsource-variable/inter';
|
||||
// Uses a MutationObserver so it reacts to all subsequent theme changes
|
||||
// (class changes for builtin themes, style-content changes for custom themes).
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from '@capacitor/core';
|
||||
import { Keyboard } from '@capacitor/keyboard';
|
||||
import { getBackgroundThemeMode } from '@/lib/colorUtils';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard)
|
||||
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
|
||||
// Hide the iOS keyboard accessory bar (prev/next/done toolbar above the keyboard).
|
||||
// Only runs on iOS — setAccessoryBarVisible is unimplemented on Android.
|
||||
if (Capacitor.getPlatform() === 'ios') {
|
||||
import('@capacitor/keyboard').then(({ Keyboard }) => {
|
||||
Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}
|
||||
/**
|
||||
* Sync the native system bar icon style with the active CSS theme.
|
||||
*
|
||||
|
||||
+20
-44
@@ -5,7 +5,7 @@ import rehypeSanitize from 'rehype-sanitize';
|
||||
import { Bot, Send, Trash2, Palette, Type } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useShakespeare, type ChatMessage, type Model } from '@/hooks/useShakespeare';
|
||||
import { useShakespeare, type ChatMessage, type Model, type ChatCompletionTool } from '@/hooks/useShakespeare';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
@@ -18,7 +18,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DorkThinking } from '@/components/DorkThinking';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
import type { ThemeConfig } from '@/themes';
|
||||
|
||||
@@ -27,9 +29,9 @@ import type { ThemeConfig } from '@/themes';
|
||||
/** Build the list of available bundled font names for the tool description. */
|
||||
const AVAILABLE_FONTS = bundledFonts.map((f) => f.family).join(', ');
|
||||
|
||||
const TOOLS = [
|
||||
const TOOLS: ChatCompletionTool[] = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'set_theme',
|
||||
description: `Set a custom theme for the application. You can set colors, a font, and a background image — all in one call. Colors are required; font and background are optional.
|
||||
@@ -136,12 +138,15 @@ function useToolExecutor() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add background if provided
|
||||
// Add background if provided (sanitize to prevent CSS injection via url())
|
||||
if (typeof background_url === 'string' && background_url.trim()) {
|
||||
themeConfig.background = {
|
||||
url: background_url.trim(),
|
||||
mode: background_mode === 'tile' ? 'tile' : 'cover',
|
||||
};
|
||||
const safeUrl = sanitizeUrl(background_url.trim());
|
||||
if (safeUrl) {
|
||||
themeConfig.background = {
|
||||
url: safeUrl,
|
||||
mode: background_mode === 'tile' ? 'tile' : 'cover',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
applyCustomTheme(themeConfig);
|
||||
@@ -289,23 +294,14 @@ export function AIChatPage() {
|
||||
// Send with tools
|
||||
const response = await sendChatMessage(apiMessages, selectedModel, {
|
||||
tools: TOOLS,
|
||||
} as Partial<Record<string, unknown>>);
|
||||
});
|
||||
|
||||
const choice = response.choices[0];
|
||||
const assistantMsg = choice.message;
|
||||
|
||||
// Check for tool calls
|
||||
const rawMessage = assistantMsg as unknown as {
|
||||
content?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
if (rawMessage.tool_calls && rawMessage.tool_calls.length > 0) {
|
||||
if (assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0) {
|
||||
// Execute tool calls
|
||||
const toolCalls: ToolCall[] = rawMessage.tool_calls.map((tc) => {
|
||||
const toolCalls: ToolCall[] = assistantMsg.tool_calls.map((tc) => {
|
||||
let args: Record<string, unknown> = {};
|
||||
try {
|
||||
args = JSON.parse(tc.function.arguments);
|
||||
@@ -327,7 +323,7 @@ export function AIChatPage() {
|
||||
const toolMsg: DisplayMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: rawMessage.content || '',
|
||||
content: assistantMsg.content || '',
|
||||
timestamp: new Date(),
|
||||
toolCalls,
|
||||
};
|
||||
@@ -340,7 +336,7 @@ export function AIChatPage() {
|
||||
// Add the assistant message with tool_calls
|
||||
followUpMessages.push({
|
||||
role: 'assistant',
|
||||
content: rawMessage.content || '',
|
||||
content: assistantMsg.content || '',
|
||||
});
|
||||
|
||||
// Add tool results
|
||||
@@ -471,7 +467,7 @@ export function AIChatPage() {
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && (
|
||||
<DorkThinking />
|
||||
<DorkThinking className="text-sm" />
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
@@ -514,27 +510,7 @@ export function AIChatPage() {
|
||||
|
||||
// ─── Sub-Components ───
|
||||
|
||||
const DORK_ANIMATION = [
|
||||
'<[o_o]>',
|
||||
'>[-_-]<',
|
||||
'<[0_0]>',
|
||||
'>[-_-]<',
|
||||
];
|
||||
|
||||
function DorkThinking() {
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % DORK_ANIMATION.length);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<pre className="text-sm font-mono text-muted-foreground leading-none">{DORK_ANIMATION[frame]}</pre>
|
||||
);
|
||||
}
|
||||
// DorkThinking is imported from the shared component
|
||||
|
||||
const DORK_GREETINGS = [
|
||||
"Hi, I'm Dork! What would you like me to do?",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user