diff --git a/docs/tag-system-explanation.md b/docs/tag-system-explanation.md
new file mode 100644
index 0000000..73e7f34
--- /dev/null
+++ b/docs/tag-system-explanation.md
@@ -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", "
"]` : 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", ""] // ID unique
+["title", ""] // Titre de la présentation
+["preview", ""] // Aperçu (optionnel)
+["mainnet_address", ""] // Adresse Bitcoin mainnet pour le sponsoring
+["total_sponsoring", ""] // Total du sponsoring reçu (en sats)
+["picture", ""] // 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", ""] // ID unique
+["title", ""] // Titre de l'article
+["preview", ""] // Aperçu (optionnel)
+["series", ""] // ID de la série (optionnel)
+["banner", ""] // URL de la bannière (optionnel)
+["zap", ""] // Montant en sats pour débloquer
+["invoice", ""] // Facture BOLT11 (optionnel)
+["payment_hash", ""] // Hash du paiement (optionnel)
+["encrypted_key", ""] // Clé de chiffrement (optionnel)
+```
+
+#### Tags pour `#series` (série)
+
+```typescript
+["series"] // Type
+["sciencefiction"] ou ["research"] // Catégorie
+["id", ""] // ID unique
+["title", ""] // Titre de la série
+["description", ""] // Description de la série
+["preview", ""] // Aperçu (optionnel)
+["cover", ""] // URL de la couverture (optionnel)
+```
+
+#### Tags pour `#quote` (avis/review)
+
+```typescript
+["quote"] // Type
+["article", ""] // ID de l'article commenté
+["reviewer", ""] // Clé publique du reviewer (optionnel)
+["title", ""] // 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.
diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts
index c327d36..2214710 100644
--- a/hooks/useArticles.ts
+++ b/hooks/useArticles.ts
@@ -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([])
@@ -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)
diff --git a/lib/accessControl.ts b/lib/accessControl.ts
new file mode 100644
index 0000000..7d6b7fd
--- /dev/null
+++ b/lib/accessControl.ts
@@ -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,
+ }
+}
diff --git a/lib/articleInvoice.ts b/lib/articleInvoice.ts
index 2d197f9..286d7e6 100644
--- a/lib/articleInvoice.ts
+++ b/lib/articleInvoice.ts
@@ -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 {
+ 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 {
// 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,
diff --git a/lib/articleMutations.ts b/lib/articleMutations.ts
index c0e97aa..75d73c4 100644
--- a/lib/articleMutations.ts
+++ b/lib/articleMutations.ts
@@ -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 {
async function publishPreviewWithInvoice(
draft: ArticleDraft,
invoice: AlbyInvoice,
+ authorPubkey: string,
presentationId: string,
extraTags?: string[][]
): Promise {
- 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')
}
diff --git a/lib/articlePublisher.ts b/lib/articlePublisher.ts
index a452fed..6e17a72 100644
--- a/lib/articlePublisher.ts
+++ b/lib/articlePublisher.ts
@@ -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 ")
+ 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')
diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts
index f7ed475..67d45b5 100644
--- a/lib/articlePublisherHelpersPresentation.ts
+++ b/lib/articlePublisherHelpersPresentation.ts
@@ -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 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/__
+ 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: 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 } : {}),
+ })
-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({
- type: 'author',
- category,
- id: eventId,
- paywall: false,
- title: draft.title,
- preview: draft.preview,
- mainnetAddress: draft.mainnetAddress,
- totalSponsoring: 0,
- ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
- }),
- content: draft.content,
+ 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?.()
})
}
diff --git a/lib/articlePublisherPublish.ts b/lib/articlePublisherPublish.ts
index bb3826d..a875209 100644
--- a/lib/articlePublisherPublish.ts
+++ b/lib/articlePublisherPublish.ts
@@ -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 {
- 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')
diff --git a/lib/articleQueries.ts b/lib/articleQueries.ts
index 5db6056..1fb8ad7 100644
--- a/lib/articleQueries.ts
+++ b/lib/articleQueries.ts
@@ -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,
},
diff --git a/lib/duplicateDetector.ts b/lib/duplicateDetector.ts
new file mode 100644
index 0000000..2b8bfc6
--- /dev/null
+++ b/lib/duplicateDetector.ts
@@ -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 {
+ 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()
+ 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()
+ 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()
+ 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()
+
+ // 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
+}
diff --git a/lib/hashIdGenerator.ts b/lib/hashIdGenerator.ts
new file mode 100644
index 0000000..398ddef
--- /dev/null
+++ b/lib/hashIdGenerator.ts
@@ -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 {
+ // 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)}`)
+ } 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): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return generateHashId({
+ type: 'sponsoring',
+ payerPubkey: sponsoringData.payerPubkey,
+ authorPubkey: sponsoringData.authorPubkey,
+ seriesId: sponsoringData.seriesId ?? '',
+ articleId: sponsoringData.articleId ?? '',
+ amount: sponsoringData.amount,
+ paymentHash: sponsoringData.paymentHash,
+ })
+}
diff --git a/lib/keyManagement.ts b/lib/keyManagement.ts
index 7272744..ba1a43e 100644
--- a/lib/keyManagement.ts
+++ b/lib/keyManagement.ts
@@ -42,7 +42,7 @@ export class KeyManagementService {
if (decoded.type === 'nsec') {
// decoded.data can be string (hex) or Uint8Array depending on nostr-tools version
if (typeof decoded.data === 'string') {
- privateKeyHex = decoded.data
+ privateKeyHex = decoded.data
} else if (decoded.data instanceof Uint8Array) {
privateKeyHex = bytesToHex(decoded.data)
} else {
diff --git a/lib/metadataExtractor.ts b/lib/metadataExtractor.ts
new file mode 100644
index 0000000..a546f6e
--- /dev/null
+++ b/lib/metadataExtractor.ts
@@ -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 | 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/lib/nostr.ts b/lib/nostr.ts
index 9f7cd94..f734873 100644
--- a/lib/nostr.ts
+++ b/lib/nostr.ts
@@ -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)
}
diff --git a/lib/nostrTagSystemBuild.ts b/lib/nostrTagSystemBuild.ts
index a804c11..70d6a8c 100644
--- a/lib/nostrTagSystemBuild.ts
+++ b/lib/nostrTagSystemBuild.ts
@@ -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'])
}
diff --git a/lib/nostrTagSystemExtract.ts b/lib/nostrTagSystemExtract.ts
index ac2dbba..a948dcd 100644
--- a/lib/nostrTagSystemExtract.ts
+++ b/lib/nostrTagSystemExtract.ts
@@ -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
diff --git a/lib/nostrTagSystemFilter.ts b/lib/nostrTagSystemFilter.ts
index 0c716f9..8c8f336 100644
--- a/lib/nostrTagSystemFilter.ts
+++ b/lib/nostrTagSystemFilter.ts
@@ -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)
diff --git a/lib/nostrTagSystemTypes.ts b/lib/nostrTagSystemTypes.ts
index 6c5be2a..7af8f2e 100644
--- a/lib/nostrTagSystemTypes.ts
+++ b/lib/nostrTagSystemTypes.ts
@@ -4,6 +4,7 @@
* - #sciencefiction or #research: for category
* - #author, #series, #publication, #quote: for type
* - #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
}
diff --git a/lib/objectModification.ts b/lib/objectModification.ts
new file mode 100644
index 0000000..1e58bab
--- /dev/null
+++ b/lib/objectModification.ts
@@ -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,
+ userPubkey: string
+): Promise {
+ // 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 {
+ // 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
+}
diff --git a/lib/platformConfig.ts b/lib/platformConfig.ts
index 195447b..1067c1c 100644
--- a/lib/platformConfig.ts
+++ b/lib/platformConfig.ts
@@ -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 {
- 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'
diff --git a/lib/presentationParsing.ts b/lib/presentationParsing.ts
index 702395f..239f89d 100644
--- a/lib/presentationParsing.ts
+++ b/lib/presentationParsing.ts
@@ -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\n\nPrésentation personnelle : \nDescription de votre contenu : \nAdresse Bitcoin mainnet (pour le sponsoring) : \n\n---\n\n[Metadata JSON]\n"
+ * 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)
diff --git a/lib/reviews.ts b/lib/reviews.ts
index fd5f7ea..df64e0a 100644
--- a/lib/reviews.ts
+++ b/lib/reviews.ts
@@ -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: {
diff --git a/lib/seriesQueries.ts b/lib/seriesQueries.ts
index 6ac352a..ebf9e53 100644
--- a/lib/seriesQueries.ts
+++ b/lib/seriesQueries.ts
@@ -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,
}),
},
]
diff --git a/lib/sponsoring.ts b/lib/sponsoring.ts
index efb4fe8..cbaf890 100644
--- a/lib/sponsoring.ts
+++ b/lib/sponsoring.ts
@@ -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 {
const filters = [
@@ -9,6 +10,7 @@ function subscribeToPresentation(pool: import('nostr-tools').SimplePool, pubkey:
...buildTagFilter({
type: 'author',
authorPubkey: pubkey,
+ service: PLATFORM_SERVICE,
}),
limit: 1,
},
diff --git a/lib/urlGenerator.ts b/lib/urlGenerator.ts
new file mode 100644
index 0000000..89c0006
--- /dev/null
+++ b/lib/urlGenerator.ts
@@ -0,0 +1,56 @@
+/**
+ * Generate URLs for objects in the format:
+ * https://zapwall.fr//__
+ *
+ * - 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
+}
diff --git a/lib/versionManager.ts b/lib/versionManager.ts
new file mode 100644
index 0000000..17de34d
--- /dev/null
+++ b/lib/versionManager.ts
@@ -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()
+
+ 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
+}
diff --git a/locales/en.txt b/locales/en.txt
index 1d5b724..04d1a72 100644
--- a/locales/en.txt
+++ b/locales/en.txt
@@ -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
diff --git a/locales/fr.txt b/locales/fr.txt
index ee63983..fe44680 100644
--- a/locales/fr.txt
+++ b/locales/fr.txt
@@ -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
diff --git a/pages/api/nip95-upload.ts b/pages/api/nip95-upload.ts
index fb3aa8a..8daee32 100644
--- a/pages/api/nip95-upload.ts
+++ b/pages/api/nip95-upload.ts
@@ -72,15 +72,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Recreate FormData for each request (needed for redirects)
const requestFormData = new FormData()
- const fileStream = fs.createReadStream(fileField.filepath)
+ const fileStream = fs.createReadStream(fileField.filepath)
// Use 'file' as field name (standard for NIP-95, but some endpoints may use different names)
// Note: nostrimg.com might expect a different field name - if issues persist, try 'image' or 'upload'
const fieldName = 'file'
requestFormData.append(fieldName, fileStream, {
- filename: fileField.originalFilename || fileField.newFilename || 'upload',
- contentType: fileField.mimetype || 'application/octet-stream',
- })
+ filename: fileField.originalFilename || fileField.newFilename || 'upload',
+ contentType: fileField.mimetype || 'application/octet-stream',
+ })
const isHttps = url.protocol === 'https:'
const clientModule = isHttps ? https : http
@@ -99,12 +99,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (url.hostname.includes('nostrimg.com')) {
console.log('NIP-95 proxy request to nostrimg.com:', {
url: url.toString(),
- method: 'POST',
+ method: 'POST',
fieldName,
filename: fileField.originalFilename || fileField.newFilename || 'upload',
contentType: fileField.mimetype || 'application/octet-stream',
fileSize: fileField.size,
- headers: {
+ headers: {
'Content-Type': headers['content-type'],
'Accept': headers['Accept'],
'User-Agent': headers['User-Agent'],
@@ -178,7 +178,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
statusCode: statusCode,
statusMessage: proxyResponse.statusMessage || 'Internal Server Error',
body: body,
- })
+ })
})
proxyResponse.on('error', (error) => {
reject(error)
diff --git a/public/locales/en.txt b/public/locales/en.txt
index 1d5b724..04d1a72 100644
--- a/public/locales/en.txt
+++ b/public/locales/en.txt
@@ -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
diff --git a/public/locales/fr.txt b/public/locales/fr.txt
index ee63983..fe44680 100644
--- a/public/locales/fr.txt
+++ b/public/locales/fr.txt
@@ -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