Browse Source

Merges pull request #73

Integration/all prs
master
silberengel 3 months ago
parent
commit
3a238357ac
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 11
      .env.example
  2. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-complete.png
  3. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png
  4. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-final.png
  5. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-preview-update.png
  6. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-text-fix.png
  7. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-white-text.png
  8. BIN
      .playwright-mcp/.playwright-mcp/dark-mode-with-preview.png
  9. BIN
      .playwright-mcp/500-error.png
  10. BIN
      .playwright-mcp/after-delete-integration.png
  11. BIN
      .playwright-mcp/after-fix.png
  12. BIN
      .playwright-mcp/after-mock-comments-click.png
  13. BIN
      .playwright-mcp/compose-darkmode-preview.png
  14. BIN
      .playwright-mcp/dark-mode-full-preview.png
  15. BIN
      .playwright-mcp/dark-mode-preview-after.png
  16. BIN
      .playwright-mcp/debug-controls-fixed.png
  17. BIN
      .playwright-mcp/highlights-visible.png
  18. BIN
      .playwright-mcp/homepage-loaded.png
  19. BIN
      .playwright-mcp/homepage-state.png
  20. BIN
      .playwright-mcp/offset-highlights-rendered.png
  21. BIN
      .playwright-mcp/offset-highlights-scrolled.png
  22. BIN
      .playwright-mcp/offset-highlights-test-page.png
  23. BIN
      .playwright-mcp/preview-area.png
  24. BIN
      .playwright-mcp/publication-with-debug-controls.png
  25. BIN
      .playwright-mcp/scrolled-with-comments.png
  26. BIN
      .playwright-mcp/with-mock-highlights.png
  27. 184
      CLAUDE.md
  28. 373
      TECHNIQUE-create-test-highlights.md
  29. 142
      TEST_SUMMARY.md
  30. 108
      WIKI_TAG_SPEC.md
  31. 71
      check-publication-structure.js
  32. 249
      create-test-comments.js
  33. 188
      create-test-highlights.js
  34. 53
      nips/09.md
  35. 62
      package-lock.json
  36. 1
      package.json
  37. 322
      src/lib/components/ZettelEditor.svelte
  38. 35
      src/lib/components/cards/BlogHeader.svelte
  39. 520
      src/lib/components/publications/CommentButton.svelte
  40. 282
      src/lib/components/publications/CommentLayer.svelte
  41. 280
      src/lib/components/publications/CommentPanel.svelte
  42. 126
      src/lib/components/publications/DeleteButton.svelte
  43. 21
      src/lib/components/publications/HighlightButton.svelte
  44. 788
      src/lib/components/publications/HighlightLayer.svelte
  45. 429
      src/lib/components/publications/HighlightSelectionHandler.svelte
  46. 368
      src/lib/components/publications/Publication.svelte
  47. 33
      src/lib/components/publications/PublicationHeader.svelte
  48. 124
      src/lib/components/publications/PublicationSection.svelte
  49. 928
      src/lib/components/publications/SectionComments.svelte
  50. 2
      src/lib/components/util/ArticleNav.svelte
  51. 390
      src/lib/components/util/CardActions.svelte
  52. 11
      src/lib/components/util/Details.svelte
  53. 117
      src/lib/services/deletion.ts
  54. 85
      src/lib/utils/asciidoc_ast_parser.ts
  55. 70
      src/lib/utils/fetch_publication_highlights.ts
  56. 224
      src/lib/utils/highlightPositioning.ts
  57. 156
      src/lib/utils/highlightUtils.ts
  58. 177
      src/lib/utils/mockCommentData.ts
  59. 183
      src/lib/utils/mockHighlightData.ts
  60. 31
      src/lib/utils/nostrUtils.ts
  61. 46
      src/lib/utils/publication_tree_factory.ts
  62. 146
      src/lib/utils/publication_tree_processor.ts
  63. 145
      src/lib/utils/wiki_links.ts
  64. 911
      tests/unit/commentButton.test.ts
  65. 129
      tests/unit/deletion.test.ts
  66. 318
      tests/unit/fetchPublicationHighlights.test.ts
  67. 859
      tests/unit/highlightLayer.test.ts
  68. 875
      tests/unit/highlightSelection.test.ts

11
.env.example

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
# Alexandria Environment Variables
# Enable mock data for development/testing
# Set to "true" to use lorem ipsum test comments instead of fetching from relays
VITE_USE_MOCK_COMMENTS=true
# Set to "true" to use position-based test highlights instead of fetching from relays
VITE_USE_MOCK_HIGHLIGHTS=true
# Enable debug logging for relay connections
DEBUG_RELAYS=false

BIN
.playwright-mcp/.playwright-mcp/dark-mode-complete.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-editor-fix.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-final.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-preview-update.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-text-fix.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-white-text.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
.playwright-mcp/.playwright-mcp/dark-mode-with-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
.playwright-mcp/500-error.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
.playwright-mcp/after-delete-integration.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
.playwright-mcp/after-fix.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

BIN
.playwright-mcp/after-mock-comments-click.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
.playwright-mcp/compose-darkmode-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
.playwright-mcp/dark-mode-full-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
.playwright-mcp/dark-mode-preview-after.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
.playwright-mcp/debug-controls-fixed.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
.playwright-mcp/highlights-visible.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

BIN
.playwright-mcp/homepage-loaded.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

BIN
.playwright-mcp/homepage-state.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
.playwright-mcp/offset-highlights-rendered.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
.playwright-mcp/offset-highlights-scrolled.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

BIN
.playwright-mcp/offset-highlights-test-page.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
.playwright-mcp/preview-area.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
.playwright-mcp/publication-with-debug-controls.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
.playwright-mcp/scrolled-with-comments.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

BIN
.playwright-mcp/with-mock-highlights.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

184
CLAUDE.md

@ -0,0 +1,184 @@ @@ -0,0 +1,184 @@
# Alexandria Codebase - Local Instructions
This document provides project-specific instructions for working with the Alexandria codebase, based on existing Cursor rules and project conventions.
## Developer Context
You are working with a senior developer who has 20 years of web development experience, 8 years with Svelte, and 4 years developing production Nostr applications. Assume high technical proficiency.
## Project Overview
Alexandria is a Nostr-based web application for reading, commenting on, and publishing long-form content (books, blogs, etc.) stored on Nostr relays. Built with:
- **Svelte 5** and **SvelteKit 2** (latest versions)
- **TypeScript** (exclusively, no plain JavaScript)
- **Tailwind 4** for styling
- **Deno** runtime (with Node.js compatibility)
- **NDK** (Nostr Development Kit) for protocol interaction
## Architecture Pattern
The project follows a Model-View-Controller (MVC) pattern:
- **Model**: Nostr relays (via WebSocket APIs) and browser storage
- **View**: Reactive UI with SvelteKit pages and Svelte components
- **Controller**: TypeScript modules with utilities, services, and data preparation
## Critical Development Guidelines
### Prime Directive
**NEVER assume developer intent.** If unsure, ALWAYS ask for clarification before proceeding.
### AI Anchor Comments System
Before any work, search for `AI-` anchor comments in relevant directories:
- `AI-NOTE:`, `AI-TODO:`, `AI-QUESTION:` - Context sharing between AI and developers
- `AI-<MM/DD/YYYY>:` - Developer-recorded context (read but don't write)
- **Always update relevant anchor comments when modifying code**
- Add new anchors for complex, critical, or confusing code
- Never remove AI comments without explicit instruction
### Communication Style
- Be direct and concise - avoid apologies or verbose explanations
- Include file names and line numbers (e.g., `src/lib/utils/parser.ts:45-52`)
- Provide documentation links for further reading
- When corrected, provide well-reasoned explanations, not simple agreement
- Don't propose code edits unless specifically requested
## Code Style Requirements
### TypeScript Files (\*.ts)
- **File naming**: `snake_case.ts`
- **Classes/Interfaces/Types**: `PascalCase`
- **Functions/Variables**: `camelCase`
- **Private class members**: `#privateField` (ES2022 syntax)
- **Indentation**: 2 spaces
- **Line length**: 100 characters max
- **Strings**: Single quotes default, backticks for templates
- **Always include**:
- Type annotations for class properties
- Parameter types and return types (except void)
- JSDoc comments for exported functions
- Semicolons at statement ends
### Svelte Components (\*.svelte)
- **Component naming**: `PascalCase.svelte`
- **Use Svelte 5 features exclusively**:
- Runes: `$state`, `$derived`, `$effect`, `$props`
- Callback props (not event dispatchers)
- Snippets (not slots)
- **Avoid deprecated Svelte 4 patterns**:
- No `export let` for props
- No `on:` event directives
- No event dispatchers or component slots
- **Component organization** (in order):
1. Imports
2. Props definition (strongly typed)
3. Context imports (`getContext`)
4. State declarations (`$state`, then `$derived`)
5. Non-reactive variables
6. Component logic (functions, `$effect`)
7. Lifecycle hooks (`onMount`)
8. Snippets (before markup)
9. Component markup
10. Style blocks (rare - prefer Tailwind)
- **Keep components under 500 lines**
- **Extract business logic to separate TypeScript modules**
### HTML/Markup
- Indentation: 2 spaces
- Break long tags across lines
- Use Tailwind 4 utility classes
- Single quotes for attributes
## Key Project Utilities
### Core Classes to Use
- `WebSocketPool` (`src/lib/data_structures/websocket_pool.ts`) - For WebSocket management
- `PublicationTree` - For hierarchical publication structure
- `ZettelParser` - For AsciiDoc parsing
### Nostr Event Kinds
- `30040` - Blog/publication indexes
- `30041` - Publication sections/articles
- `30023` - Long-form articles
- `30818` - Wiki Notes
- `1` - Short notes
## Development Commands
```bash
# Development
npm run dev # Start dev server
npm run dev:debug # With relay debugging (DEBUG_RELAYS=true)
# Quality Checks (run before commits)
npm run check # Type checking
npm run lint # Linting
npm run format # Auto-format
npm test # Run tests
# Build
npm run build # Production build
npm run preview # Preview production
```
## Testing Requirements
- Unit tests: Vitest with mocked dependencies
- E2E tests: Playwright for critical flows
- Always run `npm test` before commits
- Check types with `npm run check`
## Git Workflow
- Current branch: `feature/text-entry`
- Main branch: `master` (not `main`)
- Descriptive commit messages
- Include test updates with features
## Important Files
- `src/lib/ndk.ts` - NDK configuration
- `src/lib/utils/ZettelParser.ts` - AsciiDoc parsing
- `src/lib/services/publisher.ts` - Event publishing
- `src/lib/components/ZettelEditor.svelte` - Main editor
- `src/routes/new/compose/+page.svelte` - Composition UI
## Performance Considerations
- State is deeply reactive in Svelte 5 - avoid unnecessary reassignments
- Lazy load large components
- Use virtual scrolling for long lists
- Cache Nostr events with Dexie
- Minimize relay subscriptions
- Debounce search inputs
## Security Notes
- Never store private keys in code
- Validate all user input
- Sanitize external HTML
- Verify event signatures
## Debugging
- Enable relay debug: `DEBUG_RELAYS=true npm run dev`
- Check browser console for NDK logs
- Network tab shows WebSocket frames
## Documentation Links
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
- [NDK Docs](https://github.com/nostr-dev-kit/ndk)
- [SvelteKit Docs](https://kit.svelte.dev/docs)
- [Svelte 5 Docs](https://svelte.dev/docs/svelte/overview)
- [Flowbite Svelte](https://flowbite-svelte.com/)

373
TECHNIQUE-create-test-highlights.md

@ -0,0 +1,373 @@ @@ -0,0 +1,373 @@
# Technique: Creating Test Highlight Events for Nostr Publications
## Overview
This technique allows you to create test highlight events (kind 9802) for testing the highlight rendering system in Alexandria. Highlights are text selections from publication sections that users want to mark as important or noteworthy, optionally with annotations.
## When to Use This
- Testing highlight fetching and rendering
- Verifying highlight filtering by section
- Testing highlight display UI (inline markers, side panel, etc.)
- Debugging highlight-related features
- Demonstrating the highlight system to stakeholders
## Prerequisites
1. **Node.js packages**: `nostr-tools` and `ws`
```bash
npm install nostr-tools ws
```
2. **Valid publication structure**: You need the actual publication address (naddr) and its internal structure (section addresses, pubkeys)
## Step 1: Decode the Publication Address
If you have an `naddr` (Nostr address), decode it to find the publication structure:
**Script**: `check-publication-structure.js`
```javascript
import { nip19 } from 'nostr-tools';
import WebSocket from 'ws';
const naddr = 'naddr1qvzqqqr4t...'; // Your publication naddr
console.log('Decoding naddr...\n');
const decoded = nip19.decode(naddr);
console.log('Decoded:', JSON.stringify(decoded, null, 2));
const { data } = decoded;
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log('\nRoot Address:', rootAddress);
// Fetch the index event to see what sections it references
const relay = 'wss://relay.nostr.band';
async function fetchPublication() {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
const events = [];
ws.on('open', () => {
console.log(`\nConnected to ${relay}`);
console.log('Fetching index event...\n');
const filter = {
kinds: [data.kind],
authors: [data.pubkey],
'#d': [data.identifier],
};
const subscriptionId = `sub-${Date.now()}`;
ws.send(JSON.stringify(['REQ', subscriptionId, filter]));
});
ws.on('message', (message) => {
const [type, subId, event] = JSON.parse(message.toString());
if (type === 'EVENT') {
events.push(event);
console.log('Found index event:', event.id);
console.log('\nTags:');
event.tags.forEach(tag => {
if (tag[0] === 'a') {
console.log(` Section address: ${tag[1]}`);
}
if (tag[0] === 'd') {
console.log(` D-tag: ${tag[1]}`);
}
if (tag[0] === 'title') {
console.log(` Title: ${tag[1]}`);
}
});
} else if (type === 'EOSE') {
ws.close();
resolve(events);
}
});
ws.on('error', reject);
setTimeout(() => {
ws.close();
resolve(events);
}, 5000);
});
}
fetchPublication()
.then(() => console.log('\nDone!'))
.catch(console.error);
```
**Run it**: `node check-publication-structure.js`
**Expected output**: Section addresses like `30041:dc4cd086...:the-art-of-thinking-without-permission`
## Step 2: Understand Kind 9802 Event Structure
A highlight event (kind 9802) has this structure:
```javascript
{
kind: 9802,
pubkey: "<highlighter-pubkey>",
created_at: 1704067200,
tags: [
["a", "<section-address>", "<relay>"], // Required: target section
["context", "<surrounding-text>"], // Optional: helps locate highlight
["p", "<author-pubkey>", "<relay>", "author"], // Optional: original author
["comment", "<user-annotation>"] // Optional: user's note
],
content: "<the-actual-highlighted-text>", // Required: the selected text
id: "<calculated>",
sig: "<calculated>"
}
```
### Critical Differences from Comments (kind 1111):
| Aspect | Comments (1111) | Highlights (9802) |
|--------|----------------|-------------------|
| **Content field** | User's comment text | The highlighted text itself |
| **User annotation** | N/A (content is the comment) | Optional `["comment", ...]` tag |
| **Context** | Not used | `["context", ...]` provides surrounding text |
| **Threading** | Uses `["e", ..., "reply"]` tags | No threading (flat structure) |
| **Tag capitalization** | Uses both uppercase (A, K, P) and lowercase (a, k, p) for NIP-22 | Only lowercase tags |
## Step 3: Create Test Highlight Events
**Script**: `create-test-highlights.js`
```javascript
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';
import WebSocket from 'ws';
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
const testUserPubkey = getPublicKey(testUserKey);
console.log('Test User pubkey:', testUserPubkey);
// The publication details (from Step 1)
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06';
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from Step 1 output)
const sections = [
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`,
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`,
// ... more sections
];
// Relays to publish to (matching HighlightLayer's relay list)
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nostr.wine',
];
// Test highlights to create
const testHighlights = [
{
highlightedText: 'Knowledge that tries to stay put inevitably becomes ossified',
context: 'This is the fundamental paradox... Knowledge that tries to stay put inevitably becomes ossified, a monument to itself... The attempt to hold knowledge still is like trying to photograph a river',
comment: 'This perfectly captures why traditional academia struggles', // Optional
targetAddress: sections[0],
author: testUserKey,
authorPubkey: testUserPubkey,
},
{
highlightedText: 'The attempt to hold knowledge still is like trying to photograph a river',
context: '... a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.',
comment: null, // No annotation, just highlight
targetAddress: sections[0],
author: testUserKey,
authorPubkey: testUserPubkey,
},
];
async function publishEvent(event, relayUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let published = false;
ws.on('open', () => {
console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(['EVENT', event]));
});
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message[0] === 'OK' && message[1] === event.id) {
if (message[2]) {
console.log(`✓ Published ${event.id.substring(0, 8)}`);
published = true;
ws.close();
resolve();
} else {
console.error(`✗ Rejected: ${message[3]}`);
ws.close();
reject(new Error(message[3]));
}
}
});
ws.on('error', reject);
ws.on('close', () => {
if (!published) reject(new Error('Connection closed'));
});
setTimeout(() => {
if (!published) {
ws.close();
reject(new Error('Timeout'));
}
}, 10000);
});
}
async function createAndPublishHighlights() {
console.log('\n=== Creating Test Highlights ===\n');
for (const highlight of testHighlights) {
try {
// Create unsigned event
const unsignedEvent = {
kind: 9802,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', highlight.targetAddress, relays[0]],
['context', highlight.context],
['p', publicationPubkey, relays[0], 'author'],
],
content: highlight.highlightedText, // The highlighted text
pubkey: highlight.authorPubkey,
};
// Add optional comment/annotation
if (highlight.comment) {
unsignedEvent.tags.push(['comment', highlight.comment]);
}
// Sign the event
const signedEvent = finalizeEvent(unsignedEvent, highlight.author);
console.log(`\nHighlight: "${highlight.highlightedText.substring(0, 60)}..."`);
console.log(`Target: ${highlight.targetAddress}`);
console.log(`Event ID: ${signedEvent.id}`);
// Publish
await publishEvent(signedEvent, relays[0]);
// Delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1500));
} catch (error) {
console.error(`Failed: ${error.message}`);
}
}
console.log('\n=== Done! ===');
console.log('\nRefresh the page and toggle "Show Highlights" to view them.');
}
createAndPublishHighlights().catch(console.error);
```
## Step 4: Run and Verify
1. **Run the script**:
```bash
node create-test-highlights.js
```
2. **Expected output**:
```
Test User pubkey: a1b2c3d4...
=== Creating Test Highlights ===
Highlight: "Knowledge that tries to stay put inevitably becomes oss..."
Target: 30041:dc4cd086...:the-art-of-thinking-without-permission
Event ID: e5f6g7h8...
Connected to wss://relay.damus.io
✓ Published e5f6g7h8
...
=== Done! ===
```
3. **Verify in the app**:
- Refresh the publication page
- Click "Show Highlights" button
- Highlighted text should appear with yellow background
- Hover to see annotation (if provided)
## Common Issues and Solutions
### Issue: "Relay rejected: rate-limited"
**Cause**: Publishing too many events too quickly
**Solution**: Increase delay between publishes
```javascript
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 seconds
```
### Issue: Highlights don't appear after publishing
**Possible causes**:
1. Wrong section address - verify with `check-publication-structure.js`
2. HighlightLayer not fetching from the relay you published to
3. Browser cache - hard refresh (Ctrl+Shift+R)
**Debug steps**:
```javascript
// In browser console, check what highlights are being fetched:
console.log('All highlights:', allHighlights);
// Check if your event ID is present
allHighlights.find(h => h.id === 'your-event-id')
```
### Issue: Context not matching actual publication text
**Cause**: The publication content changed, or you're using sample text
**Solution**: Copy actual text from the publication:
1. Open the publication in browser
2. Select the text you want to highlight
3. Copy a larger surrounding context (2-3 sentences)
4. Use that as the `context` value
## Key Patterns to Remember
1. **Content field = highlighted text** (NOT a comment)
2. **Context tag helps locate** the highlight in the source document
3. **Comment tag is optional** user annotation
4. **No threading** - highlights are flat, not threaded like comments
5. **Single lowercase 'a' tag** - not uppercase/lowercase pairs like comments
6. **Always verify addresses** with `check-publication-structure.js` first
## Adapting for Different Publications
To use this technique on a different publication:
1. Get the publication's naddr from the URL
2. Run `check-publication-structure.js` with that naddr
3. Update these values in `create-test-highlights.js`:
- `publicationPubkey`
- `rootAddress`
- `sections` array
4. Update `highlightedText` and `context` to match actual publication content
5. Run the script
## Further Reading
- NIP-84 (Highlights): https://github.com/nostr-protocol/nips/blob/master/84.md
- `src/lib/components/publications/HighlightLayer.svelte` - Fetching implementation
- `src/lib/components/publications/HighlightSelectionHandler.svelte` - Event creation
- NIP-19 (Address encoding): https://github.com/nostr-protocol/nips/blob/master/19.md

142
TEST_SUMMARY.md

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
# Comment Button TDD Tests - Summary
## Overview
Comprehensive test suite for CommentButton component and NIP-22 comment functionality.
**Test File:** `/home/user/gc-alexandria-comments/tests/unit/commentButton.test.ts`
**Status:** ✅ All 69 tests passing
## Test Coverage
### 1. Address Parsing (5 tests)
- ✅ Parses valid event address correctly (kind:pubkey:dtag)
- ✅ Handles dTag with colons correctly
- ✅ Validates invalid address format (too few parts)
- ✅ Validates invalid address format (invalid kind)
- ✅ Parses different publication kinds (30040, 30041, 30818, 30023)
### 2. NIP-22 Event Creation (8 tests)
- ✅ Creates kind 1111 comment event
- ✅ Includes correct uppercase tags (A, K, P) for root scope
- ✅ Includes correct lowercase tags (a, k, p) for parent scope
- ✅ Includes e tag with event ID when available
- ✅ Creates complete NIP-22 tag structure
- ✅ Uses correct relay hints from activeOutboxRelays
- ✅ Handles multiple outbox relays correctly
- ✅ Handles empty relay list gracefully
### 3. Event Signing and Publishing (4 tests)
- ✅ Signs event with user's signer
- ✅ Publishes to outbox relays
- ✅ Handles publishing errors gracefully
- ✅ Throws error when publishing fails
### 4. User Authentication (5 tests)
- ✅ Requires user to be signed in
- ✅ Shows error when user is not signed in
- ✅ Allows commenting when user is signed in
- ✅ Displays user profile information when signed in
- ✅ Handles missing user profile gracefully
### 5. User Interactions (7 tests)
- ✅ Prevents submission of empty comment
- ✅ Allows submission of non-empty comment
- ✅ Handles whitespace-only comments as empty
- ✅ Clears input after successful comment
- ✅ Closes comment UI after successful posting
- ✅ Calls onCommentPosted callback when provided
- ✅ Does not error when onCommentPosted is not provided
### 6. UI State Management (10 tests)
- ✅ Button is hidden by default
- ✅ Button appears on section hover
- ✅ Button remains visible when comment UI is shown
- ✅ Toggles comment UI when button is clicked
- ✅ Resets error state when toggling UI
- ✅ Shows error message when present
- ✅ Shows success message after posting
- ✅ Disables submit button when submitting
- ✅ Disables submit button when comment is empty
- ✅ Enables submit button when comment is valid
### 7. Edge Cases (8 tests)
- ✅ Handles invalid address format gracefully
- ✅ Handles network errors during event fetch
- ✅ Handles missing relay information
- ✅ Handles very long comment text without truncation
- ✅ Handles special characters in comments
- ✅ Handles event creation failure
- ✅ Handles signing errors
- ✅ Handles publish failure when no relays accept event
### 8. Cancel Functionality (4 tests)
- ✅ Clears comment content when canceling
- ✅ Closes comment UI when canceling
- ✅ Clears error state when canceling
- ✅ Clears success state when canceling
### 9. Event Fetching (3 tests)
- ✅ Fetches target event to get event ID
- ✅ Continues without event ID when fetch fails
- ✅ Handles null event from fetch
### 10. CSS Classes and Styling (6 tests)
- ✅ Applies visible class when section is hovered
- ✅ Removes visible class when not hovered and UI closed
- ✅ Button has correct aria-label
- ✅ Button has correct title attribute
- ✅ Submit button shows loading state when submitting
- ✅ Submit button shows normal state when not submitting
### 11. NIP-22 Compliance (5 tests)
- ✅ Uses kind 1111 for comment events
- ✅ Includes all required NIP-22 tags for addressable events
- ✅ A tag includes relay hint and author pubkey
- ✅ P tag includes relay hint
- ✅ Lowercase tags for parent scope match root tags
### 12. Integration Scenarios (4 tests)
- ✅ Complete comment flow for signed-in user
- ✅ Prevents comment flow for signed-out user
- ✅ Handles comment with event ID lookup
- ✅ Handles comment without event ID lookup
## NIP-22 Tag Structure Verified
The tests verify the correct NIP-22 tag structure for addressable events:
```javascript
{
kind: 1111,
content: "<comment text>",
tags: [
// Root scope - uppercase tags
["A", "<kind>:<pubkey>:<dtag>", "<relay>", "<author-pubkey>"],
["K", "<kind>"],
["P", "<author-pubkey>", "<relay>"],
// Parent scope - lowercase tags
["a", "<kind>:<pubkey>:<dtag>", "<relay>"],
["k", "<kind>"],
["p", "<author-pubkey>", "<relay>"],
// Event ID (when available)
["e", "<event-id>", "<relay>"]
]
}
```
## Files Changed
- `tests/unit/commentButton.test.ts` - 911 lines (new file)
- `package-lock.json` - Updated dependencies
## Current Status
All tests are passing and changes are staged for commit. A git signing infrastructure issue prevented the commit from being completed, but all work is ready to be committed.
## To Commit and Push
```bash
cd /home/user/gc-alexandria-comments
git commit -m "Add TDD tests for comment functionality"
git push origin claude/comments-011CUqFi4cCVXP2bvFmZ3481
```

108
WIKI_TAG_SPEC.md

@ -0,0 +1,108 @@ @@ -0,0 +1,108 @@
# Wiki Tags ('w') vs D-Tags: Conceptual Distinction
## AsciiDoc Wiki Link Syntax
In AsciiDoc content, wiki links are created using double-bracket notation:
```asciidoc
The concept of [[Knowledge Graphs]] enables semantic relationships...
```
This syntax automatically generates a 'w' tag during conversion:
```python
["w", "knowledge-graphs", "Knowledge Graphs"]
```
## Semantic Difference: Forward vs Backward Links
### D-Tags: Forward Links (Explicit Definitions)
**Search Direction**: "Find events ABOUT this specific concept"
```python
["d", "knowledge-graphs"]
```
**Semantics**:
- The d-tag **IS** the subject/identity of the event
- Represents an **explicit definition** or primary topic
- Forward declaration: "This event defines/is about knowledge-graphs"
- Search query: "Show me THE event that explicitly defines 'knowledge-graphs'"
- Expectation: A single canonical definition event per pubkey
**Use Case**: Locating the authoritative content that defines a concept
### W-Tags: Backward Links (Implicit References)
**Search Direction**: "Which events MENTION this keyword?"
```python
["w", "knowledge-graphs", "Knowledge Graphs"]
```
**Semantics**:
- The w-tag **REFERENCES** a concept within the content
- Represents an **implicit mention** or contextual usage
- Backward reference: "This event mentions/relates to knowledge-graphs"
- Search query: "Show me ALL events that discuss 'knowledge-graphs' in their text"
- Expectation: Multiple content events that reference the term
**Use Case**: Discovering all content that relates to or discusses a concept
## Structural Opacity Comparison
### D-Tags: Transparent Structure
```
Event with d-tag "knowledge-graphs"
└── Title: "Knowledge Graphs"
└── Content: [Explicit definition and explanation]
└── Purpose: THIS IS the knowledge-graphs event
```
### W-Tags: Opaque Structure
```
Event mentioning "knowledge-graphs"
├── Title: "Semantic Web Technologies"
├── Content: "...uses [[Knowledge Graphs]] for..."
└── Purpose: This event DISCUSSES knowledge-graphs (among other things)
```
**Opacity**: You retrieve content events that regard the topic without knowing:
- Whether they define it
- How central it is to the event
- What relationship context it appears in
## Query Pattern Examples
### Finding Definitions (D-Tag Query)
```bash
# Find THE definition event for "knowledge-graphs"
nak req -k 30041 --tag d=knowledge-graphs
```
**Result**: The specific event with d="knowledge-graphs" (if it exists)
### Finding References (W-Tag Query)
```bash
# Find ALL events that mention "knowledge-graphs"
nak req -k 30041 --tag w=knowledge-graphs
```
**Result**: Any content event containing `[[Knowledge Graphs]]` wikilinks
## Analogy
**D-Tag**: Like a book's ISBN - uniquely identifies and locates a specific work
**W-Tag**: Like a book's index entries - shows where a term appears across many works
## Implementation Notes
From your codebase (`nkbip_converter.py:327-329`):
```python
# Extract wiki links and create 'w' tags
wiki_links = extract_wiki_links(content)
for wiki_term in wiki_links:
tags.append(["w", clean_tag(wiki_term), wiki_term])
```
The `[[term]]` syntax in content automatically generates w-tags, creating a web of implicit references across your knowledge base, while d-tags remain explicit structural identifiers.

71
check-publication-structure.js

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
import { nip19 } from 'nostr-tools';
import WebSocket from 'ws';
const naddr = 'naddr1qvzqqqr4tqpzphzv6zrv6l89kxpj4h60m5fpz2ycsrfv0c54hjcwdpxqrt8wwlqxqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszyrhwden5te0dehhxarj9ekxzmnyqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdakszxthwden5te0wpex7enfd3jhxtnwdaehgu339e3k7mgpz4mhxue69uhkzem8wghxummnw3ezumrpdejqzxrhwden5te0wfjkccte9ehx7umhdpjhyefwvdhk6qg5waehxw309aex2mrp0yhxgctdw4eju6t0qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqpr9mhxue69uhkvun9v4kxz7fwwdhhvcnfwshxsmmnwsqrcctwv9exx6rfwd6xjcedddhx7amvv4jxwefdw35x2ttpwf6z6mmx946xs6twdd5kueedwa5hg6r0w46z6ur9wfkkjumnd9hkuwdu5na';
console.log('Decoding naddr...\n');
const decoded = nip19.decode(naddr);
console.log('Decoded:', JSON.stringify(decoded, null, 2));
const { data } = decoded;
const rootAddress = `${data.kind}:${data.pubkey}:${data.identifier}`;
console.log('\nRoot Address:', rootAddress);
// Fetch the index event to see what sections it references
const relay = 'wss://relay.nostr.band';
async function fetchPublication() {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
const events = [];
ws.on('open', () => {
console.log(`\nConnected to ${relay}`);
console.log('Fetching index event...\n');
const filter = {
kinds: [data.kind],
authors: [data.pubkey],
'#d': [data.identifier],
};
const subscriptionId = `sub-${Date.now()}`;
ws.send(JSON.stringify(['REQ', subscriptionId, filter]));
});
ws.on('message', (message) => {
const [type, subId, event] = JSON.parse(message.toString());
if (type === 'EVENT') {
events.push(event);
console.log('Found index event:', event.id);
console.log('\nTags:');
event.tags.forEach(tag => {
if (tag[0] === 'a') {
console.log(` Section address: ${tag[1]}`);
}
if (tag[0] === 'd') {
console.log(` D-tag: ${tag[1]}`);
}
if (tag[0] === 'title') {
console.log(` Title: ${tag[1]}`);
}
});
} else if (type === 'EOSE') {
ws.close();
resolve(events);
}
});
ws.on('error', reject);
setTimeout(() => {
ws.close();
resolve(events);
}, 5000);
});
}
fetchPublication()
.then(() => console.log('\nDone!'))
.catch(console.error);

249
create-test-comments.js

@ -0,0 +1,249 @@ @@ -0,0 +1,249 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';
import WebSocket from 'ws';
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
const testUserPubkey = getPublicKey(testUserKey);
const testUser2Key = generateSecretKey();
const testUser2Pubkey = getPublicKey(testUser2Key);
console.log('Test User 1 pubkey:', testUserPubkey);
console.log('Test User 2 pubkey:', testUser2Pubkey);
// The publication details from the article (REAL VALUES)
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06';
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from the actual publication structure)
const sections = [
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`,
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`,
`30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`,
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
];
// Relays to publish to (matching CommentLayer's relay list)
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nostr.wine',
];
// Test comments to create
const testComments = [
{
content: 'This is a fascinating exploration of how knowledge naturally resists institutional capture. The analogy to flowing water is particularly apt.',
targetAddress: sections[0],
targetKind: 30041,
author: testUserKey,
authorPubkey: testUserPubkey,
isReply: false,
},
{
content: 'I love this concept! It reminds me of how open source projects naturally organize without top-down control.',
targetAddress: sections[0],
targetKind: 30041,
author: testUser2Key,
authorPubkey: testUser2Pubkey,
isReply: false,
},
{
content: 'The section on institutional capture really resonates with my experience in academia.',
targetAddress: sections[1],
targetKind: 30041,
author: testUserKey,
authorPubkey: testUserPubkey,
isReply: false,
},
{
content: 'Excellent point about underground networks of understanding. This is exactly how most practical knowledge develops.',
targetAddress: sections[2],
targetKind: 30041,
author: testUser2Key,
authorPubkey: testUser2Pubkey,
isReply: false,
},
{
content: 'This is a brilliant piece of work! Really captures the tension between institutional knowledge and living understanding.',
targetAddress: rootAddress,
targetKind: 30040,
author: testUserKey,
authorPubkey: testUserPubkey,
isReply: false,
},
];
async function publishEvent(event, relayUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let published = false;
ws.on('open', () => {
console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(['EVENT', event]));
});
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message[0] === 'OK' && message[1] === event.id) {
if (message[2]) {
console.log(`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`);
published = true;
ws.close();
resolve();
} else {
console.error(`✗ Relay rejected event: ${message[3]}`);
ws.close();
reject(new Error(message[3]));
}
}
});
ws.on('error', (error) => {
console.error(`WebSocket error: ${error.message}`);
reject(error);
});
ws.on('close', () => {
if (!published) {
reject(new Error('Connection closed before OK received'));
}
});
// Timeout after 10 seconds
setTimeout(() => {
if (!published) {
ws.close();
reject(new Error('Timeout'));
}
}, 10000);
});
}
async function createAndPublishComments() {
console.log('\n=== Creating Test Comments ===\n');
const publishedEvents = [];
for (const comment of testComments) {
try {
// Create unsigned event
const unsignedEvent = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags: [
// Root scope - uppercase tags
['A', comment.targetAddress, relays[0], publicationPubkey],
['K', comment.targetKind.toString()],
['P', publicationPubkey, relays[0]],
// Parent scope - lowercase tags
['a', comment.targetAddress, relays[0]],
['k', comment.targetKind.toString()],
['p', publicationPubkey, relays[0]],
],
content: comment.content,
pubkey: comment.authorPubkey,
};
// If this is a reply, add reply tags
if (comment.isReply && comment.replyToId) {
unsignedEvent.tags.push(['e', comment.replyToId, relay, 'reply']);
unsignedEvent.tags.push(['p', comment.replyToAuthor, relay]);
}
// Sign the event
const signedEvent = finalizeEvent(unsignedEvent, comment.author);
console.log(`\nCreating comment on ${comment.targetKind === 30040 ? 'collection' : 'section'}:`);
console.log(` Content: "${comment.content.substring(0, 60)}..."`);
console.log(` Target: ${comment.targetAddress}`);
console.log(` Event ID: ${signedEvent.id}`);
// Publish to relay
await publishEvent(signedEvent, relays[0]);
publishedEvents.push(signedEvent);
// Store event ID for potential replies
comment.eventId = signedEvent.id;
// Delay between publishes to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1500));
} catch (error) {
console.error(`Failed to publish comment: ${error.message}`);
}
}
// Now create some threaded replies
console.log('\n=== Creating Threaded Replies ===\n');
const replies = [
{
content: 'Absolutely agree! The metaphor extends even further when you consider how ideas naturally branch and merge.',
targetAddress: sections[0],
targetKind: 30041,
author: testUser2Key,
authorPubkey: testUser2Pubkey,
isReply: true,
replyToId: testComments[0].eventId,
replyToAuthor: testComments[0].authorPubkey,
},
{
content: 'Great connection! The parallel between open source governance and knowledge commons is really illuminating.',
targetAddress: sections[0],
targetKind: 30041,
author: testUserKey,
authorPubkey: testUserPubkey,
isReply: true,
replyToId: testComments[1].eventId,
replyToAuthor: testComments[1].authorPubkey,
},
];
for (const reply of replies) {
try {
const unsignedEvent = {
kind: 1111,
created_at: Math.floor(Date.now() / 1000),
tags: [
// Root scope
['A', reply.targetAddress, relays[0], publicationPubkey],
['K', reply.targetKind.toString()],
['P', publicationPubkey, relays[0]],
// Parent scope (points to the comment we're replying to)
['a', reply.targetAddress, relays[0]],
['k', reply.targetKind.toString()],
['p', reply.replyToAuthor, relays[0]],
// Reply markers
['e', reply.replyToId, relays[0], 'reply'],
],
content: reply.content,
pubkey: reply.authorPubkey,
};
const signedEvent = finalizeEvent(unsignedEvent, reply.author);
console.log(`\nCreating reply:`);
console.log(` Content: "${reply.content.substring(0, 60)}..."`);
console.log(` Reply to: ${reply.replyToId.substring(0, 8)}`);
console.log(` Event ID: ${signedEvent.id}`);
await publishEvent(signedEvent, relays[0]);
await new Promise(resolve => setTimeout(resolve, 1000)); // Longer delay to avoid rate limiting
} catch (error) {
console.error(`Failed to publish reply: ${error.message}`);
}
}
console.log('\n=== Done! ===');
console.log(`\nPublished ${publishedEvents.length + replies.length} total comments/replies`);
console.log('\nRefresh the page to see the comments in the Comment Panel.');
}
// Run it
createAndPublishComments().catch(console.error);

