feat: upgrade applesauce libraries to v5

Major upgrade from applesauce v4 to v5 with breaking changes:

Package updates:
- applesauce-core: ^4.0.0 → ^5.0.0
- applesauce-actions: ^4.0.0 → ^5.0.0
- applesauce-loaders: ^4.0.0 → ^5.0.0
- applesauce-react: ^4.0.0 → ^5.0.0
- applesauce-relay: ^4.0.0 → ^5.0.0
- applesauce-signers: ^4.0.0 → ^5.0.0
- applesauce-accounts: ^4.0.0 → ^5.0.0
- Added new applesauce-common: ^5.0.0 package

API migrations:
- EventFactory: applesauce-factory → applesauce-core/event-factory
- ActionHub → ActionRunner with async function pattern (not generators)
- useObservableMemo → use$ hook across all components
- Helper imports: article, highlight, threading, zap, comment, lists
  moved from applesauce-core to applesauce-common
- parseCoordinate → parseReplaceableAddress
- Subscription options: retries → reconnect
- getEventPointerFromETag now returns null instead of throwing

New features:
- Unified event loader via createEventLoaderForStore
- Updated loaders.ts to use v5 unified loader pattern

Documentation:
- Updated CLAUDE.md with v5 patterns and migration notes
- Updated applesauce-core skill for v5 changes
- Created new applesauce-common skill

Test fixes:
- Updated publish-spellbook.test.ts for v5 ActionRunner pattern
- Updated publish-spell.test.ts with eventStore mock
- Updated relay-selection.test.ts with valid test events
- Updated loaders.test.ts with valid 64-char hex event IDs
- Added createEventLoaderForStore mock
This commit is contained in:
Claude
2026-01-05 13:43:06 +00:00
parent 5ddad07ba5
commit 740d3a182a
44 changed files with 830 additions and 493 deletions

View File

@@ -0,0 +1,290 @@
---
name: applesauce-common
description: This skill should be used when working with applesauce-common library for social/NIP-specific helpers, casting system, blueprints, and operations. New in applesauce v5 - contains helpers that moved from applesauce-core.
---
# applesauce-common Skill (v5)
This skill provides comprehensive knowledge for working with applesauce-common, a new package in applesauce v5 that contains social/NIP-specific utilities, the casting system, blueprints, and operations.
**Note**: applesauce-common was introduced in v5. Many helpers that were previously in `applesauce-core/helpers` have moved here.
## When to Use This Skill
Use this skill when:
- Working with article, highlight, threading, zap, or reaction helpers
- Using the casting system for typed event access
- Creating events with blueprints
- Modifying events with operations
- Working with NIP-specific social features
## Package Structure
```
applesauce-common/
├── helpers/ # Social/NIP-specific helpers
│ ├── article.js # NIP-23 article helpers
│ ├── highlight.js # NIP-84 highlight helpers
│ ├── threading.js # NIP-10 thread helpers
│ ├── comment.js # NIP-22 comment helpers
│ ├── zap.js # NIP-57 zap helpers
│ ├── reaction.js # NIP-25 reaction helpers
│ ├── lists.js # NIP-51 list helpers
│ └── ...
├── casts/ # Typed event classes
│ ├── Note.js
│ ├── User.js
│ ├── Profile.js
│ ├── Article.js
│ └── ...
├── blueprints/ # Event creation blueprints
└── operations/ # Event modification operations
```
## Helpers (Migrated from applesauce-core)
### Article Helpers (NIP-23)
```typescript
import {
getArticleTitle,
getArticleSummary,
getArticleImage,
getArticlePublished
} from 'applesauce-common/helpers/article';
// All helpers cache internally - no useMemo needed
const title = getArticleTitle(event);
const summary = getArticleSummary(event);
const image = getArticleImage(event);
const publishedAt = getArticlePublished(event);
```
### Highlight Helpers (NIP-84)
```typescript
import {
getHighlightText,
getHighlightSourceUrl,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightContext,
getHighlightComment
} from 'applesauce-common/helpers/highlight';
const text = getHighlightText(event);
const sourceUrl = getHighlightSourceUrl(event);
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
const context = getHighlightContext(event);
const comment = getHighlightComment(event);
```
### Threading Helpers (NIP-10)
```typescript
import { getNip10References } from 'applesauce-common/helpers/threading';
// Parse NIP-10 thread structure
const refs = getNip10References(event);
if (refs.root) {
console.log('Root event:', refs.root.e);
console.log('Root address:', refs.root.a);
}
if (refs.reply) {
console.log('Reply to:', refs.reply.e);
}
```
### Comment Helpers (NIP-22)
```typescript
import { getCommentReplyPointer } from 'applesauce-common/helpers/comment';
const pointer = getCommentReplyPointer(event);
if (pointer) {
// Handle reply target
}
```
### Zap Helpers (NIP-57)
```typescript
import {
getZapAmount,
getZapSender,
getZapRecipient,
getZapComment
} from 'applesauce-common/helpers/zap';
const amount = getZapAmount(event); // In millisats
const sender = getZapSender(event); // Pubkey
const recipient = getZapRecipient(event);
const comment = getZapComment(event);
```
### List Helpers (NIP-51)
```typescript
import { getRelaysFromList } from 'applesauce-common/helpers/lists';
const relays = getRelaysFromList(event);
```
## Casting System
The casting system transforms raw Nostr events into typed classes with both synchronous properties and reactive observables.
### Basic Usage
```typescript
import { castEvent, Note, User, Profile } from 'applesauce-common/casts';
// Cast an event to a typed class
const note = castEvent(event, Note, eventStore);
// Access synchronous properties
console.log(note.id);
console.log(note.createdAt);
console.log(note.isReply);
// Subscribe to reactive observables
note.author.profile$.subscribe(profile => {
console.log('Author name:', profile?.name);
});
```
### Available Casts
- **Note** - Kind 1 short text notes
- **User** - User with profile and social graph
- **Profile** - Kind 0 profile metadata
- **Article** - Kind 30023 long-form articles
- **Reaction** - Kind 7 reactions
- **Zap** - Kind 9735 zap receipts
- **Comment** - NIP-22 comments
- **Share** - Reposts/quotes
- **Bookmarks** - NIP-51 bookmarks
- **Mutes** - NIP-51 mute lists
### With React
```typescript
import { use$ } from 'applesauce-react/hooks';
import { castEvent, Note } from 'applesauce-common/casts';
function NoteComponent({ event }) {
const note = castEvent(event, Note, eventStore);
// Subscribe to author's profile
const profile = use$(note.author.profile$);
// Subscribe to replies
const replies = use$(note.replies$);
return (
<div>
<span>{profile?.name}</span>
<p>{note.content}</p>
<span>{replies?.length} replies</span>
</div>
);
}
```
## Blueprints
Blueprints provide templates for creating events.
```typescript
import { EventFactory } from 'applesauce-core/event-factory';
import { NoteBlueprint } from 'applesauce-common/blueprints';
const factory = new EventFactory({ signer });
// Create a note using blueprint
const draft = await factory.build(NoteBlueprint({
content: 'Hello Nostr!',
tags: [['t', 'nostr']]
}));
const event = await factory.sign(draft);
```
## Operations
Operations modify existing events.
```typescript
import { addTag, removeTag } from 'applesauce-common/operations';
// Add a tag to an event
const modified = addTag(event, ['t', 'bitcoin']);
// Remove a tag
const updated = removeTag(event, 'client');
```
## Migration from v4
### Helper Import Changes
```typescript
// ❌ Old (v4)
import { getArticleTitle } from 'applesauce-core/helpers';
import { getNip10References } from 'applesauce-core/helpers/threading';
import { getZapAmount } from 'applesauce-core/helpers/zap';
// ✅ New (v5)
import { getArticleTitle } from 'applesauce-common/helpers/article';
import { getNip10References } from 'applesauce-common/helpers/threading';
import { getZapAmount } from 'applesauce-common/helpers/zap';
```
### Helpers that stayed in applesauce-core
These protocol-level helpers remain in `applesauce-core/helpers`:
- `getTagValue`, `hasNameValueTag`
- `getProfileContent`
- `parseCoordinate`, `getEventPointerFromETag`, `getAddressPointerFromATag`
- `isFilterEqual`, `matchFilter`, `mergeFilters`
- `getSeenRelays`, `mergeRelaySets`
- `getInboxes`, `getOutboxes`
- `normalizeURL`
## Best Practices
### Helper Caching
All helpers in applesauce-common cache internally using symbols:
```typescript
// ❌ Don't memoize helper calls
const title = useMemo(() => getArticleTitle(event), [event]);
// ✅ Call helpers directly
const title = getArticleTitle(event);
```
### Casting vs Helpers
Use **helpers** when you need specific fields:
```typescript
const title = getArticleTitle(event);
const amount = getZapAmount(event);
```
Use **casts** when you need reactive data or multiple related properties:
```typescript
const note = castEvent(event, Note, eventStore);
const profile$ = note.author.profile$;
const replies$ = note.replies$;
```
## Related Skills
- **applesauce-core** - Protocol-level helpers and event store
- **applesauce-signers** - Event signing abstractions
- **nostr** - Nostr protocol fundamentals

