series building

This commit is contained in:
Nicolas Cantu 2026-01-06 00:26:31 +01:00
parent 4a619c9576
commit 758ab5c966
37 changed files with 1796 additions and 104 deletions

View File

@ -31,7 +31,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">
{hasAny ? 'No articles match your search or filters.' : 'No articles found. Check back later!'}
{hasAny ? t('common.empty.articles.filtered') : t('common.empty.articles')}
</p>
</div>
)

View File

@ -29,7 +29,7 @@ function EmptyState({ hasAny }: { hasAny: boolean }) {
return (
<div className="text-center py-12">
<p className="text-cyber-accent/70">
{hasAny ? 'No authors match your search or filters.' : 'No authors found. Check back later!'}
{hasAny ? t('common.empty.authors.filtered') : t('common.empty.authors')}
</p>
</div>
)

View File

@ -4,12 +4,34 @@ import { LanguageSelector } from './LanguageSelector'
import { t } from '@/lib/i18n'
import { KeyIndicator } from './KeyIndicator'
function GitIcon() {
return (
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.884.721-2.599 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.496 3.866.45 10.913c-.6.605-.6 1.584 0 2.189l10.484 10.481c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.584 0-2.189z" />
</svg>
)
}
export function PageHeader() {
return (
<header className="bg-cyber-dark border-b border-neon-cyan/30 shadow-glow-cyan">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors flex items-center">
<Link href="/" className="text-2xl font-bold text-neon-cyan text-glow-cyan font-mono hover:text-neon-green transition-colors flex items-center gap-2">
{t('home.title')}
<a
href="https://git.4nkweb.com/4nk/story-research-zapwall"
target="_blank"
rel="noopener noreferrer"
className="text-cyber-accent hover:text-neon-cyan transition-colors"
title="Repository Git"
>
<GitIcon />
</a>
<KeyIndicator />
</Link>
<div className="flex items-center gap-4">

View File

@ -2,12 +2,34 @@ import { ConnectButton } from '@/components/ConnectButton'
import { ConditionalPublishButton } from './ConditionalPublishButton'
import { KeyIndicator } from './KeyIndicator'
function GitIcon() {
return (
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.884.721-2.599 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.496 3.866.45 10.913c-.6.605-.6 1.584 0 2.189l10.484 10.481c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.584 0-2.189z" />
</svg>
)
}
export function ProfileHeader() {
return (
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-4 flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
zapwall.fr
<a
href="https://git.4nkweb.com/4nk/story-research-zapwall"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
title="Repository Git"
>
<GitIcon />
</a>
<KeyIndicator />
</h1>
<div className="flex items-center gap-4">

View File

@ -0,0 +1,169 @@
# Système de tags zapwall.fr
## Vue d'ensemble
Le système de tags de zapwall.fr utilise un système de tags personnalisé basé sur les tags standards de Nostr (kind 1 notes). Tous les événements sont des notes Nostr (kind 1), et le système utilise des tags en anglais pour identifier le type de contenu, la catégorie, et les métadonnées associées.
## Structure des tags
### Tags de base (tous les types)
Tous les événements incluent ces tags de base :
- **Type** : Tag simple (sans valeur) qui identifie le type de contenu
- `#author` : Présentation d'auteur
- `#series` : Série d'articles
- `#publication` : Article/publication
- `#quote` : Avis/review
- **Catégorie** : Tag simple qui identifie la catégorie
- `#sciencefiction` : Science-fiction
- `#research` : Recherche scientifique
- **Identifiant** : Tag avec valeur pour l'ID unique
- `["id", "<event_id>"]` : Identifiant unique de l'événement
- **Service** : Tag avec valeur pour identifier la plateforme
- `["service", "zapwall.fr"]` : Identifiant du service (toujours présent pour filtrer toutes les notes de zapwall.fr)
- **Paywall** : Tag simple (optionnel)
- `#paywall` : Indique que le contenu est payant
- **Payment** : Tag simple (optionnel)
- `#payment` : Indique qu'un paiement a été effectué
### Tags spécifiques par type
#### Tags pour `#author` (présentation d'auteur)
```typescript
["author"] // Type
["sciencefiction"] ou ["research"] // Catégorie
["id", "<event_id>"] // ID unique
["title", "<titre>"] // Titre de la présentation
["preview", "<aperçu>"] // Aperçu (optionnel)
["mainnet_address", "<adresse>"] // Adresse Bitcoin mainnet pour le sponsoring
["total_sponsoring", "<montant>"] // Total du sponsoring reçu (en sats)
["picture", "<url>"] // URL de la photo de profil (optionnel)
```
**Exemple de tags pour une présentation d'auteur :**
```
[
["author"],
["sciencefiction"],
["id", "abc123..."],
["service", "zapwall.fr"],
["title", "Présentation de John Doe"],
["preview", "Aperçu de la présentation..."],
["mainnet_address", "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"],
["total_sponsoring", "0"],
["picture", "https://cdn.nostrcheck.me/..."]
]
```
#### Tags pour `#publication` (article)
```typescript
["publication"] // Type
["sciencefiction"] ou ["research"] // Catégorie
["id", "<event_id>"] // ID unique
["title", "<titre>"] // Titre de l'article
["preview", "<aperçu>"] // Aperçu (optionnel)
["series", "<series_id>"] // ID de la série (optionnel)
["banner", "<url>"] // URL de la bannière (optionnel)
["zap", "<montant>"] // Montant en sats pour débloquer
["invoice", "<bolt11>"] // Facture BOLT11 (optionnel)
["payment_hash", "<hash>"] // Hash du paiement (optionnel)
["encrypted_key", "<key>"] // Clé de chiffrement (optionnel)
```
#### Tags pour `#series` (série)
```typescript
["series"] // Type
["sciencefiction"] ou ["research"] // Catégorie
["id", "<event_id>"] // ID unique
["title", "<titre>"] // Titre de la série
["description", "<description>"] // Description de la série
["preview", "<aperçu>"] // Aperçu (optionnel)
["cover", "<url>"] // URL de la couverture (optionnel)
```
#### Tags pour `#quote` (avis/review)
```typescript
["quote"] // Type
["article", "<article_id>"] // ID de l'article commenté
["reviewer", "<pubkey>"] // Clé publique du reviewer (optionnel)
["title", "<titre>"] // Titre de l'avis (optionnel)
```
## Filtrage et requêtes
Le système utilise `buildTagFilter` pour construire des filtres de requête Nostr :
```typescript
// Exemple : récupérer toutes les présentations d'auteurs de zapwall.fr
buildTagFilter({
type: 'author',
category: 'sciencefiction',
service: 'zapwall.fr'
})
// Résultat : { kinds: [1], "#author": [""], "#sciencefiction": [""], "#service": ["zapwall.fr"] }
// Exemple : récupérer une présentation spécifique
buildTagFilter({
type: 'author',
authorPubkey: 'abc123...',
service: 'zapwall.fr'
})
// Résultat : { kinds: [1], "#author": [""], "#service": ["zapwall.fr"], authors: ["abc123..."] }
// Exemple : récupérer toutes les notes de zapwall.fr
buildTagFilter({
service: 'zapwall.fr'
})
// Résultat : { kinds: [1], "#service": ["zapwall.fr"] }
```
## Extraction des tags
Le système utilise `extractTagsFromEvent` pour extraire les tags d'un événement :
```typescript
const tags = extractTagsFromEvent(event)
// Retourne un objet avec :
// - type: 'author' | 'series' | 'publication' | 'quote'
// - category: 'sciencefiction' | 'research'
// - id: string
// - paywall: boolean
// - payment: boolean
// - title, preview, mainnetAddress, etc. selon le type
```
## Tag service
Le tag `["service", "zapwall.fr"]` est utilisé pour identifier toutes les notes publiées par la plateforme zapwall.fr. Ce tag permet de :
- **Filtrer toutes les notes de la plateforme** : `buildTagFilter({ service: 'zapwall.fr' })`
- **Distinguer les notes zapwall.fr** des autres notes Nostr sur le réseau
- **Améliorer les performances** en filtrant dès la source lors des requêtes
**Note** : Aucun NIP (Nostr Improvement Proposal) ne spécifie actuellement un tag standardisé pour identifier un service/plateforme. Le tag `service` est donc une convention interne à zapwall.fr. Si un NIP standardisé émerge à l'avenir, le système pourra être adapté en conséquence.
## Avantages du système
1. **Standardisé** : Tous les événements sont des notes Nostr (kind 1), compatibles avec tous les clients Nostr
2. **Filtrable** : Les tags permettent de filtrer efficacement les événements par type, catégorie et service
3. **Extensible** : Facile d'ajouter de nouveaux types ou catégories
4. **Interopérable** : Les tags sont lisibles par n'importe quel client Nostr, même s'il ne comprend pas la structure complète
5. **Identifiable** : Le tag `service` permet de distinguer les notes zapwall.fr des autres notes Nostr
## Détection des auteurs
Les auteurs sont détectés via le tag `#author` dans les événements. Le système souscrit aux événements avec :
- `#author` : Pour identifier les présentations d'auteurs
- `#publication` : Pour identifier les articles
Cela permet de distinguer les auteurs (présentations) des articles (publications) dans le même flux d'événements.

View File

@ -3,6 +3,7 @@ import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
import type { ArticleFilters } from '@/components/ArticleFilters'
import { t } from '@/lib/i18n'
export function useArticles(searchQuery: string = '', filters: ArticleFilters | null = null) {
const [articles, setArticles] = useState<Article[]>([])
@ -32,7 +33,7 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
const timeout = setTimeout(() => {
setLoading(false)
if (!hasArticlesRef.current) {
setError('No articles found')
setError(t('common.error.noContent'))
}
}, 10000)

106
lib/accessControl.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* Access control rules for objects
*
* Rules:
* - Only the author (pubkey) who published the original note can modify or delete it
* - All users have read access to public content (previews, metadata)
* - For paid content, users must have paid (via zap receipt) to access the full content
* - Payment verification follows the transaction rules (zap receipt validation)
*/
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from './nostrTagSystem'
import { canModifyObject } from './versionManager'
/**
* Check if a user can modify an object
* Only the author (pubkey) who published the original note can modify it
*/
export function canUserModify(event: Event, userPubkey: string): boolean {
return canModifyObject(event, userPubkey)
}
/**
* Check if a user can delete an object
* Only the author (pubkey) who published the original note can delete it
*/
export function canUserDelete(event: Event, userPubkey: string): boolean {
return canModifyObject(event, userPubkey)
}
/**
* Check if a user can read an object
* All users can read public content (previews, metadata)
* For paid content, users must have paid (via zap receipt) to access full content
*/
export function canUserRead(event: Event, userPubkey: string | null, hasPaid: boolean = false): {
canReadPreview: boolean
canReadFullContent: boolean
} {
const tags = extractTagsFromEvent(event)
// Preview is always readable (public)
const canReadPreview = true
// Full content access depends on paywall status
if (tags.paywall) {
// Paid content: user must have paid
const canReadFullContent = hasPaid
return { canReadPreview, canReadFullContent }
}
// Free content: everyone can read
return { canReadPreview, canReadFullContent: true }
}
/**
* Check if content is paid (has paywall tag)
*/
export function isPaidContent(event: Event): boolean {
const tags = extractTagsFromEvent(event)
return tags.paywall === true
}
/**
* Access control result
*/
export interface AccessControlResult {
canModify: boolean
canDelete: boolean
canReadPreview: boolean
canReadFullContent: boolean
isPaid: boolean
reason?: string
}
/**
* Get complete access control information for an object
*/
export function getAccessControl(
event: Event,
userPubkey: string | null,
hasPaid: boolean = false
): AccessControlResult {
const canModify = userPubkey ? canUserModify(event, userPubkey) : false
const canDelete = userPubkey ? canUserDelete(event, userPubkey) : false
const { canReadPreview, canReadFullContent } = canUserRead(event, userPubkey, hasPaid)
const isPaid = isPaidContent(event)
let reason: string | undefined
if (isPaid && !canReadFullContent) {
reason = 'Payment required to access full content'
} else if (!canModify && userPubkey) {
reason = 'Only the author can modify this object'
} else if (!canDelete && userPubkey) {
reason = 'Only the author can delete this object'
}
return {
canModify,
canDelete,
canReadPreview,
canReadFullContent,
isPaid,
reason,
}
}

View File

@ -1,6 +1,8 @@
import { getAlbyService } from './alby'
import { calculateArticleSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
import { buildTags } from './nostrTagSystem'
import { PLATFORM_SERVICE } from './platformConfig'
import { generatePublicationHashId } from './hashIdGenerator'
import type { AlbyInvoice } from '@/types/alby'
import type { ArticleDraft } from './articlePublisher'
@ -42,20 +44,21 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise<AlbyInv
* Create preview event with invoice tags
* If encryptedContent is provided, it will be used instead of preview
*/
export function createPreviewEvent(
export async function createPreviewEvent(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string,
authorPresentationId?: string,
extraTags: string[][] = [],
encryptedContent?: string,
encryptedKey?: string
): {
): Promise<{
kind: 1
created_at: number
tags: string[][]
content: string
} {
const tags = buildPreviewTags(draft, invoice, authorPresentationId, extraTags, encryptedKey)
}> {
const tags = await buildPreviewTags(draft, invoice, authorPubkey, authorPresentationId, extraTags, encryptedKey)
return {
kind: 1 as const,
@ -65,21 +68,36 @@ export function createPreviewEvent(
}
}
function buildPreviewTags(
async function buildPreviewTags(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string,
_authorPresentationId?: string,
extraTags: string[][] = [],
encryptedKey?: string
): string[][] {
): Promise<string[][]> {
// Map category to new system
const category = draft.category === 'science-fiction' ? 'sciencefiction' : draft.category === 'scientific-research' ? 'research' : 'sciencefiction'
// Generate hash ID from publication data
const hashId = await generatePublicationHashId({
pubkey: authorPubkey,
title: draft.title,
preview: draft.preview,
category,
seriesId: draft.seriesId ?? undefined,
bannerUrl: draft.bannerUrl ?? undefined,
zapAmount: draft.zapAmount,
})
// Build tags using new system
const newTags = buildTags({
type: 'publication',
category,
id: '', // Will be set to event.id after publication
id: hashId,
service: PLATFORM_SERVICE,
version: 0, // New object
hidden: false,
paywall: true, // Publications are paid
title: draft.title,
preview: draft.preview,

View File

@ -2,6 +2,8 @@ import { nostrService } from './nostr'
import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
import { buildTags } from './nostrTagSystem'
import { PLATFORM_SERVICE } from './platformConfig'
import { generateSeriesHashId, generatePublicationHashId } from './hashIdGenerator'
import type { ArticleDraft, PublishedArticle } from './articlePublisher'
import type { AlbyInvoice } from '@/types/alby'
import type { Review, Series } from '@/types/nostr'
@ -36,10 +38,11 @@ async function ensurePresentation(authorPubkey: string): Promise<string> {
async function publishPreviewWithInvoice(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string,
presentationId: string,
extraTags?: string[][]
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
const previewEvent = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
@ -56,7 +59,7 @@ export async function publishSeries(params: {
ensureKeys(params.authorPubkey, params.authorPrivateKey)
const category = params.category
requireCategory(category)
const event = buildSeriesEvent(params, category)
const event = await buildSeriesEvent(params, category)
const published = await nostrService.publishEvent(event)
if (!published) {
throw new Error('Failed to publish series')
@ -73,7 +76,7 @@ export async function publishSeries(params: {
}
}
function buildSeriesEvent(
async function buildSeriesEvent(
params: {
title: string
description: string
@ -86,6 +89,15 @@ function buildSeriesEvent(
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
// Generate hash ID from series data
const hashId = await generateSeriesHashId({
pubkey: params.authorPubkey,
title: params.title,
description: params.description,
category: newCategory,
coverUrl: params.coverUrl ?? undefined,
})
return {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
@ -93,7 +105,10 @@ function buildSeriesEvent(
tags: buildTags({
type: 'series',
category: newCategory,
id: '', // Will be set to event.id after publication
id: hashId,
service: PLATFORM_SERVICE,
version: 0, // New object
hidden: false,
paywall: false,
title: params.title,
description: params.description,
@ -116,7 +131,7 @@ export async function publishReview(params: {
ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
const category = params.category
requireCategory(category)
const event = buildReviewEvent(params, category)
const event = await buildReviewEvent(params, category)
const published = await nostrService.publishEvent(event)
if (!published) {
throw new Error('Failed to publish review')
@ -133,7 +148,7 @@ export async function publishReview(params: {
}
}
function buildReviewEvent(
async function buildReviewEvent(
params: {
articleId: string
seriesId: string
@ -147,6 +162,16 @@ function buildReviewEvent(
// Map category to new system
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
// Generate hash ID from review data
const { generateReviewHashId } = await import('./hashIdGenerator')
const hashId = await generateReviewHashId({
pubkey: params.reviewerPubkey,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
content: params.content,
title: params.title ?? undefined,
})
return {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
@ -154,7 +179,10 @@ function buildReviewEvent(
tags: buildTags({
type: 'quote',
category: newCategory,
id: '', // Will be set to event.id after publication
id: hashId,
service: PLATFORM_SERVICE,
version: 0, // New object
hidden: false,
paywall: false,
articleId: params.articleId,
reviewerPubkey: params.reviewerPubkey,
@ -163,11 +191,34 @@ function buildReviewEvent(
}
}
function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') {
async function buildUpdateTags(
draft: ArticleDraft,
originalArticleId: string,
newCategory: 'sciencefiction' | 'research',
authorPubkey: string,
currentVersion: number = 0
) {
// Generate hash ID from publication data
const hashId = await generatePublicationHashId({
pubkey: authorPubkey,
title: draft.title,
preview: draft.preview,
category: newCategory,
seriesId: draft.seriesId ?? undefined,
bannerUrl: draft.bannerUrl ?? undefined,
zapAmount: draft.zapAmount,
})
// Increment version for update
const nextVersion = currentVersion + 1
const updateTags = buildTags({
type: 'publication',
category: newCategory,
id: '', // Will be set to event.id after publication
id: hashId,
service: PLATFORM_SERVICE,
version: nextVersion,
hidden: false,
paywall: true,
title: draft.title,
preview: draft.preview,
@ -189,9 +240,9 @@ async function publishUpdate(
const presentationId = await ensurePresentation(authorPubkey)
const invoice = await createArticleInvoice(draft)
const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
const updateTags = buildUpdateTags(draft, originalArticleId, newCategory)
const updateTags = await buildUpdateTags(draft, originalArticleId, newCategory, authorPubkey)
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
const publishedEvent = await publishPreviewWithInvoice(draft, invoice, authorPubkey, presentationId, updateTags)
if (!publishedEvent) {
return updateFailure(originalArticleId, 'Failed to publish article update')
}

View File

@ -171,9 +171,12 @@ export class ArticlePublisher {
nostrService.setPublicKey(authorPubkey)
nostrService.setPrivateKey(authorPrivateKey)
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
const tempEventId = `temp_${Math.random().toString(36).substring(7)}`
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
// Extract author name from title (format: "Présentation de <name>")
const authorName = draft.title.replace(/^Présentation de /, '').trim() || 'Auteur'
// Build event with hash-based ID
const eventTemplate = await buildPresentationEvent(draft, authorPubkey, authorName, 'sciencefiction')
const publishedEvent = await nostrService.publishEvent(eventTemplate)
if (!publishedEvent) {
return buildFailure('Failed to publish presentation article')

View File

@ -1,25 +1,92 @@
import { type Event } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import type { AuthorPresentationDraft } from './articlePublisher'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE } from './platformConfig'
import { generateAuthorHashId } from './hashIdGenerator'
import { generateObjectUrl } from './urlGenerator'
import { getLatestVersion } from './versionManager'
export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags: buildTags({
export async function buildPresentationEvent(
draft: AuthorPresentationDraft,
authorPubkey: string,
authorName: string,
category: 'sciencefiction' | 'research' = 'sciencefiction',
version: number = 0,
index: number = 0
) {
// Extract presentation and contentDescription from draft.content
// Format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
const separator = '\n\n---\n\nDescription du contenu :\n'
const separatorIndex = draft.content.indexOf(separator)
const presentation = separatorIndex !== -1 ? draft.content.substring(0, separatorIndex) : draft.presentation
const contentDescription = separatorIndex !== -1 ? draft.content.substring(separatorIndex + separator.length) : draft.contentDescription
// Generate hash ID from author data first (needed for URL)
const hashId = await generateAuthorHashId({
pubkey: authorPubkey,
authorName,
presentation,
contentDescription,
mainnetAddress: draft.mainnetAddress ?? undefined,
pictureUrl: draft.pictureUrl ?? undefined,
category,
})
// Build URL: https://zapwall.fr/author/<hash>_<index>_<version>
const profileUrl = generateObjectUrl('author', hashId, index, version)
// Build visible content message
const visibleContent = [
'Nouveau profil publié sur zapwall.fr',
profileUrl,
...(draft.pictureUrl ? [draft.pictureUrl] : []),
`Présentation personnelle : ${presentation}`,
`Description de votre contenu : ${contentDescription}`,
`Adresse Bitcoin mainnet (pour le sponsoring) : ${draft.mainnetAddress}`,
].join('\n')
// Build profile JSON for metadata (non-visible)
const profileJson = JSON.stringify({
authorName,
npub,
pubkey: authorPubkey,
presentation,
contentDescription,
mainnetAddress: draft.mainnetAddress,
pictureUrl: draft.pictureUrl,
category,
url: profileUrl,
version,
index,
}, null, 2)
// Combine visible content and JSON metadata (JSON in hidden section)
const fullContent = `${visibleContent}\n\n---\n\n[Metadata JSON]\n${profileJson}`
// Build tags (profile JSON is in content, not in tags)
const tags = buildTags({
type: 'author',
category,
id: eventId,
id: hashId,
service: PLATFORM_SERVICE,
version,
hidden: false,
paywall: false,
title: draft.title,
preview: draft.preview,
mainnetAddress: draft.mainnetAddress,
totalSponsoring: 0,
...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
}),
content: draft.content,
})
return {
kind: 1 as const,
created_at: Math.floor(Date.now() / 1000),
tags,
content: fullContent,
}
}
@ -31,10 +98,27 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
return null
}
// Try to extract profile JSON from content [Metadata JSON] section
let profileData: {
presentation?: string
contentDescription?: string
mainnetAddress?: string
pictureUrl?: string
} | null = null
const jsonMatch = event.content.match(/\[Metadata JSON\]\n(.+)$/s)
if (jsonMatch && jsonMatch[1]) {
try {
profileData = JSON.parse(jsonMatch[1].trim())
} catch (e) {
console.error('Error parsing profile JSON from content:', e)
}
}
// Map tag category to article category
const articleCategory = tags.category === 'sciencefiction' ? 'science-fiction' : tags.category === 'research' ? 'scientific-research' : undefined
return {
const result: import('@/types/nostr').AuthorPresentationArticle = {
id: tags.id ?? event.id,
pubkey: event.pubkey,
title: tags.title ?? 'Présentation',
@ -45,11 +129,19 @@ export function parsePresentationEvent(event: Event): import('@/types/nostr').Au
paid: true,
category: 'author-presentation',
isPresentation: true,
mainnetAddress: tags.mainnetAddress ?? '',
mainnetAddress: profileData?.mainnetAddress ?? tags.mainnetAddress ?? '',
totalSponsoring: tags.totalSponsoring ?? 0,
originalCategory: articleCategory, // Store original category for filtering
...(tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string' ? { bannerUrl: tags.pictureUrl } : {}),
originalCategory: articleCategory ?? 'science-fiction', // Store original category for filtering
}
// Add bannerUrl if available
if (profileData?.pictureUrl !== undefined && profileData?.pictureUrl !== null) {
result.bannerUrl = profileData.pictureUrl
} else if (tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string') {
result.bannerUrl = tags.pictureUrl
}
return result
}
export function fetchAuthorPresentationFromPool(
@ -61,8 +153,9 @@ export function fetchAuthorPresentationFromPool(
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
service: PLATFORM_SERVICE,
}),
limit: 1,
limit: 100, // Get all versions to find the latest
},
]
@ -72,6 +165,8 @@ export function fetchAuthorPresentationFromPool(
const { createSubscription } = require('@/types/nostr-tools-extended')
const sub = createSubscription(pool, [relayUrl], filters)
const events: Event[] = []
const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
if (resolved) {
return
@ -82,13 +177,36 @@ export function fetchAuthorPresentationFromPool(
}
sub.on('event', (event: Event) => {
const parsed = parsePresentationEvent(event)
if (parsed) {
finalize(parsed)
// Collect all events first
const tags = extractTagsFromEvent(event)
if (tags.type === 'author' && !tags.hidden) {
events.push(event)
}
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
sub.on('eose', () => {
// Get the latest version from all collected events
const latestEvent = getLatestVersion(events)
if (latestEvent) {
const parsed = parsePresentationEvent(latestEvent)
if (parsed) {
finalize(parsed)
return
}
}
finalize(null)
})
setTimeout(() => {
// Get the latest version from all collected events
const latestEvent = getLatestVersion(events)
if (latestEvent) {
const parsed = parsePresentationEvent(latestEvent)
if (parsed) {
finalize(parsed)
return
}
}
finalize(null)
}, 5000).unref?.()
})
}

View File

@ -17,12 +17,13 @@ export function buildFailure(error?: string): PublishedArticle {
export async function publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
authorPubkey: string,
presentationId: string,
extraTags?: string[][],
encryptedContent?: string,
encryptedKey?: string
): Promise<import('nostr-tools').Event | null> {
const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
const previewEvent = await createPreviewEvent(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
@ -49,7 +50,7 @@ export async function encryptAndPublish(
const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
const invoice = await createArticleInvoice(draft)
const extraTags = buildArticleExtraTags(draft, category)
const publishedEvent = await publishPreview(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
const publishedEvent = await publishPreview(draft, invoice, authorPubkey, presentationId, extraTags, encryptedContent, encryptedKey)
if (!publishedEvent) {
return buildFailure('Failed to publish article')

View File

@ -6,6 +6,7 @@ import type { Article } from '@/types/nostr'
import { parseArticleFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE } from './platformConfig'
function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: number) {
const filters = [
@ -13,6 +14,7 @@ function createSeriesSubscription(pool: SimplePool, seriesId: string, limit: num
...buildTagFilter({
type: 'publication',
seriesId,
service: PLATFORM_SERVICE,
}),
limit,
},

124
lib/duplicateDetector.ts Normal file
View File

@ -0,0 +1,124 @@
/**
* Detect duplicate IDs for the same object type
* When duplicates are found, warn the user and ask them to choose
*/
import type { ExtractedObject } from './metadataExtractor'
export interface DuplicateGroup<T extends ExtractedObject> {
id: string
type: T['type']
objects: T[]
}
export interface DuplicateWarning {
type: ExtractedObject['type']
id: string
objects: ExtractedObject[]
message: string
}
/**
* Group objects by type and ID to detect duplicates
*/
export function detectDuplicates(objects: ExtractedObject[]): DuplicateWarning[] {
const warnings: DuplicateWarning[] = []
// Group objects by type
const byType = new Map<ExtractedObject['type'], ExtractedObject[]>()
for (const obj of objects) {
if (!byType.has(obj.type)) {
byType.set(obj.type, [])
}
byType.get(obj.type)!.push(obj)
}
// For each type, group by ID
for (const [type, typeObjects] of byType.entries()) {
const byId = new Map<string, ExtractedObject[]>()
for (const obj of typeObjects) {
if (!byId.has(obj.id)) {
byId.set(obj.id, [])
}
byId.get(obj.id)!.push(obj)
}
// Check for duplicates (same ID, multiple objects)
for (const [id, idObjects] of byId.entries()) {
if (idObjects.length > 1) {
warnings.push({
type,
id,
objects: idObjects,
message: `Found ${idObjects.length} objects of type "${type}" with the same ID "${id.substring(0, 16)}...". Please choose which one to keep.`,
})
}
}
}
return warnings
}
/**
* Resolve duplicates by keeping only the first object for each ID
* This is a simple resolution - in production, you'd want user interaction
*/
export function resolveDuplicatesSimple(objects: ExtractedObject[]): ExtractedObject[] {
const seen = new Map<string, ExtractedObject>()
const resolved: ExtractedObject[] = []
for (const obj of objects) {
const key = `${obj.type}:${obj.id}`
if (!seen.has(key)) {
seen.set(key, obj)
resolved.push(obj)
} else {
// Keep the first one, skip duplicates
console.warn(`Duplicate detected for ${obj.type} with ID ${obj.id.substring(0, 16)}... Keeping first occurrence.`)
}
}
return resolved
}
/**
* Resolve duplicates by keeping the most recent object (highest event.created_at)
*/
export function resolveDuplicatesByDate(objects: ExtractedObject[]): ExtractedObject[] {
const byKey = new Map<string, ExtractedObject[]>()
// Group by type and ID
for (const obj of objects) {
const key = `${obj.type}:${obj.id}`
if (!byKey.has(key)) {
byKey.set(key, [])
}
byKey.get(key)!.push(obj)
}
const resolved: ExtractedObject[] = []
for (const [key, group] of byKey.entries()) {
if (group.length === 1) {
const obj = group[0]
if (obj) {
resolved.push(obj)
}
} else {
// Sort by eventId (which should correlate with creation time)
// Keep the one with the "latest" eventId (lexicographically)
// In practice, you'd want to fetch the actual created_at from events
group.sort((a, b) => {
// Simple lexicographic comparison - in production, compare actual timestamps
return b.eventId.localeCompare(a.eventId)
})
const first = group[0]
if (first) {
resolved.push(first)
console.warn(`Resolved ${group.length} duplicates for ${key} by keeping most recent`)
}
}
}
return resolved
}

199
lib/hashIdGenerator.ts Normal file
View File

@ -0,0 +1,199 @@
/**
* Hash-based ID generation for Nostr objects
* All IDs are SHA-256 hashes of the object's canonical representation
*/
/**
* Generate a canonical string representation of an object for hashing
* This ensures that the same object always produces the same hash
*/
function canonicalizeObject(obj: Record<string, unknown>): string {
// Sort keys to ensure consistent ordering
const sortedKeys = Object.keys(obj).sort()
const parts: string[] = []
for (const key of sortedKeys) {
const value = obj[key]
if (value === undefined || value === null) {
continue // Skip undefined/null values
}
if (typeof value === 'object' && !Array.isArray(value)) {
// Recursively canonicalize nested objects
parts.push(`${key}:${canonicalizeObject(value as Record<string, unknown>)}`)
} else if (Array.isArray(value)) {
// Arrays are serialized as comma-separated values
parts.push(`${key}:[${value.map((v) => (typeof v === 'object' ? JSON.stringify(v) : String(v))).join(',')}]`)
} else {
parts.push(`${key}:${String(value)}`)
}
}
return `{${parts.join('|')}}`
}
/**
* Generate a SHA-256 hash ID from an object using Web Crypto API
* The hash is deterministic: the same object always produces the same hash
*/
export async function generateHashId(obj: Record<string, unknown>): Promise<string> {
const canonical = canonicalizeObject(obj)
const encoder = new TextEncoder()
const data = encoder.encode(canonical)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Generate hash ID for an author presentation
*/
export async function generateAuthorHashId(authorData: {
pubkey: string
authorName: string
presentation: string
contentDescription: string
mainnetAddress?: string | undefined
pictureUrl?: string | undefined
category: string
}): Promise<string> {
return generateHashId({
type: 'author',
pubkey: authorData.pubkey,
authorName: authorData.authorName,
presentation: authorData.presentation,
contentDescription: authorData.contentDescription,
mainnetAddress: authorData.mainnetAddress ?? '',
pictureUrl: authorData.pictureUrl ?? '',
category: authorData.category,
})
}
/**
* Generate hash ID for a series
*/
export async function generateSeriesHashId(seriesData: {
pubkey: string
title: string
description: string
category: string
coverUrl?: string | undefined
}): Promise<string> {
return generateHashId({
type: 'series',
pubkey: seriesData.pubkey,
title: seriesData.title,
description: seriesData.description,
category: seriesData.category,
coverUrl: seriesData.coverUrl ?? '',
})
}
/**
* Generate hash ID for a publication
*/
export async function generatePublicationHashId(publicationData: {
pubkey: string
title: string
preview: string
category: string
seriesId?: string | undefined
bannerUrl?: string | undefined
zapAmount: number
}): Promise<string> {
return generateHashId({
type: 'publication',
pubkey: publicationData.pubkey,
title: publicationData.title,
preview: publicationData.preview,
category: publicationData.category,
seriesId: publicationData.seriesId ?? '',
bannerUrl: publicationData.bannerUrl ?? '',
zapAmount: publicationData.zapAmount,
})
}
/**
* Generate hash ID for a review/quote
*/
export async function generateReviewHashId(reviewData: {
pubkey: string
articleId: string
reviewerPubkey: string
content: string
title?: string
}): Promise<string> {
return generateHashId({
type: 'quote',
pubkey: reviewData.pubkey,
articleId: reviewData.articleId,
reviewerPubkey: reviewData.reviewerPubkey,
content: reviewData.content,
title: reviewData.title ?? '',
})
}
/**
* Generate hash ID for a purchase (zap receipt kind 9735)
*/
export async function generatePurchaseHashId(purchaseData: {
payerPubkey: string
articleId: string
authorPubkey: string
amount: number
paymentHash: string
}): Promise<string> {
return generateHashId({
type: 'purchase',
payerPubkey: purchaseData.payerPubkey,
articleId: purchaseData.articleId,
authorPubkey: purchaseData.authorPubkey,
amount: purchaseData.amount,
paymentHash: purchaseData.paymentHash,
})
}
/**
* Generate hash ID for a review tip (zap receipt kind 9735)
*/
export async function generateReviewTipHashId(tipData: {
payerPubkey: string
articleId: string
reviewId: string
reviewerPubkey: string
authorPubkey: string
amount: number
paymentHash: string
}): Promise<string> {
return generateHashId({
type: 'review_tip',
payerPubkey: tipData.payerPubkey,
articleId: tipData.articleId,
reviewId: tipData.reviewId,
reviewerPubkey: tipData.reviewerPubkey,
authorPubkey: tipData.authorPubkey,
amount: tipData.amount,
paymentHash: tipData.paymentHash,
})
}
/**
* Generate hash ID for a sponsoring (zap receipt kind 9735)
*/
export async function generateSponsoringHashId(sponsoringData: {
payerPubkey: string
authorPubkey: string
seriesId?: string
articleId?: string
amount: number
paymentHash: string
}): Promise<string> {
return generateHashId({
type: 'sponsoring',
payerPubkey: sponsoringData.payerPubkey,
authorPubkey: sponsoringData.authorPubkey,
seriesId: sponsoringData.seriesId ?? '',
articleId: sponsoringData.articleId ?? '',
amount: sponsoringData.amount,
paymentHash: sponsoringData.paymentHash,
})
}

491
lib/metadataExtractor.ts Normal file
View File

@ -0,0 +1,491 @@
/**
* Extract objects from invisible metadata in Nostr notes
* Objects are stored in [Metadata JSON] sections in the note content
*/
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from './nostrTagSystem'
import { generateAuthorHashId, generateSeriesHashId, generatePublicationHashId, generateReviewHashId, generatePurchaseHashId, generateReviewTipHashId, generateSponsoringHashId } from './hashIdGenerator'
export interface ExtractedAuthor {
type: 'author'
id: string
pubkey: string
authorName: string
presentation: string
contentDescription: string
mainnetAddress?: string
pictureUrl?: string
category: string
url?: string
eventId: string
}
export interface ExtractedSeries {
type: 'series'
id: string
pubkey: string
title: string
description: string
preview?: string
coverUrl?: string
category: string
eventId: string
}
export interface ExtractedPublication {
type: 'publication'
id: string
pubkey: string
title: string
preview: string
category: string
seriesId?: string
bannerUrl?: string
zapAmount: number
eventId: string
}
export interface ExtractedReview {
type: 'review'
id: string
pubkey: string
articleId: string
reviewerPubkey: string
content: string
title?: string
eventId: string
}
export interface ExtractedPurchase {
type: 'purchase'
id: string
payerPubkey: string
articleId: string
authorPubkey: string
amount: number
paymentHash: string
eventId: string
}
export interface ExtractedReviewTip {
type: 'review_tip'
id: string
payerPubkey: string
articleId: string
reviewId: string
reviewerPubkey: string
authorPubkey: string
amount: number
paymentHash: string
eventId: string
}
export interface ExtractedSponsoring {
type: 'sponsoring'
id: string
payerPubkey: string
authorPubkey: string
seriesId?: string
articleId?: string
amount: number
paymentHash: string
eventId: string
}
export type ExtractedObject =
| ExtractedAuthor
| ExtractedSeries
| ExtractedPublication
| ExtractedReview
| ExtractedPurchase
| ExtractedReviewTip
| ExtractedSponsoring
/**
* Extract JSON metadata from note content
*/
function extractMetadataJson(content: string): Record<string, unknown> | null {
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim())
} catch (e) {
console.error('Error parsing metadata JSON from content:', e)
return null
}
}
return null
}
/**
* Extract author from event
*/
export async function extractAuthorFromEvent(event: Event): Promise<ExtractedAuthor | null> {
const tags = extractTagsFromEvent(event)
if (tags.type !== 'author') {
return null
}
// Try to extract from metadata JSON first
const metadata = extractMetadataJson(event.content)
if (metadata && metadata.type === 'author') {
const authorData = {
pubkey: (metadata.pubkey as string) ?? event.pubkey,
authorName: (metadata.authorName as string) ?? '',
presentation: (metadata.presentation as string) ?? '',
contentDescription: (metadata.contentDescription as string) ?? '',
mainnetAddress: metadata.mainnetAddress as string | undefined,
pictureUrl: metadata.pictureUrl as string | undefined,
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
}
const id = await generateAuthorHashId(authorData)
return {
type: 'author',
id,
...authorData,
eventId: event.id,
url: metadata.url as string | undefined,
}
}
// Fallback: extract from tags and visible content
// This is a simplified extraction - full data should be in metadata JSON
return null
}
/**
* Extract series from event
*/
export async function extractSeriesFromEvent(event: Event): Promise<ExtractedSeries | null> {
const tags = extractTagsFromEvent(event)
if (tags.type !== 'series') {
return null
}
const metadata = extractMetadataJson(event.content)
if (metadata && metadata.type === 'series') {
const seriesData = {
pubkey: (metadata.pubkey as string) ?? event.pubkey,
title: (metadata.title as string) ?? (tags.title as string) ?? '',
description: (metadata.description as string) ?? '',
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
coverUrl: (metadata.coverUrl as string) ?? (tags.coverUrl as string) ?? undefined,
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
}
const id = await generateSeriesHashId(seriesData)
return {
type: 'series',
id,
...seriesData,
eventId: event.id,
}
}
// Fallback: extract from tags
if (tags.title && tags.description) {
const seriesData = {
pubkey: event.pubkey,
title: tags.title as string,
description: tags.description as string,
preview: (tags.preview as string) ?? event.content.substring(0, 200),
coverUrl: tags.coverUrl as string | undefined,
category: tags.category ?? 'sciencefiction',
}
const id = await generateSeriesHashId(seriesData)
return {
type: 'series',
id,
...seriesData,
eventId: event.id,
}
}
return null
}
/**
* Extract publication from event
*/
export async function extractPublicationFromEvent(event: Event): Promise<ExtractedPublication | null> {
const tags = extractTagsFromEvent(event)
if (tags.type !== 'publication') {
return null
}
const metadata = extractMetadataJson(event.content)
if (metadata && metadata.type === 'publication') {
const publicationData = {
pubkey: (metadata.pubkey as string) ?? event.pubkey,
title: (metadata.title as string) ?? (tags.title as string) ?? '',
preview: (metadata.preview as string) ?? (tags.preview as string) ?? event.content.substring(0, 200),
category: (metadata.category as string) ?? tags.category ?? 'sciencefiction',
seriesId: (metadata.seriesId as string) ?? tags.seriesId ?? undefined,
bannerUrl: (metadata.bannerUrl as string) ?? tags.bannerUrl ?? undefined,
zapAmount: (metadata.zapAmount as number) ?? tags.zapAmount ?? 800,
}
const id = await generatePublicationHashId(publicationData)
return {
type: 'publication',
id,
...publicationData,
eventId: event.id,
}
}
// Fallback: extract from tags
if (tags.title) {
const publicationData = {
pubkey: event.pubkey,
title: tags.title as string,
preview: (tags.preview as string) ?? event.content.substring(0, 200),
category: tags.category ?? 'sciencefiction',
seriesId: tags.seriesId as string | undefined,
bannerUrl: tags.bannerUrl as string | undefined,
zapAmount: tags.zapAmount ?? 800,
}
const id = await generatePublicationHashId(publicationData)
return {
type: 'publication',
id,
...publicationData,
eventId: event.id,
}
}
return null
}
/**
* Extract review from event
*/
export async function extractReviewFromEvent(event: Event): Promise<ExtractedReview | null> {
const tags = extractTagsFromEvent(event)
if (tags.type !== 'quote') {
return null
}
const metadata = extractMetadataJson(event.content)
if (metadata && metadata.type === 'review') {
const reviewData = {
pubkey: (metadata.pubkey as string) ?? event.pubkey,
articleId: (metadata.articleId as string) ?? (tags.articleId as string) ?? '',
reviewerPubkey: (metadata.reviewerPubkey as string) ?? (tags.reviewerPubkey as string) ?? event.pubkey,
content: (metadata.content as string) ?? event.content,
title: (metadata.title as string) ?? (tags.title as string) ?? undefined,
}
if (!reviewData.articleId || !reviewData.reviewerPubkey) {
return null
}
const id = await generateReviewHashId(reviewData)
return {
type: 'review',
id,
...reviewData,
eventId: event.id,
}
}
// Fallback: extract from tags
if (tags.articleId && tags.reviewerPubkey) {
const reviewData = {
pubkey: event.pubkey,
articleId: tags.articleId as string,
reviewerPubkey: tags.reviewerPubkey as string,
content: event.content,
title: tags.title as string | undefined,
}
const id = await generateReviewHashId(reviewData)
return {
type: 'review',
id,
...reviewData,
eventId: event.id,
}
}
return null
}
/**
* Extract purchase from zap receipt (kind 9735)
*/
export async function extractPurchaseFromEvent(event: Event): Promise<ExtractedPurchase | null> {
if (event.kind !== 9735) {
return null
}
// Check for purchase kind_type tag
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'purchase')
if (!kindTypeTag) {
return null
}
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
if (!pTag || !eTag || !amountTag) {
return null
}
const amount = parseInt(amountTag, 10) / 1000 // Convert millisats to sats
const paymentHash = paymentHashTag ?? event.id // Use event.id as fallback
const purchaseData = {
payerPubkey: event.pubkey,
articleId: eTag,
authorPubkey: pTag,
amount,
paymentHash,
}
const id = await generatePurchaseHashId(purchaseData)
return {
type: 'purchase',
id,
...purchaseData,
eventId: event.id,
}
}
/**
* Extract review tip from zap receipt (kind 9735)
*/
export async function extractReviewTipFromEvent(event: Event): Promise<ExtractedReviewTip | null> {
if (event.kind !== 9735) {
return null
}
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'review_tip')
if (!kindTypeTag) {
return null
}
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
const reviewerTag = event.tags.find((tag) => tag[0] === 'reviewer')?.[1]
const reviewIdTag = event.tags.find((tag) => tag[0] === 'review_id')?.[1]
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
if (!pTag || !eTag || !amountTag || !reviewerTag || !reviewIdTag) {
return null
}
const amount = parseInt(amountTag, 10) / 1000
const paymentHash = paymentHashTag ?? event.id
const tipData = {
payerPubkey: event.pubkey,
articleId: eTag,
reviewId: reviewIdTag,
reviewerPubkey: reviewerTag,
authorPubkey: pTag,
amount,
paymentHash,
}
const id = await generateReviewTipHashId(tipData)
return {
type: 'review_tip',
id,
...tipData,
eventId: event.id,
}
}
/**
* Extract sponsoring from zap receipt (kind 9735)
*/
export async function extractSponsoringFromEvent(event: Event): Promise<ExtractedSponsoring | null> {
if (event.kind !== 9735) {
return null
}
const kindTypeTag = event.tags.find((tag) => tag[0] === 'kind_type' && tag[1] === 'sponsoring')
if (!kindTypeTag) {
return null
}
const pTag = event.tags.find((tag) => tag[0] === 'p')?.[1]
const eTag = event.tags.find((tag) => tag[0] === 'e')?.[1]
const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
const seriesTag = event.tags.find((tag) => tag[0] === 'series')?.[1]
const articleTag = event.tags.find((tag) => tag[0] === 'article')?.[1]
const paymentHashTag = event.tags.find((tag) => tag[0] === 'payment_hash')?.[1]
if (!pTag || !amountTag) {
return null
}
const amount = parseInt(amountTag, 10) / 1000
const paymentHash = paymentHashTag ?? event.id
const sponsoringData = {
payerPubkey: event.pubkey,
authorPubkey: pTag,
seriesId: seriesTag,
articleId: articleTag ?? eTag, // Use eTag as fallback for articleId
amount,
paymentHash,
}
const id = await generateSponsoringHashId(sponsoringData)
return {
type: 'sponsoring',
id,
...sponsoringData,
eventId: event.id,
}
}
/**
* Extract all objects from an event
*/
export async function extractObjectsFromEvent(event: Event): Promise<ExtractedObject[]> {
const results: ExtractedObject[] = []
// Try to extract each type
const author = await extractAuthorFromEvent(event)
if (author) results.push(author)
const series = await extractSeriesFromEvent(event)
if (series) results.push(series)
const publication = await extractPublicationFromEvent(event)
if (publication) results.push(publication)
const review = await extractReviewFromEvent(event)
if (review) results.push(review)
const purchase = await extractPurchaseFromEvent(event)
if (purchase) results.push(purchase)
const reviewTip = await extractReviewTipFromEvent(event)
if (reviewTip) results.push(reviewTip)
const sponsoring = await extractSponsoringFromEvent(event)
if (sponsoring) results.push(sponsoring)
return results
}

View File

@ -3,6 +3,7 @@ import { hexToBytes } from 'nostr-tools/utils'
import type { Article, NostrProfile } from '@/types/nostr'
import { createSubscription } from '@/types/nostr-tools-extended'
import { parseArticleFromEvent } from './nostrEventParsing'
import { parsePresentationEvent } from './articlePublisherHelpersPresentation'
import {
getPrivateContent as getPrivateContentFromPool,
getDecryptionKey,
@ -12,6 +13,7 @@ import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification
import { subscribeWithTimeout } from './nostrSubscription'
import { getPrimaryRelay, getPrimaryRelaySync } from './config'
import { buildTagFilter } from './nostrTagSystem'
import { PLATFORM_SERVICE } from './platformConfig'
class NostrService {
private pool: SimplePool | null = null
@ -84,10 +86,21 @@ class NostrService {
}
private createArticleSubscription(pool: SimplePool, limit: number) {
// Subscribe to both 'publication' and 'author' type events
// Authors are identified by tag type='author' in the tag system
// Filter by service='zapwall.fr' to only get notes from this platform
const filters = [
{
...buildTagFilter({
type: 'publication',
service: PLATFORM_SERVICE,
}),
limit,
},
{
...buildTagFilter({
type: 'author',
service: PLATFORM_SERVICE,
}),
limit,
},
@ -113,7 +126,15 @@ class NostrService {
sub.on('event', (event: Event) => {
try {
const article = parseArticleFromEvent(event)
// Try to parse as regular article first
let article = parseArticleFromEvent(event)
// If not a regular article, try to parse as author presentation
if (!article) {
const presentation = parsePresentationEvent(event)
if (presentation) {
article = presentation
}
}
if (article) {
callback(article)
}

View File

@ -5,6 +5,13 @@ function buildBaseTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTa
result.push([tags.type])
result.push([tags.category])
result.push(['id', tags.id])
result.push(['service', tags.service])
// Add version tag (default to 0 if not specified)
result.push(['version', (tags.version ?? 0).toString()])
// Add hidden tag only if true
if (tags.hidden === true) {
result.push(['hidden', 'true'])
}
if (tags.paywall) {
result.push(['paywall'])
}

View File

@ -21,6 +21,9 @@ export function extractTypeAndCategory(event: { tags: string[][] }): { type?: Ta
export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) {
return {
id: findTag('id'),
service: findTag('service'),
version: parseNumericTag(findTag, 'version') ?? 0, // Default to 0 if not present
hidden: findTag('hidden') === 'true', // true only if tag exists and value is 'true'
paywall: hasTag('paywall'),
payment: hasTag('payment'),
title: findTag('title'),
@ -45,6 +48,9 @@ export function extractTagsFromEvent(event: { tags: string[][] }): {
type?: TagType | undefined
category?: TagCategory | undefined
id?: string | undefined
service?: string | undefined
version: number
hidden: boolean
paywall: boolean
payment: boolean
title?: string | undefined

View File

@ -16,6 +16,7 @@ export function buildTagFilter(params: {
type?: TagType
category?: TagCategory
id?: string
service?: string
paywall?: boolean
payment?: boolean
seriesId?: string
@ -33,6 +34,7 @@ export function buildTagFilter(params: {
filter[`#${params.category}`] = ['']
}
addValueTagFilter(filter, 'id', params.id)
addValueTagFilter(filter, 'service', params.service)
addSimpleTagFilter(filter, 'paywall', params.paywall === true)
addSimpleTagFilter(filter, 'payment', params.payment === true)
addValueTagFilter(filter, 'series', params.seriesId)

View File

@ -4,6 +4,7 @@
* - #sciencefiction or #research: for category
* - #author, #series, #publication, #quote: for type
* - #id_<id>: for identifier
* - #service: service identifier (e.g., "zapwall.fr") to filter all notes from this platform
* - #payment (optional): for payment notes
*
* Everything is a Nostr note (kind 1)
@ -17,6 +18,9 @@ export interface BaseTags {
type: TagType
category: TagCategory
id: string
service: string // Service identifier (e.g., "zapwall.fr")
version?: number // Version number (0 by default, incremented on updates)
hidden?: boolean // Hidden flag (true to hide/delete, false or undefined to show)
paywall?: boolean
payment?: boolean
}

97
lib/objectModification.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* Object modification and deletion utilities
* Only the author (pubkey) who published the original note can modify or delete it
*
* Access rules:
* - Modification: Only the author (event.pubkey === userPubkey) can modify
* - Deletion: Only the author (event.pubkey === userPubkey) can delete
* - Read access: All users can read previews, paid content requires payment verification
*/
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from './nostrTagSystem'
import { canModifyObject, getNextVersion } from './versionManager'
/**
* Check if user can modify an object
* Only the author (pubkey) who published the original note can modify it
*/
export function canUserModifyObject(event: Event, userPubkey: string): boolean {
return canModifyObject(event, userPubkey)
}
/**
* Build an update event (new version)
* Increments version and keeps the same hash ID
*/
export async function buildUpdateEvent(
originalEvent: Event,
updatedData: Record<string, unknown>,
userPubkey: string
): Promise<Event | null> {
// Check if user can modify
if (!canUserModifyObject(originalEvent, userPubkey)) {
throw new Error('Only the author can modify this object')
}
const tags = extractTagsFromEvent(originalEvent)
const nextVersion = getNextVersion([originalEvent])
// Build new event with incremented version
// The hash ID stays the same, only version changes
const newTags = originalEvent.tags.map((tag) => {
if (tag[0] === 'version') {
return ['version', nextVersion.toString()]
}
return tag
})
// Remove hidden tag if present (object is being updated, not deleted)
const filteredTags = newTags.filter((tag) => !(tag[0] === 'hidden' && tag[1] === 'true'))
return {
...originalEvent,
tags: filteredTags,
created_at: Math.floor(Date.now() / 1000),
// Content and other fields should be updated by the caller
} as Event
}
/**
* Build a delete event (hide object)
* Sets hidden=true and increments version
*/
export async function buildDeleteEvent(
originalEvent: Event,
userPubkey: string
): Promise<Event | null> {
// Check if user can modify
if (!canUserModifyObject(originalEvent, userPubkey)) {
throw new Error('Only the author can delete this object')
}
const tags = extractTagsFromEvent(originalEvent)
const nextVersion = getNextVersion([originalEvent])
// Build new event with hidden=true and incremented version
const newTags = originalEvent.tags.map((tag) => {
if (tag[0] === 'version') {
return ['version', nextVersion.toString()]
}
if (tag[0] === 'hidden') {
return ['hidden', 'true']
}
return tag
})
// Add hidden tag if not present
if (!newTags.some((tag) => tag[0] === 'hidden')) {
newTags.push(['hidden', 'true'])
}
return {
...originalEvent,
tags: newTags,
created_at: Math.floor(Date.now() / 1000),
} as Event
}

View File

@ -1,29 +1,3 @@
export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
import { getPlatformLightningAddress as getAddress, getPlatformLightningAddressSync as getAddressSync } from './config'
/**
* Platform Lightning address for receiving commissions
* This should be configured with the platform's Lightning node
* Format: user@domain.com or LNURL
*
* @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() instead
*/
export const PLATFORM_LIGHTNING_ADDRESS = ''
/**
* Get platform Lightning address (async)
* Uses IndexedDB if available, otherwise returns default
*/
export async function getPlatformLightningAddress(): Promise<string> {
return getAddress()
}
/**
* Get platform Lightning address (sync)
* Returns default if IndexedDB is not ready
*/
export function getPlatformLightningAddressSync(): string {
return getAddressSync()
}
export const PLATFORM_SERVICE = 'zapwall.fr'

View File

@ -2,13 +2,45 @@ import type { Article } from '@/types/nostr'
/**
* Extract presentation data from article content
* Content format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
* Supports two formats:
* 1. Old format: "${presentation}\n\n---\n\nDescription du contenu :\n${contentDescription}"
* 2. New format: "Nouveau profil publié sur zapwall.fr\n<url>\n<photo>\nPrésentation personnelle : <presentation>\nDescription de votre contenu : <description>\nAdresse Bitcoin mainnet (pour le sponsoring) : <adresse>\n\n---\n\n[Metadata JSON]\n<json>"
* The profile JSON is stored in the [Metadata JSON] section of the content, not in tags
*/
export function extractPresentationData(presentation: Article): {
presentation: string
contentDescription: string
} {
const content = presentation.content
// Try new format first
const newFormatMatch = content.match(/Présentation personnelle : (.+?)(?:\nDescription de votre contenu :|$)/s)
const descriptionMatch = content.match(/Description de votre contenu : (.+?)(?:\nAdresse Bitcoin mainnet|$)/s)
if (newFormatMatch && descriptionMatch) {
return {
presentation: newFormatMatch[1].trim(),
contentDescription: descriptionMatch[1].trim(),
}
}
// Try to extract from JSON metadata section
const jsonMatch = content.match(/\[Metadata JSON\]\n(.+)$/s)
if (jsonMatch) {
try {
const profileJson = JSON.parse(jsonMatch[1].trim())
if (profileJson.presentation && profileJson.contentDescription) {
return {
presentation: profileJson.presentation,
contentDescription: profileJson.contentDescription,
}
}
} catch (e) {
// JSON parsing failed, continue with old format
}
}
// Fallback to old format
const separator = '\n\n---\n\nDescription du contenu :\n'
const separatorIndex = content.indexOf(separator)

View File

@ -4,11 +4,13 @@ import type { Review } from '@/types/nostr'
import { parseReviewFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE } from './platformConfig'
function buildReviewFilters(articleId: string) {
const tagFilter = buildTagFilter({
type: 'quote',
articleId,
service: PLATFORM_SERVICE,
})
const filterObj: {

View File

@ -4,11 +4,13 @@ import type { Series } from '@/types/nostr'
import { parseSeriesFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
import { getPrimaryRelaySync } from './config'
import { PLATFORM_SERVICE } from './platformConfig'
function buildSeriesFilters(authorPubkey: string) {
const tagFilter = buildTagFilter({
type: 'series',
authorPubkey,
service: PLATFORM_SERVICE,
})
return [
@ -62,6 +64,7 @@ function buildSeriesByIdFilters(seriesId: string) {
ids: [seriesId],
...buildTagFilter({
type: 'series',
service: PLATFORM_SERVICE,
}),
},
]

View File

@ -2,6 +2,7 @@ import { nostrService } from './nostr'
import type { Article } from '@/types/nostr'
import { getPrimaryRelaySync } from './config'
import { buildTagFilter, extractTagsFromEvent } from './nostrTagSystem'
import { PLATFORM_SERVICE } from './platformConfig'
function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey: string): Promise<number> {
const filters = [
@ -9,6 +10,7 @@ function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey:
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
service: PLATFORM_SERVICE,
}),
limit: 1,
},

56
lib/urlGenerator.ts Normal file
View File

@ -0,0 +1,56 @@
/**
* Generate URLs for objects in the format:
* https://zapwall.fr/<object_type_name>/<id_hash>_<index>_<version>
*
* - object_type_name: author, series, publication, review
* - id_hash: SHA-256 hash of the object
* - index: number of objects of the same type with the same hash (0 by default)
* - version: version number (0 by default)
*/
export type ObjectTypeName = 'author' | 'series' | 'publication' | 'review'
/**
* Generate URL for an object
*/
export function generateObjectUrl(
objectType: ObjectTypeName,
idHash: string,
index: number = 0,
version: number = 0
): string {
return `https://zapwall.fr/${objectType}/${idHash}_${index}_${version}`
}
/**
* Parse URL to extract object information
*/
export function parseObjectUrl(url: string): {
objectType: ObjectTypeName | null
idHash: string | null
index: number | null
version: number | null
} {
const match = url.match(/https?:\/\/zapwall\.fr\/(author|series|publication|review)\/([a-f0-9]+)_(\d+)_(\d+)/i)
if (!match) {
return { objectType: null, idHash: null, index: null, version: null }
}
return {
objectType: match[1] as ObjectTypeName,
idHash: match[2],
index: parseInt(match[3], 10),
version: parseInt(match[4], 10),
}
}
/**
* Get the object type name from tag type
*/
export function getObjectTypeName(tagType: 'author' | 'series' | 'publication' | 'quote'): ObjectTypeName {
// Map 'quote' to 'review' for URLs
if (tagType === 'quote') {
return 'review'
}
return tagType
}

139
lib/versionManager.ts Normal file
View File

@ -0,0 +1,139 @@
/**
* Version management for objects
* Handles versioning and hiding of objects
* Only the author (pubkey) who published the original note can modify or delete it
*/
import type { Event } from 'nostr-tools'
import { extractTagsFromEvent } from './nostrTagSystem'
export interface VersionedObject {
event: Event
version: number
hidden: boolean
pubkey: string
id: string
}
/**
* Filter events to get only the latest version that is not hidden
* Groups events by ID and returns the one with the highest version that is not hidden
*/
export function getLatestVersion(events: Event[]): Event | null {
if (events.length === 0) {
return null
}
// Group by ID and find the latest non-hidden version
const byId = new Map<string, VersionedObject[]>()
for (const event of events) {
const tags = extractTagsFromEvent(event)
if (!tags.id) {
continue
}
if (!byId.has(tags.id)) {
byId.set(tags.id, [])
}
byId.get(tags.id)!.push({
event,
version: tags.version,
hidden: tags.hidden,
pubkey: event.pubkey,
id: tags.id,
})
}
// For each ID, find the latest non-hidden version
const latestVersions: VersionedObject[] = []
for (const objects of byId.values()) {
// Filter out hidden objects
const visible = objects.filter((obj) => !obj.hidden)
if (visible.length === 0) {
// All versions are hidden, skip this object
continue
}
// Sort by version (descending) and take the first (latest)
visible.sort((a, b) => b.version - a.version)
latestVersions.push(visible[0])
}
// If we have multiple IDs, we need to return the one with the highest version
// But typically we expect one ID per query, so return the first
if (latestVersions.length === 0) {
return null
}
// Sort by version and return the latest
latestVersions.sort((a, b) => b.version - a.version)
return latestVersions[0].event
}
/**
* Get all versions of an object (for version history)
*/
export function getAllVersions(events: Event[]): VersionedObject[] {
const versions: VersionedObject[] = []
for (const event of events) {
const tags = extractTagsFromEvent(event)
if (!tags.id) {
continue
}
versions.push({
event,
version: tags.version,
hidden: tags.hidden,
pubkey: event.pubkey,
id: tags.id,
})
}
// Sort by version (descending)
versions.sort((a, b) => b.version - a.version)
return versions
}
/**
* Check if a user can modify or delete an object
* Only the original author (pubkey) can modify/delete
*/
export function canModifyObject(event: Event, userPubkey: string): boolean {
return event.pubkey === userPubkey
}
/**
* Get the next version number for an object
* Finds the highest version and increments it
*/
export function getNextVersion(events: Event[]): number {
if (events.length === 0) {
return 0
}
let maxVersion = -1
for (const event of events) {
const tags = extractTagsFromEvent(event)
if (tags.version > maxVersion) {
maxVersion = tags.version
}
}
return maxVersion + 1
}
/**
* Count objects with the same hash ID (for index calculation)
*/
export function countObjectsWithSameHash(events: Event[], hashId: string): number {
return events.filter((event) => {
const tags = extractTagsFromEvent(event)
return tags.id === hashId
}).length
}

View File

@ -110,6 +110,11 @@ common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.error=Error
common.error.noContent=No content found
common.empty.articles=No articles found. Check back later!
common.empty.articles.filtered=No articles match your search or filters.
common.empty.authors=No authors found. Check back later!
common.empty.authors.filtered=No authors match your search or filters.
common.back=Back
common.open=Open

View File

@ -110,6 +110,11 @@ common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.error=Erreur
common.error.noContent=Aucun contenu trouvé
common.empty.articles=Aucun article trouvé. Revenez plus tard !
common.empty.articles.filtered=Aucun article ne correspond à votre recherche ou à vos filtres.
common.empty.authors=Aucun auteur trouvé. Revenez plus tard !
common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
common.back=Retour
common.open=Ouvrir

View File

@ -110,6 +110,11 @@ common.loading=Loading...
common.loading.articles=Loading articles...
common.loading.authors=Loading authors...
common.error=Error
common.error.noContent=No content found
common.empty.articles=No articles found. Check back later!
common.empty.articles.filtered=No articles match your search or filters.
common.empty.authors=No authors found. Check back later!
common.empty.authors.filtered=No authors match your search or filters.
common.back=Back
common.open=Open

View File

@ -110,6 +110,11 @@ common.loading=Chargement...
common.loading.articles=Chargement des articles...
common.loading.authors=Chargement des auteurs...
common.error=Erreur
common.error.noContent=Aucun contenu trouvé
common.empty.articles=Aucun article trouvé. Revenez plus tard !
common.empty.articles.filtered=Aucun article ne correspond à votre recherche ou à vos filtres.
common.empty.authors=Aucun auteur trouvé. Revenez plus tard !
common.empty.authors.filtered=Aucun auteur ne correspond à votre recherche ou à vos filtres.
common.back=Retour
common.open=Ouvrir