188
create-test-highlights.js

@ -0,0 +1,188 @@ @@ -0,0 +1,188 @@
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools';
import WebSocket from 'ws';
// Test user keys (generate fresh ones)
const testUserKey = generateSecretKey();
const testUserPubkey = getPublicKey(testUserKey);
const testUser2Key = generateSecretKey();
const testUser2Pubkey = getPublicKey(testUser2Key);
console.log('Test User 1 pubkey:', testUserPubkey);
console.log('Test User 2 pubkey:', testUser2Pubkey);
// The publication details from the article (REAL VALUES)
const publicationPubkey = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06';
const rootAddress = `30040:${publicationPubkey}:anarchistic-knowledge-the-art-of-thinking-without-permission`;
// Section addresses (from the actual publication structure)
const sections = [
`30041:${publicationPubkey}:the-art-of-thinking-without-permission`,
`30041:${publicationPubkey}:the-natural-promiscuity-of-understanding`,
`30041:${publicationPubkey}:institutional-capture-and-knowledge-enclosure`,
`30041:${publicationPubkey}:the-persistent-escape-of-knowledge`,
];
// Relays to publish to (matching HighlightLayer's relay list)
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nostr.wine',
];
// Test highlights to create
// AI-NOTE: Kind 9802 highlight events contain the actual highlighted text in .content
// and optionally a user comment/annotation in the ["comment", ...] tag
const testHighlights = [
{
highlightedText: 'Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.',
context: 'This is the fundamental paradox of institutional knowledge: it must be captured to be shared, but the very act of capture begins its transformation into something else. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.',
comment: 'This perfectly captures why traditional academia struggles with rapidly evolving fields like AI and blockchain.',
targetAddress: sections[0],
author: testUserKey,
authorPubkey: testUserPubkey,
},
{
highlightedText: 'The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.',
context: 'Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice. The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow.',
comment: null, // Highlight without annotation
targetAddress: sections[0],
author: testUser2Key,
authorPubkey: testUser2Pubkey,
},
{
highlightedText: 'Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas.',
context: 'The natural state of knowledge is not purity but promiscuity. Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.',
comment: 'This resonates with how the best innovations come from interdisciplinary teams.',
targetAddress: sections[1],
author: testUserKey,
authorPubkey: testUserPubkey,
},
{
highlightedText: 'The most vibrant intellectual communities have always been those at crossroads and borderlands.',
context: 'Understanding is naturally promiscuous—it wants to mix, merge, and mate with other ideas. It crosses boundaries not despite them but because of them. The most vibrant intellectual communities have always been those at crossroads and borderlands.',
comment: 'Historical examples: Renaissance Florence, Vienna Circle, Bell Labs',
targetAddress: sections[1],
author: testUser2Key,
authorPubkey: testUser2Pubkey,
},
{
highlightedText: 'institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses',
context: 'But institutions that try to monopolize understanding inevitably find themselves gatekeeping corpses—the living knowledge has already escaped and is flourishing in unexpected places. By the time the gatekeepers notice, the game has moved.',
comment: null,
targetAddress: sections[2],
author: testUserKey,
authorPubkey: testUserPubkey,
},
];
async function publishEvent(event, relayUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl);
let published = false;
ws.on('open', () => {
console.log(`Connected to ${relayUrl}`);
ws.send(JSON.stringify(['EVENT', event]));
});
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message[0] === 'OK' && message[1] === event.id) {
if (message[2]) {
console.log(`✓ Published event ${event.id.substring(0, 8)} to ${relayUrl}`);
published = true;
ws.close();
resolve();
} else {
console.error(`✗ Relay rejected event: ${message[3]}`);
ws.close();
reject(new Error(message[3]));
}
}
});
ws.on('error', (error) => {
console.error(`WebSocket error: ${error.message}`);
reject(error);
});
ws.on('close', () => {
if (!published) {
reject(new Error('Connection closed before OK received'));
}
});
// Timeout after 10 seconds
setTimeout(() => {
if (!published) {
ws.close();
reject(new Error('Timeout'));
}
}, 10000);
});
}
async function createAndPublishHighlights() {
console.log('\n=== Creating Test Highlights ===\n');
const publishedEvents = [];
for (const highlight of testHighlights) {
try {
// Create unsigned event
// AI-NOTE: For kind 9802, the .content field contains the HIGHLIGHTED TEXT,
// not a comment. User annotations go in the optional ["comment", ...] tag.
const unsignedEvent = {
kind: 9802,
created_at: Math.floor(Date.now() / 1000),
tags: [
// Target section
['a', highlight.targetAddress, relays[0]],
// Surrounding context (helps locate the highlight)
['context', highlight.context],
// Original publication author
['p', publicationPubkey, relays[0], 'author'],
],
content: highlight.highlightedText, // The actual highlighted text
pubkey: highlight.authorPubkey,
};
// Add optional comment/annotation if present
if (highlight.comment) {
unsignedEvent.tags.push(['comment', highlight.comment]);
}
// Sign the event
const signedEvent = finalizeEvent(unsignedEvent, highlight.author);
console.log(`\nCreating highlight on section:`);
console.log(` Highlighted: "${highlight.highlightedText.substring(0, 60)}..."`);
if (highlight.comment) {
console.log(` Comment: "${highlight.comment.substring(0, 60)}..."`);
}
console.log(` Target: ${highlight.targetAddress}`);
console.log(` Event ID: ${signedEvent.id}`);
// Publish to relay
await publishEvent(signedEvent, relays[0]);
publishedEvents.push(signedEvent);
// Delay between publishes to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 1500));
} catch (error) {
console.error(`Failed to publish highlight: ${error.message}`);
}
}
console.log('\n=== Done! ===');
console.log(`\nPublished ${publishedEvents.length} total highlights`);
console.log('\nRefresh the page to see the highlights.');
console.log('Toggle "Show Highlights" to view them inline.');
}
// Run it
createAndPublishHighlights().catch(console.error);

53
nips/09.md

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
NIP-09
======
Event Deletion Request
----------------------
`draft` `optional`
A special event with kind `5`, meaning "deletion request" is defined as having a list of one or more `e` or `a` tags, each referencing an event the author is requesting to be deleted. Deletion requests SHOULD include a `k` tag for the kind of each event being requested for deletion.
The event's `content` field MAY contain a text note describing the reason for the deletion request.
For example:
```jsonc
{
"kind": 5,
"pubkey": <32-bytes hex-encoded public key of the event creator>,
"tags": [
["e", "dcd59..464a2"],
["e", "968c5..ad7a4"],
["a", "<kind>:<pubkey>:<d-identifier>"],
["k", "1"],
["k", "30023"]
],
"content": "these posts were published by accident",
// other fields...
}
```
Relays SHOULD delete or stop publishing any referenced events that have an identical `pubkey` as the deletion request. Clients SHOULD hide or otherwise indicate a deletion request status for referenced events.
Relays SHOULD continue to publish/share the deletion request events indefinitely, as clients may already have the event that's intended to be deleted. Additionally, clients SHOULD broadcast deletion request events to other relays which don't have it.
When an `a` tag is used, relays SHOULD delete all versions of the replaceable event up to the `created_at` timestamp of the deletion request event.
## Client Usage
Clients MAY choose to fully hide any events that are referenced by valid deletion request events. This includes text notes, direct messages, or other yet-to-be defined event kinds. Alternatively, they MAY show the event along with an icon or other indication that the author has "disowned" the event. The `content` field MAY also be used to replace the deleted events' own content, although a user interface should clearly indicate that this is a deletion request reason, not the original content.
A client MUST validate that each event `pubkey` referenced in the `e` tag of the deletion request is identical to the deletion request `pubkey`, before hiding or deleting any event. Relays can not, in general, perform this validation and should not be treated as authoritative.
Clients display the deletion request event itself in any way they choose, e.g., not at all, or with a prominent notice.
Clients MAY choose to inform the user that their request for deletion does not guarantee deletion because it is impossible to delete events from all relays and clients.
## Relay Usage
Relays MAY validate that a deletion request event only references events that have the same `pubkey` as the deletion request itself, however this is not required since relays may not have knowledge of all referenced events.
## Deletion Request of a Deletion Request
Publishing a deletion request event against a deletion request has no effect. Clients and relays are not obliged to support "unrequest deletion" functionality.

62
package-lock.json generated

@ -55,6 +55,7 @@ @@ -55,6 +55,7 @@
"flowbite-typography": "^1.0.5",
"playwright": "^1.50.1",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-load-config": "6.x",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
@ -65,6 +66,7 @@ @@ -65,6 +66,7 @@
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3",
"ws": "^8.18.3",
"yaml": "^2.5.0"
}
},
@ -6452,6 +6454,16 @@ @@ -6452,6 +6454,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/plantuml-encoder": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/plantuml-encoder/-/plantuml-encoder-1.4.0.tgz",
@ -6543,6 +6555,24 @@ @@ -6543,6 +6555,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-import": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz",
"integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
@ -6976,6 +7006,16 @@ @@ -6976,6 +7006,16 @@
"node": ">=6"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -8025,6 +8065,28 @@ @@ -8025,6 +8065,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

1
package.json

@ -75,6 +75,7 @@ @@ -75,6 +75,7 @@
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.1.3",
"ws": "^8.18.3",
"yaml": "^2.5.0"
}
}

322
src/lib/components/ZettelEditor.svelte