View File

@@ -3,9 +3,15 @@ name: applesauce-core
description: This skill should be used when working with applesauce-core library for Nostr client development, including event stores, queries, observables, and client utilities. Provides comprehensive knowledge of applesauce patterns for building reactive Nostr applications.
---
# applesauce-core Skill
# applesauce-core Skill (v5)
This skill provides comprehensive knowledge and patterns for working with applesauce-core, a library that provides reactive utilities and patterns for building Nostr clients.
This skill provides comprehensive knowledge and patterns for working with applesauce-core v5, a library that provides reactive utilities and patterns for building Nostr clients.
**Note**: applesauce v5 introduced package reorganization:
- Protocol-level code stays in `applesauce-core`
- Social/NIP-specific helpers moved to `applesauce-common`
- `EventFactory` moved from `applesauce-factory` to `applesauce-core/event-factory`
- `ActionHub` renamed to `ActionRunner` in `applesauce-actions`
## When to Use This Skill
@@ -396,6 +402,8 @@ function getTagValues(event, tagName) {
### Article Helpers (NIP-23)
**Note**: Article helpers moved to `applesauce-common` in v5.
```javascript
import {
getArticleTitle,
@@ -403,7 +411,7 @@ import {
getArticleImage,
getArticlePublished,
isValidArticle
} from 'applesauce-core/helpers';
} from 'applesauce-common/helpers/article';
// All cached automatically
const title = getArticleTitle(event);
@@ -419,6 +427,8 @@ if (isValidArticle(event)) {
### Highlight Helpers (NIP-84)
**Note**: Highlight helpers moved to `applesauce-common` in v5.
```javascript
import {
getHighlightText,
@@ -428,7 +438,7 @@ import {
getHighlightContext,
getHighlightComment,
getHighlightAttributions
} from 'applesauce-core/helpers';
} from 'applesauce-common/helpers/highlight';
// All cached - no useMemo needed
const text = getHighlightText(event);
@@ -514,8 +524,10 @@ if (eventPointer) {
### Threading Helpers (NIP-10)
**Note**: Threading helpers moved to `applesauce-common` in v5.
```javascript
import { getNip10References } from 'applesauce-core/helpers';
import { getNip10References } from 'applesauce-common/helpers/threading';
// Parse NIP-10 thread structure (cached)
const refs = getNip10References(event);
@@ -907,6 +919,7 @@ function createInfiniteScroll(timeline, pageSize = 50) {
## Related Skills
- **nostr-tools** - Lower-level Nostr operations
- **applesauce-common** - Social/NIP-specific helpers (article, highlight, threading, zap, etc.)
- **applesauce-signers** - Event signing abstractions
- **svelte** - Building reactive UIs
- **nostr** - Nostr protocol fundamentals

View File

@@ -37,6 +37,17 @@ Grimoire is a Nostr protocol explorer and developer tool. It's a tiling window m
**Critical**: Don't create new EventStore, RelayPool, or RelayLiveness instances - use the singletons in `src/services/`
**Event Loading** (`src/services/loaders.ts`):
- Unified loader auto-fetches missing events when queried via `eventStore.event()` or `eventStore.replaceable()`
- Custom `eventLoader()` with smart relay hint merging for explicit loading with context
- `addressLoader` and `profileLoader` for replaceable events with batching
- `createTimelineLoader` for paginated feeds
**Action System** (`src/services/hub.ts`):
- `ActionRunner` (v5) executes actions with signing and publishing
- Actions are async functions: `async ({ factory, sign, publish }) => { ... }`
- Use `await publish(event)` to publish (not generators/yield)
### Window System
Windows are rendered in a recursive binary split layout (via `react-mosaic-component`):
@@ -72,6 +83,18 @@ Applesauce uses RxJS observables for reactive data flow:
Use hooks like `useProfile()`, `useNostrEvent()`, `useTimeline()` - they handle subscriptions.
**The `use$` Hook** (applesauce v5):
```typescript
import { use$ } from "applesauce-react/hooks";
// Direct observable (for BehaviorSubjects - never undefined)
const account = use$(accounts.active$);
// Factory with deps (for dynamic observables)
const event = use$(() => eventStore.event(eventId), [eventId]);
const timeline = use$(() => eventStore.timeline(filters), [filters]);
```
### Applesauce Helpers & Caching
**Critical Performance Insight**: Applesauce helpers cache computed values internally using symbols. **You don't need `useMemo` when calling applesauce helpers.**
@@ -88,16 +111,24 @@ const text = getHighlightText(event);
**How it works**: Helpers use `getOrComputeCachedValue(event, symbol, compute)` to cache results on the event object. The first call computes and caches, subsequent calls return the cached value instantly.
**Available Helpers** (from `applesauce-core/helpers`):
**Available Helpers** (split between packages in applesauce v5):
*From `applesauce-core/helpers` (protocol-level):*
- **Tags**: `getTagValue(event, name)` - get single tag value (searches hidden tags first)
- **Article**: `getArticleTitle`, `getArticleSummary`, `getArticleImage`, `getArticlePublished`
- **Highlight**: `getHighlightText`, `getHighlightSourceUrl`, `getHighlightSourceEventPointer`, `getHighlightSourceAddressPointer`, `getHighlightContext`, `getHighlightComment`
- **Profile**: `getProfileContent(event)`, `getDisplayName(metadata, fallback)`
- **Pointers**: `parseCoordinate(aTag)`, `getEventPointerFromETag`, `getAddressPointerFromATag`, `getProfilePointerFromPTag`
- **Reactions**: `getReactionEventPointer(event)`, `getReactionAddressPointer(event)`
- **Threading**: `getNip10References(event)` - parses NIP-10 thread tags
- **Filters**: `isFilterEqual(a, b)`, `matchFilter(filter, event)`, `mergeFilters(...filters)`
- **And 50+ more** - see `node_modules/applesauce-core/dist/helpers/index.d.ts`
- **Relays**: `getSeenRelays`, `mergeRelaySets`, `getInboxes`, `getOutboxes`
- **URL**: `normalizeURL`
*From `applesauce-common/helpers` (social/NIP-specific):*
- **Article**: `getArticleTitle`, `getArticleSummary`, `getArticleImage`, `getArticlePublished`
- **Highlight**: `getHighlightText`, `getHighlightSourceUrl`, `getHighlightSourceEventPointer`, `getHighlightSourceAddressPointer`, `getHighlightContext`, `getHighlightComment`
- **Threading**: `getNip10References(event)` - parses NIP-10 thread tags
- **Comment**: `getCommentReplyPointer(event)` - parses NIP-22 comment replies
- **Zap**: `getZapAmount`, `getZapSender`, `getZapRecipient`, `getZapComment`
- **Reactions**: `getReactionEventPointer(event)`, `getReactionAddressPointer(event)`
- **Lists**: `getRelaysFromList`
**Custom Grimoire Helpers** (not in applesauce):
- `getTagValues(event, name)` - plural version to get array of tag values (src/lib/nostr-utils.ts)

View File

@@ -1,41 +1,41 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import prettier from 'eslint-plugin-prettier'
import prettierConfig from 'eslint-config-prettier'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import prettier from "eslint-plugin-prettier";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ['dist', 'node_modules', '.claude'] },
{ ignores: ["dist", "node_modules", ".claude"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'prettier': prettier,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
prettier: prettier,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
'prettier/prettier': 'error',
"prettier/prettier": "error",
},
},
prettierConfig,
)
);

320
package-lock.json generated
View File

@@ -24,14 +24,15 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/qrcode": "^1.5.6",
"applesauce-accounts": "^4.1.0",
"applesauce-actions": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"applesauce-signers": "^4.1.0",
"applesauce-accounts": "^5.0.0",
"applesauce-actions": "^5.0.0",
"applesauce-common": "^5.0.0",
"applesauce-content": "^5.0.0",
"applesauce-core": "^5.0.0",
"applesauce-loaders": "^5.0.0",
"applesauce-react": "^5.0.0",
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -673,43 +674,26 @@
}
},
"node_modules/@cashu/cashu-ts": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz",
"integrity": "sha512-4HO3LC3VqiMs0K7ccQdfSs3l1wJNL0VuE8ZQ6zAfMsoeKRwswA1eC5BaGFrEDv7PcPqjliE/RBRw3+1Hz/SmsA==",
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.2.2.tgz",
"integrity": "sha512-FD3EBYQiDRhZFwCMXuhAGRAb279WpGEWePzRQk58GJSZy16OdcP3hrYmj7L9wWdETy2fQU0wn+bCuw2NAB6szQ==",
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.9.5",
"@noble/hashes": "^1.5.0",
"@scure/bip32": "^1.5.0"
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/bip32": "^2.0.1"
},
"engines": {
"node": ">=22.4.0"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
"integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.9.0",
"@noble/hashes": "~1.8.0",
"@scure/base": "~1.2.5"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -1642,24 +1626,27 @@
}
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -4076,59 +4063,35 @@
}
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 16"
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -4974,16 +4937,14 @@
}
},
"node_modules/applesauce-accounts": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/applesauce-accounts/-/applesauce-accounts-4.1.0.tgz",
"integrity": "sha512-4vRUpkJL8RpVBiboxDb5fUPkeqv+rTIlw3Tog79K1paLHJUcUokcdzzdZLEmS/C531n0AamO2Qvr1XiBFqR5xg==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-accounts/-/applesauce-accounts-5.0.0.tgz",
"integrity": "sha512-2WR9bRpU2ONvaMTn2NINNYjsBUGeJJVuEDkgafacOwfz+TEKWJ0A9ibtfA9JfMer2GAFs/jt5TsLjzWhHY9zcg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"applesauce-core": "^4.1.0",
"applesauce-signers": "^4.1.0",
"applesauce-core": "^5.0.0",
"applesauce-signers": "^5.0.0",
"nanoid": "^5.1.5",
"nostr-tools": "~2.17",
"rxjs": "^7.8.1"
},
"funding": {
@@ -4992,14 +4953,30 @@
}
},
"node_modules/applesauce-actions": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/applesauce-actions/-/applesauce-actions-4.0.0.tgz",
"integrity": "sha512-oYAjrazKGDINeVwypNDnV9eNSv7ZDTjNeV3azo5jeUU1haEQ0t+zwVWzGxk9/VutT1yWQHFsCZBInYZIegfLhQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-actions/-/applesauce-actions-5.0.0.tgz",
"integrity": "sha512-Lw9x3P3+p9udmA9BvAssJDasDr+eIXq22SBwS3D6kt+3TOnBmJqONR3ru6K3j5S5MflYsiiy66b4TcATrBOXgQ==",
"license": "MIT",
"dependencies": {
"applesauce-core": "^4.0.0",
"applesauce-factory": "^4.0.0",
"nostr-tools": "~2.17",
"applesauce-common": "^5.0.0",
"applesauce-core": "^5.0.0",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-common": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-common/-/applesauce-common-5.0.0.tgz",
"integrity": "sha512-97ezzvy13yulozQDfjioW5MMnejPRr2ZrCqzJDbZm28elslS0x/s6rFh+NoHQFvStYuxgHOa3BIcv/E2qNhPqg==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.2.4",
"applesauce-core": "^5.0.0",
"hash-sum": "^2.0.0",
"light-bolt11-decoder": "^3.2.0",
"rxjs": "^7.8.1"
},
"funding": {
@@ -5008,18 +4985,18 @@
}
},
"node_modules/applesauce-content": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/applesauce-content/-/applesauce-content-4.0.0.tgz",
"integrity": "sha512-2ZrhM/UCQkcZcAldXJX+KfWAPAtkoTXH5BwPhYpaMw0UgHjWX8mYiy/801PtLBr2gWkKd/Dw1obdNDcPUO3idw==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-content/-/applesauce-content-5.0.0.tgz",
"integrity": "sha512-k8D+jl6XKUhAgnfDv0loeisCCsW8gGVFqLT4MQQKkQaR77vJM/zJ8O+ulq3lZraWOboTjCRzxVxpniButqW3ZA==",
"license": "MIT",
"dependencies": {
"@cashu/cashu-ts": "^2.7.2",
"@cashu/cashu-ts": "^3.1.1",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/unist": "^3.0.3",
"applesauce-core": "^4.0.0",
"applesauce-common": "^5.0.0",
"applesauce-core": "^5.0.0",
"mdast-util-find-and-replace": "^3.0.2",
"nostr-tools": "~2.17",
"remark": "^15.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
@@ -5031,19 +5008,16 @@
}
},
"node_modules/applesauce-core": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-4.4.2.tgz",
"integrity": "sha512-zuZB74Pp28UGM4e8DWbN1atR95xL7ODENvjkaGGnvAjIKvfdgMznU7m9gLxr/Hu+IHOmVbbd4YxwNmKBzCWhHQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.0.0.tgz",
"integrity": "sha512-l41CGztEakxAl8nk3KhaHrf7LLIe7+6tfDF1gC5mLnfSpV9raEsUUlmhE+t0AzQPmoEmyUPiWzc32KeP/u85YA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"@scure/base": "^1.2.4",
"debug": "^4.4.0",
"fast-deep-equal": "^3.1.3",
"hash-sum": "^2.0.0",
"light-bolt11-decoder": "^3.2.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.17",
"nostr-tools": "~2.19",
"rxjs": "^7.8.1"
},
"funding": {
@@ -5051,31 +5025,14 @@
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-factory": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/applesauce-factory/-/applesauce-factory-4.0.0.tgz",
"integrity": "sha512-Sqsg+bC7CkRXMxXLkO6YGoKxy/Aqtia9YenasS5qjPOQFmyFMwKRxaHCu6vX6KdpNSABusw0b9Tnn4gTh6CxLw==",
"license": "MIT",
"dependencies": {
"applesauce-content": "^4.0.0",
"applesauce-core": "^4.0.0",
"nanoid": "^5.0.9",
"nostr-tools": "^2.13"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-loaders": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/applesauce-loaders/-/applesauce-loaders-4.2.0.tgz",
"integrity": "sha512-FA5JH3qTcxylciN9SfWPF9DjNyCX6ZLj8iBQo6K+sNQfFBLVqcHjSXDT+whJyJ/T/Obk2yF3HxB2hqFzv8nKzA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-loaders/-/applesauce-loaders-5.0.0.tgz",
"integrity": "sha512-iu06vscZyaA+tA5cndMrKBsmYk1wLucwP5Gb0n0GmAAKeG58SPIIR8lEJhfKoVGlDoV65jiurVml7X5e9TNq0Q==",
"license": "MIT",
"dependencies": {
"applesauce-core": "^4.2.0",
"applesauce-core": "^5.0.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.17",
"rxjs": "^7.8.1"
},
"funding": {
@@ -5084,49 +5041,37 @@
}
},
"node_modules/applesauce-react": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/applesauce-react/-/applesauce-react-4.0.0.tgz",
"integrity": "sha512-eVDUf3GL1j4bsL1Y8GsC/2sywajLu1oJioCNajUsm68hf5+zIR0rLHWaA4y0o5Rcctf/O4UbYkFztj1XHcuHgg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/applesauce-react/-/applesauce-react-5.0.1.tgz",
"integrity": "sha512-MbcDpFQID+v/tF2dbLwWelmiFH8RbwjHJMDsZwm99kyieGo2CEJGkqzv2nP9xGyA5o8eJyrcbrZWsE/2QqOZVQ==",
"license": "MIT",
"dependencies": {
"applesauce-accounts": "^4.0.0",
"applesauce-actions": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "^4.0.0",
"applesauce-factory": "^4.0.0",
"applesauce-accounts": "^5.0.0",
"applesauce-actions": "^5.0.0",
"applesauce-content": "^5.0.0",
"applesauce-core": "^5.0.0",
"hash-sum": "^2.0.0",
"nostr-tools": "~2.17",
"observable-hooks": "^4.2.4",
"react": "^18.3.1",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-react/node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/applesauce-relay": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-4.4.2.tgz",
"integrity": "sha512-JAmUvIQ0jFrBWHU5SxAFx1xEG9D8xL7aiinSNX05qcULVMcFv1t9llLJivRLNERpbfS4AGYfy2tipYzc/YtyIQ==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-5.0.0.tgz",
"integrity": "sha512-XcL3ymwwENGabRPTddATuujXlP6IyDMnwV9vL/TaS0OL+WycykB5wGTG9u+FRSvndVzcoD0FFKVVqW4vL9y8hw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"applesauce-core": "^4.4.0",
"applesauce-core": "^5.0.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.17",
"nostr-tools": "~2.19",
"rxjs": "^7.8.1"
},
"funding": {
@@ -5135,18 +5080,15 @@
}
},
"node_modules/applesauce-signers": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/applesauce-signers/-/applesauce-signers-4.2.0.tgz",
"integrity": "sha512-celexNd+aLt6/vhf72XXw2oAk8ohjna+aWEg/Z2liqPwP+kbVjnqq4Z1RXvt79QQbTIQbXYGWqervXWLE8HmHg==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/applesauce-signers/-/applesauce-signers-5.0.0.tgz",
"integrity": "sha512-wybzjnK584iTH5SeAgQHVk/CwYgFFahV1T/3oU3wR6v6TecFnDe7cVhBToYMk2F8ANQVcrDYEebyeq844+al7w==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"@noble/secp256k1": "^1.7.1",
"@scure/base": "^1.2.4",
"applesauce-core": "^4.2.0",
"applesauce-core": "^5.0.0",
"debug": "^4.4.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.17",
"rxjs": "^7.8.2"
},
"funding": {
@@ -8481,9 +8423,9 @@
}
},
"node_modules/nostr-tools": {
"version": "2.17.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.17.4.tgz",
"integrity": "sha512-LGqpKufnmR93tOjFi4JZv1BTTVIAVfZAaAa+1gMqVfI0wNz2DnCB6UDXmjVTRrjQHMw2ykbk0EZLPzV5UeCIJw==",
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
@@ -8503,6 +8445,30 @@
}
}
},
"node_modules/nostr-tools/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
@@ -8527,6 +8493,32 @@
],
"license": "MIT"
},
"node_modules/nostr-tools/node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",

View File

@@ -32,14 +32,15 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/qrcode": "^1.5.6",
"applesauce-accounts": "^4.1.0",
"applesauce-actions": "^4.0.0",
"applesauce-content": "^4.0.0",
"applesauce-core": "latest",
"applesauce-loaders": "^4.2.0",
"applesauce-react": "^4.0.0",
"applesauce-relay": "latest",
"applesauce-signers": "^4.1.0",
"applesauce-accounts": "^5.0.0",
"applesauce-actions": "^5.0.0",
"applesauce-common": "^5.0.0",
"applesauce-content": "^5.0.0",
"applesauce-core": "^5.0.0",
"applesauce-loaders": "^5.0.0",
"applesauce-react": "^5.0.0",
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};

View File

@@ -1,6 +1,6 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { EventFactory } from "applesauce-factory";
import { EventFactory } from "applesauce-core/event-factory";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";

View File

@@ -31,6 +31,12 @@ vi.mock("@/services/relay-list-cache", () => ({
},
}));
vi.mock("@/services/event-store", () => ({
default: {
add: vi.fn(),
},
}));
describe("PublishSpellAction", () => {
let action: PublishSpellAction;

View File

@@ -3,7 +3,7 @@ import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { encodeSpell } from "@/lib/spell-conversion";
import { markSpellPublished } from "@/services/spell-storage";
import { EventFactory } from "applesauce-factory";
import { EventFactory } from "applesauce-core/event-factory";
import { SpellEvent } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";

View File

@@ -35,10 +35,34 @@ const mockFactory = {
})),
};
// Track published events
const publishedEvents: NostrEvent[] = [];
const mockSign = vi.fn(async (draft: any) => ({
...draft,
sig: "test-signature",
}));
// v5: publish accepts (event | events, relays?)
const mockPublish = vi.fn(
async (event: NostrEvent | NostrEvent[], _relays?: string[]) => {
if (Array.isArray(event)) {
publishedEvents.push(...event);
} else {
publishedEvents.push(event);
}
},
);
const mockContext: ActionContext = {
factory: mockFactory as any,
events: {} as any,
self: "test-pubkey",
user: {} as any,
signer: mockSigner as any,
sign: mockSign,
publish: mockPublish,
run: vi.fn(),
};
const mockState: GrimoireState = {
@@ -64,16 +88,17 @@ const mockState: GrimoireState = {
workspaceOrder: ["ws-1"],
} as any;
// Helper to run action with context
// Helper to run action with context (v5 - async function, not generator)
async function runAction(
options: Parameters<typeof PublishSpellbook>[0],
): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
// Clear published events before each run
publishedEvents.length = 0;
const action = PublishSpellbook(options);
for await (const event of action(mockContext)) {
events.push(event);
}
return events;
await action(mockContext);
return [...publishedEvents];
}
describe("PublishSpellbook action", () => {
@@ -267,13 +292,13 @@ describe("PublishSpellbook action", () => {
);
});
it("should call factory.sign with draft", async () => {
it("should call sign with draft", async () => {
await runAction({
state: mockState,
title: "Test",
});
expect(mockFactory.sign).toHaveBeenCalledWith(
expect(mockSign).toHaveBeenCalledWith(
expect.objectContaining({
kind: 30777,
}),

View File

@@ -4,7 +4,6 @@ import { GrimoireState } from "@/types/app";
import { SpellbookContent } from "@/types/spell";
import accountManager from "@/services/accounts";
import type { ActionContext } from "applesauce-actions";
import type { NostrEvent } from "nostr-tools/core";
export interface PublishSpellbookOptions {
state: GrimoireState;
@@ -20,24 +19,23 @@ export interface PublishSpellbookOptions {
* This action:
* 1. Validates inputs (title, account, signer)
* 2. Creates spellbook event from state or explicit content
* 3. Signs the event using the action hub's factory
* 4. Yields the signed event (ActionHub handles publishing)
* 3. Signs the event using the action runner's factory
* 4. Publishes the signed event via ActionRunner
*
* NOTE: This action does NOT mark the local spellbook as published.
* The caller should use hub.exec() and call markSpellbookPublished()
* AFTER successful publish to ensure data consistency.
*
* @param options - Spellbook publishing options
* @returns Action generator for ActionHub
* @returns Action for ActionRunner
*
* @throws Error if title is empty, no active account, or no signer available
*
* @example
* ```typescript
* // Publish via ActionHub with proper side-effect handling
* // Publish via ActionRunner with proper side-effect handling
* const event = await lastValueFrom(hub.exec(PublishSpellbook, options));
* if (event) {
* await publishEvent(event);
* // Only mark as published AFTER successful relay publish
* await markSpellbookPublished(localId, event as SpellbookEvent);
* }
@@ -46,9 +44,11 @@ export interface PublishSpellbookOptions {
export function PublishSpellbook(options: PublishSpellbookOptions) {
const { state, title, description, workspaceIds, content } = options;
return async function* ({
return async function ({
factory,
}: ActionContext): AsyncGenerator<NostrEvent> {
sign,
publish,
}: ActionContext): Promise<void> {
// 1. Validate inputs
if (!title || !title.trim()) {
throw new Error("Title is required");
@@ -101,12 +101,12 @@ export function PublishSpellbook(options: PublishSpellbookOptions) {
tags: eventProps.tags,
});
// 4. Sign the event
const event = (await factory.sign(draft)) as SpellbookEvent;
// 4. Sign and publish the event
const event = (await sign(draft)) as SpellbookEvent;
// 5. Yield signed event - ActionHub handles relay selection and publishing
// 5. Publish event - ActionRunner handles relay selection
// NOTE: Caller is responsible for marking local spellbook as published
// after successful publish using markSpellbookPublished()
yield event;
await publish(event);
};
}

View File

@@ -25,7 +25,7 @@ import { UserName } from "./nostr/UserName";
import { getTagValues } from "@/lib/nostr-utils";
import { getLiveHost } from "@/lib/live-activity";
import type { NostrEvent } from "@/types/nostr";
import { getZapSender } from "applesauce-core/helpers/zap";
import { getZapSender } from "applesauce-common/helpers/zap";
export interface WindowTitleData {
title: string | ReactElement;

View File

@@ -11,7 +11,7 @@ import {
Wifi,
} from "lucide-react";
import { kinds, nip19 } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { useEventStore, use$ } from "applesauce-react/hooks";
import { getInboxes, getOutboxes } from "applesauce-core/helpers/mailboxes";
import { useCopy } from "../hooks/useCopy";
import { RichText } from "./nostr/RichText";
@@ -100,7 +100,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
}, [resolvedPubkey, eventStore]);
// Get mailbox relays (kind 10002) - will update when fresh data arrives
const mailboxEvent = useObservableMemo(
const mailboxEvent = use$(
() =>
resolvedPubkey
? eventStore.replaceable(kinds.RelayList, resolvedPubkey, "")
@@ -113,7 +113,7 @@ export function ProfileViewer({ pubkey }: ProfileViewerProps) {
mailboxEvent && mailboxEvent.tags ? getOutboxes(mailboxEvent) : [];
// Get profile metadata event (kind 0)
const profileEvent = useObservableMemo(
const profileEvent = use$(
() =>
resolvedPubkey
? eventStore.replaceable(0, resolvedPubkey, "")

View File

@@ -6,12 +6,9 @@ import { UserName } from "./UserName";
import { RichText } from "./RichText";
import { Zap, CornerDownRight, Quote } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getZapAmount,
getZapSender,
getTagValue,
} from "applesauce-core/helpers";
import { getNip10References } from "applesauce-core/helpers/threading";
import { getTagValue } from "applesauce-core/helpers";
import { getZapAmount, getZapSender } from "applesauce-common/helpers/zap";
import { getNip10References } from "applesauce-common/helpers/threading";
import { useNostrEvent } from "@/hooks/useNostrEvent";
interface ChatViewProps {

View File

@@ -3,7 +3,8 @@ import type { NostrEvent } from "@/types/nostr";
import { kinds } from "nostr-tools";
import { useGrimoire } from "@/core/state";
import { formatTimestamp } from "@/hooks/useLocale";
import { getTagValue, getZapSender } from "applesauce-core/helpers";
import { getTagValue } from "applesauce-core/helpers";
import { getZapSender } from "applesauce-common/helpers/zap";
import { KindBadge } from "@/components/KindBadge";
import { UserName } from "./UserName";
import { compactRenderers, DefaultCompactPreview } from "./compact";

View File

@@ -11,7 +11,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useObservableMemo } from "applesauce-react/hooks";
import { use$ } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { parseReqCommand } from "@/lib/req-parser";
import { reconstructCommand } from "@/lib/spell-conversion";
@@ -75,7 +75,7 @@ export function SpellDialog({
existingSpell,
onSuccess,
}: SpellDialogProps) {
const activeAccount = useObservableMemo(() => accounts.active$, []);
const activeAccount = use$(accounts.active$);
// Form state
const [alias, setAlias] = useState("");

View File

@@ -6,7 +6,7 @@ import {
getZapEventPointer,
getZapAddressPointer,
getZapRequest,
} from "applesauce-core/helpers/zap";
} from "applesauce-common/helpers/zap";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { RichText } from "../RichText";

View File

@@ -4,7 +4,7 @@ import {
getArticleSummary,
getArticlePublished,
getArticleImage,
} from "applesauce-core/helpers/article";
} from "applesauce-common/helpers/article";
import { UserName } from "../UserName";
import { MediaEmbed } from "../MediaEmbed";
import { MarkdownContent } from "../MarkdownContent";

View File

@@ -6,7 +6,7 @@ import {
import {
getArticleTitle,
getArticleSummary,
} from "applesauce-core/helpers/article";
} from "applesauce-common/helpers/article";
/**
* Renderer for Kind 30023 - Long-form Article

View File

@@ -3,7 +3,7 @@ import {
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { getArticleTitle } from "applesauce-core/helpers";
import { getArticleTitle } from "applesauce-common/helpers/article";
/**
* Renderer for Kind 30817 - Community NIP

View File

@@ -1,5 +1,5 @@
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { getRelaysFromList } from "applesauce-core/helpers/lists";
import { getRelaysFromList } from "applesauce-common/helpers/lists";
import { RelayLink } from "../RelayLink";
/**

View File

@@ -7,7 +7,7 @@ import {
getHighlightSourceUrl,
getHighlightComment,
getHighlightContext,
} from "applesauce-core/helpers/highlight";
} from "applesauce-common/helpers/highlight";
import { EmbeddedEvent } from "../EmbeddedEvent";
import { UserName } from "../UserName";
import { useGrimoire } from "@/core/state";

View File

@@ -6,12 +6,12 @@ import {
getHighlightComment,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
} from "applesauce-core/helpers/highlight";
} from "applesauce-common/helpers/highlight";
import { UserName } from "../UserName";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useGrimoire } from "@/core/state";
import { RichText } from "../RichText";
import { getArticleTitle } from "applesauce-core/helpers";
import { getArticleTitle } from "applesauce-common/helpers/article";
import { KindBadge } from "@/components/KindBadge";
/**

View File

@@ -5,7 +5,7 @@ import {
isCommentAddressPointer,
isCommentEventPointer,
type CommentPointer,
} from "applesauce-core/helpers/comment";
} from "applesauce-common/helpers/comment";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { Reply } from "lucide-react";

View File

@@ -1,7 +1,7 @@
import { RichText } from "../RichText";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
import { kinds } from "nostr-tools";
import { getNip10References } from "applesauce-core/helpers/threading";
import { getNip10References } from "applesauce-common/helpers/threading";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { UserName } from "../UserName";
import { Reply } from "lucide-react";

View File

@@ -5,7 +5,7 @@ import { useMemo } from "react";
import { NostrEvent } from "@/types/nostr";
import { KindRenderer } from "./index";
import { EventCardSkeleton } from "@/components/ui/skeleton";
import { parseCoordinate } from "applesauce-core/helpers/pointers";
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
/**
* Renderer for Kind 7 - Reactions
@@ -55,9 +55,9 @@ export function Kind7Renderer({ event }: BaseEventProps) {
const aTag = event.tags.find((tag) => tag[0] === "a");
const reactedAddress = aTag?.[1]; // Format: kind:pubkey:d-tag
// Parse a tag coordinate using applesauce helper
// Parse a tag coordinate using applesauce helper (renamed in v5)
const addressPointer = reactedAddress
? parseCoordinate(reactedAddress)
? parseReplaceableAddress(reactedAddress)
: null;
// Create event pointer for fetching

View File

@@ -9,7 +9,7 @@ import {
getZapAddressPointer,
getZapSender,
isValidZap,
} from "applesauce-core/helpers/zap";
} from "applesauce-common/helpers/zap";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./index";
import { RichText } from "../RichText";

View File

@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils";
import pool from "@/services/relay-pool";
import { useObservableMemo } from "applesauce-react/hooks";
import { use$ } from "applesauce-react/hooks";
import { Relay } from "applesauce-relay";
import { Server, ServerOff } from "lucide-react";
@@ -19,7 +19,7 @@ function RelayItem({ relay }: { relay: Relay }) {
}
export default function RelayPool() {
const relays = useObservableMemo(() => pool.relays$, []);
const relays = use$(pool.relays$);
return (
<div className="flex flex-col gap-1">
{Array.from(relays.entries()).map(([url, relay]) => (

View File

@@ -3,7 +3,7 @@ import accounts from "@/services/accounts";
import { ExtensionSigner } from "applesauce-signers";
import { ExtensionAccount } from "applesauce-accounts/accounts";
import { useProfile } from "@/hooks/useProfile";
import { useObservableMemo } from "applesauce-react/hooks";
import { use$ } from "applesauce-react/hooks";
import { getDisplayName } from "@/lib/nostr-utils";
import { useGrimoire } from "@/core/state";
import { Button } from "@/components/ui/button";
@@ -52,7 +52,7 @@ function UserLabel({ pubkey }: { pubkey: string }) {
}
export default function UserMenu() {
const account = useObservableMemo(() => accounts.active$, []);
const account = use$(accounts.active$);
const { state, addWindow } = useGrimoire();
const relays = state.activeAccount?.relays;
const [showSettings, setShowSettings] = useState(false);

View File

@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { useEventStore, use$ } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { useGrimoire } from "@/core/state";
import { addressLoader } from "@/services/loaders";
@@ -14,7 +14,7 @@ export function useAccountSync() {
const eventStore = useEventStore();
// Watch active account from accounts service
const activeAccount = useObservableMemo(() => accounts.active$, []);
const activeAccount = use$(accounts.active$);
// Sync active account pubkey to state
useEffect(() => {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { useEventStore, use$ } from "applesauce-react/hooks";
import { isNostrEvent } from "@/lib/type-guards";
import { useStableValue, useStableArray } from "./useStable";
@@ -72,8 +72,7 @@ export function useLiveTimeline(
}));
const observable = pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
reconnect: 5, // v5: retries renamed to reconnect
resubscribe: true,
eventStore, // Automatically add events to store
});
@@ -112,7 +111,7 @@ export function useLiveTimeline(
}, [id, stableFilters, stableRelays, limit, stream, eventStore]);
// 2. Observable Effect - Read from EventStore
const timelineEvents = useObservableMemo(() => {
const timelineEvents = use$(() => {
// eventStore.timeline returns an Observable that emits sorted array of events matching filter
// It updates whenever relevant events are added/removed from store
return eventStore.timeline(filters);

View File

@@ -1,6 +1,6 @@
import { useEffect } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { useEventStore, use$ } from "applesauce-react/hooks";
import { eventLoader, addressLoader } from "@/services/loaders";
import type { NostrEvent } from "@/types/nostr";
@@ -43,7 +43,7 @@ export function useNostrEvent(
const eventStore = useEventStore();
// Watch event store for the specific event
const event = useObservableMemo(() => {
const event = use$(() => {
if (!pointer) return undefined;
// Handle string ID

View File

@@ -75,8 +75,7 @@ export function useReqTimeline(
}));
const observable = pool.subscription(relays, filtersWithLimit, {
retries: 5,
reconnect: 5,
reconnect: 5, // v5: retries renamed to reconnect
resubscribe: true,
eventStore,
});

View File

@@ -193,8 +193,7 @@ export function useReqTimelineEnhanced(
return relay
.subscription(filtersWithLimit, {
retries: 5,
reconnect: 5,
reconnect: 5, // v5: retries renamed to reconnect
resubscribe: true,
})
.subscribe(

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { useEventStore, use$ } from "applesauce-react/hooks";
import { createTimelineLoader } from "@/services/loaders";
import pool from "@/services/relay-pool";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
@@ -72,7 +72,7 @@ export function useTimeline(
}, [id, stableRelays, limit, eventStore, stableFilters]);
// Watch store for matching events
const timeline = useObservableMemo(() => {
const timeline = use$(() => {
return eventStore.timeline(filters, false);
}, [id]);

View File

@@ -1,7 +1,7 @@
import { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { kinds } from "nostr-tools";
import { getArticleTitle } from "applesauce-core/helpers/article";
import { getArticleTitle } from "applesauce-common/helpers/article";
import {
getRepositoryName,
getIssueTitle,

View File

@@ -1,8 +1,8 @@
import type { ProfileContent } from "applesauce-core/helpers";
import type { NostrEvent } from "nostr-tools";
import type { NostrFilter } from "@/types/nostr";
import { getNip10References } from "applesauce-core/helpers/threading";
import { getCommentReplyPointer } from "applesauce-core/helpers/comment";
import { getNip10References } from "applesauce-common/helpers/threading";
import { getCommentReplyPointer } from "applesauce-common/helpers/comment";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
export function derivePlaceholderName(pubkey: string): string {

View File

@@ -1,6 +1,6 @@
import { ActionHub } from "applesauce-actions";
import { ActionRunner } from "applesauce-actions";
import eventStore from "./event-store";
import { EventFactory } from "applesauce-factory";
import { EventFactory } from "applesauce-core/event-factory";
import pool from "./relay-pool";
import { relayListCache } from "./relay-list-cache";
import { getSeenRelays } from "applesauce-core/helpers/relays";
@@ -40,7 +40,7 @@ export async function publishEvent(event: NostrEvent): Promise<void> {
const factory = new EventFactory();
/**
* Global action hub for Grimoire
* Global action runner for Grimoire
* Used to register and execute actions throughout the application
*
* Configured with:
@@ -48,7 +48,7 @@ const factory = new EventFactory();
* - EventFactory: Creates and signs events
* - publishEvent: Publishes events to author's outbox relays (with fallback to seen relays)
*/
export const hub = new ActionHub(eventStore, factory, publishEvent);
export const hub = new ActionRunner(eventStore, factory, publishEvent);
// Sync factory signer with active account
// This ensures the hub can sign events when an account is active

View File

@@ -34,6 +34,7 @@ vi.mock("applesauce-loaders/loaders", () => ({
),
createAddressLoader: vi.fn(() => () => ({ subscribe: () => {} })),
createTimelineLoader: vi.fn(),
createEventLoaderForStore: vi.fn(),
}));
import eventStore from "./event-store";
@@ -172,10 +173,18 @@ describe("eventLoader", () => {
});
it("should extract relay hints from e tags", () => {
// Use valid 64-char hex event IDs (v5 validates event ID format)
const validEventId1 =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const validEventId2 =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const validEventId3 =
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
const event = createEventWithTags([
["e", "event-id-1", "wss://e-tag1.com/", "reply"],
["e", "event-id-2", "wss://e-tag2.com/", "root"],
["e", "event-id-3"], // No relay hint, should be skipped
["e", validEventId1, "wss://e-tag1.com/", "reply"],
["e", validEventId2, "wss://e-tag2.com/", "root"],
["e", validEventId3], // No relay hint, should be skipped
]);
const result = eventLoader({ id: "parent123" }, event);
@@ -209,11 +218,15 @@ describe("eventLoader", () => {
"wss://cached.com/",
]);
// Use valid 64-char hex event ID (v5 validates event ID format)
const validEventId =
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
const event = createMockEvent({
tags: [
["p", "author-pubkey"],
["r", "wss://r-tag.com/"],
["e", "event-id", "wss://e-tag.com/"],
["e", validEventId, "wss://e-tag.com/"],
],
});
@@ -321,9 +334,13 @@ describe("eventLoader", () => {
});
it("should handle invalid e tags gracefully", () => {
// Use valid 64-char hex event ID (v5 validates event ID format)
const validEventId =
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
const event = createEventWithTags([
["e"], // Missing event ID
["e", "valid-id", "wss://valid.com/"],
["e"], // Missing event ID - invalid, should be skipped
["e", validEventId, "wss://valid.com/"],
]);
const result = eventLoader({ id: "test123" }, event);

View File

@@ -2,12 +2,13 @@ import {
createEventLoader,
createAddressLoader,
createTimelineLoader,
createEventLoaderForStore,
} from "applesauce-loaders/loaders";
import type { EventPointer } from "nostr-tools/nip19";
import { Observable } from "rxjs";
import { getSeenRelays, mergeRelaySets } from "applesauce-core/helpers/relays";
import { getEventPointerFromETag } from "applesauce-core/helpers/pointers";
import { getTagValue } from "applesauce-core/helpers/event-tags";
import { getTagValue } from "applesauce-core/helpers/event";
import pool from "./relay-pool";
import eventStore from "./event-store";
import { relayListCache } from "./relay-list-cache";
@@ -36,12 +37,9 @@ function extractRelayContext(event: NostrEvent): {
const eTagRelays = event.tags
.filter((t) => t[0] === "e")
.map((tag) => {
try {
const pointer = getEventPointerFromETag(tag);
return pointer.relays?.[0]; // First relay hint from the pointer
} catch {
return undefined; // Invalid e tag, skip it
}
const pointer = getEventPointerFromETag(tag);
// v5: returns null for invalid tags instead of throwing
return pointer?.relays?.[0]; // First relay hint from the pointer
})
.filter((relay): relay is string => relay !== undefined);
@@ -183,3 +181,26 @@ export const profileLoader = createAddressLoader(pool, {
// Timeline loader factory - creates loader for event feeds
export { createTimelineLoader };
/**
* Setup unified event loader for automatic missing event loading
*
* This attaches a loader to the EventStore that automatically fetches
* missing events when they're requested via:
* - eventStore.event({ id: "..." })
* - eventStore.replaceable({ kind, pubkey, identifier? })
*
* The loader handles both single events and replaceable/addressable events
* through a single interface, with automatic routing based on pointer type.
*
* Configuration:
* - bufferTime: 200ms - batches requests for efficiency
* - extraRelays: AGGREGATOR_RELAYS - fallback relay discovery
*
* Note: The custom eventLoader() function above is still available for
* explicit loading with smart relay hint merging from context events.
*/
createEventLoaderForStore(eventStore, pool, {
bufferTime: 200,
extraRelays: AGGREGATOR_RELAYS,
});

View File

@@ -6,8 +6,34 @@ import { describe, it, expect, beforeEach } from "vitest";
import { selectRelaysForFilter } from "./relay-selection";
import { EventStore } from "applesauce-core";
import type { NostrEvent } from "nostr-tools";
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
import relayListCache from "./relay-list-cache";
// Helper to create valid test events
function createRelayListEvent(
secretKey: Uint8Array,
tags: string[][],
): NostrEvent {
return finalizeEvent(
{
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
},
secretKey,
);
}
// Generate valid test keys using generateSecretKey
const testSecretKeys: Uint8Array[] = [];
const testPubkeys: string[] = [];
for (let i = 0; i < 15; i++) {
const secretKey = generateSecretKey();
testSecretKeys.push(secretKey);
testPubkeys.push(getPublicKey(secretKey));
}
describe("selectRelaysForFilter", () => {
let eventStore: EventStore;
@@ -44,23 +70,14 @@ describe("selectRelaysForFilter", () => {
describe("author relay selection", () => {
it("should select write relays for authors", async () => {
const authorPubkey =
"32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
const authorPubkey = testPubkeys[0];
// Mock kind:10002 event with write relays
const relayListEvent: NostrEvent = {
id: "test-event-id",
pubkey: authorPubkey,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [
["r", "wss://relay.damus.io"],
["r", "wss://nos.lol"],
["r", "wss://relay.nostr.band", "read"],
],
content: "",
sig: "test-sig",
};
// Create valid kind:10002 event with write relays
const relayListEvent = createRelayListEvent(testSecretKeys[0], [
["r", "wss://relay.damus.io"],
["r", "wss://nos.lol"],
["r", "wss://relay.nostr.band", "read"],
]);
// Add to event store
eventStore.add(relayListEvent);
@@ -86,31 +103,19 @@ describe("selectRelaysForFilter", () => {
});
it("should handle multiple authors", async () => {
const author1 =
"32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
const author2 =
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const author1 = testPubkeys[0];
const author2 = testPubkeys[1];
// Mock relay lists for both authors
eventStore.add({
id: "event1",
pubkey: author1,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://relay.damus.io"]],
content: "",
sig: "sig1",
});
// Create valid relay lists for both authors
eventStore.add(
createRelayListEvent(testSecretKeys[0], [
["r", "wss://relay.damus.io"],
]),
);
eventStore.add({
id: "event2",
pubkey: author2,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://nos.lol"]],
content: "",
sig: "sig2",
});
eventStore.add(
createRelayListEvent(testSecretKeys[1], [["r", "wss://nos.lol"]]),
);
const result = await selectRelaysForFilter(
eventStore,
@@ -130,23 +135,14 @@ describe("selectRelaysForFilter", () => {
describe("p-tag relay selection", () => {
it("should select read relays for #p tags", async () => {
const mentionedPubkey =
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const mentionedPubkey = testPubkeys[2];
// Mock kind:10002 event with read relays
const relayListEvent: NostrEvent = {
id: "test-event-id",
pubkey: mentionedPubkey,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [
["r", "wss://relay.damus.io", "write"],
["r", "wss://nos.lol", "read"],
["r", "wss://relay.nostr.band", "read"],
],
content: "",
sig: "test-sig",
};
// Create valid kind:10002 event with read relays
const relayListEvent = createRelayListEvent(testSecretKeys[2], [
["r", "wss://relay.damus.io", "write"],
["r", "wss://nos.lol", "read"],
["r", "wss://relay.nostr.band", "read"],
]);
eventStore.add(relayListEvent);
@@ -173,32 +169,22 @@ describe("selectRelaysForFilter", () => {
describe("mixed authors and #p tags", () => {
it("should combine outbox and inbox relays", async () => {
const author =
"32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
const mentioned =
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const author = testPubkeys[3];
const mentioned = testPubkeys[4];
// Author has write relays
eventStore.add({
id: "event1",
pubkey: author,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://author-relay.com"]],
content: "",
sig: "sig1",
});
eventStore.add(
createRelayListEvent(testSecretKeys[3], [
["r", "wss://author-relay.com"],
]),
);
// Mentioned user has read relays
eventStore.add({
id: "event2",
pubkey: mentioned,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://mention-relay.com", "read"]],
content: "",
sig: "sig2",
});
eventStore.add(
createRelayListEvent(testSecretKeys[4], [
["r", "wss://mention-relay.com", "read"],
]),
);
const result = await selectRelaysForFilter(
eventStore,
@@ -229,56 +215,36 @@ describe("selectRelaysForFilter", () => {
});
it("should maintain diversity with multiple authors and p-tags", async () => {
const author1 =
"32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
const author2 =
"42e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
const mentioned1 =
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const mentioned2 =
"92341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2";
const author1 = testPubkeys[5];
const author2 = testPubkeys[6];
const mentioned1 = testPubkeys[7];
const mentioned2 = testPubkeys[8];
// Authors have write relays
eventStore.add({
id: "event1",
pubkey: author1,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://author1-relay.com"]],
content: "",
sig: "sig1",
});
eventStore.add(
createRelayListEvent(testSecretKeys[5], [
["r", "wss://author1-relay.com"],
]),
);
eventStore.add({
id: "event2",
pubkey: author2,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://author2-relay.com"]],
content: "",
sig: "sig2",
});
eventStore.add(
createRelayListEvent(testSecretKeys[6], [
["r", "wss://author2-relay.com"],
]),
);
// Mentioned users have read relays
eventStore.add({
id: "event3",
pubkey: mentioned1,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://mention1-relay.com", "read"]],
content: "",
sig: "sig3",
});
eventStore.add(
createRelayListEvent(testSecretKeys[7], [
["r", "wss://mention1-relay.com", "read"],
]),
);
eventStore.add({
id: "event4",
pubkey: mentioned2,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", "wss://mention2-relay.com", "read"]],
content: "",
sig: "sig4",
});
eventStore.add(
createRelayListEvent(testSecretKeys[8], [
["r", "wss://mention2-relay.com", "read"],
]),
);
const result = await selectRelaysForFilter(
eventStore,
@@ -314,22 +280,15 @@ describe("selectRelaysForFilter", () => {
describe("relay limits", () => {
it("should respect maxRelays limit", async () => {
// Create many authors with different relays
// Create many authors with different relays (use first 10 test keys)
const authors = Array.from({ length: 10 }, (_, i) => ({
pubkey: `pubkey${i}`.padEnd(64, "0"),
secretKey: testSecretKeys[i],
pubkey: testPubkeys[i],
relay: `wss://relay${i}.com`,
}));
authors.forEach(({ pubkey, relay }) => {
eventStore.add({
id: `event-${pubkey}`,
pubkey,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [["r", relay]],
content: "",
sig: "sig",
});
authors.forEach(({ secretKey, relay }) => {
eventStore.add(createRelayListEvent(secretKey, [["r", relay]]));
});
const result = await selectRelaysForFilter(
@@ -347,8 +306,8 @@ describe("selectRelaysForFilter", () => {
describe("edge cases", () => {
it("should handle users with no relay lists", async () => {
const pubkeyWithoutList =
"32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
// Use a pubkey that doesn't have a relay list added
const pubkeyWithoutList = testPubkeys[14];
const result = await selectRelaysForFilter(
eventStore,
@@ -363,22 +322,15 @@ describe("selectRelaysForFilter", () => {
});
it("should handle invalid relay URLs gracefully", async () => {
const pubkey =
"32e18273f41e70f79a220d7fb69b36269d74d67f569b8c4b7fc17e5b1d1a1e3e";
const pubkey = testPubkeys[10];
// Add relay list with invalid URL
eventStore.add({
id: "event",
pubkey,
kind: 10002,
created_at: Math.floor(Date.now() / 1000),
tags: [
eventStore.add(
createRelayListEvent(testSecretKeys[10], [
["r", "not-a-valid-url"],
["r", "wss://valid-relay.com"],
],
content: "",
sig: "sig",
});
]),
);
const result = await selectRelaysForFilter(
eventStore,

View File

@@ -1,83 +1,79 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
fontFamily: {
mono: ['"Oxygen Mono"', 'monospace'],
mono: ['"Oxygen Mono"', "monospace"],
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' }
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' }
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"skeleton-pulse": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
'skeleton-pulse': {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'skeleton-pulse': 'skeleton-pulse 1.5s ease-in-out infinite'
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"skeleton-pulse": "skeleton-pulse 1.5s ease-in-out infinite",
},
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
}
}
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
},
},
},
plugins: [],
}
};

View File

@@ -1,5 +1,3 @@
{
"rewrites": [
{ "source": "/(.*)", "destination": "/" }
]
"rewrites": [{ "source": "/(.*)", "destination": "/" }]
}