Merge pull request #17 from purrgrammer/claude/applesauce-helpers-investigation-EWmaW

docs: add applesauce helpers investigation and refactoring plan
This commit is contained in:
Alejandro
2025-12-22 15:19:41 +01:00
committed by GitHub
10 changed files with 761 additions and 65 deletions

View File

@@ -329,6 +329,284 @@ zaps$.subscribe(zaps => {
});
```
## Helper Functions & Caching
### Applesauce Helper System
applesauce-core provides **60+ helper functions** for extracting data from Nostr events. **Critical**: These helpers cache their results internally using symbols, so **you don't need `useMemo` when calling them**.
```javascript
// ❌ WRONG - Unnecessary memoization
const title = useMemo(() => getArticleTitle(event), [event]);
const text = useMemo(() => getHighlightText(event), [event]);
// ✅ CORRECT - Helpers cache internally
const title = getArticleTitle(event);
const text = getHighlightText(event);
```
### How Helper Caching Works
```javascript
import { getOrComputeCachedValue } from 'applesauce-core/helpers';
// Helpers use symbol-based caching
const symbol = Symbol('ArticleTitle');
export function getArticleTitle(event) {
return getOrComputeCachedValue(event, symbol, () => {
// This expensive computation only runs once
return getTagValue(event, 'title') || 'Untitled';
});
}
// First call - computes and caches
const title1 = getArticleTitle(event); // Computation happens
// Second call - returns cached value
const title2 = getArticleTitle(event); // Instant, from cache
// Same reference
console.log(title1 === title2); // true
```
### Tag Helpers
```javascript
import { getTagValue, hasNameValueTag } from 'applesauce-core/helpers';
// Get single tag value (searches hidden tags first)
const dTag = getTagValue(event, 'd');
const title = getTagValue(event, 'title');
const url = getTagValue(event, 'r');
// Check if tag exists with value
const hasTag = hasNameValueTag(event, 'client', 'grimoire');
```
**Note**: applesauce only provides `getTagValue` (singular). For multiple values, implement your own:
```javascript
function getTagValues(event, tagName) {
return event.tags
.filter(tag => tag[0] === tagName && tag[1])
.map(tag => tag[1]);
}
```
### Article Helpers (NIP-23)
```javascript
import {
getArticleTitle,
getArticleSummary,
getArticleImage,
getArticlePublished,
isValidArticle
} from 'applesauce-core/helpers';
// All cached automatically
const title = getArticleTitle(event);
const summary = getArticleSummary(event);
const image = getArticleImage(event);
const publishedAt = getArticlePublished(event);
// Validation
if (isValidArticle(event)) {
console.log('Valid article event');
}
```
### Highlight Helpers (NIP-84)
```javascript
import {
getHighlightText,
getHighlightSourceUrl,
getHighlightSourceEventPointer,
getHighlightSourceAddressPointer,
getHighlightContext,
getHighlightComment,
getHighlightAttributions
} from 'applesauce-core/helpers';
// All cached - no useMemo needed
const text = getHighlightText(event);
const url = getHighlightSourceUrl(event);
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
const context = getHighlightContext(event);
const comment = getHighlightComment(event);
const attributions = getHighlightAttributions(event);
```
### Profile Helpers
```javascript
import {
getProfileContent,
getDisplayName,
getProfilePicture,
isValidProfile
} from 'applesauce-core/helpers';
// Parse profile JSON (cached)
const profile = getProfileContent(profileEvent);
// Get display name with fallback
const name = getDisplayName(profile, 'Anonymous');
// Get profile picture with fallback
const avatar = getProfilePicture(profile, '/default-avatar.png');
// Validation
if (isValidProfile(event)) {
const profile = getProfileContent(event);
}
```
### Pointer Helpers (NIP-19)
```javascript
import {
parseCoordinate,
getEventPointerFromETag,
getEventPointerFromQTag,
getAddressPointerFromATag,
getProfilePointerFromPTag,
getEventPointerForEvent,
getAddressPointerForEvent
} from 'applesauce-core/helpers';
// Parse "a" tag coordinate (30023:pubkey:identifier)
const aTag = event.tags.find(t => t[0] === 'a')?.[1];
const pointer = parseCoordinate(aTag);
console.log(pointer.kind, pointer.pubkey, pointer.identifier);
// Extract pointers from tags
const eTag = event.tags.find(t => t[0] === 'e');
const eventPointer = getEventPointerFromETag(eTag);
const qTag = event.tags.find(t => t[0] === 'q');
const quotePointer = getEventPointerFromQTag(qTag);
// Create pointer from event
const pointer = getEventPointerForEvent(event, ['wss://relay.example.com']);
const address = getAddressPointerForEvent(replaceableEvent);
```
### Reaction Helpers
```javascript
import {
getReactionEventPointer,
getReactionAddressPointer
} from 'applesauce-core/helpers';
// Get what the reaction is reacting to
const eventPointer = getReactionEventPointer(reactionEvent);
const addressPointer = getReactionAddressPointer(reactionEvent);
if (eventPointer) {
console.log('Reacted to event:', eventPointer.id);
}
```
### Threading Helpers (NIP-10)
```javascript
import { getNip10References } from 'applesauce-core/helpers';
// Parse NIP-10 thread structure (cached)
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 event:', refs.reply.e);
console.log('Reply to address:', refs.reply.a);
}
```
### Filter Helpers
```javascript
import {
isFilterEqual,
matchFilter,
matchFilters,
mergeFilters
} from 'applesauce-core/helpers';
// Compare filters (better than JSON.stringify)
const areEqual = isFilterEqual(filter1, filter2);
// Check if event matches filter
const matches = matchFilter({ kinds: [1], authors: [pubkey] }, event);
// Check against multiple filters
const matchesAny = matchFilters([filter1, filter2], event);
// Merge filters
const combined = mergeFilters(filter1, filter2);
```
### Available Helper Categories
applesauce-core provides helpers for:
- **Tags**: getTagValue, hasNameValueTag, tag type checks
- **Articles**: getArticleTitle, getArticleSummary, getArticleImage
- **Highlights**: 7+ helpers for highlight extraction
- **Profiles**: getProfileContent, getDisplayName, getProfilePicture
- **Pointers**: parseCoordinate, pointer extraction/creation
- **Reactions**: getReactionEventPointer, getReactionAddressPointer
- **Threading**: getNip10References for NIP-10 threads
- **Comments**: getCommentReplyPointer for NIP-22
- **Filters**: isFilterEqual, matchFilter, mergeFilters
- **Bookmarks**: bookmark list parsing
- **Emoji**: custom emoji extraction
- **Zaps**: zap parsing and validation
- **Calendars**: calendar event helpers
- **Encryption**: NIP-04, NIP-44 helpers
- **And 40+ more** - explore `node_modules/applesauce-core/dist/helpers/`
### When NOT to Use useMemo with Helpers
```javascript
// ❌ DON'T memoize applesauce helper calls
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
// ✅ DO call helpers directly
const title = getArticleTitle(event);
const summary = getArticleSummary(event);
// ❌ DON'T memoize helpers that wrap other helpers
function getRepoName(event) {
return getTagValue(event, 'name');
}
const name = useMemo(() => getRepoName(event), [event]);
// ✅ DO call directly (caching propagates)
const name = getRepoName(event);
// ✅ DO use useMemo for expensive transformations
const sorted = useMemo(
() => events.sort((a, b) => b.created_at - a.created_at),
[events]
);
// ✅ DO use useMemo for object creation
const options = useMemo(
() => ({ fallbackRelays, timeout: 1000 }),
[fallbackRelays]
);
```
## NIP Helpers
### NIP-05 Verification

View File

@@ -0,0 +1,379 @@
# Applesauce Helpers Refactoring Plan
## Executive Summary
After investigating applesauce-core helpers and grimoire's codebase, I've identified several opportunities to leverage applesauce's built-in helpers and caching mechanisms. **Key insight**: Applesauce helpers use internal caching via symbols (`getOrComputeCachedValue`), so we don't need `useMemo` when calling them.
## Key Findings
### 1. Applesauce Caching System
Applesauce helpers cache computed values on event objects using symbols:
```typescript
// From applesauce-core/helpers/cache.d.ts
export declare function getOrComputeCachedValue<T>(
event: any,
symbol: symbol,
compute: () => T
): T;
```
**Implication**: When you call helpers like `getArticleTitle(event)`, `getHighlightText(event)`, etc., the result is cached on the event object. Subsequent calls return the cached value instantly. **We don't need useMemo for applesauce helper calls.**
### 2. Already Using Applesauce Helpers
Grimoire already imports and uses many applesauce helpers correctly:
-`getTagValue` - used extensively in nip34-helpers.ts, nip-c0-helpers.ts
-`getNip10References` - used in nostr-utils.ts
-`getCommentReplyPointer` - used in nostr-utils.ts
- ✅ Article helpers - `getArticleTitle`, `getArticleSummary` (ArticleRenderer.tsx)
- ✅ Highlight helpers - all 6+ helpers used (HighlightRenderer.tsx, HighlightDetailRenderer.tsx)
- ✅ Code snippet helpers - imported from nip-c0-helpers.ts
### 3. Applesauce Helpers Available but Not Used
#### Profile Helpers
```typescript
// applesauce-core/helpers/profile
export function getDisplayName(
metadata: ProfileContent | NostrEvent | undefined,
fallback?: string
): string | undefined;
```
**Current grimoire code** (src/lib/nostr-utils.ts:65-76):
```typescript
export function getDisplayName(
pubkey: string,
metadata?: ProfileContent,
): string {
if (metadata?.display_name) return metadata.display_name;
if (metadata?.name) return metadata.name;
return derivePlaceholderName(pubkey);
}
```
**Issue**: Grimoire's version requires both `pubkey` and `metadata`, while applesauce only takes `metadata`. Our version adds fallback logic with `derivePlaceholderName`.
**Recommendation**: Keep grimoire's version - it provides better UX with pubkey-based fallback.
#### Pointer Helpers
```typescript
// applesauce-core/helpers/pointers
export function getEventPointerFromETag(tag: string[]): EventPointer;
export function getEventPointerFromQTag(tag: string[]): EventPointer;
export function getAddressPointerFromATag(tag: string[]): AddressPointer;
export function getProfilePointerFromPTag(tag: string[]): ProfilePointer;
export function parseCoordinate(a: string): AddressPointerWithoutD | null;
```
**Current usage** in ReactionRenderer.tsx:58-66:
```typescript
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
```
**Recommendation**: Replace manual parsing with `parseCoordinate` helper.
#### Reaction Pointer Helpers
```typescript
// applesauce-core/helpers/reactions
export function getReactionEventPointer(event: NostrEvent): EventPointer | undefined;
export function getReactionAddressPointer(event: NostrEvent): AddressPointer | undefined;
```
**Current usage** in ReactionRenderer.tsx: Manual tag extraction and parsing.
**Recommendation**: Use built-in reaction pointer helpers.
#### Filter Comparison Helper
```typescript
// applesauce-core/helpers/filter
export function isFilterEqual(
a: FilterWithAnd | FilterWithAnd[],
b: FilterWithAnd | FilterWithAnd[]
): boolean;
```
**Current usage** in useStable.ts:55-58:
```typescript
export function useStableFilters<T>(filters: T): T {
return useMemo(() => filters, [JSON.stringify(filters)]);
}
```
**Recommendation**: Replace JSON.stringify comparison with `isFilterEqual` for more robust filter comparison.
### 4. Custom Helpers We Need to Keep
These are **not** available in applesauce and provide grimoire-specific functionality:
1. **`getTagValues` (plural)** - src/lib/nostr-utils.ts:59-63
```typescript
export function getTagValues(event: NostrEvent, tagName: string): string[] {
return event.tags
.filter((tag) => tag[0] === tagName && tag[1])
.map((tag) => tag[1]);
}
```
**Keep**: Applesauce only has `getTagValue` (singular). We need the plural version.
2. **`resolveFilterAliases`** - src/lib/nostr-utils.ts:85-156
- Resolves `$me` and `$contacts` in filters
- Grimoire-specific feature
**Keep**: No applesauce equivalent.
3. **NIP-34 helpers** - src/lib/nip34-helpers.ts
- Git repository, issue, patch, PR helpers
**Keep**: Grimoire-specific, uses `getTagValue` underneath.
4. **NIP-C0 helpers** - src/lib/nip-c0-helpers.ts
- Code snippet helpers
**Keep**: Uses `getTagValue` underneath, grimoire-specific.
5. **Custom event processing** - src/lib/spell-conversion.ts, spellbook-manager.ts
**Keep**: Grimoire-specific functionality.
## Refactoring Opportunities
### HIGH PRIORITY: Remove Unnecessary useMemo
Since applesauce helpers cache internally, **remove useMemo from all applesauce helper calls**:
#### 1. ArticleRenderer.tsx (lines 17-18)
```typescript
// BEFORE
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
// AFTER - helpers cache internally
const title = getArticleTitle(event);
const summary = getArticleSummary(event);
```
#### 2. HighlightRenderer.tsx (lines 24-36) + HighlightDetailRenderer.tsx (lines 22-35)
```typescript
// BEFORE
const highlightText = useMemo(() => getHighlightText(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
const eventPointer = useMemo(() => getHighlightSourceEventPointer(event), [event]);
const addressPointer = useMemo(() => getHighlightSourceAddressPointer(event), [event]);
const context = useMemo(() => getHighlightContext(event), [event]);
// AFTER - helpers cache internally
const highlightText = getHighlightText(event);
const sourceUrl = getHighlightSourceUrl(event);
const comment = getHighlightComment(event);
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
const context = getHighlightContext(event);
```
#### 3. CodeSnippetDetailRenderer.tsx (lines 37-44)
```typescript
// BEFORE
const name = useMemo(() => getCodeName(event), [event]);
const language = useMemo(() => getCodeLanguage(event), [event]);
const extension = useMemo(() => getCodeExtension(event), [event]);
const description = useMemo(() => getCodeDescription(event), [event]);
const runtime = useMemo(() => getCodeRuntime(event), [event]);
const licenses = useMemo(() => getCodeLicenses(event), [event]);
const dependencies = useMemo(() => getCodeDependencies(event), [event]);
const repo = useMemo(() => getCodeRepo(event), [event]);
// AFTER - our custom helpers use getTagValue which caches
const name = getCodeName(event);
const language = getCodeLanguage(event);
const extension = getCodeExtension(event);
const description = getCodeDescription(event);
const runtime = getCodeRuntime(event);
const licenses = getCodeLicenses(event);
const dependencies = getCodeDependencies(event);
const repo = getCodeRepo(event);
```
#### 4. ChatView.tsx (lines 94, 96)
```typescript
// BEFORE
const threadRefs = useMemo(() => getNip10References(event), [event]);
const qTagValue = useMemo(() => getTagValue(event, "q"), [event]);
// AFTER - helpers cache internally
const threadRefs = getNip10References(event);
const qTagValue = getTagValue(event, "q");
```
#### 5. LiveActivityRenderer.tsx (lines 20-22)
```typescript
// BEFORE - if using applesauce helpers
const activity = useMemo(() => parseLiveActivity(event), [event]);
const status = useMemo(() => getLiveStatus(event), [event]);
const hostPubkey = useMemo(() => getLiveHost(event), [event]);
// AFTER - check if these use applesauce helpers internally
// If yes, remove useMemo. If no, keep as is.
```
**Note**: Check if `parseLiveActivity`, `getLiveStatus`, `getLiveHost` use applesauce helpers or implement caching themselves.
### MEDIUM PRIORITY: Use Applesauce Pointer Helpers
#### 1. ReactionRenderer.tsx - Replace manual coordinate parsing
```typescript
// BEFORE (lines 58-66)
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
// AFTER - use parseCoordinate helper
import { parseCoordinate } from "applesauce-core/helpers";
const addressPointer = reactedAddress ? parseCoordinate(reactedAddress) : null;
// No useMemo needed - parseCoordinate is a simple function
```
#### 2. ReactionRenderer.tsx - Use reaction pointer helpers
```typescript
// CURRENT: Manual tag extraction
const reactedEventId = event.tags.find((t) => t[0] === "e")?.[1];
const reactedAddress = event.tags.find((t) => t[0] === "a")?.[1];
// POTENTIAL ALTERNATIVE: Use built-in helpers
import { getReactionEventPointer, getReactionAddressPointer } from "applesauce-core/helpers";
const eventPointer = getReactionEventPointer(event);
const addressPointer = getReactionAddressPointer(event);
```
**Trade-off**: Current code gets raw values, helpers return typed pointers. May require component changes. **Evaluate if worth it.**
### MEDIUM PRIORITY: Improve Filter Comparison
#### useStable.ts - Use isFilterEqual instead of JSON.stringify
```typescript
// BEFORE (lines 55-58)
export function useStableFilters<T>(filters: T): T {
return useMemo(() => filters, [JSON.stringify(filters)]);
}
// AFTER - use isFilterEqual for comparison
import { isFilterEqual } from "applesauce-core/helpers";
export function useStableFilters<T>(filters: T): T {
const prevFiltersRef = useRef<T>();
if (!prevFiltersRef.current || !isFilterEqual(prevFiltersRef.current as any, filters as any)) {
prevFiltersRef.current = filters;
}
return prevFiltersRef.current;
}
```
**Benefits**:
- More robust comparison (handles undefined values correctly)
- Avoids JSON serialization overhead
- Supports NIP-ND AND operator (`&` prefix)
**Note**: May need to handle non-filter types (arrays, objects).
### LOW PRIORITY: Code Organization
#### 1. Document applesauce caching in code comments
Add comments to custom helpers that use applesauce helpers:
```typescript
// nip34-helpers.ts
/**
* Get the repository name from a repository event
* Note: Uses applesauce getTagValue which caches internally
* @param event Repository event (kind 30617)
* @returns Repository name or undefined
*/
export function getRepositoryName(event: NostrEvent): string | undefined {
return getTagValue(event, "name");
}
```
#### 2. Consider consolidating tag extraction
Since we use `getTagValue` extensively, ensure all single-tag extractions use it instead of manual `find()`:
```typescript
// PREFER
const value = getTagValue(event, "tagName");
// AVOID
const value = event.tags.find(t => t[0] === "tagName")?.[1];
```
## Testing Recommendations
1. **Test helper caching**: Verify applesauce helpers actually cache (call twice, ensure same reference)
2. **Performance testing**: Measure before/after removing useMemo (expect minimal change due to caching)
3. **Filter comparison**: Test `isFilterEqual` edge cases (undefined, empty arrays, NIP-ND AND operator)
4. **Pointer parsing**: Test `parseCoordinate` with various coordinate formats
## Migration Strategy
### Phase 1: Remove Unnecessary useMemo (Low Risk)
1. Remove useMemo from applesauce helper calls in kind renderers
2. Test rendering performance
3. Verify no issues
4. Commit
### Phase 2: Replace Pointer Parsing (Medium Risk)
1. Replace manual coordinate parsing with `parseCoordinate`
2. Update types if needed
3. Test reaction rendering
4. Commit
### Phase 3: Improve Filter Comparison (Medium Risk)
1. Implement `useStableFilters` with `isFilterEqual`
2. Test filter subscription behavior
3. Verify no unnecessary re-subscriptions
4. Commit
### Phase 4: Documentation (Low Risk)
1. Update CLAUDE.md with applesauce helper guidance
2. Add code comments documenting caching
3. Update skills if needed
## Questions to Investigate
1. **Do all our custom helpers cache?** Check `parseLiveActivity`, `getLiveStatus`, `getEventDisplayTitle`, etc.
2. **Should we create a shared cache util?** For custom helpers that don't use applesauce helpers underneath
3. **Is getTagValues used enough to add to applesauce?** Consider contributing upstream
4. **Filter aliases**: Could `resolveFilterAliases` be contributed to applesauce?
## Summary of Changes
| Category | Impact | Files Affected | Effort |
|----------|--------|----------------|--------|
| Remove useMemo from applesauce helpers | Performance (minor), Code clarity (major) | 8+ renderer files | Low |
| Use pointer helpers | Type safety, Code clarity | ReactionRenderer.tsx | Medium |
| Improve filter comparison | Correctness, Performance | useStable.ts | Medium |
| Documentation | Developer experience | CLAUDE.md, skills | Low |
**Total estimated effort**: 4-6 hours
**Risk level**: Low-Medium
**Expected benefits**: Cleaner code, better alignment with applesauce patterns, easier maintenance
## References
- [Applesauce Documentation](https://hzrd149.github.io/applesauce/)
- [Applesauce GitHub](https://github.com/hzrd149/applesauce)
- [Applesauce Core TypeDoc](https://hzrd149.github.io/applesauce/typedoc/modules/applesauce-core.html)

View File

@@ -66,6 +66,47 @@ Applesauce uses RxJS observables for reactive data flow:
Use hooks like `useProfile()`, `useNostrEvent()`, `useTimeline()` - they handle subscriptions.
### Applesauce Helpers & Caching
**Critical Performance Insight**: Applesauce helpers cache computed values internally using symbols. **You don't need `useMemo` when calling applesauce helpers.**
```typescript
// ❌ WRONG - Unnecessary memoization
const title = useMemo(() => getArticleTitle(event), [event]);
const text = useMemo(() => getHighlightText(event), [event]);
// ✅ CORRECT - Helpers cache internally
const title = getArticleTitle(event);
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`):
- **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`
**Custom Grimoire Helpers** (not in applesauce):
- `getTagValues(event, name)` - plural version to get array of tag values (src/lib/nostr-utils.ts)
- `resolveFilterAliases(filter, pubkey, contacts)` - resolves `$me`/`$contacts` aliases (src/lib/nostr-utils.ts)
- `getDisplayName(pubkey, metadata)` - enhanced version with pubkey fallback (src/lib/nostr-utils.ts)
- NIP-34 git helpers (src/lib/nip34-helpers.ts) - wraps `getTagValue` for repository, issue, patch metadata
- NIP-C0 code snippet helpers (src/lib/nip-c0-helpers.ts) - wraps `getTagValue` for code metadata
**When to use `useMemo`**:
- ✅ Complex transformations not using applesauce helpers (sorting, filtering, mapping)
- ✅ Creating objects/arrays for dependency tracking (options, configurations)
- ✅ Expensive computations that don't call applesauce helpers
- ❌ Direct calls to applesauce helpers (they cache internally)
- ❌ Grimoire helpers that wrap `getTagValue` (caching propagates)
## Key Conventions
- **Path Alias**: `@/` = `./src/`

View File

@@ -91,9 +91,10 @@ export function ChatView({ events, className }: ChatViewProps) {
}
function ChatMessage({ event }: { event: NostrEvent }) {
const threadRefs = useMemo(() => getNip10References(event), [event]);
// Both helpers cache internally, no useMemo needed
const threadRefs = getNip10References(event);
const replyToId = threadRefs.reply?.e?.id;
const qTagValue = useMemo(() => getTagValue(event, "q"), [event]);
const qTagValue = getTagValue(event, "q");
return (
<div className="flex flex-col gap-0.5">

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import {
BaseEventContainer,
BaseEventProps,
@@ -12,10 +11,11 @@ import {
/**
* Renderer for Kind 30023 - Long-form Article
* Displays article title and summary in feed
* Note: getArticleTitle and getArticleSummary cache internally, no useMemo needed
*/
export function Kind30023Renderer({ event }: BaseEventProps) {
const title = useMemo(() => getArticleTitle(event), [event]);
const summary = useMemo(() => getArticleSummary(event), [event]);
const title = getArticleTitle(event);
const summary = getArticleSummary(event);
return (
<BaseEventContainer event={event}>

View File

@@ -29,19 +29,21 @@ interface Kind1337DetailRendererProps {
/**
* Detail renderer for Kind 1337 - Code Snippet (NIP-C0)
* Full view with all metadata and complete code
* Note: NIP-C0 helpers wrap getTagValue which caches internally
*/
export function Kind1337DetailRenderer({ event }: Kind1337DetailRendererProps) {
const { addWindow } = useGrimoire();
const { copy, copied } = useCopy();
const name = useMemo(() => getCodeName(event), [event]);
const language = useMemo(() => getCodeLanguage(event), [event]);
const extension = useMemo(() => getCodeExtension(event), [event]);
const description = useMemo(() => getCodeDescription(event), [event]);
const runtime = useMemo(() => getCodeRuntime(event), [event]);
const licenses = useMemo(() => getCodeLicenses(event), [event]);
const dependencies = useMemo(() => getCodeDependencies(event), [event]);
const repo = useMemo(() => getCodeRepo(event), [event]);
// All these helpers wrap getTagValue, which caches internally
const name = getCodeName(event);
const language = getCodeLanguage(event);
const extension = getCodeExtension(event);
const description = getCodeDescription(event);
const runtime = getCodeRuntime(event);
const licenses = getCodeLicenses(event);
const dependencies = getCodeDependencies(event);
const repo = getCodeRepo(event);
// Parse NIP-34 repository address if present
const repoPointer = useMemo(() => {

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { ExternalLink } from "lucide-react";
import type { NostrEvent } from "@/types/nostr";
import {
@@ -16,23 +15,18 @@ import { useGrimoire } from "@/core/state";
/**
* Detail renderer for Kind 9802 - Highlight
* Shows highlighted text, comment, context, and embedded source event
* Note: All applesauce helpers cache internally, no useMemo needed
*/
export function Kind9802DetailRenderer({ event }: { event: NostrEvent }) {
const { addWindow } = useGrimoire();
const highlightText = useMemo(() => getHighlightText(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
const context = useMemo(() => getHighlightContext(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
const highlightText = getHighlightText(event);
const comment = getHighlightComment(event);
const context = getHighlightContext(event);
const sourceUrl = getHighlightSourceUrl(event);
// Get source event pointer (e tag) or address pointer (a tag)
const eventPointer = useMemo(
() => getHighlightSourceEventPointer(event),
[event],
);
const addressPointer = useMemo(
() => getHighlightSourceAddressPointer(event),
[event],
);
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
// Format created date
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { BaseEventContainer, BaseEventProps } from "./BaseEventRenderer";
import { ExternalLink } from "lucide-react";
import {
@@ -18,36 +17,28 @@ import { KindBadge } from "@/components/KindBadge";
/**
* Renderer for Kind 9802 - Highlight
* Displays highlighted text with optional comment, compact source event preview, and source URL
* Note: All applesauce helpers cache internally, no useMemo needed
*/
export function Kind9802Renderer({ event }: BaseEventProps) {
const { addWindow } = useGrimoire();
const highlightText = useMemo(() => getHighlightText(event), [event]);
const sourceUrl = useMemo(() => getHighlightSourceUrl(event), [event]);
const comment = useMemo(() => getHighlightComment(event), [event]);
const highlightText = getHighlightText(event);
const sourceUrl = getHighlightSourceUrl(event);
const comment = getHighlightComment(event);
// Get source event pointer (e tag) or address pointer (a tag) for Nostr event references
const eventPointer = useMemo(
() => getHighlightSourceEventPointer(event),
[event],
);
const addressPointer = useMemo(
() => getHighlightSourceAddressPointer(event),
[event],
);
const eventPointer = getHighlightSourceEventPointer(event);
const addressPointer = getHighlightSourceAddressPointer(event);
// Load the source event for preview
const sourceEvent = useNostrEvent(eventPointer || addressPointer);
// Extract title or content preview from source event
const sourcePreview = useMemo(() => {
// Extract title or content preview from source event (getArticleTitle caches internally)
const sourcePreview = (() => {
if (!sourceEvent) return null;
const title = getArticleTitle(sourceEvent);
if (title) return title;
// Fall back to content
return sourceEvent.content || null;
}, [sourceEvent]);
})();
// Handle click to open source event
const handleOpenEvent = () => {

View File

@@ -5,6 +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";
/**
* Renderer for Kind 7 - Reactions
@@ -54,16 +55,10 @@ 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 into components
const addressParts = useMemo(() => {
if (!reactedAddress) return null;
const parts = reactedAddress.split(":");
return {
kind: parseInt(parts[0], 10),
pubkey: parts[1],
dTag: parts[2],
};
}, [reactedAddress]);
// Parse a tag coordinate using applesauce helper
const addressPointer = reactedAddress
? parseCoordinate(reactedAddress)
: null;
// Create event pointer for fetching
const eventPointer = useMemo(() => {
@@ -73,16 +68,16 @@ export function Kind7Renderer({ event }: BaseEventProps) {
relays: reactedRelay ? [reactedRelay] : undefined,
};
}
if (addressParts) {
if (addressPointer) {
return {
kind: addressParts.kind,
pubkey: addressParts.pubkey,
identifier: addressParts.dTag || "",
kind: addressPointer.kind,
pubkey: addressPointer.pubkey,
identifier: addressPointer.identifier || "",
relays: [],
};
}
return undefined;
}, [reactedEventId, reactedRelay, addressParts]);
}, [reactedEventId, reactedRelay, addressPointer]);
// Fetch the reacted event
const reactedEvent = useNostrEvent(eventPointer);

View File

@@ -1,4 +1,6 @@
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import { isFilterEqual } from "applesauce-core/helpers/filter";
import type { Filter } from "nostr-tools";
/**
* Stabilize a value for use in dependency arrays
@@ -46,13 +48,26 @@ export function useStableArray<T extends string>(arr: T[]): T[] {
/**
* Stabilize a Nostr filter or array of filters
*
* Specialized stabilizer for Nostr filters which are commonly
* recreated on each render.
* Uses applesauce's isFilterEqual for robust filter comparison.
* Better than JSON.stringify as it handles undefined values correctly
* and supports NIP-ND AND operator.
*
* @param filters - Single filter or array of filters
* @returns The memoized filter(s)
*/
export function useStableFilters<T>(filters: T): T {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => filters, [JSON.stringify(filters)]);
export function useStableFilters<T extends Filter | Filter[]>(filters: T): T {
const prevFiltersRef = useRef<T | undefined>(undefined);
// Only update if filters actually changed (per isFilterEqual)
if (
!prevFiltersRef.current ||
!isFilterEqual(
prevFiltersRef.current as Filter | Filter[],
filters as Filter | Filter[],
)
) {
prevFiltersRef.current = filters;
}
return prevFiltersRef.current!;
}