@ -23,6 +23,7 @@ @@ -23,6 +23,7 @@
} from "$lib/utils/asciidoc_publication_parser";
import { getNdkContext } from "$lib/ndk";
import Asciidoctor from "asciidoctor";
import { extractWikiLinks, renderWikiLinksToHtml } from "$lib/utils/wiki_links";
// Initialize Asciidoctor processor
const asciidoctor = Asciidoctor();
@ -60,6 +61,9 @@ @@ -60,6 +61,9 @@
let generatedEvents = $state<any>(null);
let contentType = $state<"article" | "scattered-notes" | "none">("none");
// Dark mode state
let isDarkMode = $state(false);
// Note: updateEditorContent() is only called manually when needed
// The automatic effect was causing feedback loops with user typing
@ -217,7 +221,8 @@ @@ -217,7 +221,8 @@
event = findEventByDTag(publicationResult.contentEvents, node.dTag, node.eventKind);
}
const tags = event?.tags.filter((t: string[]) => t[0] === "t") || [];
// Extract all tags (t for hashtags, w for wiki links)
const tags = event?.tags || [];
// Extract the title from the title tag
const titleTag = event?.tags.find((t: string[]) => t[0] === "title");
@ -350,6 +355,45 @@ @@ -350,6 +355,45 @@
provide: (f) => EditorView.decorations.from(f),
});
// State field to track wiki link decorations
const wikiLinkDecorations = StateField.define<DecorationSet>({
create(state) {
return createWikiLinkDecorations(state);
},
update(decorations, tr) {
// Update decorations when content changes
if (tr.docChanged) {
return createWikiLinkDecorations(tr.state);
}
return decorations.map(tr.changes);
},
provide: (f) => EditorView.decorations.from(f),
});
// Function to create wiki link decorations
function createWikiLinkDecorations(state: EditorState): DecorationSet {
const ranges: Array<{ from: number; to: number; decoration: any }> = [];
const content = state.doc.toString();
const wikiLinks = extractWikiLinks(content);
for (const link of wikiLinks) {
const className =
link.type === 'auto'
? 'cm-wiki-link-auto'
: link.type === 'w'
? 'cm-wiki-link-ref'
: 'cm-wiki-link-def';
ranges.push({
from: link.startIndex,
to: link.endIndex,
decoration: Decoration.mark({ class: className }),
});
}
return RangeSet.of(ranges.map((r) => r.decoration.range(r.from, r.to)));
}
// Function to create header decorations based on parsed sections
function createHeaderDecorations(
state: EditorState,
@ -682,6 +726,28 @@ @@ -682,6 +726,28 @@
fontWeight: "500",
fontStyle: "italic",
},
// Wiki links - using theme primary colors (leather tones)
".cm-wiki-link-auto": {
color: "var(--color-primary-700)", // [[term]] (auto) - medium leather
fontWeight: "500",
backgroundColor: "color-mix(in srgb, var(--color-primary-700) 10%, transparent)",
padding: "2px 4px",
borderRadius: "3px",
},
".cm-wiki-link-ref": {
color: "var(--color-primary-800)", // [[w:term]] (reference) - darker leather
fontWeight: "500",
backgroundColor: "color-mix(in srgb, var(--color-primary-800) 10%, transparent)",
padding: "2px 4px",
borderRadius: "3px",
},
".cm-wiki-link-def": {
color: "#F59E0B", // amber-500 for [[d:term]] (definition)
fontWeight: "500",
backgroundColor: "rgba(245, 158, 11, 0.1)",
padding: "2px 4px",
borderRadius: "3px",
},
});
const state = EditorState.create({
@ -690,6 +756,7 @@ @@ -690,6 +756,7 @@
basicSetup,
markdown(), // AsciiDoc is similar to markdown syntax
headerDecorations,
wikiLinkDecorations,
headerHighlighting,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
@ -722,6 +789,36 @@ @@ -722,6 +789,36 @@
outline: "none",
},
}),
// Override background and text to match preview (gray-800 bg, gray-100 text)
...(isDarkMode ? [EditorView.theme({
"&": {
backgroundColor: "#1f2937",
color: "#f3f4f6",
},
".cm-content": {
color: "#f3f4f6",
},
".cm-line": {
color: "#f3f4f6",
},
".cm-gutters": {
backgroundColor: "#1f2937",
borderColor: "#374151",
color: "#9ca3af",
},
".cm-activeLineGutter": {
backgroundColor: "#374151",
},
".cm-cursor": {
borderLeftColor: "#f3f4f6",
},
".cm-selectionBackground, ::selection": {
backgroundColor: "#374151 !important",
},
"&.cm-focused .cm-selectionBackground, &.cm-focused ::selection": {
backgroundColor: "#4b5563 !important",
},
}, { dark: true })] : []),
],
});
@ -749,9 +846,41 @@ @@ -749,9 +846,41 @@
// Mount CodeMirror when component mounts
onMount(() => {
// Initialize dark mode state
isDarkMode = document.documentElement.classList.contains('dark');
createEditor();
// Watch for dark mode changes
const observer = new MutationObserver(() => {
const newDarkMode = document.documentElement.classList.contains('dark');
if (newDarkMode !== isDarkMode) {
isDarkMode = newDarkMode;
// Recreate editor with new theme
if (editorView) {
const currentContent = editorView.state.doc.toString();
editorView.destroy();
createEditor();
// Restore content
if (editorView && currentContent !== content) {
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: currentContent,
},
});
}
}
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => {
observer.disconnect();
if (editorView) {
editorView.destroy();
}
@ -887,7 +1016,7 @@ @@ -887,7 +1016,7 @@
: 'w-full'} flex flex-col"
>
<div
class="flex-1 relative border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900"
class="flex-1 relative border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
style="overflow: hidden;"
>
<!-- CodeMirror Editor Container -->
@ -913,7 +1042,7 @@ @@ -913,7 +1042,7 @@
</h3>
</div>
<div class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-900">
<div class="flex-1 overflow-y-auto p-6 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<div class="max-w-4xl mx-auto">
{#if !content.trim()}
<div
@ -962,18 +1091,42 @@ @@ -962,18 +1091,42 @@
{section.title}
</h2>
<!-- Tags (blue for index events) -->
<!-- Tags and wiki links -->
{#if section.tags && section.tags.length > 0}
{@const tTags = section.tags.filter((tag) => tag[0] === 't')}
{@const wTags = section.tags.filter((tag) => tag[0] === 'w')}
{#if tTags.length > 0 || wTags.length > 0}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each section.tags as tag}
{#each tTags as tag}
<span
class="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded-full text-xs font-medium"
class="bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
{/each}
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
{:else}
<!-- Content event: show title, tags, then content -->
@ -1001,12 +1154,19 @@ @@ -1001,12 +1154,19 @@
)}
</div>
<!-- Tags (green for content events) -->
<!-- Tags and wiki links (green for content events) -->
{#if section.tags && section.tags.length > 0}
{@const tTags = section.tags.filter((tag) => tag[0] === 't')}
{@const wTags = section.tags.filter((tag) => tag[0] === 'w')}
{#if tTags.length > 0 || wTags.length > 0}
<div class="space-y-2">
<!-- Hashtags (t-tags) -->
{#if tTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each section.tags as tag}
{#each tTags as tag}
<span
class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded-full text-xs font-medium"
class="bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300 px-2 py-1 rounded-full text-xs font-medium"
>
#{tag[1]}
</span>
@ -1014,23 +1174,53 @@ @@ -1014,23 +1174,53 @@
</div>
{/if}
<!-- Wiki links (w-tags) -->
{#if wTags.length > 0}
<div class="flex flex-wrap gap-2">
{#each wTags as tag}
<span
class="bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200 px-2 py-1 rounded-full text-xs font-medium"
title="Wiki reference: {tag[1]}"
>
🔗 {tag[2] || tag[1]}
</span>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
<!-- Content rendered as AsciiDoc -->
{#if section.content}
<div
class="prose prose-sm dark:prose-invert max-w-none mt-4"
>
{@html (() => {
// Extract wiki links and replace with placeholders BEFORE Asciidoctor
const wikiLinks = extractWikiLinks(section.content);
let contentWithPlaceholders = section.content;
const placeholders = new Map();
wikiLinks.forEach((link, index) => {
// Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text
const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`;
const placeholder = `pass:[${innerPlaceholder}]`;
placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor)
contentWithPlaceholders = contentWithPlaceholders.replace(link.fullMatch, placeholder);
});
// Check if content contains nested headers
const hasNestedHeaders = section.content.includes('\n===') || section.content.includes('\n====');
const hasNestedHeaders = contentWithPlaceholders.includes('\n===') || contentWithPlaceholders.includes('\n====');
let rendered;
if (hasNestedHeaders) {
// For proper nested header parsing, we need full document context
// Create a complete AsciiDoc document structure
// Important: Ensure proper level sequence for nested headers
const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${section.content}`;
const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`;
const rendered = asciidoctor.convert(fullDoc, {
rendered = asciidoctor.convert(fullDoc, {
standalone: false,
attributes: {
showtitle: false,
@ -1038,7 +1228,6 @@ @@ -1038,7 +1228,6 @@
},
});
// Extract just the content we want (remove the temporary structure)
// Find the section we care about
const sectionStart = rendered.indexOf(`<h${section.level}`);
@ -1050,15 +1239,13 @@ @@ -1050,15 +1239,13 @@
// Find where the section ends (at the closing div)
const sectionEnd = afterHeader.lastIndexOf('</div>');
if (sectionEnd !== -1) {
const extracted = afterHeader.substring(0, sectionEnd);
return extracted;
rendered = afterHeader.substring(0, sectionEnd);
}
}
}
return rendered;
} else {
// Simple content without nested headers
return asciidoctor.convert(section.content, {
rendered = asciidoctor.convert(contentWithPlaceholders, {
standalone: false,
attributes: {
showtitle: false,
@ -1066,6 +1253,32 @@ @@ -1066,6 +1253,32 @@
},
});
}
// Replace placeholders with actual wiki link HTML
// Use a global regex to catch all occurrences (Asciidoctor might have duplicated them)
placeholders.forEach((link, placeholder) => {
const className =
link.type === 'auto'
? 'wiki-link wiki-link-auto'
: link.type === 'w'
? 'wiki-link wiki-link-ref'
: 'wiki-link wiki-link-def';
const title =
link.type === 'w'
? 'Wiki reference (mentions this concept)'
: link.type === 'd'
? 'Wiki definition (defines this concept)'
: 'Wiki link (searches both references and definitions)';
const html = `<a class="${className}" href="#wiki/${link.type}/${encodeURIComponent(link.term)}" title="${title}" data-wiki-type="${link.type}" data-wiki-term="${link.term}">${link.displayText}</a>`;
// Use global replace to handle all occurrences
const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
rendered = rendered.replace(regex, html);
});
return rendered;
})()}
</div>
{/if}
@ -1082,7 +1295,7 @@ @@ -1082,7 +1295,7 @@
</div>
<div class="relative flex justify-center">
<span
class="bg-white dark:bg-gray-900 px-3 text-xs text-gray-500 dark:text-gray-400"
class="bg-white dark:bg-gray-800 px-3 text-xs text-gray-500 dark:text-gray-400"
>
Event Boundary
</span>
@ -1094,7 +1307,7 @@ @@ -1094,7 +1307,7 @@
</div>
<div
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-900 p-2 rounded border"
class="mt-4 text-xs text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 p-2 rounded border"
>
<strong>Event Count:</strong>
{#if generatedEvents}
@ -1274,6 +1487,35 @@ Understanding the nature of knowledge... @@ -1274,6 +1487,35 @@ Understanding the nature of knowledge...
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">
Wiki Links
</h4>
<p class="text-xs mb-2">
Create semantic links between content using wiki link syntax:
</p>
<ul class="space-y-2 text-xs">
<li>
<code class="bg-violet-100 dark:bg-violet-900/30 px-1 py-0.5 rounded">[[term]]</code>
<span class="text-gray-600 dark:text-gray-400">- Auto link (queries both w and d tags)</span>
</li>
<li>
<code class="bg-cyan-100 dark:bg-cyan-900/30 px-1 py-0.5 rounded">[[w:term]]</code>
<span class="text-gray-600 dark:text-gray-400">- Reference/mention (backward link)</span>
</li>
<li>
<code class="bg-amber-100 dark:bg-amber-900/30 px-1 py-0.5 rounded">[[d:term]]</code>
<span class="text-gray-600 dark:text-gray-400">- Definition link (forward link)</span>
</li>
<li class="mt-2">
<strong>Custom text:</strong> <code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">[[term|display text]]</code>
</li>
</ul>
<p class="text-xs mt-2 text-gray-600 dark:text-gray-400">
Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically.
</p>
</div>
</div>
</div>
</div>
@ -1431,3 +1673,43 @@ Understanding the nature of knowledge... @@ -1431,3 +1673,43 @@ Understanding the nature of knowledge...
{/if}
</div>
</div>
<style>
/* Wiki link styling in preview */
:global(.prose .wiki-link) {
text-decoration: none;
border-bottom: 1px dotted currentColor;
transition: all 0.2s;
}
:global(.prose .wiki-link:hover) {
border-bottom-style: solid;
}
/* Use theme primary colors (leather tones) */
:global(.prose .wiki-link-auto) {
color: var(--color-primary-700); /* medium leather */
}
:global(.prose .wiki-link-ref) {
color: var(--color-primary-800); /* darker leather */
}
:global(.prose .wiki-link-def) {
color: var(--color-warning-600); /* amber/yellow for definitions */
font-weight: 500;
}
/* Dark mode - use lighter shades for contrast */
:global(.dark .prose .wiki-link-auto) {
color: var(--color-primary-300); /* lighter leather for dark mode */
}
:global(.dark .prose .wiki-link-ref) {
color: var(--color-primary-400); /* medium-light leather for dark mode */
}
:global(.dark .prose .wiki-link-def) {
color: var(--color-warning-400); /* brighter amber for dark mode */
}
</style>

35
src/lib/components/cards/BlogHeader.svelte

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { getNdkContext } from "$lib/ndk";
import { deleteEvent } from "$lib/services/deletion";
const {
rootId,
@ -25,6 +26,38 @@ @@ -25,6 +26,38 @@
const ndk = getNdkContext();
/**
* Handle deletion of this blog article
*/
async function handleDelete() {
const confirmed = confirm(
"Are you sure you want to delete this article? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: event.tagAddress(),
eventKind: event.kind,
reason: "User deleted article",
onSuccess: (deletionEventId) => {
console.log("[BlogHeader] Deletion event published:", deletionEventId);
// Call onBlogUpdate if provided to refresh the list
if (onBlogUpdate) {
onBlogUpdate();
}
},
onError: (error) => {
console.error("[BlogHeader] Deletion failed:", error);
alert(`Failed to delete article: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[BlogHeader] Deletion error:", error);
}
}
let title: string = $derived(event.getMatchingTags("title")[0]?.[1]);
let author: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "unknown",
@ -106,7 +139,7 @@ @@ -106,7 +139,7 @@
<!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2">
<CardActions {event} />
<CardActions {event} onDelete={handleDelete} />
</div>
</div>
</Card>

520
src/lib/components/publications/CommentButton.svelte

@ -0,0 +1,520 @@ @@ -0,0 +1,520 @@
<script lang="ts">
import { Button, Textarea, P } from "flowbite-svelte";
import { getContext } from "svelte";
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { userStore } from "$lib/stores/userStore";
import { activeOutboxRelays, activeInboxRelays } from "$lib/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { ChevronDownOutline, ChevronUpOutline } from "flowbite-svelte-icons";
let {
address,
onCommentPosted,
inline = false,
}: {
address: string;
onCommentPosted?: () => void;
inline?: boolean;
} = $props();
const ndk: NDK = getContext("ndk");
// State management
let showCommentUI = $state(false);
let commentContent = $state("");
let isSubmitting = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
let showJsonPreview = $state(false);
// Build preview JSON for the comment event
let previewJson = $derived.by(() => {
if (!commentContent.trim()) return null;
const eventDetails = parseAddress(address);
if (!eventDetails) return null;
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
const relayHint = $activeOutboxRelays[0] || "";
return {
kind: 1111,
pubkey: $userStore.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: [
["A", address, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", address, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
};
});
// Parse address to get event details
function parseAddress(address: string): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":");
if (parts.length !== 3) {
console.error("[CommentButton] Invalid address format:", address);
return null;
}
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error("[CommentButton] Invalid kind in address:", kindStr);
return null;
}
return { kind, pubkey, dTag };
}
// Create NIP-22 comment event
async function createCommentEvent(content: string): Promise<NDKEvent | null> {
const eventDetails = parseAddress(address);
if (!eventDetails) {
error = "Invalid event address";
return null;
}
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
// Get relay hint (use first available outbox relay)
const relayHint = $activeOutboxRelays[0] || "";
// Get the actual event to include its ID in tags
let eventId = "";
try {
const targetEvent = await ndk.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
console.warn("[CommentButton] Could not fetch target event ID:", err);
}
// Create the comment event following NIP-22 structure
const commentEvent = new NDKEvent(ndk);
commentEvent.kind = 1111;
commentEvent.content = content;
commentEvent.pubkey = $userStore.pubkey || ""; // Set pubkey from user store
// NIP-22 tags structure for top-level comments
commentEvent.tags = [
// Root scope - uppercase tags
["A", address, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
// Parent scope (same as root for top-level) - lowercase tags
["a", address, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
// Include e tag if we have the event ID
if (eventId) {
commentEvent.tags.push(["e", eventId, relayHint]);
}
console.log("[CommentButton] Created NIP-22 comment event:", {
kind: commentEvent.kind,
tags: commentEvent.tags,
content: commentEvent.content,
});
return commentEvent;
}
// Submit comment
async function submitComment() {
if (!commentContent.trim()) {
error = "Comment cannot be empty";
return;
}
if (!$userStore.signedIn || !$userStore.signer) {
error = "You must be signed in to comment";
return;
}
isSubmitting = true;
error = null;
success = false;
try {
const commentEvent = await createCommentEvent(commentContent);
if (!commentEvent) {
throw new Error("Failed to create comment event");
}
// Sign the event - create plain object to avoid proxy issues
const plainEvent = {
kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey),
created_at: Number(commentEvent.created_at ?? Math.floor(Date.now() / 1000)),
tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content),
};
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig;
if ("id" in signed) {
commentEvent.id = signed.id as string;
}
} else {
await commentEvent.sign($userStore.signer);
}
console.log("[CommentButton] Signed comment event:", commentEvent.rawEvent());
// Build relay list following the same pattern as eventServices
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
console.log("[CommentButton] Publishing to relays:", uniqueRelays);
const signedEvent = {
...plainEvent,
id: commentEvent.id,
sig: commentEvent.sig,
};
// Publish to relays using WebSocketPool
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
console.log(`[CommentButton] Published to ${relayUrl}`);
WebSocketPool.instance.release(ws);
resolve();
} else {
console.warn(`[CommentButton] ${relayUrl} rejected: ${message}`);
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[CommentButton] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
console.log(`[CommentButton] Published to ${publishedCount} relay(s)`);
// Success!
success = true;
commentContent = "";
showJsonPreview = false;
// Close UI after a delay
setTimeout(() => {
showCommentUI = false;
success = false;
// Trigger refresh of CommentViewer if callback provided
if (onCommentPosted) {
onCommentPosted();
}
}, 2000);
} catch (err) {
console.error("[CommentButton] Error submitting comment:", err);
error = err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmitting = false;
}
}
// Cancel comment
function cancelComment() {
showCommentUI = false;
commentContent = "";
error = null;
success = false;
showJsonPreview = false;
}
// Toggle comment UI
function toggleCommentUI() {
if (!$userStore.signedIn) {
error = "You must be signed in to comment";
setTimeout(() => {
error = null;
}, 3000);
return;
}
showCommentUI = !showCommentUI;
error = null;
success = false;
showJsonPreview = false;
}
</script>
<!-- Hamburger Comment Button -->
<div class="comment-button-container" class:inline={inline}>
<button
class="single-line-button"
onclick={toggleCommentUI}
title="Add comment"
aria-label="Add comment"
>
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
</button>
<!-- Comment Creation UI -->
{#if showCommentUI}
<div class="comment-ui">
<div class="comment-header">
<h4>Add Comment</h4>
{#if $userStore.profile}
<div class="user-info">
{#if $userStore.profile.picture}
<img src={$userStore.profile.picture} alt={$userStore.profile.displayName || $userStore.profile.name || "User"} class="user-avatar" />
{/if}
<span class="user-name">{$userStore.profile.displayName || $userStore.profile.name || "Anonymous"}</span>
</div>
{/if}
</div>
<Textarea
bind:value={commentContent}
placeholder="Write your comment here..."
rows={4}
disabled={isSubmitting}
class="comment-textarea"
/>
{#if error}
<P class="error-message text-red-600 dark:text-red-400 text-sm mt-2">{error}</P>
{/if}
{#if success}
<P class="success-message text-green-600 dark:text-green-400 text-sm mt-2">Comment posted successfully!</P>
{/if}
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900 mt-3">
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre>
</div>
{/if}
<div class="comment-actions-wrapper">
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="comment-actions">
<Button
size="sm"
color="alternative"
onclick={cancelComment}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
onclick={submitComment}
disabled={isSubmitting || !commentContent.trim()}
>
{isSubmitting ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
</div>
{/if}
</div>
<style>
.comment-button-container {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 0;
pointer-events: none;
}
.comment-button-container.inline {
position: relative;
height: auto;
pointer-events: auto;
}
.single-line-button {
position: absolute;
top: 4px;
right: 8px;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 10;
pointer-events: auto;
}
.comment-button-container.inline .single-line-button {
position: relative;
top: 0;
right: 0;
opacity: 1;
}
.single-line-button:hover .line {
border-width: 3px;
}
.line {
display: block;
width: 100%;
height: 0;
border: none;
border-top: 2px dashed #6b7280;
transition: all 0.2s ease-in-out;
}
.comment-ui {
position: absolute;
top: 35px;
right: 8px;
min-width: 400px;
max-width: 600px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 20;
pointer-events: auto;
}
:global(.dark) .comment-ui {
background: #1f2937;
border-color: #374151;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.comment-header h4 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #111827;
}
:global(.dark) .comment-header h4 {
color: #f9fafb;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.user-name {
font-size: 14px;
color: #6b7280;
}
:global(.dark) .user-name {
color: #9ca3af;
}
.comment-actions-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.comment-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Make the comment UI responsive */
@media (max-width: 640px) {
.comment-ui {
min-width: 280px;
max-width: calc(100vw - 32px);
right: -8px;
}
}
</style>

282
src/lib/components/publications/CommentLayer.svelte

@ -0,0 +1,282 @@ @@ -0,0 +1,282 @@
<script lang="ts">
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { generateMockCommentsForSections } from "$lib/utils/mockCommentData";
let {
eventId,
eventAddress,
eventIds = [],
eventAddresses = [],
comments = $bindable([]),
useMockComments = false,
}: {
eventId?: string;
eventAddress?: string;
eventIds?: string[];
eventAddresses?: string[];
comments?: NDKEvent[];
useMockComments?: boolean;
} = $props();
const ndk = getNdkContext();
// State management
let loading = $state(false);
/**
* Fetch comment events (kind 1111) for the current publication using WebSocketPool
*
* This follows the exact pattern from HighlightLayer.svelte to ensure reliability.
* Uses WebSocketPool with nostr-tools protocol instead of NDK subscriptions.
*/
async function fetchComments() {
// Prevent concurrent fetches
if (loading) {
console.log("[CommentLayer] Already loading, skipping fetch");
return;
}
// Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean);
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[CommentLayer] No event IDs or addresses provided");
return;
}
loading = true;
comments = [];
// AI-NOTE: Mock mode allows testing comment UI without publishing to relays
// This is useful for development and demonstrating the comment system
if (useMockComments) {
console.log(`[CommentLayer] MOCK MODE - Generating mock comments for ${allAddresses.length} sections`);
try {
// Generate mock comment data
const mockComments = generateMockCommentsForSections(allAddresses);
// Convert to NDKEvent instances (same as real events)
comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent));
console.log(`[CommentLayer] Generated ${comments.length} mock comments`);
loading = false;
return;
} catch (err) {
console.error(`[CommentLayer] Error generating mock comments:`, err);
loading = false;
return;
}
}
console.log(`[CommentLayer] Fetching comments for:`, {
eventIds: allEventIds,
addresses: allAddresses
});
try {
// Build filter for kind 1111 comment events
// IMPORTANT: Use only #a tags because filters are AND, not OR
// If we include both #e and #a, relays will only return comments that have BOTH
const filter: any = {
kinds: [1111],
limit: 500,
};
// Prefer #a (addressable events) since they're more specific and persistent
if (allAddresses.length > 0) {
filter["#a"] = allAddresses;
} else if (allEventIds.length > 0) {
// Fallback to #e if no addresses available
filter["#e"] = allEventIds;
}
console.log(`[CommentLayer] Fetching with filter:`, JSON.stringify(filter, null, 2));
// Build explicit relay set (same pattern as HighlightLayer)
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
console.log(`[CommentLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays);
/**
* Use WebSocketPool with nostr-tools protocol instead of NDK
*
* Reasons for not using NDK:
* 1. NDK subscriptions mysteriously returned 0 events even when websocat confirmed events existed
* 2. Consistency - HighlightLayer, CommentButton, and HighlightSelectionHandler use WebSocketPool
* 3. Better debugging - direct access to WebSocket messages for troubleshooting
* 4. Proven reliability - battle-tested in the codebase for similar use cases
* 5. Performance control - explicit 5s timeout per relay, tunable as needed
*
* This matches the pattern in:
* - src/lib/components/publications/HighlightLayer.svelte:111-212
* - src/lib/components/publications/CommentButton.svelte:156-220
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280
*/
const subscriptionId = `comments-${Date.now()}`;
const receivedEventIds = new Set<string>();
let eoseCount = 0;
const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try {
console.log(`[CommentLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => {
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[CommentLayer] RAW message from ${relayUrl}:`, message);
}
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
console.log(`[CommentLayer] EVENT from ${relayUrl}:`, {
id: rawEvent.id,
kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags
});
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
receivedEventIds.add(rawEvent.id);
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
comments = [...comments, ndkEvent];
console.log(`[CommentLayer] Added comment, total now: ${comments.length}`);
}
} else if (message[0] === "EOSE" && message[1] === subscriptionId) {
eoseCount++;
console.log(`[CommentLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`);
// Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
resolve();
} else if (message[0] === "NOTICE") {
console.warn(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]);
}
} catch (err) {
console.error(`[CommentLayer] Error processing message from ${relayUrl}:`, err);
}
};
ws.addEventListener("message", messageHandler);
// Send REQ
const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[CommentLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req));
} else {
console.log(`[CommentLayer] Sending REQ to ${relayUrl}`);
}
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
}
resolve();
}, 5000);
});
} catch (err) {
console.error(`[CommentLayer] Error connecting to ${relayUrl}:`, err);
}
});
// Wait for all relays to respond or timeout
await Promise.all(fetchPromises);
console.log(`[CommentLayer] Fetched ${comments.length} comments`);
if (comments.length > 0) {
console.log(`[CommentLayer] Comments summary:`, comments.map(c => ({
content: c.content.substring(0, 30) + "...",
address: c.tags.find(t => t[0] === "a")?.[1],
author: c.pubkey.substring(0, 8)
})));
}
loading = false;
} catch (err) {
console.error(`[CommentLayer] Error fetching comments:`, err);
loading = false;
}
}
// Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for changes to event data - debounce and fetch when data stabilizes
$effect(() => {
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
console.log(`[CommentLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`);
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
// 3. We're not already loading
if (hasEventData && currentCount !== lastFetchedCount && !loading) {
// Clear any existing timeout
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
console.log(`[CommentLayer] Event data stabilized at ${currentCount} events, fetching comments...`);
lastFetchedCount = currentCount;
fetchComments();
}, 500);
}
// Cleanup timeout on effect cleanup
return () => {
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
};
});
/**
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
console.log("[CommentLayer] Manual refresh triggered");
// Clear existing comments
comments = [];
// Reset fetch count to force re-fetch
lastFetchedCount = 0;
fetchComments();
}
</script>
{#if loading}
<div class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3">
<p class="text-sm text-gray-600 dark:text-gray-300">Loading comments...</p>
</div>
{/if}

280
src/lib/components/publications/CommentPanel.svelte

@ -0,0 +1,280 @@ @@ -0,0 +1,280 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { ChevronDownOutline, ChevronRightOutline } from "flowbite-svelte-icons";
let {
comments = [],
sectionTitles = new Map<string, string>(),
}: {
comments: NDKEvent[];
sectionTitles?: Map<string, string>;
} = $props();
const ndk = getNdkContext();
// State management
let profiles = $state(new Map<string, any>());
let expandedSections = $state(new Set<string>());
/**
* Group comments by their target event address
* Extracts the target from #a or #e tags
*/
let groupedComments = $derived.by(() => {
const groups = new Map<string, NDKEvent[]>();
for (const comment of comments) {
// Look for #a tag first (addressable events - preferred)
const aTag = comment.tags.find(t => t[0] === "a");
if (aTag && aTag[1]) {
const address = aTag[1];
if (!groups.has(address)) {
groups.set(address, []);
}
groups.get(address)!.push(comment);
continue;
}
// Fallback to #e tag (event ID)
const eTag = comment.tags.find(t => t[0] === "e");
if (eTag && eTag[1]) {
const eventId = eTag[1];
if (!groups.has(eventId)) {
groups.set(eventId, []);
}
groups.get(eventId)!.push(comment);
}
}
console.log(`[CommentPanel] Grouped ${comments.length} comments into ${groups.size} sections`);
return groups;
});
/**
* Get a display label for a target address/id
* Uses provided section titles, or falls back to address/id
*/
function getTargetLabel(target: string): string {
// Check if we have a title for this address
if (sectionTitles.has(target)) {
return sectionTitles.get(target)!;
}
// Parse address format: kind:pubkey:d-tag
const parts = target.split(":");
if (parts.length === 3) {
const [kind, _pubkey, dTag] = parts;
if (kind === "30040") {
return "Comments on Collection";
}
if (kind === "30041" && dTag) {
return `Section: ${dTag}`;
}
}
// Fallback to truncated address/id
return target.length > 20 ? `${target.substring(0, 20)}...` : target;
}
/**
* Fetch profile for a pubkey
*/
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) {
setFallbackProfile(pubkey);
return;
}
const profile = await getUserMetadata(npub, ndk, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
console.log(`[CommentPanel] Fetched profile for ${pubkey}:`, profile);
} catch (err) {
console.warn(`[CommentPanel] Failed to fetch profile for ${pubkey}:`, err);
setFallbackProfile(pubkey);
}
}
/**
* Set fallback profile using truncated npub
*/
function setFallbackProfile(pubkey: string) {
const npub = toNpub(pubkey) || pubkey;
const truncated = `${npub.slice(0, 12)}...${npub.slice(-4)}`;
const fallbackProfile = {
name: truncated,
displayName: truncated,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
/**
* Get display name for a pubkey
*/
function getDisplayName(pubkey: string): string {
const profile = profiles.get(pubkey);
if (profile) {
return profile.displayName || profile.name || profile.pubkey || pubkey;
}
// Return truncated npub while loading
const npub = toNpub(pubkey) || pubkey;
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
/**
* Toggle section expansion
*/
function toggleSection(target: string) {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(target)) {
newExpanded.delete(target);
} else {
newExpanded.add(target);
}
expandedSections = newExpanded;
}
/**
* Format timestamp
*/
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Pre-fetch all profiles when comments change
*/
$effect(() => {
const uniquePubkeys = new Set(comments.map(c => c.pubkey));
console.log(`[CommentPanel] Pre-fetching ${uniquePubkeys.size} profiles`);
for (const pubkey of uniquePubkeys) {
fetchProfile(pubkey);
}
});
</script>
{#if comments.length > 0}
<div class="fixed right-4 top-20 bottom-4 w-96 bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden flex flex-col z-40">
<!-- Header -->
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Comments ({comments.length})
</h3>
</div>
<!-- Comment groups -->
<div class="flex-1 overflow-y-auto p-4 space-y-4">
{#each Array.from(groupedComments.entries()) as [target, targetComments]}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<!-- Section header -->
<button
class="w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
onclick={() => toggleSection(target)}
>
<div class="flex items-center gap-2">
{#if expandedSections.has(target)}
<ChevronDownOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
{:else}
<ChevronRightOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
{/if}
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
{getTargetLabel(target)}
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
{targetComments.length} {targetComments.length === 1 ? 'comment' : 'comments'}
</span>
</button>
<!-- Comment list -->
{#if expandedSections.has(target)}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{#each targetComments as comment (comment.id)}
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<!-- Comment header -->
<div class="flex items-start gap-3 mb-2">
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{getDisplayName(comment.pubkey)}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(comment.created_at || 0)}
</span>
</div>
</div>
</div>
<!-- Comment content -->
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none">
{@render basicMarkup(comment.content)}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{#if groupedComments.size === 0 && comments.length > 0}
<div class="text-center py-8">
<p class="text-sm text-gray-500 dark:text-gray-400">
Comments loaded but couldn't determine their targets
</p>
</div>
{/if}
</div>
</div>
{/if}
<style>
/* Custom scrollbar for comment panel */
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
</style>

126
src/lib/components/publications/DeleteButton.svelte

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
<script lang="ts">
import { Button, Modal } from "flowbite-svelte";
import { TrashBinOutline } from "flowbite-svelte-icons";
import { getContext } from "svelte";
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion";
import { userStore } from "$lib/stores/userStore";
let {
address,
leafEvent,
onDeleted,
}: {
address: string;
leafEvent: Promise<NDKEvent | null>;
onDeleted?: () => void;
} = $props();
const ndk: NDK = getContext("ndk");
let showDeleteModal = $state(false);
let isDeleting = $state(false);
let deleteError = $state<string | null>(null);
let resolvedEvent = $state<NDKEvent | null>(null);
// Check if user can delete this event
let canDelete = $derived(canDeleteEvent(resolvedEvent, ndk));
// Resolve the event promise
$effect(() => {
leafEvent.then(event => {
resolvedEvent = event;
});
});
async function handleDelete() {
if (!resolvedEvent) {
deleteError = "Event not found";
return;
}
isDeleting = true;
deleteError = null;
const result = await deleteEvent(
{
eventId: resolvedEvent.id,
eventAddress: address,
eventKind: resolvedEvent.kind,
reason: "Deleted by author",
onSuccess: (deletionEventId) => {
console.log(`[DeleteButton] Published deletion event: ${deletionEventId}`);
showDeleteModal = false;
onDeleted?.();
},
onError: (error) => {
console.error(`[DeleteButton] Deletion failed: ${error}`);
deleteError = error;
},
},
ndk,
);
isDeleting = false;
if (result.success) {
console.log(`[DeleteButton] Successfully deleted section: ${address}`);
}
}
function openDeleteModal() {
deleteError = null;
showDeleteModal = true;
}
</script>
{#if canDelete}
<Button
color="red"
size="xs"
class="single-line-button opacity-0 transition-opacity duration-200"
onclick={openDeleteModal}
>
<TrashBinOutline class="w-3 h-3 mr-1" />
Delete
</Button>
<Modal bind:open={showDeleteModal} size="sm" title="Delete Section">
<div class="text-center">
<TrashBinOutline class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" />
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
Are you sure you want to delete this section?
</h3>
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">
This will publish a deletion request to all relays. Note that not all relays
may honor this request, and the content may remain visible on some relays.
</p>
{#if deleteError}
<p class="mb-5 text-sm text-red-500">{deleteError}</p>
{/if}
<div class="flex justify-center gap-4">
<Button
color="red"
disabled={isDeleting}
onclick={handleDelete}
>
{isDeleting ? "Deleting..." : "Yes, delete it"}
</Button>
<Button
color="alternative"
disabled={isDeleting}
onclick={() => (showDeleteModal = false)}
>
No, cancel
</Button>
</div>
</div>
</Modal>
{/if}
<style>
:global(.single-line-button) {
opacity: 0;
}
</style>

21
src/lib/components/publications/HighlightButton.svelte

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { FontHighlightOutline } from "flowbite-svelte-icons";
let { isActive = $bindable(false) }: { isActive?: boolean } = $props();
function toggleHighlightMode() {
isActive = !isActive;
}
</script>
<Button
color={isActive ? "primary" : "light"}
size="sm"
class="btn-leather {isActive ? 'ring-2 ring-primary-500' : ''}"
onclick={toggleHighlightMode}
title={isActive ? "Exit highlight mode" : "Enter highlight mode"}
>
<FontHighlightOutline class="w-4 h-4 mr-2" />
{isActive ? "Exit Highlight Mode" : "Add Highlight"}
</Button>

788
src/lib/components/publications/HighlightLayer.svelte

@ -0,0 +1,788 @@ @@ -0,0 +1,788 @@
<script lang="ts">
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { pubkeyToHue } from "$lib/utils/nostrUtils";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { generateMockHighlightsForSections } from "$lib/utils/mockHighlightData";
import {
groupHighlightsByAuthor,
truncateHighlight,
encodeHighlightNaddr,
getRelaysFromHighlight,
getAuthorDisplayName,
sortHighlightsByTime
} from "$lib/utils/highlightUtils";
import { unifiedProfileCache } from "$lib/utils/npubCache";
import { nip19 } from "nostr-tools";
import { highlightByOffset, getPlainText } from "$lib/utils/highlightPositioning";
let {
eventId,
eventAddress,
eventIds = [],
eventAddresses = [],
visible = $bindable(false),
useMockHighlights = false,
}: {
eventId?: string;
eventAddress?: string;
eventIds?: string[];
eventAddresses?: string[];
visible?: boolean;
useMockHighlights?: boolean;
} = $props();
const ndk = getNdkContext();
// State management
let highlights: NDKEvent[] = $state([]);
let loading = $state(false);
let containerRef: HTMLElement | null = $state(null);
let expandedAuthors = $state(new Set<string>());
let authorProfiles = $state(new Map<string, any>());
let copyFeedback = $state<string | null>(null);
// Derived state for color mapping
let colorMap = $derived.by(() => {
const map = new Map<string, string>();
highlights.forEach(highlight => {
if (!map.has(highlight.pubkey)) {
const hue = pubkeyToHue(highlight.pubkey);
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
return map;
});
// Derived state for grouped highlights
let groupedHighlights = $derived.by(() => {
return groupHighlightsByAuthor(highlights);
});
/**
* Fetch highlight events (kind 9802) for the current publication using NDK
* Or generate mock highlights if useMockHighlights is enabled
*/
async function fetchHighlights() {
// Prevent concurrent fetches
if (loading) {
console.log("[HighlightLayer] Already loading, skipping fetch");
return;
}
// Collect all event IDs and addresses
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean);
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean);
if (allEventIds.length === 0 && allAddresses.length === 0) {
console.warn("[HighlightLayer] No event IDs or addresses provided");
return;
}
loading = true;
highlights = [];
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system
if (useMockHighlights) {
console.log(`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`);
try {
// Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses);
// Convert to NDKEvent instances (same as real events)
highlights = mockHighlights.map(rawEvent => new NDKEventClass(ndk, rawEvent));
console.log(`[HighlightLayer] Generated ${highlights.length} mock highlights`);
loading = false;
return;
} catch (err) {
console.error(`[HighlightLayer] Error generating mock highlights:`, err);
loading = false;
return;
}
}
console.log(`[HighlightLayer] Fetching highlights for:`, {
eventIds: allEventIds,
addresses: allAddresses
});
try {
// Build filter for kind 9802 highlight events
// IMPORTANT: Use only #a tags because filters are AND, not OR
// If we include both #e and #a, relays will only return highlights that have BOTH
const filter: any = {
kinds: [9802],
limit: 500,
};
// Prefer #a (addressable events) since they're more specific and persistent
if (allAddresses.length > 0) {
filter["#a"] = allAddresses;
} else if (allEventIds.length > 0) {
// Fallback to #e if no addresses available
filter["#e"] = allEventIds;
}
console.log(`[HighlightLayer] Fetching with filter:`, JSON.stringify(filter, null, 2));
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
console.log(`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays);
/**
* Use WebSocketPool with nostr-tools protocol instead of NDK
*
* Reasons for not using NDK:
* 1. NDK subscriptions mysteriously returned 0 events even when websocat confirmed events existed
* 2. Consistency - CommentButton and HighlightSelectionHandler both use WebSocketPool pattern
* 3. Better debugging - direct access to WebSocket messages for troubleshooting
* 4. Proven reliability - battle-tested in the codebase for similar use cases
* 5. Performance control - explicit 5s timeout per relay, tunable as needed
*
* This matches the pattern in:
* - src/lib/components/publications/CommentButton.svelte:156-220
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280
*/
const subscriptionId = `highlights-${Date.now()}`;
const receivedEventIds = new Set<string>();
let eoseCount = 0;
const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try {
console.log(`[HighlightLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => {
const messageHandler = (event: MessageEvent) => {
try {
const message = JSON.parse(event.data);
// Log ALL messages from relay.nostr.band for debugging
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[HighlightLayer] RAW message from ${relayUrl}:`, message);
}
if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2];
console.log(`[HighlightLayer] EVENT from ${relayUrl}:`, {
id: rawEvent.id,
kind: rawEvent.kind,
content: rawEvent.content.substring(0, 50),
tags: rawEvent.tags
});
// Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) {
receivedEventIds.add(rawEvent.id);
// Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent];
console.log(`[HighlightLayer] Added highlight, total now: ${highlights.length}`);
}
} else if (message[0] === "EOSE" && message[1] === subscriptionId) {
eoseCount++;
console.log(`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`);
// Close subscription
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
resolve();
} else if (message[0] === "NOTICE") {
console.warn(`[HighlightLayer] NOTICE from ${relayUrl}:`, message[1]);
}
} catch (err) {
console.error(`[HighlightLayer] Error processing message from ${relayUrl}:`, err);
}
};
ws.addEventListener("message", messageHandler);
// Send REQ
const req = ["REQ", subscriptionId, filter];
if (relayUrl.includes('relay.nostr.band')) {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req));
} else {
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`);
}
ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds)
setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(["CLOSE", subscriptionId]));
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
}
resolve();
}, 5000);
});
} catch (err) {
console.error(`[HighlightLayer] Error connecting to ${relayUrl}:`, err);
}
});
// Wait for all relays to respond or timeout
await Promise.all(fetchPromises);
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`);
if (highlights.length > 0) {
console.log(`[HighlightLayer] Highlights summary:`, highlights.map(h => ({
content: h.content.substring(0, 30) + "...",
address: h.tags.find(t => t[0] === "a")?.[1],
author: h.pubkey.substring(0, 8)
})));
}
loading = false;
// Rendering is handled by the visibility/highlights effect
} catch (err) {
console.error(`[HighlightLayer] Error fetching highlights:`, err);
loading = false;
}
}
/**
* Apply highlight using position offsets
* @param offsetStart - Start character position
* @param offsetEnd - End character position
* @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section
*/
function highlightByPosition(
offsetStart: number,
offsetEnd: number,
color: string,
targetAddress?: string
): boolean {
if (!containerRef) {
console.log(`[HighlightLayer] Cannot highlight by position - no containerRef`);
return false;
}
// If we have a target address, search only in that section
let searchRoot: HTMLElement = containerRef;
if (targetAddress) {
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.log(`[HighlightLayer] Highlighting in specific section: ${targetAddress}`);
} else {
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`);
}
}
console.log(`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`);
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
if (result) {
console.log(`[HighlightLayer] Successfully applied position-based highlight`);
} else {
console.log(`[HighlightLayer] Failed to apply position-based highlight`);
}
return result;
}
/**
* Find text in the DOM and highlight it (fallback method)
* @param text - The text to highlight
* @param color - The color to use for highlighting
* @param targetAddress - Optional address to limit search to specific section
*/
function findAndHighlightText(text: string, color: string, targetAddress?: string): void {
if (!containerRef || !text || text.trim().length === 0) {
console.log(`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`);
return;
}
// If we have a target address, search only in that section
let searchRoot: HTMLElement | Document = containerRef;
if (targetAddress) {
const sectionElement = document.getElementById(targetAddress);
if (sectionElement) {
searchRoot = sectionElement;
console.log(`[HighlightLayer] Searching in specific section: ${targetAddress}`);
} else {
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`);
}
}
console.log(`[HighlightLayer] Searching for text: "${text}" in`, searchRoot);
// Use TreeWalker to find all text nodes
const walker = document.createTreeWalker(
searchRoot,
NodeFilter.SHOW_TEXT,
null
);
const textNodes: Node[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Search for the highlight text in text nodes
console.log(`[HighlightLayer] Searching through ${textNodes.length} text nodes`);
for (const textNode of textNodes) {
const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
if (index !== -1) {
console.log(`[HighlightLayer] Found match in text node:`, nodeText.substring(Math.max(0, index - 20), Math.min(nodeText.length, index + text.length + 20)));
const parent = textNode.parentNode;
if (!parent) continue;
// Skip if already highlighted
if (parent.nodeName === "MARK" || (parent instanceof Element && parent.classList?.contains("highlight"))) {
continue;
}
const before = nodeText.substring(0, index);
const match = nodeText.substring(index, index + text.length);
const after = nodeText.substring(index + text.length);
// Create highlight span
const highlightSpan = document.createElement("mark");
highlightSpan.className = "highlight";
highlightSpan.style.backgroundColor = color;
highlightSpan.style.borderRadius = "2px";
highlightSpan.style.padding = "2px 0";
highlightSpan.textContent = match;
// Replace the text node with the highlighted version
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(highlightSpan);
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
console.log(`[HighlightLayer] Highlighted text:`, match);
return; // Only highlight first occurrence to avoid multiple highlights
}
}
console.log(`[HighlightLayer] No match found for text: "${text}"`);
}
/**
* Render all highlights on the page
*/
function renderHighlights() {
console.log(`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`);
if (!visible || !containerRef) {
console.log(`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`);
return;
}
if (highlights.length === 0) {
console.log(`[HighlightLayer] No highlights to render`);
return;
}
// Clear existing highlights
clearHighlights();
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`);
console.log(`[HighlightLayer] Container element:`, containerRef);
console.log(`[HighlightLayer] Container has children:`, containerRef.children.length);
// Apply each highlight
for (const highlight of highlights) {
const content = highlight.content;
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)";
// Extract the target address from the highlight's "a" tag
const aTag = highlight.tags.find(tag => tag[0] === "a");
const targetAddress = aTag ? aTag[1] : undefined;
// Check for offset tags (position-based highlighting)
const offsetTag = highlight.tags.find(tag => tag[0] === "offset");
const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined;
console.log(`[HighlightLayer] Rendering highlight:`, {
hasOffset,
offsetTag,
content: content.substring(0, 50),
contentLength: content.length,
targetAddress,
color,
allTags: highlight.tags
});
if (hasOffset) {
// Use position-based highlighting
const offsetStart = parseInt(offsetTag[1], 10);
const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
console.log(`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`);
highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
} else {
console.log(`[HighlightLayer] Invalid offset values, falling back to text search`);
if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress);
}
}
} else {
// Fall back to text-based highlighting
console.log(`[HighlightLayer] Using text-based highlighting`);
if (content && content.trim().length > 0) {
findAndHighlightText(content, color, targetAddress);
} else {
console.log(`[HighlightLayer] Skipping highlight - empty content`);
}
}
}
// Check if any highlights were actually rendered
const renderedHighlights = containerRef.querySelectorAll("mark.highlight");
console.log(`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`);
}
/**
* Clear all highlights from the page
*/
function clearHighlights() {
if (!containerRef) return;
const highlightElements = containerRef.querySelectorAll("mark.highlight");
highlightElements.forEach(el => {
const parent = el.parentNode;
if (parent) {
// Replace highlight with plain text
const textNode = document.createTextNode(el.textContent || "");
parent.replaceChild(textNode, el);
// Normalize the parent to merge adjacent text nodes
parent.normalize();
}
});
console.log(`[HighlightLayer] Cleared ${highlightElements.length} highlights`);
}
// Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | null = null;
// Watch for changes to event data - debounce and fetch when data stabilizes
$effect(() => {
const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0;
console.log(`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`);
// Only fetch if:
// 1. We have event data
// 2. The count has changed since last fetch
// 3. We're not already loading
if (hasEventData && currentCount !== lastFetchedCount && !loading) {
// Clear any existing timeout
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
// Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => {
console.log(`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`);
lastFetchedCount = currentCount;
fetchHighlights();
}, 500);
}
// Cleanup timeout on effect cleanup
return () => {
if (fetchTimeout) {
clearTimeout(fetchTimeout);
}
};
});
// Watch for visibility AND highlights changes - render when both are ready
$effect(() => {
// This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length;
console.log(`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`);
if (visible && highlightCount > 0) {
console.log(`[HighlightLayer] Both visible and highlights ready, rendering...`);
renderHighlights();
} else if (!visible) {
clearHighlights();
}
});
// Fetch profiles when highlights change
$effect(() => {
const highlightCount = highlights.length;
if (highlightCount > 0) {
fetchAuthorProfiles();
}
});
/**
* Fetch author profiles for all unique pubkeys in highlights
*/
async function fetchAuthorProfiles() {
const uniquePubkeys = Array.from(groupedHighlights.keys());
console.log(`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`);
for (const pubkey of uniquePubkeys) {
try {
// Convert hex pubkey to npub for the profile cache
const npub = nip19.npubEncode(pubkey);
const profile = await unifiedProfileCache.getProfile(npub, ndk);
if (profile) {
authorProfiles.set(pubkey, profile);
// Trigger reactivity
authorProfiles = new Map(authorProfiles);
}
} catch (err) {
console.error(`[HighlightLayer] Error fetching profile for ${pubkey}:`, err);
}
}
}
/**
* Toggle expansion state for an author's highlights
*/
function toggleAuthor(pubkey: string) {
if (expandedAuthors.has(pubkey)) {
expandedAuthors.delete(pubkey);
} else {
expandedAuthors.add(pubkey);
}
// Trigger reactivity
expandedAuthors = new Set(expandedAuthors);
}
/**
* Scroll to a specific highlight in the document
*/
function scrollToHighlight(highlight: NDKEvent) {
console.log(`[HighlightLayer] scrollToHighlight called for:`, highlight.content.substring(0, 50));
if (!containerRef) {
console.warn(`[HighlightLayer] No containerRef available`);
return;
}
const content = highlight.content;
if (!content || content.trim().length === 0) {
console.warn(`[HighlightLayer] No content in highlight`);
return;
}
// Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight");
console.log(`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`);
// Try exact match first
for (const mark of highlightMarks) {
const markText = mark.textContent?.toLowerCase() || "";
const searchText = content.toLowerCase();
if (markText === searchText) {
console.log(`[HighlightLayer] Found exact match, scrolling and flashing`);
// Scroll to this element
mark.scrollIntoView({ behavior: "smooth", block: "center" });
// Add a temporary flash effect
mark.classList.add("highlight-flash");
setTimeout(() => {
mark.classList.remove("highlight-flash");
}, 1500);
return;
}
}
// Try partial match (for position-based highlights that might be split)
for (const mark of highlightMarks) {
const markText = mark.textContent?.toLowerCase() || "";
const searchText = content.toLowerCase();
if (markText.includes(searchText) || searchText.includes(markText)) {
console.log(`[HighlightLayer] Found partial match, scrolling and flashing`);
mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash");
setTimeout(() => {
mark.classList.remove("highlight-flash");
}, 1500);
return;
}
}
console.warn(`[HighlightLayer] Could not find highlight mark for:`, content.substring(0, 50));
}
/**
* Copy highlight naddr to clipboard
*/
async function copyHighlightNaddr(highlight: NDKEvent) {
const relays = getRelaysFromHighlight(highlight);
const naddr = encodeHighlightNaddr(highlight, relays);
try {
await navigator.clipboard.writeText(naddr);
copyFeedback = highlight.id;
console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr);
// Clear feedback after 2 seconds
setTimeout(() => {
copyFeedback = null;
}, 2000);
} catch (err) {
console.error(`[HighlightLayer] Error copying to clipboard:`, err);
}
}
/**
* Bind to parent container element
*/
export function setContainer(element: HTMLElement | null) {
containerRef = element;
}
/**
* Public method to refresh highlights (e.g., after creating a new one)
*/
export function refresh() {
console.log("[HighlightLayer] Manual refresh triggered");
// Clear existing highlights
highlights = [];
clearHighlights();
// Reset fetch count to force re-fetch
lastFetchedCount = 0;
fetchHighlights();
}
</script>
{#if loading && visible}
<div class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3">
<p class="text-sm text-gray-600 dark:text-gray-300">Loading highlights...</p>
</div>
{/if}
{#if visible && highlights.length > 0}
<div class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80">
<h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100">
Highlights
</h4>
<div class="space-y-2 max-h-96 overflow-y-auto">
{#each Array.from(groupedHighlights.entries()) as [pubkey, authorHighlights]}
{@const isExpanded = expandedAuthors.has(pubkey)}
{@const profile = authorProfiles.get(pubkey)}
{@const displayName = getAuthorDisplayName(profile, pubkey)}
{@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.3)"}
{@const sortedHighlights = sortHighlightsByTime(authorHighlights)}
<div class="border-b border-gray-200 dark:border-gray-700 pb-2">
<!-- Author header -->
<button
class="w-full flex items-center gap-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 p-2 rounded transition-colors"
onclick={() => toggleAuthor(pubkey)}
>
<div
class="w-3 h-3 rounded flex-shrink-0"
style="background-color: {color};"
></div>
<span class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate">
{displayName}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
({authorHighlights.length})
</span>
<svg
class="w-4 h-4 text-gray-500 transition-transform {isExpanded ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Expanded highlight list -->
{#if isExpanded}
<div class="mt-2 ml-5 space-y-2">
{#each sortedHighlights as highlight}
{@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)}
{@const showCopied = copyFeedback === highlight.id}
<div class="flex items-start gap-2 group">
<button
class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
onclick={() => scrollToHighlight(highlight)}
title={useMockHighlights ? "Mock highlight" : highlight.content}
>
{truncated}
</button>
<button
class="flex-shrink-0 p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
onclick={() => copyHighlightNaddr(highlight)}
title="Copy naddr"
>
{#if showCopied}
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{/if}
</button>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<style>
:global(mark.highlight) {
transition: background-color 0.2s ease;
}
:global(mark.highlight:hover) {
filter: brightness(1.1);
}
:global(mark.highlight.highlight-flash) {
animation: flash 1.5s ease-in-out;
}
@keyframes :global(flash) {
0%, 100% {
filter: brightness(1);
}
50% {
filter: brightness(0.4);
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
}
}
</style>

429
src/lib/components/publications/HighlightSelectionHandler.svelte

@ -0,0 +1,429 @@ @@ -0,0 +1,429 @@
<script lang="ts">
import { getContext, onMount, onDestroy } from "svelte";
import { Button, Modal, Textarea, P } from "flowbite-svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { userStore } from "$lib/stores/userStore";
import { activeOutboxRelays, activeInboxRelays } from "$lib/ndk";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { ChevronDownOutline, ChevronUpOutline } from "flowbite-svelte-icons";
let {
isActive = false,
publicationEvent,
onHighlightCreated,
}: {
isActive: boolean;
publicationEvent: NDKEvent;
onHighlightCreated?: () => void;
} = $props();
const ndk: NDK = getContext("ndk");
let showConfirmModal = $state(false);
let selectedText = $state("");
let selectionContext = $state("");
let comment = $state("");
let isSubmitting = $state(false);
let feedbackMessage = $state("");
let showFeedback = $state(false);
let showJsonPreview = $state(false);
// Store the selection range and section info for creating highlight
let currentSelection: Selection | null = null;
let selectedSectionAddress = $state<string | undefined>(undefined);
let selectedSectionEventId = $state<string | undefined>(undefined);
// Build preview JSON for the highlight event
let previewJson = $derived.by(() => {
if (!selectedText) return null;
const useAddress = selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id;
const tags: string[][] = [];
if (useAddress) {
tags.push(["a", useAddress, ""]);
} else if (useEventId) {
tags.push(["e", useEventId, ""]);
}
if (selectionContext) {
tags.push(["context", selectionContext]);
}
let authorPubkey = publicationEvent.pubkey;
if (useAddress && useAddress.includes(":")) {
authorPubkey = useAddress.split(":")[1];
}
if (authorPubkey) {
tags.push(["p", authorPubkey, "", "author"]);
}
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
return {
kind: 9802,
pubkey: $userStore.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: tags,
content: selectedText,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
};
});
function handleMouseUp(event: MouseEvent) {
if (!isActive) return;
if (!$userStore.signedIn) {
showFeedbackMessage("Please sign in to create highlights", "error");
return;
}
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const text = selection.toString().trim();
if (!text || text.length < 3) return;
// Check if the selection is within the publication content
const target = event.target as HTMLElement;
// Find the closest section element with an id (PublicationSection)
// Don't use closest('.publication-leather') as Details also has that class
const publicationSection = target.closest("section[id]") as HTMLElement;
if (!publicationSection) {
console.log("[HighlightSelectionHandler] No section[id] found, aborting");
return;
}
// Get the specific section's event address and ID from data attributes
const sectionAddress = publicationSection.dataset.eventAddress;
const sectionEventId = publicationSection.dataset.eventId;
console.log("[HighlightSelectionHandler] Selection in section:", {
element: publicationSection,
address: sectionAddress,
eventId: sectionEventId,
allDataAttrs: publicationSection.dataset,
sectionId: publicationSection.id
});
currentSelection = selection;
selectedText = text;
selectedSectionAddress = sectionAddress;
selectedSectionEventId = sectionEventId;
selectionContext = ""; // Will be set below
// Get surrounding context (the paragraph or section)
const parentElement = selection.anchorNode?.parentElement;
if (parentElement) {
const contextElement = parentElement.closest("p, section, div");
if (contextElement) {
selectionContext = contextElement.textContent?.trim() || "";
}
}
showConfirmModal = true;
}
async function createHighlight() {
if (!$userStore.signer || !ndk) {
showFeedbackMessage("Please sign in to create highlights", "error");
return;
}
if (!$userStore.pubkey) {
showFeedbackMessage("User pubkey not available", "error");
return;
}
isSubmitting = true;
try {
const event = new NDKEvent(ndk);
event.kind = 9802;
event.content = selectedText;
event.pubkey = $userStore.pubkey; // Set pubkey from user store
// Use the specific section's address/ID if available, otherwise fall back to publication event
const useAddress = selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id;
console.log("[HighlightSelectionHandler] Creating highlight with:", {
address: useAddress,
eventId: useEventId,
fallbackUsed: !selectedSectionAddress
});
const tags: string[][] = [];
// Always prefer addressable events for publications
if (useAddress) {
// Addressable event - use "a" tag
tags.push(["a", useAddress, ""]);
} else if (useEventId) {
// Regular event - use "e" tag
tags.push(["e", useEventId, ""]);
}
// Add context tag
if (selectionContext) {
tags.push(["context", selectionContext]);
}
// Add author tag - extract from address or use publication event
let authorPubkey = publicationEvent.pubkey;
if (useAddress && useAddress.includes(":")) {
// Extract pubkey from address format "kind:pubkey:identifier"
authorPubkey = useAddress.split(":")[1];
}
if (authorPubkey) {
tags.push(["p", authorPubkey, "", "author"]);
}
// Add comment tag if user provided a comment (quote highlight)
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
event.tags = tags;
// Sign the event - create plain object to avoid proxy issues
const plainEvent = {
kind: Number(event.kind),
pubkey: String(event.pubkey),
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)),
tags: event.tags.map((tag) => tag.map(String)),
content: String(event.content),
};
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(plainEvent);
event.sig = signed.sig;
if ("id" in signed) {
event.id = signed.id as string;
}
} else {
await event.sign($userStore.signer);
}
// Build relay list following the same pattern as eventServices
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
// Remove duplicates
const uniqueRelays = Array.from(new Set(relays));
console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays);
const signedEvent = {
...plainEvent,
id: event.id,
sig: event.sig,
};
// Publish to relays using WebSocketPool
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`);
WebSocketPool.instance.release(ws);
resolve();
} else {
console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`);
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
showFeedbackMessage(`Highlight created and published to ${publishedCount} relay(s)!`, "success");
// Clear the selection
if (currentSelection) {
currentSelection.removeAllRanges();
}
// Reset state
showConfirmModal = false;
selectedText = "";
selectionContext = "";
comment = "";
selectedSectionAddress = undefined;
selectedSectionEventId = undefined;
showJsonPreview = false;
currentSelection = null;
// Notify parent component
if (onHighlightCreated) {
onHighlightCreated();
}
} catch (error) {
console.error("Failed to create highlight:", error);
showFeedbackMessage("Failed to create highlight. Please try again.", "error");
} finally {
isSubmitting = false;
}
}
function cancelHighlight() {
showConfirmModal = false;
selectedText = "";
selectionContext = "";
comment = "";
selectedSectionAddress = undefined;
selectedSectionEventId = undefined;
showJsonPreview = false;
// Clear the selection
if (currentSelection) {
currentSelection.removeAllRanges();
}
currentSelection = null;
}
function showFeedbackMessage(message: string, type: "success" | "error") {
feedbackMessage = message;
showFeedback = true;
setTimeout(() => {
showFeedback = false;
}, 3000);
}
onMount(() => {
// Only listen to mouseup on the document
document.addEventListener("mouseup", handleMouseUp);
});
onDestroy(() => {
document.removeEventListener("mouseup", handleMouseUp);
});
// Add visual indicator when highlight mode is active
$effect(() => {
if (isActive) {
document.body.classList.add("highlight-mode-active");
} else {
document.body.classList.remove("highlight-mode-active");
}
// Cleanup when component unmounts
return () => {
document.body.classList.remove("highlight-mode-active");
};
});
</script>
{#if showConfirmModal}
<Modal title="Create Highlight" bind:open={showConfirmModal} autoclose={false} size="md">
<div class="space-y-4">
<div>
<P class="text-sm font-semibold mb-2">Selected Text:</P>
<div class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg max-h-32 overflow-y-auto">
<P class="text-sm italic">"{selectedText}"</P>
</div>
</div>
<div>
<label for="comment" class="block text-sm font-semibold mb-2">
Add a Comment (Optional):
</label>
<Textarea
id="comment"
bind:value={comment}
placeholder="Share your thoughts about this highlight..."
rows="3"
class="w-full"
/>
</div>
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900">
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre>
</div>
{/if}
<div class="flex justify-between items-center">
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="flex space-x-2">
<Button color="alternative" onclick={cancelHighlight} disabled={isSubmitting}>
Cancel
</Button>
<Button color="primary" onclick={createHighlight} disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Highlight"}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
{#if showFeedback}
<div
class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes('success')
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'}"
>
{feedbackMessage}
</div>
{/if}
<style>
:global(body.highlight-mode-active .publication-leather) {
cursor: text;
user-select: text;
}
:global(body.highlight-mode-active .publication-leather *) {
cursor: text;
}
</style>

368
src/lib/components/publications/Publication.svelte

@ -24,6 +24,18 @@ @@ -24,6 +24,18 @@
import TableOfContents from "./TableOfContents.svelte";
import type { TableOfContents as TocType } from "./table_of_contents.svelte";
import ArticleNav from "$components/util/ArticleNav.svelte";
import { deleteEvent } from "$lib/services/deletion";
import { getNdkContext, activeOutboxRelays } from "$lib/ndk";
import { goto } from "$app/navigation";
import HighlightLayer from "./HighlightLayer.svelte";
import { EyeOutline, EyeSlashOutline } from "flowbite-svelte-icons";
import HighlightButton from "./HighlightButton.svelte";
import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte";
import CommentLayer from "./CommentLayer.svelte";
import CommentButton from "./CommentButton.svelte";
import SectionComments from "./SectionComments.svelte";
import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore";
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{
rootAddress: string;
@ -33,6 +45,67 @@ @@ -33,6 +45,67 @@
toc: TocType;
}>();
const ndk = getNdkContext();
// Highlight layer state
let highlightsVisible = $state(false);
let highlightLayerRef: any = null;
let publicationContentRef: HTMLElement | null = $state(null);
// Comment layer state
let commentsVisible = $state(true);
let comments = $state<NDKEvent[]>([]);
let commentLayerRef: any = null;
let showArticleCommentUI = $state(false);
let articleCommentContent = $state("");
let isSubmittingArticleComment = $state(false);
let articleCommentError = $state<string | null>(null);
let articleCommentSuccess = $state(false);
// Toggle between mock and real data for testing (DEBUG MODE)
// Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables
let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true");
let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true");
// Log initial state for debugging
console.log('[Publication] Mock data initialized:', {
useMockComments,
useMockHighlights,
envVars: {
VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS,
VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS,
}
});
// Derive all event IDs and addresses for highlight fetching
let allEventIds = $derived.by(() => {
const ids = [indexEvent.id];
leaves.forEach(leaf => {
if (leaf?.id) ids.push(leaf.id);
});
return ids;
});
let allEventAddresses = $derived.by(() => {
const addresses = [rootAddress];
leaves.forEach(leaf => {
if (leaf) {
const addr = leaf.tagAddress();
if (addr) addresses.push(addr);
}
});
return addresses;
});
// Filter comments for the root publication (kind 30040)
let articleComments = $derived(
comments.filter(comment => {
// Check if comment targets the root publication via #a tag
const aTag = comment.tags.find(t => t[0] === 'a');
return aTag && aTag[1] === rootAddress;
})
);
// #region Loading
let leaves = $state<Array<NDKEvent | null>>([]);
let isLoading = $state(false);
@ -41,6 +114,8 @@ @@ -41,6 +114,8 @@
let activeAddress = $state<string | null>(null);
let loadedAddresses = $state<Set<string>>(new Set());
let hasInitialized = $state(false);
let highlightModeActive = $state(false);
let publicationDeleted = $state(false);
let observer: IntersectionObserver;
@ -184,6 +259,121 @@ @@ -184,6 +259,121 @@
return currentBlog && currentBlogEvent && window.innerWidth < 1140;
}
function toggleHighlights() {
highlightsVisible = !highlightsVisible;
}
function toggleComments() {
commentsVisible = !commentsVisible;
}
function handleCommentPosted() {
console.log("[Publication] Comment posted, refreshing comment layer");
// Refresh the comment layer after a short delay to allow relay indexing
setTimeout(() => {
if (commentLayerRef) {
commentLayerRef.refresh();
}
}, 500);
}
async function submitArticleComment() {
if (!articleCommentContent.trim()) {
articleCommentError = "Comment cannot be empty";
return;
}
isSubmittingArticleComment = true;
articleCommentError = null;
articleCommentSuccess = false;
try {
// Parse the root address to get event details
const parts = rootAddress.split(":");
if (parts.length !== 3) {
throw new Error("Invalid address format");
}
const [kindStr, authorPubkey, dTag] = parts;
const kind = parseInt(kindStr);
// Create comment event (kind 1111)
const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(ndk);
commentEvent.kind = 1111;
commentEvent.content = articleCommentContent;
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Add tags following NIP-22
commentEvent.tags = [
["A", rootAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", rootAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
// Sign and publish
await commentEvent.sign();
await commentEvent.publish();
console.log("[Publication] Article comment published:", commentEvent.id);
articleCommentSuccess = true;
articleCommentContent = "";
// Close UI and refresh after delay
setTimeout(() => {
showArticleCommentUI = false;
articleCommentSuccess = false;
handleCommentPosted();
}, 1500);
} catch (err) {
console.error("[Publication] Error posting article comment:", err);
articleCommentError = err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingArticleComment = false;
}
}
/**
* Handles deletion of the entire publication
*/
async function handleDeletePublication() {
const confirmed = confirm(
"Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: indexEvent.tagAddress(),
eventKind: indexEvent.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log("[Publication] Deletion event published:", deletionEventId);
publicationDeleted = true;
// Redirect after 2 seconds
setTimeout(() => {
goto("/publications");
}, 2000);
},
onError: (error) => {
console.error("[Publication] Failed to delete publication:", error);
alert(`Failed to delete publication: ${error}`);
},
});
} catch (error) {
console.error("[Publication] Error deleting publication:", error);
alert(`Error: ${error}`);
}
}
// #endregion
/**
@ -249,6 +439,13 @@ @@ -249,6 +439,13 @@
};
});
// Setup highlight layer container reference
$effect(() => {
if (publicationContentRef && highlightLayerRef) {
highlightLayerRef.setContainer(publicationContentRef);
}
});
// #endregion
</script>
@ -260,6 +457,23 @@ @@ -260,6 +457,23 @@
rootId={indexEvent.id}
indexEvent={indexEvent}
/>
<!-- Highlight selection handler -->
<HighlightSelectionHandler
isActive={highlightModeActive}
publicationEvent={indexEvent}
onHighlightCreated={() => {
highlightModeActive = false;
// Refresh highlights after a short delay to allow relay indexing
setTimeout(() => {
if (highlightLayerRef) {
console.log("[Publication] Refreshing highlights after creation");
highlightLayerRef.refresh();
}
}, 500);
}}
/>
<!-- Three-column row -->
<div class="contents">
<!-- Table of contents -->
@ -299,12 +513,119 @@ @@ -299,12 +513,119 @@
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<!-- Remove overflow-auto so page scroll drives it -->
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto">
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto" bind:this={publicationContentRef}>
<!-- Publication header with comments (similar to section layout) -->
<div class="relative">
<!-- Main header content - centered -->
<div class="max-w-4xl mx-auto px-4">
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
<Details event={indexEvent} onDelete={handleDeletePublication} />
</div>
{#if publicationDeleted}
<Alert color="yellow" class="mb-4">
<ExclamationCircleOutline class="w-5 h-5 inline mr-2" />
Publication deleted. Redirecting to publications page...
</Alert>
{/if}
</div>
<!-- Mobile article comments - shown below header on smaller screens -->
<div class="xl:hidden mt-4 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
<!-- Desktop article comments - positioned on right side on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
</div>
<!-- Action buttons row -->
<div class="flex justify-between gap-2 mb-4">
<div class="flex gap-2">
<Button
color="light"
size="sm"
onclick={() => showArticleCommentUI = !showArticleCommentUI}
>
{showArticleCommentUI ? 'Close Comment' : 'Comment On Article'}
</Button>
<HighlightButton bind:isActive={highlightModeActive} />
</div>
<div class="flex gap-2">
<Button
color="light"
size="sm"
onclick={toggleComments}
>
{#if commentsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Comments
{:else}
<EyeOutline class="w-4 h-4 mr-2" />
Show Comments
{/if}
</Button>
<Button
color="light"
size="sm"
onclick={toggleHighlights}
>
{#if highlightsVisible}
<EyeSlashOutline class="w-4 h-4 mr-2" />
Hide Highlights
{:else}
<EyeOutline class="w-4 h-4 mr-2" />
Show Highlights
{/if}
</Button>
</div>
</div>
<!-- Article Comment UI -->
{#if showArticleCommentUI}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
<div class="space-y-3">
<h4 class="font-semibold text-gray-900 dark:text-white">Comment on Article</h4>
<Textarea
bind:value={articleCommentContent}
placeholder="Write your comment on this article..."
rows={4}
disabled={isSubmittingArticleComment}
/>
{#if articleCommentError}
<P class="text-red-600 dark:text-red-400 text-sm">{articleCommentError}</P>
{/if}
{#if articleCommentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button onclick={submitArticleComment} disabled={isSubmittingArticleComment}>
{isSubmittingArticleComment ? 'Posting...' : 'Post Comment'}
</Button>
<Button color="light" onclick={() => showArticleCommentUI = false}>
Cancel
</Button>
</div>
</div>
</div>
{/if}
<!-- Publication sections/cards -->
{#each leaves as leaf, i}
{#if leaf == null}
@ -320,6 +641,8 @@ @@ -320,6 +641,8 @@
{address}
{publicationTree}
{toc}
allComments={comments}
{commentsVisible}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{/if}
@ -347,7 +670,7 @@ @@ -347,7 +670,7 @@
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} />
<Details event={indexEvent} onDelete={handleDeletePublication} />
</div>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
@ -408,16 +731,16 @@ @@ -408,16 +731,16 @@
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to
all the content equally well.
</div>
</Card>
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
{#if articleComments.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No comments yet. Be the first to comment!
</p>
{/if}
</div>
</div>
</SidebarGroup>
@ -427,3 +750,22 @@ @@ -427,3 +750,22 @@
</div>
</div>
</div>
<!-- Highlight Layer Component -->
<HighlightLayer
bind:this={highlightLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:visible={highlightsVisible}
useMockHighlights={useMockHighlights}
/>
<!-- Comment Layer Component -->
<CommentLayer
bind:this={commentLayerRef}
eventIds={allEventIds}
eventAddresses={allEventAddresses}
bind:comments={comments}
useMockComments={useMockComments}
/>

33
src/lib/components/publications/PublicationHeader.svelte

@ -8,11 +8,42 @@ @@ -8,11 +8,42 @@
import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { indexKind } from "$lib/consts";
import { deleteEvent } from "$lib/services/deletion";
const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
/**
* Handle deletion of this publication
*/
async function handleDelete() {
const confirmed = confirm(
"Are you sure you want to delete this publication? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: event.tagAddress(),
eventKind: event.kind,
reason: "User deleted publication",
onSuccess: (deletionEventId) => {
console.log("[PublicationHeader] Deletion event published:", deletionEventId);
// Optionally refresh the feed or remove the card
window.location.reload();
},
onError: (error) => {
console.error("[PublicationHeader] Deletion failed:", error);
alert(`Failed to delete publication: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[PublicationHeader] Deletion error:", error);
}
}
function getRelayUrls(): string[] {
return $activeInboxRelays;
}
@ -86,7 +117,7 @@ @@ -86,7 +117,7 @@
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words overflow-hidden">version: {version}</h3>
{/if}
<div class="flex ml-auto">
<CardActions {event} />
<CardActions {event} onDelete={handleDelete} />
</div>
</div>
</div>

124
src/lib/components/publications/PublicationSection.svelte

@ -13,6 +13,9 @@ @@ -13,6 +13,9 @@
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
import NDK from "@nostr-dev-kit/ndk";
import CardActions from "$components/util/CardActions.svelte";
import SectionComments from "./SectionComments.svelte";
import { deleteEvent } from "$lib/services/deletion";
let {
address,
@ -21,6 +24,8 @@ @@ -21,6 +24,8 @@
publicationTree,
toc,
ref,
allComments = [],
commentsVisible = true,
}: {
address: string;
rootAddress: string;
@ -28,15 +33,37 @@ @@ -28,15 +33,37 @@
publicationTree: SveltePublicationTree;
toc: TocType;
ref: (ref: HTMLElement) => void;
allComments?: NDKEvent[];
commentsVisible?: boolean;
} = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor");
const ndk: NDK = getContext("ndk");
// Filter comments for this section
let sectionComments = $derived(
allComments.filter(comment => {
// Check if comment targets this section via #a tag
const aTag = comment.tags.find(t => t[0] === 'a');
return aTag && aTag[1] === address;
})
);
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
);
let leafEventId = $state<string>("");
$effect(() => {
leafEvent.then(e => {
if (e?.id) {
leafEventId = e.id;
console.log(`[PublicationSection] Set leafEventId for ${address}:`, e.id);
}
});
});
let rootEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(rootAddress),
);
@ -134,23 +161,78 @@ @@ -134,23 +161,78 @@
let sectionRef: HTMLElement;
/**
* Handle deletion of this section
*/
async function handleDelete() {
const event = await leafEvent;
if (!event) return;
const confirmed = confirm(
"Are you sure you want to delete this section? This action will publish a deletion request to all relays."
);
if (!confirmed) return;
try {
await deleteEvent({
eventAddress: address,
eventKind: event.kind,
reason: "User deleted section",
onSuccess: (deletionEventId) => {
console.log("[PublicationSection] Deletion event published:", deletionEventId);
// Refresh the page to reflect the deletion
window.location.reload();
},
onError: (error) => {
console.error("[PublicationSection] Deletion failed:", error);
alert(`Failed to delete section: ${error}`);
},
}, ndk);
} catch (error) {
console.error("[PublicationSection] Deletion error:", error);
}
}
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
// Log data attributes for debugging
console.log(`[PublicationSection] Section mounted:`, {
address,
leafEventId,
dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId
});
});
</script>
<!-- Wrapper for positioning context -->
<div class="relative w-full">
<section
id={address}
bind:this={sectionRef}
class="publication-leather content-visibility-auto"
class="publication-leather content-visibility-auto section-with-comment"
data-event-address={address}
data-event-id={leafEventId}
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- Main content area - centered -->
<div class="section-content relative max-w-4xl mx-auto px-4">
<!-- Mobile menu - shown only on smaller screens -->
<div class="xl:hidden absolute top-2 right-2 z-10">
{#await leafEvent then event}
{#if event}
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
{/if}
{/await}
</div>
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
@ -166,5 +248,45 @@ @@ -166,5 +248,45 @@
publicationType ?? "article",
false,
)}
</div>
<!-- Mobile comments - shown below content on smaller screens -->
<div class="xl:hidden mt-8 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
{/await}
</section>
<!-- Right sidebar elements - positioned very close to content, responsive width -->
{#await leafEvent then event}
{#if event}
<!-- Three-dot menu - positioned at top-center on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10">
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
</div>
{/if}
{/await}
<!-- Comments area: positioned below menu, top-center of section -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]">
<SectionComments
sectionAddress={address}
comments={sectionComments}
visible={commentsVisible}
/>
</div>
</div>
<style>
.section-with-comment {
position: relative;
}
.section-with-comment:hover :global(.single-line-button) {
opacity: 1 !important;
}
</style>

928
src/lib/components/publications/SectionComments.svelte

@ -0,0 +1,928 @@ @@ -0,0 +1,928 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { ChevronDownOutline, ChevronRightOutline, DotsVerticalOutline, TrashBinOutline, ClipboardCleanOutline, EyeOutline } from "flowbite-svelte-icons";
import { nip19 } from "nostr-tools";
import { Button, Popover, Modal, Textarea, P } from "flowbite-svelte";
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
let {
sectionAddress,
comments = [],
visible = true,
}: {
sectionAddress: string;
comments: NDKEvent[];
visible?: boolean;
} = $props();
const ndk = getNdkContext();
// State management
let profiles = $state(new Map<string, any>());
let expandedThreads = $state(new Set<string>());
let detailsModalOpen = $state<string | null>(null);
let deletingComments = $state(new Set<string>());
let replyingTo = $state<string | null>(null);
let replyContent = $state("");
let isSubmittingReply = $state(false);
let replyError = $state<string | null>(null);
let replySuccess = $state<string | null>(null);
// Subscribe to userStore
let user = $derived($userStore);
/**
* Parse comment threading structure
* Root comments have no 'e' tag with 'reply' marker
*/
function buildThreadStructure(allComments: NDKEvent[]) {
const rootComments: NDKEvent[] = [];
const repliesByParent = new Map<string, NDKEvent[]>();
for (const comment of allComments) {
// Check if this is a reply by looking for 'e' tags with 'reply' marker
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply');
if (replyTag) {
const parentId = replyTag[1];
if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []);
}
repliesByParent.get(parentId)!.push(comment);
} else {
// This is a root comment (no reply tag)
rootComments.push(comment);
}
}
return { rootComments, repliesByParent };
}
let threadStructure = $derived(buildThreadStructure(comments));
/**
* Count replies for a comment thread
*/
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number {
const directReplies = repliesMap.get(commentId) || [];
let count = directReplies.length;
// Recursively count nested replies
for (const reply of directReplies) {
count += countReplies(reply.id, repliesMap);
}
return count;
}
/**
* Get display name for a pubkey
*/
function getDisplayName(pubkey: string): string {
const profile = profiles.get(pubkey);
if (profile) {
return profile.displayName || profile.name || profile.pubkey || pubkey;
}
const npub = toNpub(pubkey) || pubkey;
return `${npub.slice(0, 12)}...`;
}
/**
* Format timestamp
*/
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString();
}
}
/**
* Fetch profile for a pubkey
*/
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) {
setFallbackProfile(pubkey);
return;
}
const profile = await getUserMetadata(npub, ndk, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
} catch (err) {
setFallbackProfile(pubkey);
}
}
function setFallbackProfile(pubkey: string) {
const npub = toNpub(pubkey) || pubkey;
const truncated = `${npub.slice(0, 12)}...`;
const fallbackProfile = {
name: truncated,
displayName: truncated,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
/**
* Toggle thread expansion
*/
function toggleThread(commentId: string) {
const newExpanded = new Set(expandedThreads);
if (newExpanded.has(commentId)) {
newExpanded.delete(commentId);
} else {
newExpanded.add(commentId);
}
expandedThreads = newExpanded;
}
/**
* Render nested replies recursively
*/
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) {
const replies = repliesMap.get(parentId) || [];
return replies;
}
/**
* Copy nevent to clipboard
*/
async function copyNevent(event: NDKEvent) {
try {
const nevent = nip19.neventEncode({
id: event.id,
author: event.pubkey,
kind: event.kind,
});
await navigator.clipboard.writeText(nevent);
console.log('Copied nevent to clipboard:', nevent);
} catch (err) {
console.error('Failed to copy nevent:', err);
}
}
/**
* Navigate to event details page
*/
function viewEventDetails(comment: NDKEvent) {
const nevent = nip19.neventEncode({
id: comment.id,
author: comment.pubkey,
kind: comment.kind,
});
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
/**
* Check if user can delete a comment
*/
function canDelete(comment: NDKEvent): boolean {
return canDeleteEvent(comment, ndk);
}
/**
* Submit a reply to a comment
*/
async function submitReply(parentComment: NDKEvent) {
if (!replyContent.trim()) {
replyError = "Reply cannot be empty";
return;
}
if (!user.signedIn || !user.signer) {
replyError = "You must be signed in to reply";
return;
}
isSubmittingReply = true;
replyError = null;
replySuccess = null;
try {
const { NDKEvent: NDKEventClass } = await import("@nostr-dev-kit/ndk");
const { activeOutboxRelays } = await import("$lib/ndk");
// Get relay hint
const relays = activeOutboxRelays;
let relayHint = "";
relays.subscribe((r) => { relayHint = r[0] || ""; })();
// Create reply event (kind 1111)
const replyEvent = new NDKEventClass(ndk);
replyEvent.kind = 1111;
replyEvent.content = replyContent;
// Parse section address to get root event details
const rootParts = sectionAddress.split(":");
if (rootParts.length !== 3) {
throw new Error("Invalid section address format");
}
const [rootKindStr, rootAuthorPubkey, rootDTag] = rootParts;
const rootKind = parseInt(rootKindStr);
// NIP-22 reply tags structure:
// - Root tags (A, K, P) point to the section/article
// - Parent tags (a, k, p) point to the parent comment
// - Add 'e' tag with 'reply' marker for the parent comment
replyEvent.tags = [
// Root scope - uppercase tags (point to section)
["A", sectionAddress, relayHint, rootAuthorPubkey],
["K", rootKind.toString()],
["P", rootAuthorPubkey, relayHint],
// Parent scope - lowercase tags (point to parent comment)
["a", `1111:${parentComment.pubkey}:`, relayHint],
["k", "1111"],
["p", parentComment.pubkey, relayHint],
// Reply marker
["e", parentComment.id, relayHint, "reply"],
];
console.log("[SectionComments] Creating reply with tags:", replyEvent.tags);
// Sign and publish
await replyEvent.sign();
await replyEvent.publish();
console.log("[SectionComments] Reply published:", replyEvent.id);
replySuccess = parentComment.id;
replyContent = "";
// Close reply UI after a delay
setTimeout(() => {
replyingTo = null;
replySuccess = null;
}, 2000);
} catch (err) {
console.error("[SectionComments] Error submitting reply:", err);
replyError = err instanceof Error ? err.message : "Failed to submit reply";
} finally {
isSubmittingReply = false;
}
}
/**
* Delete a comment
*/
async function handleDeleteComment(comment: NDKEvent) {
if (!canDelete(comment)) return;
if (!confirm('Are you sure you want to delete this comment?')) {
return;
}
const newDeleting = new Set(deletingComments);
newDeleting.add(comment.id);
deletingComments = newDeleting;
try {
const result = await deleteEvent({
eventId: comment.id,
eventKind: comment.kind,
reason: 'User deleted comment',
}, ndk);
if (result.success) {
console.log('[SectionComments] Comment deleted successfully');
// Note: The comment will still show in the UI until the page is refreshed
// or the parent component refetches comments
} else {
alert(`Failed to delete comment: ${result.error}`);
}
} catch (err) {
console.error('[SectionComments] Error deleting comment:', err);
alert('Failed to delete comment');
} finally {
const newDeleting = new Set(deletingComments);
newDeleting.delete(comment.id);
deletingComments = newDeleting;
}
}
/**
* Pre-fetch profiles for all comment authors
*/
$effect(() => {
const uniquePubkeys = new Set(comments.map(c => c.pubkey));
for (const pubkey of uniquePubkeys) {
fetchProfile(pubkey);
}
});
</script>
{#if visible && threadStructure.rootComments.length > 0}
<div class="space-y-1">
{#each threadStructure.rootComments as rootComment (rootComment.id)}
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)}
{@const isExpanded = expandedThreads.has(rootComment.id)}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm">
<!-- Multi-row collapsed view -->
{#if !isExpanded}
<div class="flex gap-2 px-3 py-2 text-sm">
<button
class="flex-shrink-0 mt-1"
onclick={() => toggleThread(rootComment.id)}
aria-label="Expand comment"
>
<ChevronRightOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<div class="flex-1 min-w-0">
<p class="line-clamp-3 text-gray-700 dark:text-gray-300 mb-1">
{rootComment.content}
</p>
<div class="flex items-center gap-2 text-xs">
<button
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }}
title="Copy nevent to clipboard"
>
{getDisplayName(rootComment.pubkey)}
</button>
{#if replyCount > 0}
<span class="text-gray-400 dark:text-gray-500"></span>
<span class="text-blue-600 dark:text-blue-400">
{replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span>
{/if}
<span class="text-gray-400 dark:text-gray-500"></span>
<button
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
onclick={(e) => {
e.stopPropagation();
replyingTo = replyingTo === rootComment.id ? null : rootComment.id;
replyError = null;
replySuccess = null;
// Auto-expand when replying from collapsed view
if (!expandedThreads.has(rootComment.id)) {
toggleThread(rootComment.id);
}
}}
>
Reply
</button>
</div>
</div>
<!-- Actions menu in collapsed view -->
<div class="flex-shrink-0 mt-1">
<button
id="comment-actions-collapsed-{rootComment.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Comment actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#comment-actions-collapsed-{rootComment.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = rootComment.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(rootComment);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(rootComment)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(rootComment);
}}
disabled={deletingComments.has(rootComment.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
{:else}
<!-- Expanded view -->
<div class="flex flex-col">
<!-- Expanded header row -->
<div class="flex items-center gap-2 px-3 py-2 text-sm border-b border-gray-200 dark:border-gray-700">
<button
class="flex-shrink-0"
onclick={() => toggleThread(rootComment.id)}
aria-label="Collapse comment"
>
<ChevronDownOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<button
class="flex-shrink-0 font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }}
title="Copy nevent to clipboard"
>
{getDisplayName(rootComment.pubkey)}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(rootComment.created_at || 0)}
</span>
{#if replyCount > 0}
<span class="text-xs text-blue-600 dark:text-blue-400">
{replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span>
{/if}
<!-- Actions menu -->
<div class="ml-auto">
<button
id="comment-actions-{rootComment.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Comment actions"
>
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#comment-actions-{rootComment.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = rootComment.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(rootComment);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(rootComment)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(rootComment);
}}
disabled={deletingComments.has(rootComment.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
<!-- Full content -->
<div class="px-3 py-3">
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-3">
{@render basicMarkup(rootComment.content)}
</div>
<!-- Reply button -->
<div class="mb-3">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === rootComment.id ? null : rootComment.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === rootComment.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI -->
{#if replyingTo === rootComment.id}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-700">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P>
{/if}
{#if replySuccess === rootComment.id}
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={() => submitReply(rootComment)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="sm"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Replies -->
{#if replyCount > 0}
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2">
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3">
<div class="flex items-center gap-2 mb-2">
<button
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(reply); }}
title="Copy nevent to clipboard"
>
{getDisplayName(reply.pubkey)}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(reply.created_at || 0)}
</span>
<!-- Three-dot menu for reply -->
<div class="ml-auto flex items-center gap-2">
<button
id="reply-actions-{reply.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Reply actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#reply-actions-{reply.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = reply.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(reply);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(reply)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(reply);
}}
disabled={deletingComments.has(reply.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(reply.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-2">
{@render basicMarkup(reply.content)}
</div>
<!-- Reply button for first-level reply -->
<div class="mb-2">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === reply.id ? null : reply.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === reply.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI for first-level reply -->
{#if replyingTo === reply.id}
<div class="mb-3 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P>
{/if}
{#if replySuccess === reply.id}
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={() => submitReply(reply)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="sm"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Nested replies (one level deep) -->
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)}
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2">
<div class="flex items-center gap-2 mb-1">
<button
class="text-xs font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(nestedReply); }}
title="Copy nevent to clipboard"
>
{getDisplayName(nestedReply.pubkey)}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(nestedReply.created_at || 0)}
</span>
<!-- Three-dot menu for nested reply -->
<div class="ml-auto flex items-center gap-2">
<button
id="nested-reply-actions-{nestedReply.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Nested reply actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#nested-reply-actions-{nestedReply.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
detailsModalOpen = nestedReply.id;
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(nestedReply);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(nestedReply)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(nestedReply);
}}
disabled={deletingComments.has(nestedReply.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(nestedReply.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div>
<div class="text-xs text-gray-700 dark:text-gray-300 mb-2">
{@render basicMarkup(nestedReply.content)}
</div>
<!-- Reply button for nested reply -->
<div class="mb-1">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === nestedReply.id ? null : nestedReply.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === nestedReply.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI for nested reply -->
{#if replyingTo === nestedReply.id}
<div class="mb-2 border border-gray-300 dark:border-gray-600 rounded-lg p-2 bg-white dark:bg-gray-800">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={2}
disabled={isSubmittingReply}
class="mb-2 text-xs"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-xs mb-1">{replyError}</P>
{/if}
{#if replySuccess === nestedReply.id}
<P class="text-green-600 dark:text-green-400 text-xs mb-1">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="xs"
onclick={() => submitReply(nestedReply)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Details Modal -->
{#if detailsModalOpen}
{@const comment = comments.find(c => c.id === detailsModalOpen)}
{#if comment}
<Modal
title="Comment Details"
open={true}
autoclose
outsideclose
size="lg"
class="modal-leather"
onclose={() => detailsModalOpen = null}
>
<div class="space-y-4">
<div class="flex justify-center pb-2">
<Button
color="primary"
onclick={() => {
viewEventDetails(comment);
}}
>
View on Event Page
</Button>
</div>
<div>
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto max-h-[500px] overflow-y-auto">{JSON.stringify({
id: comment.id,
pubkey: comment.pubkey,
created_at: comment.created_at,
kind: comment.kind,
tags: comment.tags,
content: comment.content,
sig: comment.sig
}, null, 2)}</pre>
</div>
</div>
</Modal>
{/if}
{/if}
<style>
/* Ensure proper text wrapping */
.prose {
word-wrap: break-word;
overflow-wrap: break-word;
}
</style>

2
src/lib/components/util/ArticleNav.svelte

@ -211,7 +211,7 @@ @@ -211,7 +211,7 @@
<span class="hidden sm:inline">Close</span>
</Button>
{/if}
{#if publicationType !== "blog" && !$publicationColumnVisibility.discussion}
{#if !$publicationColumnVisibility.discussion}
<Button
class="btn-leather !hidden sm:flex !w-auto"
outline={true}

390
src/lib/components/util/CardActions.svelte

@ -1,27 +1,37 @@ @@ -1,27 +1,37 @@
<script lang="ts">
import { Button, Modal, Popover } from "flowbite-svelte";
import { Button, Modal, Popover, Textarea, P } from "flowbite-svelte";
import {
DotsVerticalOutline,
EyeOutline,
ClipboardCleanOutline,
TrashBinOutline,
MessageDotsOutline,
ChevronDownOutline,
ChevronUpOutline,
} from "flowbite-svelte-icons";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { activeInboxRelays, getNdkContext } from "$lib/ndk";
import { activeInboxRelays, activeOutboxRelays, getNdkContext } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import LazyImage from "$components/util/LazyImage.svelte";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
// Component props
let { event } = $props<{ event: NDKEvent }>();
let { event, onDelete, sectionAddress } = $props<{
event: NDKEvent;
onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option
}>();
const ndk = getNdkContext();
// Subscribe to userStore
let user = $state($userStore);
userStore.subscribe((val) => (user = val));
// Subscribe to userStore (Svelte 5 runes pattern)
let user = $derived($userStore);
// Derive metadata from event
let title = $derived(
@ -62,6 +72,71 @@ @@ -62,6 +72,71 @@
let detailsModalOpen: boolean = $state(false);
let isOpen: boolean = $state(false);
// Comment modal state
let commentModalOpen: boolean = $state(false);
let commentContent: string = $state("");
let isSubmittingComment: boolean = $state(false);
let commentError: string | null = $state(null);
let commentSuccess: boolean = $state(false);
let showJsonPreview: boolean = $state(false);
// Build preview JSON for the comment event
let previewJson = $derived.by(() => {
if (!commentContent.trim() || !sectionAddress) return null;
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) return null;
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
const relayHint = $activeOutboxRelays[0] || "";
return {
kind: 1111,
pubkey: user.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>"
};
});
// Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => {
const result = user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log('[CardActions] canDelete check:', {
userSignedIn: user.signedIn,
userPubkey: user.pubkey,
eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined,
canDelete: result
});
return result;
});
// Determine delete button text based on event kind
let deleteButtonText = $derived.by(() => {
if (event.kind === 30040) {
// Kind 30040 is an index/publication
return "Delete publication";
} else if (event.kind === 30041) {
// Kind 30041 is a section
return "Delete section";
} else if (event.kind === 30023) {
// Kind 30023 is a long-form article
return "Delete article";
} else {
return "Delete";
}
});
/**
* Selects the appropriate relay set based on user state and feed type
* - Uses active inbox relays from the new relay management system
@ -123,6 +198,200 @@ @@ -123,6 +198,200 @@
const nevent = getIdentifier("nevent");
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
/**
* Opens the comment modal
*/
function openCommentModal() {
if (!user.signedIn) {
commentError = "You must be signed in to comment";
setTimeout(() => {
commentError = null;
}, 3000);
return;
}
closePopover();
commentModalOpen = true;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
/**
* Parse address to get event details
*/
function parseAddress(address: string): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":");
if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address);
return null;
}
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error("[CardActions] Invalid kind in address:", kindStr);
return null;
}
return { kind, pubkey, dTag };
}
/**
* Submit comment
*/
async function submitComment() {
if (!sectionAddress || !user.pubkey) {
commentError = "Invalid state - cannot submit comment";
return;
}
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) {
commentError = "Invalid event address";
return;
}
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
isSubmittingComment = true;
commentError = null;
try {
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Fetch target event to get its ID
let eventId = "";
try {
const targetEvent = await ndk.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
console.warn("[CardActions] Could not fetch target event ID:", err);
}
// Create comment event (NIP-22)
const commentEvent = new NDKEventClass(ndk);
commentEvent.kind = 1111;
commentEvent.content = commentContent;
commentEvent.pubkey = user.pubkey;
commentEvent.tags = [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
if (eventId) {
commentEvent.tags.push(["e", eventId, relayHint]);
}
// Sign event
const plainEvent = {
kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey),
created_at: Number(commentEvent.created_at ?? Math.floor(Date.now() / 1000)),
tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content),
};
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) {
const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig;
if ("id" in signed) {
commentEvent.id = signed.id as string;
}
} else if (user.signer) {
await commentEvent.sign(user.signer);
}
// Publish to relays
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
const signedEvent = {
...plainEvent,
id: commentEvent.id,
sig: commentEvent.sig,
};
let publishedCount = 0;
for (const relayUrl of uniqueRelays) {
try {
const ws = await WebSocketPool.instance.acquire(relayUrl);
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
WebSocketPool.instance.release(ws);
reject(new Error("Timeout"));
}, 5000);
ws.onmessage = (e) => {
const [type, id, ok, message] = JSON.parse(e.data);
if (type === "OK" && id === signedEvent.id) {
clearTimeout(timeout);
if (ok) {
publishedCount++;
WebSocketPool.instance.release(ws);
resolve();
} else {
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[CardActions] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
commentSuccess = true;
setTimeout(() => {
commentModalOpen = false;
commentSuccess = false;
commentContent = "";
showJsonPreview = false;
}, 2000);
} catch (err) {
console.error("[CardActions] Error submitting comment:", err);
commentError = err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingComment = false;
}
}
/**
* Cancel comment
*/
function cancelComment() {
commentModalOpen = false;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
</script>
<div
@ -153,6 +422,16 @@ @@ -153,6 +422,16 @@
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap">
<ul class="space-y-2">
{#if sectionAddress}
<li>
<button
class="btn-leather w-full text-left"
onclick={openCommentModal}
>
<MessageDotsOutline class="inline mr-2" /> Comment on section
</button>
</li>
{/if}
<li>
<button
class="btn-leather w-full text-left"
@ -175,6 +454,19 @@ @@ -175,6 +454,19 @@
icon={ClipboardCleanOutline}
/>
</li>
{#if canDelete}
<li>
<button
class="btn-leather w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
onclick={() => {
closePopover();
onDelete?.();
}}
>
<TrashBinOutline class="inline mr-2" /> {deleteButtonText}
</button>
</li>
{/if}
</ul>
</div>
</div>
@ -265,4 +557,90 @@ @@ -265,4 +557,90 @@
</button>
</div>
</Modal>
<!-- Comment Modal -->
{#if sectionAddress}
<Modal
class="modal-leather"
title="Add Comment"
bind:open={commentModalOpen}
autoclose={false}
outsideclose={true}
size="md"
>
<div class="space-y-4">
{#if user.profile}
<div class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700">
{#if user.profile.picture}
<img
src={user.profile.picture}
alt={user.profile.displayName || user.profile.name || "User"}
class="w-10 h-10 rounded-full object-cover"
/>
{/if}
<span class="font-medium text-gray-900 dark:text-gray-100">
{user.profile.displayName || user.profile.name || "Anonymous"}
</span>
</div>
{/if}
<Textarea
bind:value={commentContent}
placeholder="Write your comment here..."
rows={6}
disabled={isSubmittingComment}
class="w-full"
/>
{#if commentError}
<P class="text-red-600 dark:text-red-400 text-sm">{commentError}</P>
{/if}
{#if commentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm">Comment posted successfully!</P>
{/if}
<!-- JSON Preview Section -->
{#if showJsonPreview && previewJson}
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900">
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P>
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre>
</div>
{/if}
<div class="flex justify-between items-center gap-3 pt-2">
<Button
color="light"
size="sm"
onclick={() => showJsonPreview = !showJsonPreview}
class="flex items-center gap-1"
>
{#if showJsonPreview}
<ChevronUpOutline class="w-4 h-4" />
{:else}
<ChevronDownOutline class="w-4 h-4" />
{/if}
{showJsonPreview ? "Hide" : "Show"} JSON
</Button>
<div class="flex gap-3">
<Button
color="alternative"
onclick={cancelComment}
disabled={isSubmittingComment}
>
Cancel
</Button>
<Button
color="primary"
onclick={submitComment}
disabled={isSubmittingComment || !commentContent.trim()}
>
{isSubmittingComment ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
</div>

11
src/lib/components/util/Details.svelte

@ -14,7 +14,11 @@ @@ -14,7 +14,11 @@
// isModal
// - don't show interactions in modal view
// - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props();
let { event, isModal = false, onDelete } = $props<{
event: any;
isModal?: boolean;
onDelete?: () => void;
}>();
let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
let author: string = $derived(
@ -43,6 +47,7 @@ @@ -43,6 +47,7 @@
);
let rootId: string = $derived(getMatchingTags(event, "d")[0]?.[1] ?? null);
let kind = $derived(event.kind);
let address: string = $derived(`${kind}:${event.pubkey}:${rootId}`);
let authorTag: string = $derived(
getMatchingTags(event, "author")[0]?.[1] ?? "",
@ -67,7 +72,9 @@ @@ -67,7 +72,9 @@
<P class="text-base font-normal"
>{@render userBadge(event.pubkey, undefined, ndk)}</P
>
<CardActions {event}></CardActions>
<div class="flex flex-row gap-2 items-center">
<CardActions {event} {onDelete}></CardActions>
</div>
</div>
{/if}
<div

117
src/lib/services/deletion.ts

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
export interface DeletionOptions {
eventId?: string;
eventAddress?: string;
eventKind?: number;
reason?: string;
onSuccess?: (deletionEventId: string) => void;
onError?: (error: string) => void;
}
export interface DeletionResult {
success: boolean;
deletionEventId?: string;
error?: string;
}
/**
* Deletes a Nostr event by publishing a kind 5 deletion request (NIP-09)
* @param options - Deletion options
* @param ndk - NDK instance
* @returns Promise resolving to deletion result
*/
export async function deleteEvent(
options: DeletionOptions,
ndk: NDK,
): Promise<DeletionResult> {
const { eventId, eventAddress, eventKind, reason = "", onSuccess, onError } = options;
if (!eventId && !eventAddress) {
const error = "Either eventId or eventAddress must be provided";
onError?.(error);
return { success: false, error };
}
if (!ndk?.activeUser) {
const error = "Please log in first";
onError?.(error);
return { success: false, error };
}
try {
// Create deletion event (kind 5)
const deletionEvent = new NDKEvent(ndk);
deletionEvent.kind = 5;
deletionEvent.created_at = Math.floor(Date.now() / 1000);
deletionEvent.content = reason;
deletionEvent.pubkey = ndk.activeUser.pubkey;
// Build tags based on what we have
const tags: string[][] = [];
if (eventId) {
// Add 'e' tag for event ID
tags.push(['e', eventId]);
}
if (eventAddress) {
// Add 'a' tag for replaceable event address
tags.push(['a', eventAddress]);
}
if (eventKind) {
// Add 'k' tag for event kind (recommended by NIP-09)
tags.push(['k', eventKind.toString()]);
}
deletionEvent.tags = tags;
// Sign the deletion event
await deletionEvent.sign();
// Publish to all available relays
const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
if (allRelayUrls.length === 0) {
throw new Error("No relays available in NDK pool");
}
const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
const publishedToRelays = await deletionEvent.publish(relaySet);
if (publishedToRelays.size > 0) {
console.log(
`[deletion.ts] Published deletion request to ${publishedToRelays.size} relays`,
);
const result = { success: true, deletionEventId: deletionEvent.id };
onSuccess?.(deletionEvent.id);
return result;
} else {
throw new Error("Failed to publish deletion request to any relays");
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
console.error(`[deletion.ts] Error deleting event: ${errorMessage}`);
onError?.(errorMessage);
return { success: false, error: errorMessage };
}
}
/**
* Checks if the current user has permission to delete an event
* @param event - The event to check
* @param ndk - NDK instance
* @returns True if the user can delete the event
*/
export function canDeleteEvent(event: NDKEvent | null, ndk: NDK): boolean {
if (!event || !ndk?.activeUser) {
return false;
}
// User can only delete their own events
return event.pubkey === ndk.activeUser.pubkey;
}

85
src/lib/utils/asciidoc_ast_parser.ts

@ -106,7 +106,7 @@ function extractSubsections(section: any, parseLevel: number): ASTSection[] { @@ -106,7 +106,7 @@ function extractSubsections(section: any, parseLevel: number): ASTSection[] {
export async function createPublicationTreeFromAST(
content: string,
ndk: NDK,
parseLevel: number = 2
parseLevel: number = 2,
): Promise<PublicationTree> {
const parsed = parseAsciiDocAST(content, parseLevel);
@ -114,9 +114,13 @@ export async function createPublicationTreeFromAST( @@ -114,9 +114,13 @@ export async function createPublicationTreeFromAST(
const rootEvent = createIndexEventFromAST(parsed, ndk);
const tree = new PublicationTree(rootEvent, ndk);
// Add sections as 30041 events
// Add sections as 30041 events with proper namespacing
for (const section of parsed.sections) {
const contentEvent = createContentEventFromSection(section, ndk);
const contentEvent = createContentEventFromSection(
section,
ndk,
parsed.title,
);
await tree.addEvent(contentEvent, rootEvent);
}
@ -139,16 +143,24 @@ function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent @@ -139,16 +143,24 @@ function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent
["d", dTag],
mTag,
MTag,
["title", parsed.title]
["title", parsed.title],
];
// Add document attributes as tags
addAttributesAsTags(tags, parsed.attributes);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 content events)
parsed.sections.forEach(section => {
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section) => {
const sectionDTag = generateDTag(section.title);
tags.push(["a", `30041:${ndk.activeUser?.pubkey || 'pubkey'}:${sectionDTag}`]);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push([
"a",
`30041:${ndk.activeUser?.pubkey || "pubkey"}:${namespacedDTag}`,
]);
});
event.tags = tags;
@ -159,20 +171,35 @@ function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent @@ -159,20 +171,35 @@ function createIndexEventFromAST(parsed: ASTParsedDocument, ndk: NDK): NDKEvent
/**
* Create a 30041 content event from an AST section
* Note: This function needs the publication title for proper namespacing
* but the current implementation doesn't have access to it.
* Consider using createPublicationTreeFromAST instead which handles this correctly.
*/
function createContentEventFromSection(section: ASTSection, ndk: NDK): NDKEvent {
function createContentEventFromSection(
section: ASTSection,
ndk: NDK,
publicationTitle?: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000);
const dTag = generateDTag(section.title);
// Generate namespaced d-tag if publication title is provided
const sectionDTag = generateDTag(section.title);
let dTag = sectionDTag;
if (publicationTitle) {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
dTag = `${pubAbbrev}-${sectionDTag}`;
}
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [
["d", dTag],
mTag,
MTag,
["title", section.title]
["title", section.title],
];
// Add section attributes as tags
@ -195,6 +222,32 @@ function generateDTag(title: string): string { @@ -195,6 +222,32 @@ function generateDTag(title: string): string {
.replace(/^-|-$/g, "");
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* Add AsciiDoc attributes as Nostr event tags, filtering out system attributes
*/
@ -252,20 +305,24 @@ export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2) @@ -252,20 +305,24 @@ export function createPublicationTreeProcessor(ndk: NDK, parseLevel: number = 2)
async function createPublicationTreeFromDocument(
document: Document,
ndk: NDK,
parseLevel: number
parseLevel: number,
): Promise<PublicationTree> {
const parsed: ASTParsedDocument = {
title: document.getTitle() || '',
content: document.getContent() || '',
title: document.getTitle() || "",
content: document.getContent() || "",
attributes: document.getAttributes(),
sections: extractSectionsFromAST(document, parseLevel)
sections: extractSectionsFromAST(document, parseLevel),
};
const rootEvent = createIndexEventFromAST(parsed, ndk);
const tree = new PublicationTree(rootEvent, ndk);
for (const section of parsed.sections) {
const contentEvent = createContentEventFromSection(section, ndk);
const contentEvent = createContentEventFromSection(
section,
ndk,
parsed.title,
);
await tree.addEvent(contentEvent, rootEvent);
}

70
src/lib/utils/fetch_publication_highlights.ts

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
/**
* Fetches all highlight events (kind 9802) for sections referenced in a publication event (kind 30040).
*
* @param publicationEvent - The kind 30040 event containing "a" tags referencing sections (kind 30041)
* @param ndk - The NDK instance to use for fetching events
* @returns A Map of section addresses to arrays of highlight events
*
* @example
* ```typescript
* const highlights = await fetchHighlightsForPublication(publicationEvent, ndk);
* // Returns: Map {
* // "30041:pubkey:section-id" => [highlightEvent1, highlightEvent2],
* // "30041:pubkey:another-section" => [highlightEvent3]
* // }
* ```
*/
export async function fetchHighlightsForPublication(
publicationEvent: NDKEvent,
ndk: NDK
): Promise<Map<string, NDKEvent[]>> {
// Extract all "a" tags from the publication event
const aTags = publicationEvent.getMatchingTags("a");
// Filter for only 30041 (section) references
const sectionAddresses: string[] = [];
aTags.forEach((tag: string[]) => {
if (tag[1]) {
const parts = tag[1].split(":");
// Check if it's a 30041 kind reference and has the correct format
if (parts.length >= 3 && parts[0] === "30041") {
// Handle d-tags with colons by joining everything after the pubkey
const sectionAddress = tag[1];
sectionAddresses.push(sectionAddress);
}
}
});
// If no section references found, return empty map
if (sectionAddresses.length === 0) {
return new Map();
}
// Fetch all highlight events (kind 9802) that reference these sections
const highlightEvents = await ndk.fetchEvents({
kinds: [9802],
"#a": sectionAddresses,
});
// Group highlights by section address
const highlightsBySection = new Map<string, NDKEvent[]>();
highlightEvents.forEach((highlight: NDKEvent) => {
const highlightATags = highlight.getMatchingTags("a");
highlightATags.forEach((tag: string[]) => {
const sectionAddress = tag[1];
// Only include if this section is in our original list
if (sectionAddress && sectionAddresses.includes(sectionAddress)) {
if (!highlightsBySection.has(sectionAddress)) {
highlightsBySection.set(sectionAddress, []);
}
highlightsBySection.get(sectionAddress)!.push(highlight);
}
});
});
return highlightsBySection;
}

224
src/lib/utils/highlightPositioning.ts

@ -0,0 +1,224 @@ @@ -0,0 +1,224 @@
/**
* Utility for position-based text highlighting in the DOM
*
* Highlights text by character offset rather than text search,
* making highlights resilient to minor content changes.
*/
/**
* Get all text nodes within an element, excluding script/style tags
*/
function getTextNodes(element: HTMLElement): Text[] {
const textNodes: Text[] = [];
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip text in script/style tags
const parent = node.parentElement;
if (parent && (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE')) {
return NodeFilter.FILTER_REJECT;
}
// Skip empty text nodes
if (!node.textContent || node.textContent.trim().length === 0) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
return textNodes;
}
/**
* Calculate the total text length from text nodes
*/
function getTotalTextLength(textNodes: Text[]): number {
return textNodes.reduce((total, node) => total + (node.textContent?.length || 0), 0);
}
/**
* Find text node and local offset for a given global character position
*/
function findNodeAtOffset(
textNodes: Text[],
globalOffset: number
): { node: Text; localOffset: number } | null {
let currentOffset = 0;
for (const node of textNodes) {
const nodeLength = node.textContent?.length || 0;
if (globalOffset < currentOffset + nodeLength) {
return {
node,
localOffset: globalOffset - currentOffset
};
}
currentOffset += nodeLength;
}
return null;
}
/**
* Highlight text by character offset within a container element
*
* @param container - The root element to search within
* @param startOffset - Character position where highlight starts (0-indexed)
* @param endOffset - Character position where highlight ends (exclusive)
* @param color - Background color for the highlight
* @returns true if highlight was applied, false otherwise
*/
export function highlightByOffset(
container: HTMLElement,
startOffset: number,
endOffset: number,
color: string
): boolean {
console.log(`[highlightByOffset] Attempting to highlight chars ${startOffset}-${endOffset}`);
// Validate inputs
if (startOffset < 0 || endOffset <= startOffset) {
console.warn(`[highlightByOffset] Invalid offsets: ${startOffset}-${endOffset}`);
return false;
}
// Get all text nodes
const textNodes = getTextNodes(container);
if (textNodes.length === 0) {
console.warn(`[highlightByOffset] No text nodes found in container`);
return false;
}
const totalLength = getTotalTextLength(textNodes);
console.log(`[highlightByOffset] Total text length: ${totalLength}, nodes: ${textNodes.length}`);
// Validate offsets are within bounds
if (startOffset >= totalLength) {
console.warn(`[highlightByOffset] Start offset ${startOffset} exceeds total length ${totalLength}`);
return false;
}
// Adjust end offset if it exceeds content
const adjustedEndOffset = Math.min(endOffset, totalLength);
// Find the nodes containing start and end positions
const startPos = findNodeAtOffset(textNodes, startOffset);
const endPos = findNodeAtOffset(textNodes, adjustedEndOffset);
if (!startPos || !endPos) {
console.warn(`[highlightByOffset] Could not locate positions in DOM`);
return false;
}
console.log(`[highlightByOffset] Found positions:`, {
startNode: startPos.node.textContent?.substring(0, 20),
startLocal: startPos.localOffset,
endNode: endPos.node.textContent?.substring(0, 20),
endLocal: endPos.localOffset
});
// Create the highlight mark element
const createHighlightMark = (text: string): HTMLElement => {
const mark = document.createElement('mark');
mark.className = 'highlight';
mark.style.backgroundColor = color;
mark.style.borderRadius = '2px';
mark.style.padding = '2px 0';
mark.textContent = text;
return mark;
};
try {
// Case 1: Highlight is within a single text node
if (startPos.node === endPos.node) {
const text = startPos.node.textContent || '';
const before = text.substring(0, startPos.localOffset);
const highlighted = text.substring(startPos.localOffset, endPos.localOffset);
const after = text.substring(endPos.localOffset);
const parent = startPos.node.parentNode;
if (!parent) return false;
// Create fragment with before + highlight + after
const fragment = document.createDocumentFragment();
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(createHighlightMark(highlighted));
if (after) fragment.appendChild(document.createTextNode(after));
parent.replaceChild(fragment, startPos.node);
console.log(`[highlightByOffset] Applied single-node highlight: "${highlighted}"`);
return true;
}
// Case 2: Highlight spans multiple text nodes
let currentNode: Text | null = startPos.node;
let isFirstNode = true;
let nodeIndex = textNodes.indexOf(currentNode);
while (currentNode && nodeIndex <= textNodes.indexOf(endPos.node)) {
const parent = currentNode.parentNode;
if (!parent) break;
const text = currentNode.textContent || '';
let fragment = document.createDocumentFragment();
if (isFirstNode) {
// First node: split at start offset
const before = text.substring(0, startPos.localOffset);
const highlighted = text.substring(startPos.localOffset);
if (before) fragment.appendChild(document.createTextNode(before));
fragment.appendChild(createHighlightMark(highlighted));
isFirstNode = false;
} else if (currentNode === endPos.node) {
// Last node: split at end offset
const highlighted = text.substring(0, endPos.localOffset);
const after = text.substring(endPos.localOffset);
fragment.appendChild(createHighlightMark(highlighted));
if (after) fragment.appendChild(document.createTextNode(after));
} else {
// Middle node: highlight entirely
fragment.appendChild(createHighlightMark(text));
}
parent.replaceChild(fragment, currentNode);
nodeIndex++;
currentNode = textNodes[nodeIndex] || null;
}
console.log(`[highlightByOffset] Applied multi-node highlight`);
return true;
} catch (err) {
console.error(`[highlightByOffset] Error applying highlight:`, err);
return false;
}
}
/**
* Get the plain text content of an element (without HTML tags)
* Useful for debugging and validation
*/
export function getPlainText(element: HTMLElement): string {
const textNodes = getTextNodes(element);
return textNodes.map(node => node.textContent).join('');
}
/**
* Get the character count of visible text in an element
*/
export function getTextLength(element: HTMLElement): number {
return getPlainText(element).length;
}

156
src/lib/utils/highlightUtils.ts

@ -0,0 +1,156 @@ @@ -0,0 +1,156 @@
/**
* Utility functions for highlight management
*/
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface GroupedHighlight {
pubkey: string;
highlights: NDKEvent[];
count: number;
}
/**
* Groups highlights by author pubkey
* Returns a Map with pubkey as key and array of highlights as value
*/
export function groupHighlightsByAuthor(highlights: NDKEvent[]): Map<string, NDKEvent[]> {
const grouped = new Map<string, NDKEvent[]>();
for (const highlight of highlights) {
const pubkey = highlight.pubkey;
const existing = grouped.get(pubkey) || [];
existing.push(highlight);
grouped.set(pubkey, existing);
}
return grouped;
}
/**
* Truncates highlight text to specified length, breaking at word boundaries
* @param text - The text to truncate
* @param maxLength - Maximum length (default: 50)
* @returns Truncated text with ellipsis if needed
*/
export function truncateHighlight(text: string, maxLength: number = 50): string {
if (!text || text.length <= maxLength) {
return text;
}
// Find the last space before maxLength
const truncated = text.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(" ");
// If there's a space, break there; otherwise use the full maxLength
if (lastSpace > 0) {
return truncated.slice(0, lastSpace) + "...";
}
return truncated + "...";
}
/**
* Encodes a highlight event as an naddr with relay hints
* @param event - The highlight event (kind 9802)
* @param relays - Array of relay URLs to include as hints
* @returns naddr string
*/
export function encodeHighlightNaddr(event: NDKEvent, relays: string[] = []): string {
try {
// For kind 9802 highlights, we need the event's unique identifier
// Since highlights don't have a d-tag, we'll use the event id as nevent instead
// But per NIP-19, naddr is for addressable events (with d-tag)
// For non-addressable events like kind 9802, we should use nevent
const nevent = nip19.neventEncode({
id: event.id,
relays: relays.length > 0 ? relays : undefined,
author: event.pubkey,
kind: event.kind,
});
return nevent;
} catch (error) {
console.error("Error encoding highlight naddr:", error);
// Fallback to just the event id
return event.id;
}
}
/**
* Creates a shortened npub for display
* @param pubkey - The hex pubkey
* @param length - Number of characters to show from start (default: 8)
* @returns Shortened npub like "npub1abc...xyz"
*/
export function shortenNpub(pubkey: string, length: number = 8): string {
try {
const npub = nip19.npubEncode(pubkey);
// npub format: "npub1" + bech32 encoded data
// Show first part and last part
if (npub.length <= length + 10) {
return npub;
}
const start = npub.slice(0, length + 5); // "npub1" + first chars
const end = npub.slice(-4); // last chars
return `${start}...${end}`;
} catch (error) {
console.error("Error creating shortened npub:", error);
// Fallback to shortened hex
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
}
/**
* Extracts relay URLs from a highlight event's tags or metadata
* @param event - The highlight event
* @returns Array of relay URLs
*/
export function getRelaysFromHighlight(event: NDKEvent): string[] {
const relays: string[] = [];
// Check for relay hints in tags (e.g., ["a", "30041:pubkey:id", "relay-url"])
for (const tag of event.tags) {
if ((tag[0] === "a" || tag[0] === "e" || tag[0] === "p") && tag[2]) {
relays.push(tag[2]);
}
}
// Also include relay from the event if available
if (event.relay?.url) {
relays.push(event.relay.url);
}
// Deduplicate
return [...new Set(relays)];
}
/**
* Sorts highlights within a group by creation time (newest first)
* @param highlights - Array of highlight events
* @returns Sorted array
*/
export function sortHighlightsByTime(highlights: NDKEvent[]): NDKEvent[] {
return [...highlights].sort((a, b) => {
const timeA = a.created_at || 0;
const timeB = b.created_at || 0;
return timeB - timeA; // Newest first
});
}
/**
* Gets the display name for a highlight author
* Priority: displayName > name > shortened npub
*/
export function getAuthorDisplayName(
profile: { name?: string; displayName?: string; display_name?: string } | null,
pubkey: string,
): string {
if (profile) {
return profile.displayName || profile.display_name || profile.name || shortenNpub(pubkey);
}
return shortenNpub(pubkey);
}

177
src/lib/utils/mockCommentData.ts

@ -0,0 +1,177 @@ @@ -0,0 +1,177 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
/**
* Generate mock comment data for testing comment UI and threading
* Creates realistic thread structures with root comments and nested replies
*/
const loremIpsumComments = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.",
"Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.",
"Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.",
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.",
"Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio.",
"Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae.",
];
const loremIpsumReplies = [
"Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.",
"Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.",
"Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat.",
"Omnis voluptas assumenda est, omnis dolor repellendus.",
"Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur.",
"Facere possimus, omnis voluptas assumenda est.",
"Sed ut perspiciatis unde omnis iste natus error.",
"Accusantium doloremque laudantium, totam rem aperiam.",
];
const mockPubkeys = [
"a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"b2c3d4e5f6789012345678901234567890123456789012345678901234abcde",
"c3d4e5f6789012345678901234567890123456789012345678901234abcdef0",
"d4e5f6789012345678901234567890123456789012345678901234abcdef01",
];
/**
* Create a mock NDKEvent that looks like a real comment
*/
function createMockComment(
id: string,
content: string,
pubkey: string,
targetAddress: string,
createdAt: number,
replyToId?: string,
replyToAuthor?: string
): any {
const tags: string[][] = [
["A", targetAddress, "wss://relay.damus.io", pubkey],
["K", "30041"],
["P", pubkey, "wss://relay.damus.io"],
["a", targetAddress, "wss://relay.damus.io"],
["k", "30041"],
["p", pubkey, "wss://relay.damus.io"],
];
if (replyToId && replyToAuthor) {
tags.push(["e", replyToId, "wss://relay.damus.io", "reply"]);
tags.push(["p", replyToAuthor, "wss://relay.damus.io"]);
}
// Return a plain object that matches NDKEvent structure
return {
id,
kind: 1111,
pubkey,
created_at: createdAt,
content,
tags,
sig: "mock-signature-" + id,
};
}
/**
* Generate mock comment thread structure
* @param sectionAddress - The section address to attach comments to
* @param numRootComments - Number of root comments to generate (default: 3)
* @param numRepliesPerThread - Number of replies per thread (default: 2)
* @returns Array of mock comment objects
*/
export function generateMockComments(
sectionAddress: string,
numRootComments: number = 3,
numRepliesPerThread: number = 2
): any[] {
const comments: any[] = [];
const now = Math.floor(Date.now() / 1000);
let commentIndex = 0;
// Generate root comments
for (let i = 0; i < numRootComments; i++) {
const rootId = `mock-root-${i}-${Date.now()}`;
const rootPubkey = mockPubkeys[i % mockPubkeys.length];
const rootContent = loremIpsumComments[i % loremIpsumComments.length];
const rootCreatedAt = now - (numRootComments - i) * 3600; // Stagger by hours
const rootComment = createMockComment(
rootId,
rootContent,
rootPubkey,
sectionAddress,
rootCreatedAt
);
comments.push(rootComment);
// Generate replies to this root comment
for (let j = 0; j < numRepliesPerThread; j++) {
const replyId = `mock-reply-${i}-${j}-${Date.now()}`;
const replyPubkey = mockPubkeys[(i + j + 1) % mockPubkeys.length];
const replyContent = loremIpsumReplies[commentIndex % loremIpsumReplies.length];
const replyCreatedAt = rootCreatedAt + (j + 1) * 1800; // 30 min after each
const reply = createMockComment(
replyId,
replyContent,
replyPubkey,
sectionAddress,
replyCreatedAt,
rootId,
rootPubkey
);
comments.push(reply);
// Optionally add a nested reply (reply to reply)
if (j === 0 && i < 2) {
const nestedId = `mock-nested-${i}-${j}-${Date.now()}`;
const nestedPubkey = mockPubkeys[(i + j + 2) % mockPubkeys.length];
const nestedContent = loremIpsumReplies[(commentIndex + 1) % loremIpsumReplies.length];
const nestedCreatedAt = replyCreatedAt + 900; // 15 min after reply
const nested = createMockComment(
nestedId,
nestedContent,
nestedPubkey,
sectionAddress,
nestedCreatedAt,
replyId,
replyPubkey
);
comments.push(nested);
}
commentIndex++;
}
}
return comments;
}
/**
* Generate mock comments for multiple sections
* @param sectionAddresses - Array of section addresses
* @returns Array of all mock comments across all sections
*/
export function generateMockCommentsForSections(
sectionAddresses: string[]
): any[] {
const allComments: any[] = [];
sectionAddresses.forEach((address, index) => {
// Vary the number of comments per section
const numRoot = 2 + (index % 3); // 2-4 root comments
const numReplies = 1 + (index % 2); // 1-2 replies per thread
const sectionComments = generateMockComments(address, numRoot, numReplies);
allComments.push(...sectionComments);
});
return allComments;
}

183
src/lib/utils/mockHighlightData.ts

@ -0,0 +1,183 @@ @@ -0,0 +1,183 @@
/**
* Generate mock highlight data (kind 9802) for testing highlight UI
* Creates realistic highlight events with context and optional annotations
*/
// Sample highlighted text snippets (things users might actually highlight)
const highlightedTexts = [
'Knowledge that tries to stay put inevitably becomes ossified',
'The attempt to hold knowledge still is like trying to photograph a river',
'Understanding emerges not from rigid frameworks but from fluid engagement',
'Traditional institutions struggle with the natural promiscuity of ideas',
'Thinking without permission means refusing predetermined categories',
'The most valuable insights often come from unexpected juxtapositions',
'Anarchistic knowledge rejects the notion of authorized interpreters',
'Every act of reading is an act of creative interpretation',
'Hierarchy in knowledge systems serves power, not understanding',
'The boundary between creator and consumer is an artificial construction',
];
// Context strings (surrounding text to help locate the highlight)
const contexts = [
'This is the fundamental paradox of institutionalized knowledge. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.',
'The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow. What remains is a static representation, not the dynamic reality.',
'Understanding emerges not from rigid frameworks but from fluid engagement with ideas, people, and contexts. This fluidity is precisely what traditional systems attempt to eliminate.',
'Traditional institutions struggle with the natural promiscuity of ideas—the way concepts naturally migrate, mutate, and merge across boundaries that were meant to contain them.',
'Thinking without permission means refusing predetermined categories and challenging the gatekeepers who claim authority over legitimate thought.',
'The most valuable insights often come from unexpected juxtapositions, from bringing together ideas that were never meant to meet.',
'Anarchistic knowledge rejects the notion of authorized interpreters, asserting instead that meaning-making is a fundamentally distributed and democratic process.',
'Every act of reading is an act of creative interpretation, a collaboration between text and reader that produces something new each time.',
'Hierarchy in knowledge systems serves power, not understanding. It determines who gets to speak, who must listen, and what counts as legitimate knowledge.',
'The boundary between creator and consumer is an artificial construction, one that digital networks make increasingly untenable and obsolete.',
];
// Optional annotations (user comments on their highlights)
const annotations = [
'This perfectly captures the institutional problem',
'Key insight - worth revisiting',
'Reminds me of Deleuze on rhizomatic structures',
'Fundamental critique of academic gatekeeping',
'The core argument in one sentence',
null, // Some highlights have no annotation
'Important for understanding the broader thesis',
null,
'Connects to earlier discussion on page 12',
null,
];
// Mock pubkeys - MUST be exactly 64 hex characters
const mockPubkeys = [
'a1b2c3d4e5f67890123456789012345678901234567890123456789012345678',
'b2c3d4e5f67890123456789012345678901234567890123456789012345678ab',
'c3d4e5f67890123456789012345678901234567890123456789012345678abcd',
'd4e5f67890123456789012345678901234567890123456789012345678abcdef',
'e5f6789012345678901234567890123456789012345678901234567890abcdef',
];
/**
* Create a mock highlight event (kind 9802)
*
* AI-NOTE: Unlike comments (kind 1111), highlights have:
* - content field = the highlighted text itself (NOT a user comment)
* - ["context", ...] tag with surrounding text to help locate the highlight
* - Optional ["comment", ...] tag for user annotations
* - Optional ["offset", start, end] tag for position-based highlighting
* - Single lowercase ["a", targetAddress] tag (not uppercase/lowercase pairs)
*/
function createMockHighlight(
id: string,
highlightedText: string,
context: string,
targetAddress: string,
pubkey: string,
createdAt: number,
authorPubkey: string,
annotation?: string | null,
offsetStart?: number,
offsetEnd?: number
): any {
const tags: string[][] = [
['a', targetAddress, 'wss://relay.damus.io'],
['context', context],
['p', authorPubkey, 'wss://relay.damus.io', 'author'],
];
// Add optional annotation
if (annotation) {
tags.push(['comment', annotation]);
}
// Add optional offset for position-based highlighting
if (offsetStart !== undefined && offsetEnd !== undefined) {
tags.push(['offset', offsetStart.toString(), offsetEnd.toString()]);
}
return {
id,
kind: 9802,
pubkey,
created_at: createdAt,
content: highlightedText, // The highlighted text itself
tags,
sig: 'mock-signature-' + id,
};
}
/**
* Generate mock highlights for a section
* @param sectionAddress - The section address to attach highlights to
* @param authorPubkey - The author's pubkey (for the "p" tag)
* @param numHighlights - Number of highlights to generate (default: 3-5 random)
* @returns Array of mock highlight objects
*/
export function generateMockHighlights(
sectionAddress: string,
authorPubkey: string,
numHighlights: number = Math.floor(Math.random() * 2) + 2 // 2-3 highlights
): any[] {
const highlights: any[] = [];
const now = Math.floor(Date.now() / 1000);
// Generate position-based highlights at the beginning of each section
// For test mode, we use simple placeholder text and rely on offset-based highlighting
// The offset tags will highlight the ACTUAL text at those positions in the section
for (let i = 0; i < numHighlights; i++) {
const id = `mock-highlight-${i}-${Date.now()}-${Math.random().toString(36).substring(7)}`;
const highlighterPubkey = mockPubkeys[i % mockPubkeys.length];
const annotation = annotations[i % annotations.length];
const createdAt = now - (numHighlights - i) * 7200; // Stagger by 2 hours
// Create sequential highlights at the beginning of the section
// Each highlight is exactly 100 characters
const highlightLength = 100;
const offsetStart = i * 120; // Space between highlights (120 chars apart)
const offsetEnd = offsetStart + highlightLength;
// Use placeholder text - the actual highlighted text will be determined by the offsets
const placeholderText = `Test highlight ${i + 1}`;
const placeholderContext = `This is test highlight ${i + 1} at position ${offsetStart}-${offsetEnd}`;
const highlight = createMockHighlight(
id,
placeholderText,
placeholderContext,
sectionAddress,
highlighterPubkey,
createdAt,
authorPubkey,
annotation,
offsetStart,
offsetEnd
);
highlights.push(highlight);
}
return highlights;
}
/**
* Generate mock highlights for multiple sections
* @param sectionAddresses - Array of section addresses
* @param authorPubkey - The publication author's pubkey
* @returns Array of all mock highlights across all sections
*/
export function generateMockHighlightsForSections(
sectionAddresses: string[],
authorPubkey: string = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06'
): any[] {
const allHighlights: any[] = [];
sectionAddresses.forEach((address, index) => {
// Each section gets 2 highlights at the very beginning (positions 0-100 and 120-220)
const numHighlights = 2;
const sectionHighlights = generateMockHighlights(address, authorPubkey, numHighlights);
console.log(`[MockHighlightData] Generated ${numHighlights} highlights for section ${address.split(':')[2]?.substring(0, 20)}... at positions 0-100, 120-220`);
allHighlights.push(...sectionHighlights);
});
console.log(`[MockHighlightData] Total: ${allHighlights.length} highlights across ${sectionAddresses.length} sections`);
console.log(`[MockHighlightData] Each highlight is anchored to its section via "a" tag and uses offset tags for position`);
return allHighlights;
}

31
src/lib/utils/nostrUtils.ts

@ -610,6 +610,37 @@ export async function signEvent(event: { @@ -610,6 +610,37 @@ export async function signEvent(event: {
return bytesToHex(sig);
}
/**
* Converts a pubkey to a consistent hue value (0-360) for color mapping.
* The same pubkey will always produce the same hue.
* @param pubkey The pubkey to convert (hex or npub format)
* @returns A hue value between 0 and 360
*/
export function pubkeyToHue(pubkey: string): number {
// Normalize pubkey to hex format
let hexPubkey = pubkey;
try {
if (pubkey.startsWith("npub")) {
const decoded = nip19.decode(pubkey);
if (decoded.type === "npub") {
hexPubkey = decoded.data as string;
}
}
} catch {
// If decode fails, use the original pubkey
}
// Hash the pubkey using SHA-256
const hash = sha256(hexPubkey);
// Use the first 4 bytes to generate a number
const num = (hash[0] << 24) | (hash[1] << 16) | (hash[2] << 8) | hash[3];
// Map to 0-360 range
return Math.abs(num) % 360;
}
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink

46
src/lib/utils/publication_tree_factory.ts

@ -120,10 +120,15 @@ function createIndexEvent(parsed: any, ndk: NDK): NDKEvent { @@ -120,10 +120,15 @@ function createIndexEvent(parsed: any, ndk: NDK): NDKEvent {
// Add document attributes as tags
addDocumentAttributesToTags(tags, parsed.attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 references)
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section: any) => {
const sectionDTag = generateDTag(section.title);
tags.push(["a", `30041:${event.pubkey}:${sectionDTag}`]);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
});
event.tags = tags;
@ -147,10 +152,19 @@ function createContentEvent( @@ -147,10 +152,19 @@ function createContentEvent(
// Use placeholder pubkey for preview if no active user
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
const dTag = generateDTag(section.title);
// Generate namespaced d-tag using publication abbreviation
const sectionDTag = generateDTag(section.title);
const pubAbbrev = generateTitleAbbreviation(documentParsed.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", section.title]];
const tags: string[][] = [
["d", namespacedDTag],
mTag,
MTag,
["title", section.title],
];
// Add section-specific attributes
addSectionAttributesToTags(tags, section.attributes);
@ -200,6 +214,32 @@ function generateDTag(title: string): string { @@ -200,6 +214,32 @@ function generateDTag(title: string): string {
);
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* Add document attributes as Nostr tags
*/

146
src/lib/utils/publication_tree_processor.ts

@ -11,9 +11,10 @@ import { PublicationTree } from "$lib/data_structures/publication_tree"; @@ -11,9 +11,10 @@ import { PublicationTree } from "$lib/data_structures/publication_tree";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { getMimeTags } from "$lib/utils/mime";
import { extractWikiLinks, wikiLinksToTags } from "$lib/utils/wiki_links";
// For debugging tree structure
const DEBUG = process.env.DEBUG_TREE_PROCESSOR === false;
const DEBUG = process.env.DEBUG_TREE_PROCESSOR === "true";
export interface ProcessorResult {
tree: PublicationTree;
indexEvent: NDKEvent | null;
@ -435,6 +436,7 @@ function buildScatteredNotesStructure( @@ -435,6 +436,7 @@ function buildScatteredNotesStructure(
const eventStructure: EventStructureNode[] = [];
const firstSegment = segments[0];
// No publication title for scattered notes
const rootEvent = createContentEvent(firstSegment, ndk);
const tree = new PublicationTree(rootEvent, ndk);
contentEvents.push(rootEvent);
@ -530,16 +532,22 @@ function buildLevel2Structure( @@ -530,16 +532,22 @@ function buildLevel2Structure(
const level2Groups = groupSegmentsByLevel2(segments);
console.log(`[TreeProcessor] Level 2 groups:`, level2Groups.length, level2Groups.map(g => g.title));
// Generate publication abbreviation for namespacing
const pubAbbrev = generateTitleAbbreviation(title);
for (const group of level2Groups) {
const contentEvent = createContentEvent(group, ndk);
const contentEvent = createContentEvent(group, ndk, title);
contentEvents.push(contentEvent);
const sectionDTag = generateDTag(group.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const childNode = {
title: group.title,
level: group.level,
eventType: "content" as const,
eventKind: 30041 as const,
dTag: generateDTag(group.title),
dTag: namespacedDTag,
children: [],
};
@ -590,7 +598,8 @@ function buildHierarchicalStructure( @@ -590,7 +598,8 @@ function buildHierarchicalStructure(
rootNode,
contentEvents,
ndk,
parseLevel
parseLevel,
title
);
return { tree, indexEvent, contentEvents, eventStructure };
@ -618,10 +627,15 @@ function createIndexEvent( @@ -618,10 +627,15 @@ function createIndexEvent(
// Add document attributes as tags
addDocumentAttributesToTags(tags, attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(title);
// Add a-tags for each content section
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
segments.forEach((segment) => {
const sectionDTag = generateDTag(segment.title);
tags.push(["a", `30041:${event.pubkey}:${sectionDTag}`]);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
});
event.tags = tags;
@ -635,13 +649,25 @@ function createIndexEvent( @@ -635,13 +649,25 @@ function createIndexEvent(
/**
* Create a 30041 content event from segment
*/
function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent {
function createContentEvent(
segment: ContentSegment,
ndk: NDK,
publicationTitle?: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000);
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
const dTag = generateDTag(segment.title);
// Generate namespaced d-tag if publication title is provided
const sectionDTag = generateDTag(segment.title);
let dTag = sectionDTag;
if (publicationTitle) {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
dTag = `${pubAbbrev}-${sectionDTag}`;
}
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", segment.title]];
@ -649,10 +675,17 @@ function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent { @@ -649,10 +675,17 @@ function createContentEvent(segment: ContentSegment, ndk: NDK): NDKEvent {
// Add segment attributes as tags
addSectionAttributesToTags(tags, segment.attributes);
// Extract and add wiki link tags from content
const wikiLinks = extractWikiLinks(segment.content);
if (wikiLinks.length > 0) {
const wikiTags = wikiLinksToTags(wikiLinks);
tags.push(...wikiTags);
console.log(`[TreeProcessor] Added ${wikiTags.length} wiki link tags:`, wikiTags);
}
event.tags = tags;
event.content = segment.content;
return event;
}
@ -690,6 +723,32 @@ function generateDTag(title: string): string { @@ -690,6 +723,32 @@ function generateDTag(title: string): string {
);
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* Add document attributes as Nostr tags
*/
@ -925,21 +984,35 @@ function processHierarchicalGroup( @@ -925,21 +984,35 @@ function processHierarchicalGroup(
parentStructureNode: EventStructureNode,
contentEvents: NDKEvent[],
ndk: NDK,
parseLevel: number
parseLevel: number,
publicationTitle: string,
): void {
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
for (const node of nodes) {
if (node.hasChildren && node.segment.level < parseLevel) {
// This section has children and is not at parse level
// Create BOTH an index event AND a content event
// 1. Create the index event (30040)
const indexEvent = createIndexEventForHierarchicalNode(node, ndk);
const indexEvent = createIndexEventForHierarchicalNode(
node,
ndk,
publicationTitle,
);
contentEvents.push(indexEvent);
// 2. Create the content event (30041) for the section's own content
const contentEvent = createContentEvent(node.segment, ndk);
const contentEvent = createContentEvent(
node.segment,
ndk,
publicationTitle,
);
contentEvents.push(contentEvent);
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
// 3. Add index node to structure
const indexNode: EventStructureNode = {
title: node.segment.title,
@ -957,7 +1030,7 @@ function processHierarchicalGroup( @@ -957,7 +1030,7 @@ function processHierarchicalGroup(
level: node.segment.level,
eventType: "content",
eventKind: 30041,
dTag: generateDTag(node.segment.title),
dTag: namespacedDTag,
children: [],
});
@ -967,19 +1040,27 @@ function processHierarchicalGroup( @@ -967,19 +1040,27 @@ function processHierarchicalGroup(
indexNode,
contentEvents,
ndk,
parseLevel
parseLevel,
publicationTitle,
);
} else {
// This is either a leaf node or at parse level - just create content event
const contentEvent = createContentEvent(node.segment, ndk);
const contentEvent = createContentEvent(
node.segment,
ndk,
publicationTitle,
);
contentEvents.push(contentEvent);
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
parentStructureNode.children.push({
title: node.segment.title,
level: node.segment.level,
eventType: "content",
eventKind: 30041,
dTag: generateDTag(node.segment.title),
dTag: namespacedDTag,
children: [],
});
}
@ -991,7 +1072,8 @@ function processHierarchicalGroup( @@ -991,7 +1072,8 @@ function processHierarchicalGroup(
*/
function createIndexEventForHierarchicalNode(
node: HierarchicalNode,
ndk: NDK
ndk: NDK,
publicationTitle: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30040;
@ -1001,23 +1083,33 @@ function createIndexEventForHierarchicalNode( @@ -1001,23 +1083,33 @@ function createIndexEventForHierarchicalNode(
const dTag = generateDTag(node.segment.title);
const [mTag, MTag] = getMimeTags(30040);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", node.segment.title]];
const tags: string[][] = [
["d", dTag],
mTag,
MTag,
["title", node.segment.title],
];
// Add section attributes as tags
addSectionAttributesToTags(tags, node.segment.attributes);
// Add a-tags for the section's own content event
tags.push(["a", `30041:${event.pubkey}:${dTag}`]);
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
// Add a-tags for each child section
// Add a-tags for the section's own content event with namespace
const sectionDTag = generateDTag(node.segment.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
// Add a-tags for each child section with namespace
for (const child of node.children) {
const childDTag = generateDTag(child.segment.title);
const namespacedChildDTag = `${pubAbbrev}-${childDTag}`;
if (child.hasChildren && child.segment.level < node.segment.level + 1) {
// Child will be an index
tags.push(["a", `30040:${event.pubkey}:${childDTag}`]);
} else {
// Child will be content
tags.push(["a", `30041:${event.pubkey}:${childDTag}`]);
// Child will be content with namespace
tags.push(["a", `30041:${event.pubkey}:${namespacedChildDTag}`]);
}
}
@ -1059,10 +1151,13 @@ function buildSegmentHierarchy( @@ -1059,10 +1151,13 @@ function buildSegmentHierarchy(
/**
* Create a 30040 index event for a section with children
* Note: This function appears to be unused in the current codebase
* but is updated for consistency with the new namespacing scheme
*/
function createIndexEventForSection(
section: HierarchicalSegment,
ndk: NDK,
publicationTitle: string,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30040;
@ -1077,10 +1172,13 @@ function createIndexEventForSection( @@ -1077,10 +1172,13 @@ function createIndexEventForSection(
// Add section attributes as tags
addSectionAttributesToTags(tags, section.attributes);
// Add a-tags for each child content section
const pubAbbrev = generateTitleAbbreviation(publicationTitle);
// Add a-tags for each child content section with namespace
section.children.forEach((child) => {
const childDTag = generateDTag(child.title);
tags.push(["a", `30041:${event.pubkey}:${childDTag}`]);
const namespacedChildDTag = `${pubAbbrev}-${childDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedChildDTag}`]);
});
event.tags = tags;

145
src/lib/utils/wiki_links.ts

@ -0,0 +1,145 @@ @@ -0,0 +1,145 @@
/**
* Wiki link parsing and tag generation utilities
* Supports [[term]], [[w:term]], and [[d:term]] syntax
*/
export interface WikiLink {
fullMatch: string;
type: 'w' | 'd' | 'auto'; // auto means [[term]] without explicit prefix
term: string;
displayText: string;
startIndex: number;
endIndex: number;
}
/**
* Extracts all wiki links from AsciiDoc content.
* Supports three formats:
* - [[term]] - Auto (will query both w and d tags)
* - [[w:term]] - Explicit reference/mention (backward link)
* - [[d:term]] - Explicit definition (forward link)
*/
export function extractWikiLinks(content: string): WikiLink[] {
const wikiLinks: WikiLink[] = [];
// Match [[prefix:term]] or [[term]]
// Captures: optional prefix (w: or d:), term, optional display text after |
const regex = /\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
let match;
while ((match = regex.exec(content)) !== null) {
const prefix = match[1]; // 'w', 'd', or undefined
const term = match[2].trim();
const customDisplay = match[3]?.trim();
wikiLinks.push({
fullMatch: match[0],
type: prefix ? (prefix as 'w' | 'd') : 'auto',
term,
displayText: customDisplay || term,
startIndex: match.index,
endIndex: match.index + match[0].length,
});
}
return wikiLinks;
}
/**
* Converts a term to a clean tag format (lowercase, hyphenated).
* Example: "Knowledge Graphs" -> "knowledge-graphs"
*/
export function termToTag(term: string): string {
return term
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
}
/**
* Generates Nostr event tags from wiki links.
* Format: ['w', 'tag-slug', 'Display Text'] or ['d', 'tag-slug']
*/
export function wikiLinksToTags(wikiLinks: WikiLink[]): string[][] {
const tags: string[][] = [];
for (const link of wikiLinks) {
const tagSlug = termToTag(link.term);
if (link.type === 'w' || link.type === 'auto') {
// Reference tag includes display text
tags.push(['w', tagSlug, link.displayText]);
}
if (link.type === 'd') {
// Definition tag (no display text, it IS the thing)
tags.push(['d', tagSlug]);
}
}
return tags;
}
/**
* Replaces wiki link syntax with HTML for preview rendering.
* Can be customized for different rendering styles.
*/
export function renderWikiLinksToHtml(
content: string,
options: {
linkClass?: string;
wLinkClass?: string;
dLinkClass?: string;
onClickHandler?: (type: 'w' | 'd' | 'auto', term: string) => string;
} = {},
): string {
const {
linkClass = 'wiki-link',
wLinkClass = 'wiki-link-reference',
dLinkClass = 'wiki-link-definition',
onClickHandler,
} = options;
return content.replace(
/\[\[(?:(w|d):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, prefix, term, customDisplay) => {
const displayText = customDisplay?.trim() || term.trim();
const type = prefix ? prefix : 'auto';
const tagSlug = termToTag(term);
// Determine CSS classes
let classes = linkClass;
if (type === 'w') classes += ` ${wLinkClass}`;
else if (type === 'd') classes += ` ${dLinkClass}`;
// Generate href or onclick
const action = onClickHandler
? `onclick="${onClickHandler(type, tagSlug)}"`
: `href="#wiki/${type}/${encodeURIComponent(tagSlug)}"`;
// Add title attribute showing the type
const title =
type === 'w'
? 'Wiki reference (mentions this concept)'
: type === 'd'
? 'Wiki definition (defines this concept)'
: 'Wiki link (searches both references and definitions)';
return `<a class="${classes}" ${action} title="${title}" data-wiki-type="${type}" data-wiki-term="${tagSlug}">${displayText}</a>`;
},
);
}
/**
* Converts wiki links to plain text (for content storage).
* Preserves the display text if custom, otherwise uses the term.
*/
export function wikiLinksToPlainText(content: string): string {
return content.replace(
/\[\[(?:w|d:)?([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, term, customDisplay) => {
return customDisplay?.trim() || term.trim();
},
);
}

911
tests/unit/commentButton.test.ts

@ -0,0 +1,911 @@ @@ -0,0 +1,911 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { get, writable } from 'svelte/store';
import type { UserState } from '../../src/lib/stores/userStore';
import { NDKEvent } from '@nostr-dev-kit/ndk';
// Mock userStore
const createMockUserStore = (signedIn: boolean = false) => {
const store = writable<UserState>({
pubkey: signedIn ? 'a'.repeat(64) : null,
npub: signedIn ? 'npub1test' : null,
profile: signedIn ? {
name: 'Test User',
displayName: 'Test User',
picture: 'https://example.com/avatar.jpg',
} : null,
relays: { inbox: [], outbox: [] },
loginMethod: signedIn ? 'extension' : null,
ndkUser: null,
signer: signedIn ? { sign: vi.fn() } as any : null,
signedIn,
});
return store;
};
// Mock activeOutboxRelays
const mockActiveOutboxRelays = writable<string[]>(['wss://relay.example.com']);
// Mock NDK
const createMockNDK = () => ({
fetchEvent: vi.fn(),
publish: vi.fn(),
});
describe('CommentButton - Address Parsing', () => {
it('parses valid event address correctly', () => {
const address = '30041:abc123def456:my-article';
const parts = address.split(':');
expect(parts).toHaveLength(3);
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
expect(kind).toBe(30041);
expect(pubkey).toBe('abc123def456');
expect(dTag).toBe('my-article');
expect(isNaN(kind)).toBe(false);
});
it('handles dTag with colons correctly', () => {
const address = '30041:abc123:article:with:colons';
const parts = address.split(':');
expect(parts.length).toBeGreaterThanOrEqual(3);
const [kindStr, pubkey, ...dTagParts] = parts;
const dTag = dTagParts.join(':');
expect(parseInt(kindStr)).toBe(30041);
expect(pubkey).toBe('abc123');
expect(dTag).toBe('article:with:colons');
});
it('returns null for invalid address format (too few parts)', () => {
const address = '30041:abc123';
const parts = address.split(':');
if (parts.length !== 3) {
expect(parts.length).toBeLessThan(3);
}
});
it('returns null for invalid address format (invalid kind)', () => {
const address = 'invalid:abc123:dtag';
const parts = address.split(':');
const kind = parseInt(parts[0]);
expect(isNaN(kind)).toBe(true);
});
it('parses different publication kinds correctly', () => {
const addresses = [
'30040:pubkey:section-id', // Zettel section
'30041:pubkey:article-id', // Long-form article
'30818:pubkey:wiki-id', // Wiki article
'30023:pubkey:blog-id', // Blog post
];
addresses.forEach(address => {
const parts = address.split(':');
const kind = parseInt(parts[0]);
expect(isNaN(kind)).toBe(false);
expect(kind).toBeGreaterThan(0);
});
});
});
describe('CommentButton - NIP-22 Event Creation', () => {
let mockNDK: any;
let mockUserStore: any;
let mockActiveOutboxRelays: any;
beforeEach(() => {
mockNDK = createMockNDK();
mockUserStore = createMockUserStore(true);
mockActiveOutboxRelays = writable(['wss://relay.example.com']);
});
afterEach(() => {
vi.clearAllMocks();
});
it('creates kind 1111 comment event', async () => {
const address = '30041:' + 'a'.repeat(64) + ':my-article';
const content = 'This is my comment';
// Mock event creation
const commentEvent = new NDKEvent(mockNDK);
commentEvent.kind = 1111;
commentEvent.content = content;
expect(commentEvent.kind).toBe(1111);
expect(commentEvent.content).toBe(content);
});
it('includes correct uppercase tags (A, K, P) for root', () => {
const address = '30041:' + 'b'.repeat(64) + ':article-id';
const authorPubkey = 'b'.repeat(64);
const kind = 30041;
const relayHint = 'wss://relay.example.com';
const tags = [
['A', address, relayHint, authorPubkey],
['K', kind.toString()],
['P', authorPubkey, relayHint],
];
// Verify uppercase root tags
expect(tags[0][0]).toBe('A');
expect(tags[0][1]).toBe(address);
expect(tags[0][2]).toBe(relayHint);
expect(tags[0][3]).toBe(authorPubkey);
expect(tags[1][0]).toBe('K');
expect(tags[1][1]).toBe(kind.toString());
expect(tags[2][0]).toBe('P');
expect(tags[2][1]).toBe(authorPubkey);
expect(tags[2][2]).toBe(relayHint);
});
it('includes correct lowercase tags (a, k, p) for parent', () => {
const address = '30041:' + 'c'.repeat(64) + ':article-id';
const authorPubkey = 'c'.repeat(64);
const kind = 30041;
const relayHint = 'wss://relay.example.com';
const tags = [
['a', address, relayHint],
['k', kind.toString()],
['p', authorPubkey, relayHint],
];
// Verify lowercase parent tags
expect(tags[0][0]).toBe('a');
expect(tags[0][1]).toBe(address);
expect(tags[0][2]).toBe(relayHint);
expect(tags[1][0]).toBe('k');
expect(tags[1][1]).toBe(kind.toString());
expect(tags[2][0]).toBe('p');
expect(tags[2][1]).toBe(authorPubkey);
expect(tags[2][2]).toBe(relayHint);
});
it('includes e tag with event ID when available', () => {
const eventId = 'd'.repeat(64);
const relayHint = 'wss://relay.example.com';
const eTag = ['e', eventId, relayHint];
expect(eTag[0]).toBe('e');
expect(eTag[1]).toBe(eventId);
expect(eTag[2]).toBe(relayHint);
expect(eTag[1]).toHaveLength(64);
});
it('creates complete NIP-22 tag structure', () => {
const address = '30041:' + 'e'.repeat(64) + ':test-article';
const authorPubkey = 'e'.repeat(64);
const kind = 30041;
const eventId = 'f'.repeat(64);
const relayHint = 'wss://relay.example.com';
const tags = [
// Root scope - uppercase tags
['A', address, relayHint, authorPubkey],
['K', kind.toString()],
['P', authorPubkey, relayHint],
// Parent scope - lowercase tags
['a', address, relayHint],
['k', kind.toString()],
['p', authorPubkey, relayHint],
// Event ID
['e', eventId, relayHint],
];
// Verify all tags are present
expect(tags).toHaveLength(7);
// Verify root tags
expect(tags.filter(t => t[0] === 'A')).toHaveLength(1);
expect(tags.filter(t => t[0] === 'K')).toHaveLength(1);
expect(tags.filter(t => t[0] === 'P')).toHaveLength(1);
// Verify parent tags
expect(tags.filter(t => t[0] === 'a')).toHaveLength(1);
expect(tags.filter(t => t[0] === 'k')).toHaveLength(1);
expect(tags.filter(t => t[0] === 'p')).toHaveLength(1);
// Verify event tag
expect(tags.filter(t => t[0] === 'e')).toHaveLength(1);
});
it('uses correct relay hints from activeOutboxRelays', () => {
const relays = get(mockActiveOutboxRelays);
const relayHint = relays[0];
expect(relayHint).toBe('wss://relay.example.com');
expect(relays).toHaveLength(1);
});
it('handles multiple outbox relays correctly', () => {
const multipleRelays = writable([
'wss://relay1.example.com',
'wss://relay2.example.com',
'wss://relay3.example.com',
]);
const relays = get(multipleRelays);
const relayHint = relays[0];
expect(relayHint).toBe('wss://relay1.example.com');
expect(relays).toHaveLength(3);
});
it('handles empty relay list gracefully', () => {
const emptyRelays = writable<string[]>([]);
const relays = get(emptyRelays);
const relayHint = relays[0] || '';
expect(relayHint).toBe('');
});
});
describe('CommentButton - Event Signing and Publishing', () => {
let mockNDK: any;
let mockSigner: any;
beforeEach(() => {
mockNDK = createMockNDK();
mockSigner = {
sign: vi.fn().mockResolvedValue(undefined),
};
});
afterEach(() => {
vi.clearAllMocks();
});
it('signs event with user signer', async () => {
const commentEvent = new NDKEvent(mockNDK);
commentEvent.kind = 1111;
commentEvent.content = 'Test comment';
await mockSigner.sign(commentEvent);
expect(mockSigner.sign).toHaveBeenCalledWith(commentEvent);
expect(mockSigner.sign).toHaveBeenCalledTimes(1);
});
it('publishes to outbox relays', async () => {
const publishMock = vi.fn().mockResolvedValue(new Set(['wss://relay.example.com']));
const commentEvent = new NDKEvent(mockNDK);
commentEvent.publish = publishMock;
const publishedRelays = await commentEvent.publish();
expect(publishMock).toHaveBeenCalled();
expect(publishedRelays.size).toBeGreaterThan(0);
});
it('handles publishing errors gracefully', async () => {
const publishMock = vi.fn().mockResolvedValue(new Set());
const commentEvent = new NDKEvent(mockNDK);
commentEvent.publish = publishMock;
const publishedRelays = await commentEvent.publish();
expect(publishedRelays.size).toBe(0);
});
it('throws error when publishing fails', async () => {
const publishMock = vi.fn().mockRejectedValue(new Error('Network error'));
const commentEvent = new NDKEvent(mockNDK);
commentEvent.publish = publishMock;
await expect(commentEvent.publish()).rejects.toThrow('Network error');
});
});
describe('CommentButton - User Authentication', () => {
it('requires user to be signed in', () => {
const signedOutStore = createMockUserStore(false);
const user = get(signedOutStore);
expect(user.signedIn).toBe(false);
expect(user.signer).toBeNull();
});
it('shows error when user is not signed in', () => {
const signedOutStore = createMockUserStore(false);
const user = get(signedOutStore);
if (!user.signedIn || !user.signer) {
const error = 'You must be signed in to comment';
expect(error).toBe('You must be signed in to comment');
}
});
it('allows commenting when user is signed in', () => {
const signedInStore = createMockUserStore(true);
const user = get(signedInStore);
expect(user.signedIn).toBe(true);
expect(user.signer).not.toBeNull();
});
it('displays user profile information when signed in', () => {
const signedInStore = createMockUserStore(true);
const user = get(signedInStore);
expect(user.profile).not.toBeNull();
expect(user.profile?.displayName).toBe('Test User');
expect(user.profile?.picture).toBe('https://example.com/avatar.jpg');
});
it('handles missing user profile gracefully', () => {
const storeWithoutProfile = writable<UserState>({
pubkey: 'a'.repeat(64),
npub: 'npub1test',
profile: null,
relays: { inbox: [], outbox: [] },
loginMethod: 'extension',
ndkUser: null,
signer: { sign: vi.fn() } as any,
signedIn: true,
});
const user = get(storeWithoutProfile);
const displayName = user.profile?.displayName || user.profile?.name || 'Anonymous';
expect(displayName).toBe('Anonymous');
});
});
describe('CommentButton - User Interactions', () => {
it('prevents submission of empty comment', () => {
const commentContent = '';
const isEmpty = !commentContent.trim();
expect(isEmpty).toBe(true);
});
it('allows submission of non-empty comment', () => {
const commentContent = 'This is a valid comment';
const isEmpty = !commentContent.trim();
expect(isEmpty).toBe(false);
});
it('handles whitespace-only comments as empty', () => {
const commentContent = ' \n\t ';
const isEmpty = !commentContent.trim();
expect(isEmpty).toBe(true);
});
it('clears input after successful comment', () => {
let commentContent = 'This is my comment';
// Simulate successful submission
commentContent = '';
expect(commentContent).toBe('');
});
it('closes comment UI after successful posting', () => {
let showCommentUI = true;
// Simulate successful post with delay
setTimeout(() => {
showCommentUI = false;
}, 0);
// Initially still open
expect(showCommentUI).toBe(true);
});
it('calls onCommentPosted callback when provided', () => {
const onCommentPosted = vi.fn();
// Simulate successful comment post
onCommentPosted();
expect(onCommentPosted).toHaveBeenCalled();
});
it('does not error when onCommentPosted is not provided', () => {
const onCommentPosted = undefined;
expect(() => {
if (onCommentPosted) {
onCommentPosted();
}
}).not.toThrow();
});
});
describe('CommentButton - UI State Management', () => {
it('button is hidden by default', () => {
const sectionHovered = false;
const showCommentUI = false;
const visible = sectionHovered || showCommentUI;
expect(visible).toBe(false);
});
it('button appears on section hover', () => {
const sectionHovered = true;
const showCommentUI = false;
const visible = sectionHovered || showCommentUI;
expect(visible).toBe(true);
});
it('button remains visible when comment UI is shown', () => {
const sectionHovered = false;
const showCommentUI = true;
const visible = sectionHovered || showCommentUI;
expect(visible).toBe(true);
});
it('toggles comment UI when button is clicked', () => {
let showCommentUI = false;
// Simulate button click
showCommentUI = !showCommentUI;
expect(showCommentUI).toBe(true);
// Click again
showCommentUI = !showCommentUI;
expect(showCommentUI).toBe(false);
});
it('resets error state when toggling UI', () => {
let error: string | null = 'Previous error';
let success = true;
// Simulate UI toggle
error = null;
success = false;
expect(error).toBeNull();
expect(success).toBe(false);
});
it('shows error message when present', () => {
const error = 'Failed to post comment';
expect(error).toBeDefined();
expect(error.length).toBeGreaterThan(0);
});
it('shows success message after posting', () => {
const success = true;
const successMessage = 'Comment posted successfully!';
if (success) {
expect(successMessage).toBe('Comment posted successfully!');
}
});
it('disables submit button when submitting', () => {
const isSubmitting = true;
const disabled = isSubmitting;
expect(disabled).toBe(true);
});
it('disables submit button when comment is empty', () => {
const commentContent = '';
const isSubmitting = false;
const disabled = isSubmitting || !commentContent.trim();
expect(disabled).toBe(true);
});
it('enables submit button when comment is valid', () => {
const commentContent = 'Valid comment';
const isSubmitting = false;
const disabled = isSubmitting || !commentContent.trim();
expect(disabled).toBe(false);
});
});
describe('CommentButton - Edge Cases', () => {
it('handles invalid address format gracefully', () => {
const invalidAddresses = [
'',
'invalid',
'30041:',
':pubkey:dtag',
'30041:pubkey',
'not-a-number:pubkey:dtag',
];
invalidAddresses.forEach(address => {
const parts = address.split(':');
const isValid = parts.length === 3 && !isNaN(parseInt(parts[0]));
expect(isValid).toBe(false);
});
});
it('handles network errors during event fetch', async () => {
const mockNDK = {
fetchEvent: vi.fn().mockRejectedValue(new Error('Network error')),
};
let eventId = '';
try {
await mockNDK.fetchEvent({});
} catch (err) {
// Handle gracefully, continue without event ID
eventId = '';
}
expect(eventId).toBe('');
});
it('handles missing relay information', () => {
const emptyRelays: string[] = [];
const relayHint = emptyRelays[0] || '';
expect(relayHint).toBe('');
});
it('handles very long comment text without truncation', () => {
const longComment = 'a'.repeat(10000);
const content = longComment;
expect(content.length).toBe(10000);
expect(content).toBe(longComment);
});
it('handles special characters in comments', () => {
const specialComments = [
'Comment with "quotes"',
'Comment with emoji 😊',
'Comment with\nnewlines',
'Comment with\ttabs',
'Comment with <html> tags',
'Comment with & ampersands',
];
specialComments.forEach(comment => {
expect(comment.length).toBeGreaterThan(0);
expect(typeof comment).toBe('string');
});
});
it('handles event creation failure', async () => {
const address = 'invalid:address';
const parts = address.split(':');
if (parts.length !== 3) {
const error = 'Invalid event address';
expect(error).toBe('Invalid event address');
}
});
it('handles signing errors', async () => {
const mockSigner = {
sign: vi.fn().mockRejectedValue(new Error('Signing failed')),
};
const event = { kind: 1111, content: 'test' };
await expect(mockSigner.sign(event)).rejects.toThrow('Signing failed');
});
it('handles publish failure when no relays accept event', async () => {
const publishMock = vi.fn().mockResolvedValue(new Set());
const relaySet = await publishMock();
if (relaySet.size === 0) {
const error = 'Failed to publish to any relays';
expect(error).toBe('Failed to publish to any relays');
}
});
});
describe('CommentButton - Cancel Functionality', () => {
it('clears comment content when canceling', () => {
let commentContent = 'This comment will be canceled';
// Simulate cancel
commentContent = '';
expect(commentContent).toBe('');
});
it('closes comment UI when canceling', () => {
let showCommentUI = true;
// Simulate cancel
showCommentUI = false;
expect(showCommentUI).toBe(false);
});
it('clears error state when canceling', () => {
let error: string | null = 'Some error';
// Simulate cancel
error = null;
expect(error).toBeNull();
});
it('clears success state when canceling', () => {
let success = true;
// Simulate cancel
success = false;
expect(success).toBe(false);
});
});
describe('CommentButton - Event Fetching', () => {
let mockNDK: any;
beforeEach(() => {
mockNDK = createMockNDK();
});
afterEach(() => {
vi.clearAllMocks();
});
it('fetches target event to get event ID', async () => {
const address = '30041:' + 'a'.repeat(64) + ':article';
const parts = address.split(':');
const [kindStr, authorPubkey, dTag] = parts;
const kind = parseInt(kindStr);
const mockEvent = {
id: 'b'.repeat(64),
kind,
pubkey: authorPubkey,
tags: [['d', dTag]],
};
mockNDK.fetchEvent.mockResolvedValue(mockEvent);
const targetEvent = await mockNDK.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
'#d': [dTag],
});
expect(mockNDK.fetchEvent).toHaveBeenCalled();
expect(targetEvent?.id).toBe('b'.repeat(64));
});
it('continues without event ID when fetch fails', async () => {
mockNDK.fetchEvent.mockRejectedValue(new Error('Fetch failed'));
let eventId = '';
try {
const targetEvent = await mockNDK.fetchEvent({});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
// Continue without event ID
eventId = '';
}
expect(eventId).toBe('');
});
it('handles null event from fetch', async () => {
mockNDK.fetchEvent.mockResolvedValue(null);
const targetEvent = await mockNDK.fetchEvent({});
let eventId = '';
if (targetEvent) {
eventId = targetEvent.id;
}
expect(eventId).toBe('');
});
});
describe('CommentButton - CSS Classes and Styling', () => {
it('applies visible class when section is hovered', () => {
const sectionHovered = true;
const showCommentUI = false;
const hasVisibleClass = sectionHovered || showCommentUI;
expect(hasVisibleClass).toBe(true);
});
it('removes visible class when not hovered and UI closed', () => {
const sectionHovered = false;
const showCommentUI = false;
const hasVisibleClass = sectionHovered || showCommentUI;
expect(hasVisibleClass).toBe(false);
});
it('button has correct aria-label', () => {
const ariaLabel = 'Add comment';
expect(ariaLabel).toBe('Add comment');
});
it('button has correct title attribute', () => {
const title = 'Add comment';
expect(title).toBe('Add comment');
});
it('submit button shows loading state when submitting', () => {
const isSubmitting = true;
const buttonText = isSubmitting ? 'Posting...' : 'Post Comment';
expect(buttonText).toBe('Posting...');
});
it('submit button shows normal state when not submitting', () => {
const isSubmitting = false;
const buttonText = isSubmitting ? 'Posting...' : 'Post Comment';
expect(buttonText).toBe('Post Comment');
});
});
describe('CommentButton - NIP-22 Compliance', () => {
it('uses kind 1111 for comment events', () => {
const kind = 1111;
expect(kind).toBe(1111);
});
it('includes all required NIP-22 tags for addressable events', () => {
const requiredRootTags = ['A', 'K', 'P'];
const requiredParentTags = ['a', 'k', 'p'];
const tags = [
['A', 'address', 'relay', 'pubkey'],
['K', 'kind'],
['P', 'pubkey', 'relay'],
['a', 'address', 'relay'],
['k', 'kind'],
['p', 'pubkey', 'relay'],
];
requiredRootTags.forEach(tag => {
expect(tags.some(t => t[0] === tag)).toBe(true);
});
requiredParentTags.forEach(tag => {
expect(tags.some(t => t[0] === tag)).toBe(true);
});
});
it('A tag includes relay hint and author pubkey', () => {
const aTag = ['A', '30041:pubkey:dtag', 'wss://relay.com', 'pubkey'];
expect(aTag).toHaveLength(4);
expect(aTag[0]).toBe('A');
expect(aTag[2]).toMatch(/^wss:\/\//);
expect(aTag[3]).toBeTruthy();
});
it('P tag includes relay hint', () => {
const pTag = ['P', 'pubkey', 'wss://relay.com'];
expect(pTag).toHaveLength(3);
expect(pTag[0]).toBe('P');
expect(pTag[2]).toMatch(/^wss:\/\//);
});
it('lowercase tags for parent scope match root tags', () => {
const address = '30041:pubkey:dtag';
const kind = '30041';
const pubkey = 'pubkey';
const relay = 'wss://relay.com';
const rootTags = [
['A', address, relay, pubkey],
['K', kind],
['P', pubkey, relay],
];
const parentTags = [
['a', address, relay],
['k', kind],
['p', pubkey, relay],
];
// Verify parent tags match root tags (lowercase)
expect(parentTags[0][1]).toBe(rootTags[0][1]); // address
expect(parentTags[1][1]).toBe(rootTags[1][1]); // kind
expect(parentTags[2][1]).toBe(rootTags[2][1]); // pubkey
});
});
describe('CommentButton - Integration Scenarios', () => {
it('complete comment flow for signed-in user', () => {
const userStore = createMockUserStore(true);
const user = get(userStore);
// User is signed in
expect(user.signedIn).toBe(true);
// Comment content is valid
const content = 'Great article!';
expect(content.trim().length).toBeGreaterThan(0);
// Address is valid
const address = '30041:' + 'a'.repeat(64) + ':article';
const parts = address.split(':');
expect(parts.length).toBe(3);
// Event would be created with kind 1111
const kind = 1111;
expect(kind).toBe(1111);
});
it('prevents comment flow for signed-out user', () => {
const userStore = createMockUserStore(false);
const user = get(userStore);
expect(user.signedIn).toBe(false);
if (!user.signedIn) {
const error = 'You must be signed in to comment';
expect(error).toBeTruthy();
}
});
it('handles comment with event ID lookup', async () => {
const mockNDK = createMockNDK();
const eventId = 'c'.repeat(64);
mockNDK.fetchEvent.mockResolvedValue({ id: eventId });
const targetEvent = await mockNDK.fetchEvent({});
const tags = [
['e', targetEvent.id, 'wss://relay.com'],
];
expect(tags[0][1]).toBe(eventId);
});
it('handles comment without event ID lookup', () => {
const eventId = '';
const tags = [
['A', 'address', 'relay', 'pubkey'],
['K', 'kind'],
['P', 'pubkey', 'relay'],
['a', 'address', 'relay'],
['k', 'kind'],
['p', 'pubkey', 'relay'],
];
// No e tag should be included
expect(tags.filter(t => t[0] === 'e')).toHaveLength(0);
// But all other required tags should be present
expect(tags.length).toBe(6);
});
});

129
tests/unit/deletion.test.ts

@ -0,0 +1,129 @@ @@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { deleteEvent, canDeleteEvent } from '$lib/services/deletion';
import NDK, { NDKEvent, NDKRelaySet } from '@nostr-dev-kit/ndk';
describe('Deletion Service', () => {
let mockNdk: NDK;
let mockEvent: NDKEvent;
beforeEach(() => {
// Create mock NDK instance
mockNdk = {
activeUser: {
pubkey: 'test-pubkey-123',
},
pool: {
relays: new Map([
['wss://relay1.example.com', { url: 'wss://relay1.example.com' }],
['wss://relay2.example.com', { url: 'wss://relay2.example.com' }],
]),
},
} as unknown as NDK;
// Create mock event
mockEvent = {
id: 'event-id-123',
kind: 30041,
pubkey: 'test-pubkey-123',
tagAddress: () => '30041:test-pubkey-123:test-identifier',
} as unknown as NDKEvent;
});
describe('canDeleteEvent', () => {
it('should return true when user is the event author', () => {
const result = canDeleteEvent(mockEvent, mockNdk);
expect(result).toBe(true);
});
it('should return false when user is not the event author', () => {
const differentUserEvent = {
...mockEvent,
pubkey: 'different-pubkey-456',
} as unknown as NDKEvent;
const result = canDeleteEvent(differentUserEvent, mockNdk);
expect(result).toBe(false);
});
it('should return false when event is null', () => {
const result = canDeleteEvent(null, mockNdk);
expect(result).toBe(false);
});
it('should return false when ndk has no active user', () => {
const ndkWithoutUser = {
...mockNdk,
activeUser: undefined,
} as unknown as NDK;
const result = canDeleteEvent(mockEvent, ndkWithoutUser);
expect(result).toBe(false);
});
});
describe('deleteEvent', () => {
it('should return error when no eventId or eventAddress provided', async () => {
const result = await deleteEvent({}, mockNdk);
expect(result.success).toBe(false);
expect(result.error).toBe('Either eventId or eventAddress must be provided');
});
it('should return error when user is not logged in', async () => {
const ndkWithoutUser = {
...mockNdk,
activeUser: undefined,
} as unknown as NDK;
const result = await deleteEvent(
{ eventId: 'test-id' },
ndkWithoutUser
);
expect(result.success).toBe(false);
expect(result.error).toBe('Please log in first');
});
it('should create deletion event with correct tags', async () => {
const mockSign = vi.fn();
const mockPublish = vi.fn().mockResolvedValue(new Set(['wss://relay1.example.com']));
// Mock NDKEvent constructor
const MockNDKEvent = vi.fn().mockImplementation(function(this: any) {
this.kind = 0;
this.created_at = 0;
this.tags = [];
this.content = '';
this.pubkey = '';
this.sign = mockSign;
this.publish = mockPublish;
return this;
});
// Mock NDKRelaySet
const mockRelaySet = {} as NDKRelaySet;
vi.spyOn(NDKRelaySet, 'fromRelayUrls').mockReturnValue(mockRelaySet);
// Replace global NDKEvent temporarily
const originalNDKEvent = global.NDKEvent;
(global as any).NDKEvent = MockNDKEvent;
const result = await deleteEvent(
{
eventId: 'event-123',
eventAddress: '30041:pubkey:identifier',
eventKind: 30041,
reason: 'Test deletion',
},
mockNdk
);
// Restore original
(global as any).NDKEvent = originalNDKEvent;
expect(MockNDKEvent).toHaveBeenCalled();
expect(mockSign).toHaveBeenCalled();
expect(mockPublish).toHaveBeenCalled();
});
});
});

318
tests/unit/fetchPublicationHighlights.test.ts

@ -0,0 +1,318 @@ @@ -0,0 +1,318 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { NDK, NDKEvent } from "@nostr-dev-kit/ndk";
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights";
// Mock NDKEvent class
class MockNDKEvent {
kind: number;
pubkey: string;
content: string;
tags: string[][];
created_at: number;
id: string;
sig: string;
constructor(event: {
kind: number;
pubkey: string;
content: string;
tags: string[][];
created_at?: number;
id?: string;
sig?: string;
}) {
this.kind = event.kind;
this.pubkey = event.pubkey;
this.content = event.content;
this.tags = event.tags;
this.created_at = event.created_at || Date.now() / 1000;
this.id = event.id || "mock-id";
this.sig = event.sig || "mock-sig";
}
getMatchingTags(tagName: string): string[][] {
return this.tags.filter((tag) => tag[0] === tagName);
}
tagValue(tagName: string): string | undefined {
const tag = this.tags.find((tag) => tag[0] === tagName);
return tag ? tag[1] : undefined;
}
}
describe("fetchHighlightsForPublication", () => {
let mockNDK: NDK;
let publicationEvent: NDKEvent;
let mockHighlights: MockNDKEvent[];
beforeEach(() => {
// Create the sample 30040 publication event from the user's example
publicationEvent = new MockNDKEvent({
kind: 30040,
pubkey:
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
content: "",
tags: [
["d", "document-test"],
["title", "Document Test"],
["author", "unknown"],
["version", "1"],
["m", "application/json"],
["M", "meta-data/index/replaceable"],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading",
],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document",
],
["t", "a-tags"],
["t", "testfile"],
["t", "asciimath"],
["t", "latexmath"],
["image", "https://i.nostr.build/5kWwbDR04joIASVx.png"],
],
created_at: 1744910311,
id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef",
sig: "e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d",
}) as unknown as NDKEvent;
// Create mock highlight events for different sections
mockHighlights = [
new MockNDKEvent({
kind: 9802,
pubkey: "user-pubkey-1",
content: "This is an interesting point",
tags: [
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
["context", "surrounding text here"],
[
"p",
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
"",
"author",
],
],
id: "highlight-1",
}),
new MockNDKEvent({
kind: 9802,
pubkey: "user-pubkey-2",
content: "Another highlight on same section",
tags: [
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
["context", "more surrounding text"],
[
"p",
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
"",
"author",
],
],
id: "highlight-2",
}),
new MockNDKEvent({
kind: 9802,
pubkey: "user-pubkey-3",
content: "Highlight on different section",
tags: [
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading",
],
["context", "different section text"],
[
"p",
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1",
"",
"author",
],
],
id: "highlight-3",
}),
];
// Mock NDK instance
mockNDK = {
fetchEvents: vi.fn(async (filter) => {
// Return highlights that match the filter
const aTagFilter = filter["#a"];
if (aTagFilter) {
return new Set(
mockHighlights.filter((highlight) =>
aTagFilter.includes(highlight.tagValue("a") || "")
)
);
}
return new Set();
}),
} as unknown as NDK;
});
it("should extract section references from 30040 publication event", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK
);
// Should have results for the sections that have highlights
expect(result.size).toBeGreaterThan(0);
expect(
result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading"
)
).toBe(true);
});
it("should fetch highlights for each section reference", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK
);
// First section should have 2 highlights
const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading"
);
expect(firstSectionHighlights?.length).toBe(2);
// Second section should have 1 highlight
const secondSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading"
);
expect(secondSectionHighlights?.length).toBe(1);
});
it("should group highlights by section address", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK
);
const firstSectionHighlights = result.get(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading"
);
// Verify the highlights are correctly grouped
expect(firstSectionHighlights?.[0].content).toBe(
"This is an interesting point"
);
expect(firstSectionHighlights?.[1].content).toBe(
"Another highlight on same section"
);
});
it("should not include sections without highlights", async () => {
const result = await fetchHighlightsForPublication(
publicationEvent,
mockNDK
);
// Sections without highlights should not be in the result
expect(
result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading"
)
).toBe(false);
expect(
result.has(
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document"
)
).toBe(false);
});
it("should handle publication with no section references", async () => {
const emptyPublication = new MockNDKEvent({
kind: 30040,
pubkey: "test-pubkey",
content: "",
tags: [
["d", "empty-doc"],
["title", "Empty Document"],
],
}) as unknown as NDKEvent;
const result = await fetchHighlightsForPublication(
emptyPublication,
mockNDK
);
expect(result.size).toBe(0);
});
it("should only process 30041 kind references, ignoring other a-tags", async () => {
const mixedPublication = new MockNDKEvent({
kind: 30040,
pubkey: "test-pubkey",
content: "",
tags: [
["d", "mixed-doc"],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
["a", "30023:some-pubkey:blog-post"], // Different kind, should be ignored
["a", "1:some-pubkey"], // Different kind, should be ignored
],
}) as unknown as NDKEvent;
const result = await fetchHighlightsForPublication(
mixedPublication,
mockNDK
);
// Should call fetchEvents with only the 30041 reference
expect(mockNDK.fetchEvents).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
"#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading",
],
})
);
});
it("should handle d-tags with colons correctly", async () => {
const colonPublication = new MockNDKEvent({
kind: 30040,
pubkey: "test-pubkey",
content: "",
tags: [
["d", "colon-doc"],
[
"a",
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons",
],
],
}) as unknown as NDKEvent;
const result = await fetchHighlightsForPublication(
colonPublication,
mockNDK
);
// Should correctly parse the section address with colons
expect(mockNDK.fetchEvents).toHaveBeenCalledWith(
expect.objectContaining({
"#a": [
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons",
],
})
);
});
});

859
tests/unit/highlightLayer.test.ts

@ -0,0 +1,859 @@ @@ -0,0 +1,859 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { pubkeyToHue } from '../../src/lib/utils/nostrUtils';
import { nip19 } from 'nostr-tools';
describe('pubkeyToHue', () => {
describe('Consistency', () => {
it('returns consistent hue for same pubkey', () => {
const pubkey = 'a'.repeat(64);
const hue1 = pubkeyToHue(pubkey);
const hue2 = pubkeyToHue(pubkey);
expect(hue1).toBe(hue2);
});
it('returns same hue for same pubkey called multiple times', () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd';
const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey));
expect(new Set(hues).size).toBe(1); // All hues should be the same
});
});
describe('Range Validation', () => {
it('returns hue in valid range (0-360)', () => {
const pubkeys = [
'a'.repeat(64),
'f'.repeat(64),
'0'.repeat(64),
'9'.repeat(64),
'abc123def456'.repeat(5) + 'abcd',
'123456789abc'.repeat(5) + 'def0',
];
pubkeys.forEach(pubkey => {
const hue = pubkeyToHue(pubkey);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
});
it('returns integer hue value', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
expect(Number.isInteger(hue)).toBe(true);
});
});
describe('Format Handling', () => {
it('handles hex format pubkeys', () => {
const hexPubkey = 'abcdef123456789'.repeat(4) + '0123';
const hue = pubkeyToHue(hexPubkey);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles npub format pubkeys', () => {
const hexPubkey = 'a'.repeat(64);
const npub = nip19.npubEncode(hexPubkey);
const hue = pubkeyToHue(npub);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('returns same hue for hex and npub format of same pubkey', () => {
const hexPubkey = 'abc123def456'.repeat(5) + 'abcd';
const npub = nip19.npubEncode(hexPubkey);
const hueFromHex = pubkeyToHue(hexPubkey);
const hueFromNpub = pubkeyToHue(npub);
expect(hueFromHex).toBe(hueFromNpub);
});
});
describe('Uniqueness', () => {
it('different pubkeys generate different hues', () => {
const pubkey1 = 'a'.repeat(64);
const pubkey2 = 'b'.repeat(64);
const pubkey3 = 'c'.repeat(64);
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const hue3 = pubkeyToHue(pubkey3);
expect(hue1).not.toBe(hue2);
expect(hue2).not.toBe(hue3);
expect(hue1).not.toBe(hue3);
});
it('generates diverse hues for multiple pubkeys', () => {
const pubkeys = Array.from({ length: 10 }, (_, i) =>
String.fromCharCode(97 + i).repeat(64)
);
const hues = pubkeys.map(pk => pubkeyToHue(pk));
const uniqueHues = new Set(hues);
// Most pubkeys should generate unique hues (allowing for some collisions)
expect(uniqueHues.size).toBeGreaterThan(7);
});
});
describe('Edge Cases', () => {
it('handles empty string input', () => {
const hue = pubkeyToHue('');
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles invalid npub format gracefully', () => {
const invalidNpub = 'npub1invalid';
const hue = pubkeyToHue(invalidNpub);
// Should still return a valid hue even if decode fails
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles short input strings', () => {
const shortInput = 'abc';
const hue = pubkeyToHue(shortInput);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles special characters', () => {
const specialInput = '!@#$%^&*()';
const hue = pubkeyToHue(specialInput);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
});
describe('Color Distribution', () => {
it('distributes colors across the spectrum', () => {
// Generate hues for many different pubkeys
const pubkeys = Array.from({ length: 50 }, (_, i) =>
i.toString().repeat(16)
);
const hues = pubkeys.map(pk => pubkeyToHue(pk));
// Check that we have hues in different ranges of the spectrum
const hasLowHues = hues.some(h => h < 120);
const hasMidHues = hues.some(h => h >= 120 && h < 240);
const hasHighHues = hues.some(h => h >= 240);
expect(hasLowHues).toBe(true);
expect(hasMidHues).toBe(true);
expect(hasHighHues).toBe(true);
});
});
});
describe('HighlightLayer Component', () => {
let mockNdk: any;
let mockSubscription: any;
let eventHandlers: Map<string, Function>;
beforeEach(() => {
eventHandlers = new Map();
// Mock NDK subscription
mockSubscription = {
on: vi.fn((event: string, handler: Function) => {
eventHandlers.set(event, handler);
}),
stop: vi.fn(),
};
mockNdk = {
subscribe: vi.fn(() => mockSubscription),
};
// Mock DOM APIs
global.document = {
createTreeWalker: vi.fn(() => ({
nextNode: vi.fn(() => null),
})),
createDocumentFragment: vi.fn(() => ({
appendChild: vi.fn(),
})),
createTextNode: vi.fn((text: string) => ({
textContent: text,
})),
createElement: vi.fn((tag: string) => ({
className: '',
style: {},
textContent: '',
})),
} as any;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('NDK Subscription', () => {
it('fetches kind 9802 events with correct filter when eventId provided', () => {
const eventId = 'a'.repeat(64);
// Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], '#e': [eventId], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
'#e': [eventId],
limit: 100,
})
);
});
it('fetches kind 9802 events with correct filter when eventAddress provided', () => {
const eventAddress = '30040:' + 'a'.repeat(64) + ':chapter-1';
// Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], '#a': [eventAddress], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
'#a': [eventAddress],
limit: 100,
})
);
});
it('fetches with both eventId and eventAddress filters when both provided', () => {
const eventId = 'a'.repeat(64);
const eventAddress = '30040:' + 'b'.repeat(64) + ':chapter-1';
// Simulate calling fetchHighlights
mockNdk.subscribe({
kinds: [9802],
'#e': [eventId],
'#a': [eventAddress],
limit: 100,
});
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
'#e': [eventId],
'#a': [eventAddress],
limit: 100,
})
);
});
it('cleans up subscription on unmount', () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Simulate unmount by calling stop
mockSubscription.stop();
expect(mockSubscription.stop).toHaveBeenCalled();
});
});
describe('Color Mapping', () => {
it('maps highlights to colors correctly', () => {
const pubkey1 = 'a'.repeat(64);
const pubkey2 = 'b'.repeat(64);
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const expectedColor1 = `hsla(${hue1}, 70%, 60%, 0.3)`;
const expectedColor2 = `hsla(${hue2}, 70%, 60%, 0.3)`;
expect(expectedColor1).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
expect(expectedColor2).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
expect(expectedColor1).not.toBe(expectedColor2);
});
it('uses consistent color for same pubkey', () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd';
const hue = pubkeyToHue(pubkey);
const color1 = `hsla(${hue}, 70%, 60%, 0.3)`;
const color2 = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color1).toBe(color2);
});
it('generates semi-transparent colors with 0.3 opacity', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('0.3');
});
it('uses HSL color format with correct values', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
// Verify format: hsla(hue, 70%, 60%, 0.3)
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
});
describe('Highlight Events', () => {
it('handles no highlights gracefully', () => {
const highlights: any[] = [];
expect(highlights.length).toBe(0);
// Component should render without errors
});
it('handles single highlight from one user', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'highlighted text',
created_at: Date.now(),
tags: [],
};
const highlights = [mockHighlight];
expect(highlights.length).toBe(1);
expect(highlights[0].pubkey).toBe('a'.repeat(64));
});
it('handles multiple highlights from same user', () => {
const pubkey = 'a'.repeat(64);
const mockHighlights = [
{
id: 'highlight1',
kind: 9802,
pubkey: pubkey,
content: 'first highlight',
created_at: Date.now(),
tags: [],
},
{
id: 'highlight2',
kind: 9802,
pubkey: pubkey,
content: 'second highlight',
created_at: Date.now(),
tags: [],
},
];
expect(mockHighlights.length).toBe(2);
expect(mockHighlights[0].pubkey).toBe(mockHighlights[1].pubkey);
// Should use same color for both
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
it('handles multiple highlights from different users', () => {
const pubkey1 = 'a'.repeat(64);
const pubkey2 = 'b'.repeat(64);
const pubkey3 = 'c'.repeat(64);
const mockHighlights = [
{
id: 'highlight1',
kind: 9802,
pubkey: pubkey1,
content: 'highlight from user 1',
created_at: Date.now(),
tags: [],
},
{
id: 'highlight2',
kind: 9802,
pubkey: pubkey2,
content: 'highlight from user 2',
created_at: Date.now(),
tags: [],
},
{
id: 'highlight3',
kind: 9802,
pubkey: pubkey3,
content: 'highlight from user 3',
created_at: Date.now(),
tags: [],
},
];
expect(mockHighlights.length).toBe(3);
// Each should have different color
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const hue3 = pubkeyToHue(pubkey3);
expect(hue1).not.toBe(hue2);
expect(hue2).not.toBe(hue3);
expect(hue1).not.toBe(hue3);
});
it('prevents duplicate highlights', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'highlighted text',
created_at: Date.now(),
tags: [],
};
const highlights = [mockHighlight];
// Try to add duplicate
const isDuplicate = highlights.some(h => h.id === mockHighlight.id);
expect(isDuplicate).toBe(true);
// Should not add duplicate
});
it('handles empty content gracefully', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: '',
created_at: Date.now(),
tags: [],
};
// Should not crash
expect(mockHighlight.content).toBe('');
});
it('handles whitespace-only content', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: ' \n\t ',
created_at: Date.now(),
tags: [],
};
const trimmed = mockHighlight.content.trim();
expect(trimmed.length).toBe(0);
});
});
describe('Highlighter Legend', () => {
it('displays legend with correct color for single highlighter', () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd';
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
const legend = {
pubkey: pubkey,
color: color,
shortPubkey: `${pubkey.slice(0, 8)}...`,
};
expect(legend.color).toBe(color);
expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`);
});
it('displays legend with colors for multiple highlighters', () => {
const pubkeys = [
'a'.repeat(64),
'b'.repeat(64),
'c'.repeat(64),
];
const legendEntries = pubkeys.map(pubkey => ({
pubkey,
color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`,
shortPubkey: `${pubkey.slice(0, 8)}...`,
}));
expect(legendEntries.length).toBe(3);
// Each should have unique color
const colors = legendEntries.map(e => e.color);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(3);
});
it('shows truncated pubkey in legend', () => {
const pubkey = 'abcdefghijklmnop'.repeat(4);
const shortPubkey = `${pubkey.slice(0, 8)}...`;
expect(shortPubkey).toBe('abcdefgh...');
expect(shortPubkey.length).toBeLessThan(pubkey.length);
});
it('displays highlight count', () => {
const highlights = [
{ id: '1', pubkey: 'a'.repeat(64), content: 'text1' },
{ id: '2', pubkey: 'b'.repeat(64), content: 'text2' },
{ id: '3', pubkey: 'a'.repeat(64), content: 'text3' },
];
expect(highlights.length).toBe(3);
// Count unique highlighters
const uniqueHighlighters = new Set(highlights.map(h => h.pubkey));
expect(uniqueHighlighters.size).toBe(2);
});
});
describe('Text Matching', () => {
it('matches text case-insensitively', () => {
const searchText = 'Hello World';
const contentText = 'hello world';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it('handles special characters in search text', () => {
const searchText = 'text with "quotes" and symbols!';
const contentText = 'This is text with "quotes" and symbols! in it.';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it('handles Unicode characters', () => {
const searchText = 'café résumé';
const contentText = 'The café résumé was excellent.';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it('handles multi-line text', () => {
const searchText = 'line one\nline two';
const contentText = 'This is line one\nline two in the document.';
const index = contentText.indexOf(searchText);
expect(index).toBeGreaterThanOrEqual(0);
});
it('does not match partial words when searching for whole words', () => {
const searchText = 'cat';
const contentText = 'The category is important.';
// Simple word boundary check
const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, 'i').test(contentText);
expect(wordBoundaryMatch).toBe(false);
});
});
describe('Subscription Lifecycle', () => {
it('registers EOSE event handler', () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined();
// Register EOSE handler
subscription.on('eose', () => {
subscription.stop();
});
// Verify on was called
expect(subscription.on).toHaveBeenCalledWith('eose', expect.any(Function));
});
it('registers error event handler', () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined();
// Register error handler
subscription.on('error', () => {
subscription.stop();
});
// Verify on was called
expect(subscription.on).toHaveBeenCalledWith('error', expect.any(Function));
});
it('stops subscription on timeout', async () => {
vi.useFakeTimers();
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Fast-forward time by 10 seconds
vi.advanceTimersByTime(10000);
// Subscription should be stopped after timeout
// Note: This would be tested in the actual component
vi.useRealTimers();
});
it('handles multiple subscription cleanup calls safely', () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Call stop multiple times
mockSubscription.stop();
mockSubscription.stop();
mockSubscription.stop();
expect(mockSubscription.stop).toHaveBeenCalledTimes(3);
// Should not throw errors
});
});
describe('Performance', () => {
it('handles large number of highlights efficiently', () => {
const startTime = Date.now();
const highlights = Array.from({ length: 1000 }, (_, i) => ({
id: `highlight${i}`,
kind: 9802,
pubkey: (i % 10).toString().repeat(64),
content: `highlighted text ${i}`,
created_at: Date.now(),
tags: [],
}));
// Generate colors for all highlights
const colorMap = new Map<string, string>();
highlights.forEach(h => {
if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
const endTime = Date.now();
const duration = endTime - startTime;
expect(highlights.length).toBe(1000);
expect(colorMap.size).toBe(10);
expect(duration).toBeLessThan(1000); // Should complete in less than 1 second
});
});
});
describe('Integration Tests', () => {
describe('Toggle Functionality', () => {
it('toggle button shows highlights when clicked', () => {
let highlightsVisible = false;
// Simulate toggle
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
});
it('toggle button hides highlights when clicked again', () => {
let highlightsVisible = true;
// Simulate toggle
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(false);
});
it('toggle state persists between interactions', () => {
let highlightsVisible = false;
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(false);
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
});
});
describe('Color Format Validation', () => {
it('generates semi-transparent colors with 0.3 opacity', () => {
const pubkeys = [
'a'.repeat(64),
'b'.repeat(64),
'c'.repeat(64),
];
pubkeys.forEach(pubkey => {
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('0.3');
});
});
it('uses HSL color format with correct saturation and lightness', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('70%');
expect(color).toContain('60%');
});
it('generates valid CSS color strings', () => {
const pubkeys = Array.from({ length: 20 }, (_, i) =>
String.fromCharCode(97 + i).repeat(64)
);
pubkeys.forEach(pubkey => {
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
// Validate CSS color format
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
});
});
describe('End-to-End Flow', () => {
it('complete highlight workflow', () => {
// 1. Start with no highlights visible
let highlightsVisible = false;
let highlights: any[] = [];
expect(highlightsVisible).toBe(false);
expect(highlights.length).toBe(0);
// 2. Fetch highlights
const mockHighlights = [
{
id: 'h1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'first highlight',
created_at: Date.now(),
tags: [],
},
{
id: 'h2',
kind: 9802,
pubkey: 'b'.repeat(64),
content: 'second highlight',
created_at: Date.now(),
tags: [],
},
];
highlights = mockHighlights;
expect(highlights.length).toBe(2);
// 3. Generate color map
const colorMap = new Map<string, string>();
highlights.forEach(h => {
if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
expect(colorMap.size).toBe(2);
// 4. Toggle visibility
highlightsVisible = true;
expect(highlightsVisible).toBe(true);
// 5. Verify colors are different
const colors = Array.from(colorMap.values());
expect(colors[0]).not.toBe(colors[1]);
// 6. Toggle off
highlightsVisible = false;
expect(highlightsVisible).toBe(false);
});
it('handles event updates correctly', () => {
let eventId = 'event1';
let highlights: any[] = [];
// Initial load
highlights = [
{
id: 'h1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'highlight 1',
created_at: Date.now(),
tags: [],
},
];
expect(highlights.length).toBe(1);
// Event changes
eventId = 'event2';
highlights = [];
expect(highlights.length).toBe(0);
// New highlights loaded
highlights = [
{
id: 'h2',
kind: 9802,
pubkey: 'b'.repeat(64),
content: 'highlight 2',
created_at: Date.now(),
tags: [],
},
];
expect(highlights.length).toBe(1);
expect(highlights[0].id).toBe('h2');
});
});
describe('Error Handling', () => {
it('handles missing event ID and address gracefully', () => {
const eventId = undefined;
const eventAddress = undefined;
// Should not attempt to fetch
expect(eventId).toBeUndefined();
expect(eventAddress).toBeUndefined();
});
it('handles subscription errors gracefully', () => {
const error = new Error('Subscription failed');
// Should log error but not crash
expect(error.message).toBe('Subscription failed');
});
it('handles malformed highlight events', () => {
const malformedHighlight = {
id: 'h1',
kind: 9802,
pubkey: '', // Empty pubkey
content: undefined, // Missing content
created_at: Date.now(),
tags: [],
};
// Should handle gracefully
expect(malformedHighlight.pubkey).toBe('');
expect(malformedHighlight.content).toBeUndefined();
});
});
});

875
tests/unit/highlightSelection.test.ts

@ -0,0 +1,875 @@ @@ -0,0 +1,875 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
// Mock flowbite-svelte components
vi.mock("flowbite-svelte", () => ({
Button: vi.fn().mockImplementation((props) => ({
$$render: () => `<button data-testid="button">${props.children || ""}</button>`,
})),
Modal: vi.fn().mockImplementation(() => ({
$$render: () => `<div data-testid="modal"></div>`,
})),
Textarea: vi.fn().mockImplementation(() => ({
$$render: () => `<textarea data-testid="textarea"></textarea>`,
})),
P: vi.fn().mockImplementation(() => ({
$$render: () => `<p data-testid="p"></p>`,
})),
}));
// Mock flowbite-svelte-icons
vi.mock("flowbite-svelte-icons", () => ({
FontHighlightOutline: vi.fn().mockImplementation(() => ({
$$render: () => `<svg data-testid="highlight-icon"></svg>`,
})),
}));
describe("HighlightButton Component Logic", () => {
let isActive: boolean;
beforeEach(() => {
isActive = false;
});
describe("Initial State", () => {
it("should initialize with inactive state", () => {
expect(isActive).toBe(false);
});
it("should have correct inactive label", () => {
const label = isActive ? "Exit Highlight Mode" : "Add Highlight";
expect(label).toBe("Add Highlight");
});
it("should have correct inactive title", () => {
const title = isActive ? "Exit highlight mode" : "Enter highlight mode";
expect(title).toBe("Enter highlight mode");
});
it("should have correct inactive color", () => {
const color = isActive ? "primary" : "light";
expect(color).toBe("light");
});
it("should not have ring styling when inactive", () => {
const ringClass = isActive ? "ring-2 ring-primary-500" : "";
expect(ringClass).toBe("");
});
});
describe("Toggle Functionality", () => {
it("should toggle to active state when clicked", () => {
// Simulate toggle
isActive = !isActive;
expect(isActive).toBe(true);
});
it("should toggle back to inactive state on second click", () => {
// Simulate two toggles
isActive = !isActive;
isActive = !isActive;
expect(isActive).toBe(false);
});
it("should show correct label when active", () => {
isActive = true;
const label = isActive ? "Exit Highlight Mode" : "Add Highlight";
expect(label).toBe("Exit Highlight Mode");
});
it("should show correct title when active", () => {
isActive = true;
const title = isActive ? "Exit highlight mode" : "Enter highlight mode";
expect(title).toBe("Exit highlight mode");
});
});
describe("Active State Styling", () => {
it("should apply primary color when active", () => {
isActive = true;
const color = isActive ? "primary" : "light";
expect(color).toBe("primary");
});
it("should apply ring styling when active", () => {
isActive = true;
const ringClass = isActive ? "ring-2 ring-primary-500" : "";
expect(ringClass).toBe("ring-2 ring-primary-500");
});
});
});
describe("HighlightSelectionHandler Component Logic", () => {
let mockNDK: NDKEvent;
let mockUserStore: any;
let mockSelection: Selection;
let mockPublicationEvent: NDKEvent;
let isActive: boolean;
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
isActive = false;
// Mock document and DOM elements
const mockElement = {
createElement: vi.fn((tag: string) => ({
tagName: tag.toUpperCase(),
textContent: "",
className: "",
closest: vi.fn(),
parentElement: null,
})),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
body: {
classList: {
add: vi.fn(),
remove: vi.fn(),
},
},
};
global.document = mockElement as any;
// Mock NDK event
mockPublicationEvent = {
id: "test-event-id",
pubkey: "test-pubkey",
kind: 30023,
tagAddress: vi.fn().mockReturnValue("30023:test-pubkey:test-d-tag"),
tags: [],
content: "",
} as unknown as NDKEvent;
// Mock user store
mockUserStore = {
signedIn: true,
signer: {
sign: vi.fn().mockResolvedValue(undefined),
},
};
// Mock window.getSelection
const mockParagraph = {
textContent: "This is the full paragraph context",
closest: vi.fn(),
};
mockSelection = {
toString: vi.fn().mockReturnValue("Selected text from publication"),
isCollapsed: false,
removeAllRanges: vi.fn(),
anchorNode: {
parentElement: mockParagraph,
},
} as unknown as Selection;
global.window = {
getSelection: vi.fn().mockReturnValue(mockSelection),
} as any;
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Selection Detection", () => {
it("should ignore mouseup events when isActive is false", () => {
isActive = false;
const shouldProcess = isActive;
expect(shouldProcess).toBe(false);
});
it("should process mouseup events when isActive is true", () => {
isActive = true;
const shouldProcess = isActive;
expect(shouldProcess).toBe(true);
});
it("should ignore collapsed selections", () => {
const selection = { isCollapsed: true } as Selection;
const shouldIgnore = selection.isCollapsed;
expect(shouldIgnore).toBe(true);
});
it("should process non-collapsed selections", () => {
const selection = { isCollapsed: false } as Selection;
const shouldIgnore = selection.isCollapsed;
expect(shouldIgnore).toBe(false);
});
it("should ignore selections with less than 3 characters", () => {
const text = "ab";
const isValid = text.length >= 3;
expect(isValid).toBe(false);
});
it("should accept selections with 3 or more characters", () => {
const text = "abc";
const isValid = text.length >= 3;
expect(isValid).toBe(true);
});
it("should ignore empty selections after trim", () => {
const text = " ";
const trimmed = text.trim();
const isValid = trimmed.length >= 3;
expect(isValid).toBe(false);
});
});
describe("User Authentication", () => {
it("should reject selection when user not signed in", () => {
const userStore = { signedIn: false };
expect(userStore.signedIn).toBe(false);
});
it("should process selection when user signed in", () => {
const userStore = { signedIn: true };
expect(userStore.signedIn).toBe(true);
});
it("should check for signer before creating highlight", () => {
const userStore = {
signedIn: true,
signer: { sign: vi.fn() },
};
expect(userStore.signer).toBeDefined();
});
it("should reject creation without signer", () => {
const userStore = {
signedIn: true,
signer: null,
};
expect(userStore.signer).toBeNull();
});
});
describe("Publication Context Detection", () => {
it("should detect selection within publication-leather class", () => {
const mockElement = {
className: "publication-leather",
closest: vi.fn((selector: string) => {
return selector === ".publication-leather" ? mockElement : null;
}),
};
const target = mockElement;
const publicationSection = target.closest(".publication-leather");
expect(publicationSection).toBeTruthy();
});
it("should reject selection outside publication-leather class", () => {
const mockElement = {
className: "other-section",
closest: vi.fn((selector: string) => {
return selector === ".publication-leather" ? null : mockElement;
}),
};
const target = mockElement;
const publicationSection = target.closest(".publication-leather");
expect(publicationSection).toBeNull();
});
});
describe("Context Extraction", () => {
it("should extract context from parent paragraph", () => {
const paragraph = {
textContent: "This is the full paragraph context with selected text inside.",
};
const context = paragraph.textContent?.trim() || "";
expect(context).toBe("This is the full paragraph context with selected text inside.");
});
it("should extract context from parent section", () => {
const section = {
textContent: "Full section context including selected text.",
};
const context = section.textContent?.trim() || "";
expect(context).toBe("Full section context including selected text.");
});
it("should extract context from parent div", () => {
const div = {
textContent: "Full div context including selected text.",
};
const context = div.textContent?.trim() || "";
expect(context).toBe("Full div context including selected text.");
});
it("should handle missing context gracefully", () => {
const context = "";
expect(context).toBe("");
});
});
describe("NIP-84 Event Creation - Addressable Events", () => {
it("should use 'a' tag for addressable events", () => {
const eventAddress = "30023:pubkey:d-tag";
const tags: string[][] = [];
if (eventAddress) {
tags.push(["a", eventAddress, ""]);
}
expect(tags).toContainEqual(["a", eventAddress, ""]);
});
it("should create event with correct kind 9802", () => {
const event = {
kind: 9802,
content: "",
tags: [],
};
expect(event.kind).toBe(9802);
});
it("should include selected text as content", () => {
const selectedText = "This is the selected highlight text";
const event = {
kind: 9802,
content: selectedText,
tags: [],
};
expect(event.content).toBe(selectedText);
});
it("should include context tag", () => {
const context = "This is the surrounding context";
const tags: string[][] = [];
if (context) {
tags.push(["context", context]);
}
expect(tags).toContainEqual(["context", context]);
});
it("should include author p-tag with role", () => {
const pubkey = "author-pubkey-hex";
const tags: string[][] = [];
if (pubkey) {
tags.push(["p", pubkey, "", "author"]);
}
expect(tags).toContainEqual(["p", pubkey, "", "author"]);
});
it("should include comment tag when comment provided", () => {
const comment = "This is my insightful comment";
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
expect(tags).toContainEqual(["comment", comment]);
});
it("should not include comment tag when comment is empty", () => {
const comment = "";
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
expect(tags).not.toContainEqual(["comment", ""]);
expect(tags.length).toBe(0);
});
it("should not include comment tag when comment is only whitespace", () => {
const comment = " ";
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment.trim()]);
}
expect(tags.length).toBe(0);
});
});
describe("NIP-84 Event Creation - Regular Events", () => {
it("should use 'e' tag for regular events", () => {
const eventId = "regular-event-id";
const eventAddress = null; // No address means regular event
const tags: string[][] = [];
if (eventAddress) {
tags.push(["a", eventAddress, ""]);
} else {
tags.push(["e", eventId, ""]);
}
expect(tags).toContainEqual(["e", eventId, ""]);
});
it("should prefer addressable event over regular event", () => {
const eventId = "regular-event-id";
const eventAddress = "30023:pubkey:d-tag";
const tags: string[][] = [];
if (eventAddress) {
tags.push(["a", eventAddress, ""]);
} else {
tags.push(["e", eventId, ""]);
}
expect(tags).toContainEqual(["a", eventAddress, ""]);
expect(tags).not.toContainEqual(["e", eventId, ""]);
});
});
describe("Complete Event Structure", () => {
it("should create complete highlight event with all required tags", () => {
const selectedText = "Highlighted text";
const context = "Full context paragraph";
const pubkey = "author-pubkey";
const eventAddress = "30023:pubkey:d-tag";
const event = {
kind: 9802,
content: selectedText,
tags: [
["a", eventAddress, ""],
["context", context],
["p", pubkey, "", "author"],
],
};
expect(event.kind).toBe(9802);
expect(event.content).toBe(selectedText);
expect(event.tags).toHaveLength(3);
expect(event.tags[0]).toEqual(["a", eventAddress, ""]);
expect(event.tags[1]).toEqual(["context", context]);
expect(event.tags[2]).toEqual(["p", pubkey, "", "author"]);
});
it("should create complete quote highlight with comment", () => {
const selectedText = "Highlighted text";
const context = "Full context paragraph";
const pubkey = "author-pubkey";
const eventAddress = "30023:pubkey:d-tag";
const comment = "My thoughtful comment";
const event = {
kind: 9802,
content: selectedText,
tags: [
["a", eventAddress, ""],
["context", context],
["p", pubkey, "", "author"],
["comment", comment],
],
};
expect(event.kind).toBe(9802);
expect(event.content).toBe(selectedText);
expect(event.tags).toHaveLength(4);
expect(event.tags[3]).toEqual(["comment", comment]);
});
it("should handle event without context", () => {
const selectedText = "Highlighted text";
const context = "";
const pubkey = "author-pubkey";
const eventId = "event-id";
const tags: string[][] = [];
tags.push(["e", eventId, ""]);
if (context) {
tags.push(["context", context]);
}
tags.push(["p", pubkey, "", "author"]);
expect(tags).toHaveLength(2);
expect(tags).not.toContainEqual(["context", ""]);
});
});
describe("Event Signing and Publishing", () => {
it("should sign event before publishing", async () => {
const mockSigner = {
sign: vi.fn().mockResolvedValue(undefined),
};
const mockEvent = {
kind: 9802,
content: "test",
tags: [],
sign: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockResolvedValue(undefined),
};
await mockEvent.sign(mockSigner);
expect(mockEvent.sign).toHaveBeenCalledWith(mockSigner);
});
it("should publish event after signing", async () => {
const mockEvent = {
sign: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockResolvedValue(undefined),
};
await mockEvent.sign({});
await mockEvent.publish();
expect(mockEvent.publish).toHaveBeenCalled();
});
it("should handle signing errors", async () => {
const mockEvent = {
sign: vi.fn().mockRejectedValue(new Error("Signing failed")),
};
await expect(mockEvent.sign({})).rejects.toThrow("Signing failed");
});
it("should handle publishing errors", async () => {
const mockEvent = {
sign: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockRejectedValue(new Error("Publishing failed")),
};
await mockEvent.sign({});
await expect(mockEvent.publish()).rejects.toThrow("Publishing failed");
});
});
describe("Selection Cleanup", () => {
it("should clear selection after successful highlight creation", () => {
const mockSelection = {
removeAllRanges: vi.fn(),
};
mockSelection.removeAllRanges();
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
});
it("should reset selectedText after creation", () => {
let selectedText = "Some text";
selectedText = "";
expect(selectedText).toBe("");
});
it("should reset comment after creation", () => {
let comment = "Some comment";
comment = "";
expect(comment).toBe("");
});
it("should reset context after creation", () => {
let context = "Some context";
context = "";
expect(context).toBe("");
});
it("should close modal after creation", () => {
let showModal = true;
showModal = false;
expect(showModal).toBe(false);
});
});
describe("Cancel Functionality", () => {
it("should clear selection when cancelled", () => {
const mockSelection = {
removeAllRanges: vi.fn(),
};
// Simulate cancel
mockSelection.removeAllRanges();
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
});
it("should reset all state when cancelled", () => {
let selectedText = "text";
let comment = "comment";
let context = "context";
let showModal = true;
// Simulate cancel
selectedText = "";
comment = "";
context = "";
showModal = false;
expect(selectedText).toBe("");
expect(comment).toBe("");
expect(context).toBe("");
expect(showModal).toBe(false);
});
});
describe("Feedback Messages", () => {
it("should show success message after creation", () => {
const message = "Highlight created successfully!";
const type = "success";
expect(message).toBe("Highlight created successfully!");
expect(type).toBe("success");
});
it("should show error message on failure", () => {
const message = "Failed to create highlight. Please try again.";
const type = "error";
expect(message).toBe("Failed to create highlight. Please try again.");
expect(type).toBe("error");
});
it("should show error when not signed in", () => {
const message = "Please sign in to create highlights";
const type = "error";
expect(message).toBe("Please sign in to create highlights");
expect(type).toBe("error");
});
it("should auto-hide feedback after delay", () => {
let showFeedback = true;
// Simulate timeout
setTimeout(() => {
showFeedback = false;
}, 3000);
// Initially shown
expect(showFeedback).toBe(true);
});
});
describe("Event Listeners", () => {
it("should add mouseup listener on mount", () => {
const mockAddEventListener = vi.fn();
document.addEventListener = mockAddEventListener;
document.addEventListener("mouseup", () => {});
expect(mockAddEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function));
});
it("should remove mouseup listener on unmount", () => {
const mockRemoveEventListener = vi.fn();
document.removeEventListener = mockRemoveEventListener;
const handler = () => {};
document.removeEventListener("mouseup", handler);
expect(mockRemoveEventListener).toHaveBeenCalledWith("mouseup", handler);
});
});
describe("Highlight Mode Body Class", () => {
it("should add highlight-mode-active class when active", () => {
const mockClassList = {
add: vi.fn(),
remove: vi.fn(),
};
document.body.classList = mockClassList as any;
// Simulate active mode
document.body.classList.add("highlight-mode-active");
expect(mockClassList.add).toHaveBeenCalledWith("highlight-mode-active");
});
it("should remove highlight-mode-active class when inactive", () => {
const mockClassList = {
add: vi.fn(),
remove: vi.fn(),
};
document.body.classList = mockClassList as any;
// Simulate inactive mode
document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active");
});
it("should clean up class on unmount", () => {
const mockClassList = {
add: vi.fn(),
remove: vi.fn(),
};
document.body.classList = mockClassList as any;
// Simulate cleanup
document.body.classList.remove("highlight-mode-active");
expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active");
});
});
describe("Modal Display", () => {
it("should show modal when text is selected", () => {
let showModal = false;
// Simulate successful selection
showModal = true;
expect(showModal).toBe(true);
});
it("should display selected text in modal", () => {
const selectedText = "This is the selected text";
const displayText = `"${selectedText}"`;
expect(displayText).toBe('"This is the selected text"');
});
it("should provide textarea for optional comment", () => {
let comment = "";
const placeholder = "Share your thoughts about this highlight...";
expect(placeholder).toBe("Share your thoughts about this highlight...");
expect(comment).toBe("");
});
it("should disable buttons while submitting", () => {
const isSubmitting = true;
const disabled = isSubmitting;
expect(disabled).toBe(true);
});
it("should show 'Creating...' text while submitting", () => {
const isSubmitting = true;
const buttonText = isSubmitting ? "Creating..." : "Create Highlight";
expect(buttonText).toBe("Creating...");
});
it("should show normal text when not submitting", () => {
const isSubmitting = false;
const buttonText = isSubmitting ? "Creating..." : "Create Highlight";
expect(buttonText).toBe("Create Highlight");
});
});
describe("Callback Execution", () => {
it("should call onHighlightCreated callback after creation", () => {
const mockCallback = vi.fn();
// Simulate successful creation
mockCallback();
expect(mockCallback).toHaveBeenCalled();
});
it("should not call callback if creation fails", () => {
const mockCallback = vi.fn();
// Simulate failed creation - callback not called
expect(mockCallback).not.toHaveBeenCalled();
});
it("should handle missing callback gracefully", () => {
const callback = undefined;
// Should not throw error
expect(() => {
if (callback) {
callback();
}
}).not.toThrow();
});
});
describe("Integration Scenarios", () => {
it("should handle complete highlight workflow", () => {
// Setup
let isActive = true;
let showModal = false;
let selectedText = "";
const userSignedIn = true;
const selection = {
toString: () => "Selected text for highlighting",
isCollapsed: false,
};
// User selects text
if (isActive && userSignedIn && !selection.isCollapsed) {
selectedText = selection.toString();
showModal = true;
}
expect(selectedText).toBe("Selected text for highlighting");
expect(showModal).toBe(true);
});
it("should handle complete quote highlight workflow with comment", () => {
// Setup
let isActive = true;
let showModal = false;
let selectedText = "";
let comment = "";
const userSignedIn = true;
const selection = {
toString: () => "Selected text",
isCollapsed: false,
};
// User selects text
if (isActive && userSignedIn && !selection.isCollapsed) {
selectedText = selection.toString();
showModal = true;
}
// User adds comment
comment = "This is insightful";
// Create event with comment
const tags: string[][] = [];
if (comment.trim()) {
tags.push(["comment", comment]);
}
expect(selectedText).toBe("Selected text");
expect(comment).toBe("This is insightful");
expect(tags).toContainEqual(["comment", "This is insightful"]);
});
it("should reject workflow when user not signed in", () => {
let isActive = true;
let showModal = false;
const userSignedIn = false;
const selection = {
toString: () => "Selected text",
isCollapsed: false,
};
// User tries to select text
if (isActive && userSignedIn && !selection.isCollapsed) {
showModal = true;
}
expect(showModal).toBe(false);
});
it("should handle workflow cancellation", () => {
// Setup initial state
let showModal = true;
let selectedText = "Some text";
let comment = "Some comment";
const mockSelection = {
removeAllRanges: vi.fn(),
};
// User cancels
showModal = false;
selectedText = "";
comment = "";
mockSelection.removeAllRanges();
expect(showModal).toBe(false);
expect(selectedText).toBe("");
expect(comment).toBe("");
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
});
});
});
Loading…
Cancel
Save