@@ -130,3 +97,13 @@ export function ProfileView(props: ProfileViewProps) {
>
)
}
+
+function ProfileHead() {
+ return (
+
+
My Profile - zapwall4Science
+
+
+
+ )
+}
diff --git a/components/SeriesCard.tsx b/components/SeriesCard.tsx
new file mode 100644
index 0000000..624d7b7
--- /dev/null
+++ b/components/SeriesCard.tsx
@@ -0,0 +1,48 @@
+import Image from 'next/image'
+import Link from 'next/link'
+import type { Series } from '@/types/nostr'
+
+interface SeriesCardProps {
+ series: Series
+ onSelect: (seriesId: string | undefined) => void
+ selected?: boolean
+}
+
+export function SeriesCard({ series, onSelect, selected }: SeriesCardProps) {
+ return (
+
+ {series.coverUrl && (
+
+
+
+ )}
+
{series.title}
+
{series.description}
+
+ {series.category === 'science-fiction' ? 'Science-fiction' : 'Recherche scientifique'}
+ onSelect(series.id)}
+ >
+ Ouvrir
+
+
+
+
+ Voir la page de la série
+
+
+
+ )
+}
diff --git a/components/SeriesList.tsx b/components/SeriesList.tsx
new file mode 100644
index 0000000..376413c
--- /dev/null
+++ b/components/SeriesList.tsx
@@ -0,0 +1,21 @@
+import type { Series } from '@/types/nostr'
+import { SeriesCard } from './SeriesCard'
+
+interface SeriesListProps {
+ series: Series[]
+ onSelect: (seriesId: string | undefined) => void
+ selectedId?: string | undefined
+}
+
+export function SeriesList({ series, onSelect, selectedId }: SeriesListProps) {
+ if (series.length === 0) {
+ return
Aucune série pour cet auteur.
+ }
+ return (
+
+ {series.map((s) => (
+
+ ))}
+
+ )
+}
diff --git a/components/SeriesSection.tsx b/components/SeriesSection.tsx
new file mode 100644
index 0000000..97f096d
--- /dev/null
+++ b/components/SeriesSection.tsx
@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useState } from 'react'
+import type { Series } from '@/types/nostr'
+import { SeriesList } from './SeriesList'
+import { SeriesStats } from './SeriesStats'
+import { getSeriesByAuthor } from '@/lib/seriesQueries'
+import { getSeriesAggregates } from '@/lib/seriesAggregation'
+
+interface SeriesSectionProps {
+ authorPubkey: string
+ onSelect: (seriesId: string | undefined) => void
+ selectedId?: string | undefined
+}
+
+export function SeriesSection({ authorPubkey, onSelect, selectedId }: SeriesSectionProps) {
+ const [{ series, loading, error, aggregates }, load] = useSeriesData(authorPubkey)
+
+ if (loading) {
+ return
Chargement des séries...
+ }
+ if (error) {
+ return
{error}
+ }
+ return (
+
+
+
+
+
+ )
+}
+
+function SeriesControls({
+ onSelect,
+ onReload,
+}: {
+ onSelect: (id: string | undefined) => void
+ onReload: () => Promise
+}) {
+ return (
+
+ onSelect(undefined)}
+ >
+ Toutes les séries
+
+ {
+ void onReload()
+ }}
+ >
+ Recharger
+
+
+ )
+}
+
+function SeriesAggregatesList({
+ series,
+ aggregates,
+}: {
+ series: Series[]
+ aggregates: Record
+}) {
+ return (
+ <>
+ {series.map((s) => {
+ const agg = aggregates[s.id] ?? { sponsoring: 0, purchases: 0, reviewTips: 0 }
+ return (
+
+
+
+ )
+ })}
+ >
+ )
+}
+
+function useSeriesData(authorPubkey: string): [
+ {
+ series: Series[]
+ loading: boolean
+ error: string | null
+ aggregates: Record
+ },
+ () => Promise
+] {
+ const [series, setSeries] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [aggregates, setAggregates] = useState>({})
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const { items, aggregates: agg } = await fetchSeriesAndAggregates(authorPubkey)
+ setSeries(items)
+ setAggregates(agg)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Erreur lors du chargement des séries')
+ } finally {
+ setLoading(false)
+ }
+ }, [authorPubkey])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ return [{ series, loading, error, aggregates }, load]
+}
+
+async function fetchSeriesAndAggregates(authorPubkey: string): Promise<{
+ items: Series[]
+ aggregates: Record
+}> {
+ const items = await getSeriesByAuthor(authorPubkey)
+ const aggEntries = await Promise.all(
+ items.map(async (s) => {
+ const agg = await getSeriesAggregates({ authorPubkey, seriesId: s.id })
+ return [s.id, agg] as const
+ })
+ )
+ const aggMap: Record = {}
+ aggEntries.forEach(([id, agg]) => {
+ aggMap[id] = agg
+ })
+ return { items, aggregates: aggMap }
+}
diff --git a/components/SeriesStats.tsx b/components/SeriesStats.tsx
new file mode 100644
index 0000000..b95397c
--- /dev/null
+++ b/components/SeriesStats.tsx
@@ -0,0 +1,27 @@
+interface SeriesStatsProps {
+ sponsoring: number
+ purchases: number
+ reviewTips: number
+}
+
+function formatSats(value: number): string {
+ return `${value} sats`
+}
+
+export function SeriesStats({ sponsoring, purchases, reviewTips }: SeriesStatsProps) {
+ const items = [
+ { label: 'Sponsoring (hors frais)', value: formatSats(sponsoring) },
+ { label: 'Paiements articles (hors frais)', value: formatSats(purchases) },
+ { label: 'Remerciements critiques (hors frais)', value: formatSats(reviewTips) },
+ ]
+ return (
+
+ {items.map((item) => (
+
+
{item.label}
+
{item.value}
+
+ ))}
+
+ )
+}
diff --git a/components/UserArticles.tsx b/components/UserArticles.tsx
index 24a7548..3a66216 100644
--- a/components/UserArticles.tsx
+++ b/components/UserArticles.tsx
@@ -1,6 +1,8 @@
-import { useState } from 'react'
-import { ArticleCard } from './ArticleCard'
+import { useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import type { Article } from '@/types/nostr'
+import { useArticleEditing } from '@/hooks/useArticleEditing'
+import { UserArticlesView } from './UserArticlesList'
+import { EditPanel } from './UserArticlesEditPanel'
interface UserArticlesProps {
articles: Article[]
@@ -8,67 +10,8 @@ interface UserArticlesProps {
error: string | null
onLoadContent: (articleId: string, authorPubkey: string) => Promise
showEmptyMessage?: boolean
-}
-
-function ArticlesLoading() {
- return (
-
- )
-}
-
-function ArticlesError({ message }: { message: string }) {
- return (
-
- )
-}
-
-function EmptyState({ show }: { show: boolean }) {
- if (!show) {
- return null
- }
- return (
-
-
No articles published yet.
-
- )
-}
-
-function UserArticlesView({
- articles,
- loading,
- error,
- showEmptyMessage,
- unlockedArticles,
- onUnlock,
-}: Omit & { unlockedArticles: Set; onUnlock: (article: Article) => void }) {
- if (loading) {
- return
- }
- if (error) {
- return
- }
- if (articles.length === 0) {
- return
- }
-
- return (
-
- {articles.map((article) => (
-
- ))}
-
- )
+ currentPubkey: string | null
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
}
export function UserArticles({
@@ -77,27 +20,205 @@ export function UserArticles({
error,
onLoadContent,
showEmptyMessage = true,
+ currentPubkey,
+ onSelectSeries,
}: UserArticlesProps) {
- const [unlockedArticles, setUnlockedArticles] = useState>(new Set())
-
- const handleUnlock = async (article: Article) => {
- const fullArticle = await onLoadContent(article.id, article.pubkey)
- if (fullArticle?.paid) {
- setUnlockedArticles((prev) => new Set([...prev, article.id]))
- }
- }
-
+ const controller = useUserArticlesController({ articles, onLoadContent, currentPubkey })
return (
- {
- void handleUnlock(a)
- }}
+ showEmptyMessage={showEmptyMessage ?? true}
+ currentPubkey={currentPubkey}
+ onSelectSeries={onSelectSeries}
/>
)
}
+
+function useUserArticlesController({
+ articles,
+ onLoadContent,
+ currentPubkey,
+}: {
+ articles: Article[]
+ onLoadContent: (articleId: string, authorPubkey: string) => Promise
+ currentPubkey: string | null
+}) {
+ const [localArticles, setLocalArticles] = useState(articles)
+ const [unlockedArticles, setUnlockedArticles] = useState>(new Set())
+ const [pendingDeleteId, setPendingDeleteId] = useState(null)
+ const editingCtx = useArticleEditing(currentPubkey)
+
+ useEffect(() => setLocalArticles(articles), [articles])
+
+ return {
+ localArticles,
+ unlockedArticles,
+ pendingDeleteId,
+ requestDelete: (id: string) => setPendingDeleteId(id),
+ handleUnlock: createHandleUnlock(onLoadContent, setUnlockedArticles),
+ handleDelete: createHandleDelete(editingCtx.deleteArticle, setLocalArticles, setPendingDeleteId),
+ handleEditSubmit: createHandleEditSubmit(
+ editingCtx.submitEdit,
+ editingCtx.editingDraft,
+ currentPubkey,
+ setLocalArticles
+ ),
+ ...editingCtx,
+ }
+}
+
+function createHandleUnlock(
+ onLoadContent: (id: string, pubkey: string) => Promise,
+ setUnlocked: Dispatch>>
+) {
+ return async (article: Article) => {
+ const full = await onLoadContent(article.id, article.pubkey)
+ if (full?.paid) {
+ setUnlocked((prev) => new Set([...prev, article.id]))
+ }
+ }
+}
+
+function createHandleDelete(
+ deleteArticle: (id: string) => Promise,
+ setLocalArticles: Dispatch>,
+ setPendingDeleteId: Dispatch>
+) {
+ return async (article: Article) => {
+ const ok = await deleteArticle(article.id)
+ if (ok) {
+ setLocalArticles((prev) => prev.filter((a) => a.id !== article.id))
+ }
+ setPendingDeleteId(null)
+ }
+}
+
+function createHandleEditSubmit(
+ submitEdit: () => Promise,
+ draft: ReturnType['editingDraft'],
+ currentPubkey: string | null,
+ setLocalArticles: Dispatch>
+) {
+ return async () => {
+ const result = await submitEdit()
+ if (result && draft) {
+ const updated = buildUpdatedArticle(draft, currentPubkey ?? '', result.articleId)
+ setLocalArticles((prev) => {
+ const filtered = prev.filter((a) => a.id !== result.originalArticleId)
+ return [updated, ...filtered]
+ })
+ }
+ }
+}
+
+function buildUpdatedArticle(
+ draft: NonNullable['editingDraft']>,
+ pubkey: string,
+ newId: string
+): Article {
+ return {
+ id: newId,
+ pubkey,
+ title: draft.title,
+ preview: draft.preview,
+ content: '',
+ createdAt: Math.floor(Date.now() / 1000),
+ zapAmount: draft.zapAmount,
+ paid: false,
+ ...(draft.category ? { category: draft.category } : {}),
+ }
+}
+
+function UserArticlesLayout({
+ controller,
+ loading,
+ error,
+ showEmptyMessage,
+ currentPubkey,
+ onSelectSeries,
+}: {
+ controller: ReturnType
+ loading: boolean
+ error: string | null
+ showEmptyMessage: boolean
+ currentPubkey: string | null
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+}) {
+ const { editPanelProps, listProps } = createLayoutProps(controller, {
+ loading,
+ error,
+ showEmptyMessage,
+ currentPubkey,
+ onSelectSeries,
+ })
+
+ return (
+
+
+
+
+ )
+}
+
+function createLayoutProps(
+ controller: ReturnType,
+ view: {
+ loading: boolean
+ error: string | null
+ showEmptyMessage: boolean
+ currentPubkey: string | null
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+ }
+) {
+ return {
+ editPanelProps: buildEditPanelProps(controller),
+ listProps: buildListProps(controller, view),
+ }
+}
+
+function buildEditPanelProps(controller: ReturnType) {
+ return {
+ draft: controller.editingDraft,
+ editingArticleId: controller.editingArticleId,
+ loading: controller.loading,
+ error: controller.error,
+ onCancel: controller.cancelEditing,
+ onDraftChange: controller.updateDraft,
+ onSubmit: controller.handleEditSubmit,
+ }
+}
+
+function buildListProps(
+ controller: ReturnType,
+ view: {
+ loading: boolean
+ error: string | null
+ showEmptyMessage: boolean
+ currentPubkey: string | null
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+ }
+) {
+ return {
+ articles: controller.localArticles,
+ loading: view.loading,
+ error: view.error,
+ showEmptyMessage: view.showEmptyMessage,
+ unlockedArticles: controller.unlockedArticles,
+ onUnlock: (a: Article) => {
+ void controller.handleUnlock(a)
+ },
+ onEdit: (a: Article) => {
+ void controller.startEditing(a)
+ },
+ onDelete: (a: Article) => {
+ void controller.handleDelete(a)
+ },
+ editingArticleId: controller.editingArticleId,
+ currentPubkey: view.currentPubkey,
+ pendingDeleteId: controller.pendingDeleteId,
+ requestDelete: controller.requestDelete,
+ ...(view.onSelectSeries ? { onSelectSeries: view.onSelectSeries } : {}),
+ }
+}
diff --git a/components/UserArticlesEditPanel.tsx b/components/UserArticlesEditPanel.tsx
new file mode 100644
index 0000000..c9662f3
--- /dev/null
+++ b/components/UserArticlesEditPanel.tsx
@@ -0,0 +1,42 @@
+import { ArticleEditorForm } from './ArticleEditorForm'
+import type { ArticleDraft } from '@/lib/articlePublisher'
+
+interface EditPanelProps {
+ draft: ArticleDraft | null
+ editingArticleId: string | null
+ loading: boolean
+ error: string | null
+ onCancel: () => void
+ onDraftChange: (draft: ArticleDraft) => void
+ onSubmit: () => void
+}
+
+export function EditPanel({
+ draft,
+ editingArticleId,
+ loading,
+ error,
+ onCancel,
+ onDraftChange,
+ onSubmit,
+}: EditPanelProps) {
+ if (!draft || !editingArticleId) {
+ return null
+ }
+ return (
+
+
Edit article
+
{
+ e.preventDefault()
+ onSubmit()
+ }}
+ loading={loading}
+ error={error}
+ onCancel={onCancel}
+ />
+
+ )
+}
diff --git a/components/UserArticlesList.tsx b/components/UserArticlesList.tsx
new file mode 100644
index 0000000..2d13519
--- /dev/null
+++ b/components/UserArticlesList.tsx
@@ -0,0 +1,199 @@
+import { ArticleCard } from './ArticleCard'
+import type { Article } from '@/types/nostr'
+import { memo } from 'react'
+import Link from 'next/link'
+
+interface UserArticlesViewProps {
+ articles: Article[]
+ loading: boolean
+ error: string | null
+ showEmptyMessage?: boolean
+ unlockedArticles: Set
+ onUnlock: (article: Article) => void
+ onEdit: (article: Article) => void
+ onDelete: (article: Article) => void
+ editingArticleId: string | null
+ currentPubkey: string | null
+ pendingDeleteId: string | null
+ requestDelete: (articleId: string) => void
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+}
+
+const ArticlesLoading = () => (
+
+)
+
+const ArticlesError = ({ message }: { message: string }) => (
+
+)
+
+const EmptyState = ({ show }: { show: boolean }) =>
+ show ? (
+
+
No articles published yet.
+
+ ) : null
+
+function ArticleActions({
+ article,
+ onEdit,
+ onDelete,
+ editingArticleId,
+ pendingDeleteId,
+ requestDelete,
+}: {
+ article: Article
+ onEdit: (article: Article) => void
+ onDelete: (article: Article) => void
+ editingArticleId: string | null
+ pendingDeleteId: string | null
+ requestDelete: (articleId: string) => void
+}) {
+ return (
+
+ onEdit(article)}
+ className="px-3 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
+ disabled={editingArticleId !== null && editingArticleId !== article.id}
+ >
+ Edit
+
+ (pendingDeleteId === article.id ? onDelete(article) : requestDelete(article.id))}
+ className="px-3 py-1 text-sm rounded bg-red-600 text-white hover:bg-red-700"
+ >
+ {pendingDeleteId === article.id ? 'Confirm delete' : 'Delete'}
+
+
+ )
+}
+
+function ArticleRow(
+ props: Omit & {
+ article: Article
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+ }
+) {
+ const content = buildArticleContent(props)
+ return {content}
+}
+
+function buildArticleContent(
+ props: Omit & {
+ article: Article
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+ }
+) {
+ const parts = [buildArticleCard(props), buildSeriesLink(props), buildActions(props)].filter(Boolean)
+ return parts as JSX.Element[]
+}
+
+function buildArticleCard(
+ props: Omit & { article: Article }
+) {
+ const { article, unlockedArticles, onUnlock } = props
+ return (
+
+ )
+}
+
+function buildSeriesLink(
+ props: Omit & {
+ article: Article
+ onSelectSeries?: ((seriesId: string | undefined) => void) | undefined
+ }
+) {
+ const { article, onSelectSeries } = props
+ if (!article.seriesId) {
+ return null
+ }
+ return (
+
+ Série :
+
+ Ouvrir
+
+ {onSelectSeries && (
+ onSelectSeries(article.seriesId)}>
+ Filtrer
+
+ )}
+
+ )
+}
+
+function buildActions(
+ props: Omit & { article: Article }
+) {
+ const { article, currentPubkey, onEdit, onDelete, editingArticleId, pendingDeleteId, requestDelete } = props
+ if (currentPubkey !== article.pubkey) {
+ return null
+ }
+ return (
+
+ )
+}
+
+function UserArticlesViewComponent(props: UserArticlesViewProps) {
+ if (props.loading) {
+ return
+ }
+ if (props.error) {
+ return
+ }
+ if ((props.showEmptyMessage ?? true) && props.articles.length === 0) {
+ return
+ }
+ return renderArticles(props)
+}
+
+function renderArticles({
+ articles,
+ unlockedArticles,
+ onUnlock,
+ onEdit,
+ onDelete,
+ editingArticleId,
+ currentPubkey,
+ pendingDeleteId,
+ requestDelete,
+ onSelectSeries,
+}: UserArticlesViewProps) {
+ return (
+
+ {articles.map((article) => (
+
+ ))}
+
+ )
+}
+
+export const UserArticlesView = memo(UserArticlesViewComponent)
diff --git a/components/UserProfile.tsx b/components/UserProfile.tsx
index 4befa41..339f8a3 100644
--- a/components/UserProfile.tsx
+++ b/components/UserProfile.tsx
@@ -25,8 +25,8 @@ export function UserProfile({ profile, pubkey, articleCount }: UserProfileProps)
{profile.about && {profile.about}
}
{articleCount !== undefined && }
diff --git a/features/article-edit-delete.md b/features/article-edit-delete.md
new file mode 100644
index 0000000..056d6ea
--- /dev/null
+++ b/features/article-edit-delete.md
@@ -0,0 +1,22 @@
+# Article edit/delete via Nostr events
+
+**Objectif**
+Permettre aux auteurs d’éditer ou supprimer leurs articles en publiant des événements Nostr dédiés (update + delete), avec confirmation explicite côté UI.
+
+**Impacts**
+- Parcours auteur : édition depuis la liste de mes articles, suppression confirmée avant envoi de l’événement kind 5.
+- Stockage local : contenu privé ré-encrypté et ré-enregistré pour les mises à jour.
+- Pas d’impact côté lecteurs (pas de fallback).
+
+**Modifications**
+- `lib/articleMutations.ts` : publication update/delete (tags e, replace), réutilisation du stockage chiffré.
+- `components/UserArticles.tsx`, `components/UserArticlesList.tsx`, `components/UserArticlesEditPanel.tsx` : UI édition/suppression avec confirmation, découpage pour respecter lint/max-lines.
+- `lib/articleInvoice.ts` : factorisation des tags de preview.
+
+**Modalités de déploiement**
+Standard front : build Next.js habituel. Pas de migrations ni dépendances supplémentaires.
+
+**Modalités d’analyse**
+- Vérifier qu’un auteur connecté peut éditer puis voir son article mis à jour dans la liste.
+- Vérifier que la suppression publie l’événement et retire l’article de la liste locale.
+- Sur erreur de publication, message d’erreur affiché (aucun fallback silencieux).
diff --git a/features/documentation-plan.md b/features/documentation-plan.md
new file mode 100644
index 0000000..6952737
--- /dev/null
+++ b/features/documentation-plan.md
@@ -0,0 +1,24 @@
+# Documentation technique à compléter
+
+## Objectif
+Formaliser la structure technique (services, hooks, types) et le flux Nostr/stockage, sans ajouter de tests ni d’analytics.
+
+## Cibles
+- Services Nostr : `lib/nostr.ts`, `lib/nostrRemoteSigner.ts`, `lib/articleMutations.ts`, `lib/zapVerification.ts`, `lib/nostrconnect.ts`.
+- Paiement/Alby/WebLN : `lib/alby.ts`, `lib/payment.ts`, `lib/paymentPolling.ts`.
+- Stockage : `lib/storage/indexedDB.ts`, `lib/storage/cryptoHelpers.ts`, `lib/articleStorage.ts`.
+- Hooks : `hooks/useArticles.ts`, `hooks/useUserArticles.ts`, `hooks/useArticleEditing.ts`.
+- Types : `types/nostr.ts`, `types/nostr-tools-extended.ts`, `types/alby.ts`.
+- UI clés : `components/UserArticles*.tsx`, `components/ArticleEditor*.tsx`, `components/AlbyInstaller.tsx`.
+
+## Plan
+1) Cartographie des services/hooks/types (diagramme ou tableau : responsabilités, entrées/sorties, dépendances).
+2) Guide Nostr : publication, update/delete, zap verification, remote signer.
+3) Guide stockage : chiffrement IndexedDB, gestion des expirations.
+4) Guide paiements : création facture, polling, envoi contenu privé.
+5) Contrib : référencer dans `CONTRIBUTING.md`.
+
+## Contraintes
+- Pas de tests, pas d’analytics.
+- Pas de fallback implicite; erreurs loguées et surfacées.
+- Respect lint/typage/accessibilité.
diff --git a/features/final-cleanup-summary.md b/features/final-cleanup-summary.md
index 683a391..e003c98 100644
--- a/features/final-cleanup-summary.md
+++ b/features/final-cleanup-summary.md
@@ -1,6 +1,6 @@
# Résumé final du nettoyage et optimisation
-**Date** : Décembre 2024
+**Date** : Décembre 2025 (addendum)
## ✅ Objectifs complétés
@@ -28,9 +28,16 @@ Toutes les fonctions longues ont été extraites dans des modules dédiés :
- Handler NostrConnect → `nostrconnectHandler.ts`
### 4. Correction des erreurs de lint
-- ✅ Aucune erreur de lint dans le code TypeScript
+- ✅ Aucune erreur de lint dans le code TypeScript (déc. 2025 : `npm run lint` OK)
- ✅ Code propre et optimisé
+## Addendum Déc 2025
+- Séries, critiques, agrégations zap : nouvelles sections UI/logic (`Series*`, `ArticleReviews`, `zapAggregation*`).
+- Upload médias NIP-95 (images/vidéos) avec validations de taille et type.
+- Stockage contenu privé chiffré en IndexedDB + helpers WebCrypto.
+- Respect strict `exactOptionalPropertyTypes`, fonctions < 40 lignes, fichiers < 250 lignes (refactors composants profil/articles, sélecteurs de séries).
+- Pas de tests ajoutés, pas d’analytics.
+
## Nouveaux fichiers créés (9 fichiers)
1. **`lib/nostrEventParsing.ts`** (40 lignes)
diff --git a/features/implementation-summary.md b/features/implementation-summary.md
index 457d771..d21fd5a 100644
--- a/features/implementation-summary.md
+++ b/features/implementation-summary.md
@@ -1,6 +1,17 @@
# Résumé des implémentations - Nostr Paywall
-**Date** : Décembre 2024
+**Date** : Décembre 2025 (mise à jour)
+
+## ✅ Mises à jour Déc 2025
+
+- **Séries et critiques** : pages série dédiées (`pages/series/[id].tsx`), agrégats sponsoring/achats/remerciements, filtrage par série sur le profil, affichage des critiques avec formatage auteur/date et total des remerciements.
+- **NIP-95 médias** : upload images (≤5Mo PNG/JPG/JPEG/WebP) et vidéos (≤45Mo MP4/WebM) via `lib/nip95.ts`, insertion dans markdown et bannière.
+- **Agrégations zap** : `lib/zapAggregation.ts`, `lib/seriesAggregation.ts`, `lib/reviewAggregation.ts` pour cumuls sponsoring/achats/remerciements par auteur/série/article.
+- **Tags Nostr enrichis** : `lib/nostrTags.ts`, `lib/articlePublisher.ts`, `lib/articleMutations.ts` intègrent `kind_type`, `seriesId`, bannières, médias, catégories, site/type (science-fiction/research).
+- **Hooks et subscriptions** : `useArticles` / `useUserArticles` corrigent la gestion des unsub synchro (pas de `.then`), pas de fallback.
+- **Stockage privé** : contenu chiffré en IndexedDB (WebCrypto AES-GCM) via `lib/storage/indexedDB.ts` + helpers `lib/storage/cryptoHelpers.ts`.
+- **Qualité stricte** : `exactOptionalPropertyTypes` respecté (props optionnelles typées `| undefined`), fonctions < 40 lignes et fichiers < 250 lignes (refactors multiples).
+- **Navigation** : liens directs vers page série depuis cartes et liste d’articles, filtrage par série depuis la liste utilisateur.
## ✅ Implémentations complétées
@@ -112,8 +123,8 @@
### Technologies utilisées
- **Frontend** : Next.js 14, React, TypeScript, Tailwind CSS
-- **Nostr** : `nostr-tools` (v2.3.4)
-- **Lightning** : Alby/WebLN (`@getalby/sdk`)
+- **Nostr** : `nostr-tools` 1.17.0 (compat `signEvent`, `verifyEvent`)
+- **Lightning** : Alby/WebLN
- **QR Code** : `react-qr-code`
## Fichiers créés/modifiés
diff --git a/features/notifications-scope.md b/features/notifications-scope.md
new file mode 100644
index 0000000..b1ff588
--- /dev/null
+++ b/features/notifications-scope.md
@@ -0,0 +1,14 @@
+# Notifications scope (Dec 2025)
+
+**Decision:** Do not implement notifications for mentions, reposts, or likes. Only payment notifications remain active. If comment notifications are later requested, they must be explicitly approved.
+
+**Impacts:**
+- No parsing/subscription for mention/repost/like events.
+- UI does not surface badges or panels for these types.
+- Avoids extra relay traffic and parsing logic.
+
+**Constraints/quality:**
+- No fallbacks.
+- No analytics.
+- Keep error logging structured if new notification types are ever added.
+- Respect accessibility and lint/typing rules already in place.
diff --git a/features/series-and-media-spec.md b/features/series-and-media-spec.md
new file mode 100644
index 0000000..4adc844
--- /dev/null
+++ b/features/series-and-media-spec.md
@@ -0,0 +1,69 @@
+# Séries, média NIP-95 et événements Nostr (spec v1, Jan 2026)
+
+## 1) Événements et tags (rien en local)
+
+Namespace tag communs (tous les events) :
+- `site`: `zapwall4science`
+- `category`: `science-fiction` | `scientific-research`
+- `author`: pubkey auteur
+- `series`: id série (event id de la série)
+- `article`: id article (event id de l’article)
+
+Kinds proposés (réutilisation kind 1 pour compat) :
+- Série : kind `1` avec tag `kind_type: series`
+ - tags : `site`, `category`, `author`, `series` (self id), `title`, `description`, `cover` (URL NIP-95), `preview`
+- Article : kind `1` avec tag `kind_type: article`
+ - tags : `site`, `category`, `author`, `series`, `article` (self id), `title`, `preview`, `banner` (URL NIP-95), `media` (0..n URLs NIP-95)
+ - contenu markdown public (preview) ; privé chiffré inchangé côté storage
+- Avis (critique) : kind `1` avec tag `kind_type: review`
+ - tags : `site`, `category`, `author`, `series`, `article`, `reviewer` (pubkey), `title`, `created_at`
+ - contenu = avis en clair
+- Achat article (zap receipt) : kind `9735` (zap) avec tags standard `p`, `e`, plus `site`, `category`, `author`, `series`, `article`, `kind_type: purchase`
+ - amount = millisats, hors frais site gérés off-chain
+- Paiement remerciement avis : kind `9735` zap avec `kind_type: review_tip`, tags `site`, `category`, `author`, `series`, `article`, `reviewer`, `review_id`
+- Paiement sponsoring : kind `9735` zap avec `kind_type: sponsoring`, tags `site`, `category`, `author`, `series` (optionnel), `article` (présentation si ciblé)
+
+Notes :
+- Tous les cumuls (sponsoring, paiements article, remerciements avis) calculés via zap receipts filtrés par `kind_type`.
+- Séries sans sponsoring autorisées (0).
+
+## 2) Média NIP-95 (images/vidéos)
+
+- Upload via NIP-95 (encrypted file events). Contraintes :
+ - Images/photos : max 5 Mo, png/jpg/jpeg/webp.
+ - Vidéos : max 45 Mo.
+- Stockage chiffré (même logique qu’articles) ; URL NIP-95 insérée dans markdown et bannière.
+- Validation côté client : type MIME, taille, échec → erreur surfacée (pas de fallback).
+
+## 3) Pages / navigation
+
+Hiérarchie : site → catégorie (SF/Recherche) → auteurs → auteur → série → articles → article.
+
+- Page auteur : liste des séries (cover type “livre”, titre, desc, preview, cumul sponsoring/paiements agrégés via zap receipts). Profil Nostr affiché.
+- Page série : détails série (cover, desc, preview), cumul sponsoring série + paiements articles de la série, liste d’articles de la série.
+- Article : preview public, contenu privé chiffré inchangé, bannière NIP-95, média insérés dans markdown.
+- Rédaction : éditeur markdown + preview live, upload/paste NIP-95 pour images/vidéos, champs bannière (URL NIP-95), sélection série.
+
+## 4) Agrégations financières (hors frais/site)
+
+- Sponsoring : zap receipts `kind_type: sponsoring`, filtres `site`, `author`, option `series`.
+- Paiements articles : zap receipts `kind_type: purchase`.
+- Remerciements avis : zap receipts `kind_type: review_tip`.
+- Cumuls par auteur et par série ; pas de détail de lecteurs (sauf auteur du zap pour avis si nécessaire au wording minimal).
+
+## 5) Wording “critiques”
+
+- Affichage des avis en tant que “critiques”.
+- Liste des critiques : afficher contenu + auteur (pubkey→profil) ; pas de liste distincte “critiques” séparée des avis (juste les avis).
+
+## 6) TODO d’implémentation (proposé)
+
+- Types : étendre `types/nostr.ts` avec Series, Review, media refs; enum `KindType`.
+- Upload NIP-95 : service dédié (validation taille/type, retour URL).
+- Publisher : ajouter création série (event), article avec tags série/media/banner.
+- Parsing : `nostrEventParsing` pour séries/articles/avis avec tags `kind_type`.
+- Aggregation : service zap pour cumuls (sponsoring/purchase/review_tip) par auteur/série.
+- UI :
+ - Form auteur/série/article (cover/banner, sélection série, markdown+preview, upload media).
+ - Pages auteur/série avec stats cumulées.
+- Pas de stockage local pour méta (tout via events).
diff --git a/features/storage-encryption.md b/features/storage-encryption.md
new file mode 100644
index 0000000..a578e99
--- /dev/null
+++ b/features/storage-encryption.md
@@ -0,0 +1,23 @@
+# Storage encryption (IndexedDB) – Dec 2025
+
+**Scope**
+- Encrypt private article content and invoices stored in IndexedDB using Web Crypto (AES-GCM).
+- Deterministic per-article secret derived from a persisted master key.
+- No fallbacks; fails if IndexedDB or Web Crypto is unavailable.
+
+**Key management**
+- Master key generated once in browser (`article_storage_master_key`, random 32 bytes, base64) and kept in localStorage.
+- Per-article secret: `:` (used only client-side).
+
+**Implementation**
+- `lib/storage/cryptoHelpers.ts`: AES-GCM helpers (base64 encode/decode, encrypt/decrypt).
+- `lib/storage/indexedDB.ts`: store/get now require a secret; payloads encrypted; unchanged API surface via `storageService`.
+- `lib/articleStorage.ts`: derives per-article secret, encrypts content+invoice on write, decrypts on read, same expiration (30 days).
+
+**Behavior**
+- If IndexedDB or crypto is unavailable, operations throw (no silent fallback).
+- Existing data written before encryption won’t decrypt; new writes are encrypted.
+
+**Next steps (optional)**
+- Rotate master key with migration plan.
+- Add per-user secrets or hardware-bound keys if required.
diff --git a/features/technical-doc.md b/features/technical-doc.md
new file mode 100644
index 0000000..1ea0cb9
--- /dev/null
+++ b/features/technical-doc.md
@@ -0,0 +1,71 @@
+# Documentation technique – zapwall4science (Dec 2025)
+
+## 1) Cartographie services/hooks/types (responsabilités, dépendances)
+
+- **Services Nostr**
+ - `lib/nostr.ts` : pool SimplePool, publish générique, clés locales; queries article.
+ - `lib/nostrEventParsing.ts` : parsing events → Article/Series/Review (tags kind_type, series, media, banner).
+ - `lib/nostrTags.ts` : construction des tags article/série/avis (site, type science-fiction/research, kind_type, media/bannière).
+ - `lib/seriesQueries.ts`, `lib/articleQueries.ts`, `lib/reviews.ts` : fetch séries, articles par série, critiques.
+ - `lib/articlePublisher.ts`, `lib/articleMutations.ts` : publication article/preview/presentation, update/delete, kind_type, séries, médias, bannière.
+ - `lib/seriesAggregation.ts`, `lib/reviewAggregation.ts`, `lib/zapAggregation.ts` : agrégats sponsoring/achats/remerciements.
+ - `lib/zapVerification.ts` : vérification zap receipts (verifyEvent).
+ - `lib/nostrconnect.ts` : Nostr Connect (handler, sécurité).
+ - `lib/nostrRemoteSigner.ts` : signature distante (pas de fallback silencieux).
+ - `lib/nip95.ts` : upload médias NIP-95 (images ≤5Mo, vidéos ≤45Mo, types restreints).
+- **Paiement / Lightning**
+ - `lib/alby.ts` : WebLN (enable, makeInvoice, sendPayment).
+ - `lib/payment.ts`, `lib/paymentPolling.ts` : statut paiements, polling.
+ - `lib/articleInvoice.ts` : facture article (zapAmount, expiry), tags preview.
+- **Stockage**
+ - `lib/storage/cryptoHelpers.ts` : AES-GCM, dérivation/import clé, conversions b64.
+ - `lib/storage/indexedDB.ts` : set/get/delete/clearExpired chiffré, TTL.
+ - `lib/articleStorage.ts` : clé maître locale (base64), secret par article, persistance contenu privé + facture.
+- **Hooks**
+ - `hooks/useArticles.ts`, `hooks/useUserArticles.ts` : subscriptions articles, filtres catégorie, unsubscribe direct (pas de .then).
+ - `hooks/useArticleEditing.ts` : édition/suppression article.
+ - Autres : `useNotificationCenter`, `useNostrConnect`, etc.
+- **Types**
+ - `types/nostr.ts` : Article, Series, Review, MediaRef, KindType, catégories; exactOptionalPropertyTypes respecté.
+ - `types/nostr-tools-extended.ts` : extensions SimplePool/Sub.
+ - `types/alby.ts` : WebLNProvider, invoice types.
+
+## 2) Guide Nostr (publication, update/delete, zap, remote signer)
+
+- Publication générique : `nostrService.publishEvent(eventTemplate)` (signEvent, created_at contrôlé).
+- Article : `articlePublisher.publishArticle` (preview + tags kind_type/site/category/series/banner/media), contenu privé chiffré stocké.
+- Update article : `publishArticleUpdate` (`lib/articleMutations.ts`) tags `e` (original) + `replace`, publie preview + contenu privé chiffré.
+- Delete article : `deleteArticleEvent` (kind 5, tag e) — erreurs remontées, pas de fallback.
+- Série : `publishSeries` (kind 1, tags kind_type=series, cover, description).
+- Avis : `publishReview` (kind 1, kind_type=review, article/series/author tags).
+- Zap verification : `lib/zapVerification.ts` (verifyEvent).
+- Agrégats : `aggregateZapSats` / `getSeriesAggregates` / `getReviewTipsForArticle`.
+- Remote signer : `lib/nostrRemoteSigner.ts` (clé fournie), sans valeurs de repli implicites.
+
+## 3) Guide Stockage
+
+- Chiffrement : `cryptoHelpers` (AES-GCM), entrée BufferSource conforme Web Crypto.
+- IndexedDB : `storage/indexedDB.ts` stocke `{ iv, ciphertext, expiresAt }`, purge `clearExpired`.
+- Secret : `articleStorage` génère clé maître (localStorage, base64, 32 bytes) puis secret `:`.
+- Données : contenu privé + facture, TTL 30 jours, suppression via `removeStoredPrivateContent`.
+
+## 4) Guide Paiements
+
+- WebLN/Alby : `lib/alby.ts` (enable, makeInvoice, sendPayment).
+- Facture article : `createArticleInvoice` (zapAmount, expiry).
+- Publication article : `articlePublisher` gère preview event + stockage contenu privé.
+- Polling / vérif : `paymentPolling.ts`, `payment.ts`; agrégats via zap receipts (`zapAggregation.ts`).
+- Envoi contenu privé : via publisher/mutations après paiement confirmé.
+
+## 5) Médias / NIP-95
+
+- Upload via `uploadMedia(file)` (URL `NEXT_PUBLIC_NIP95_UPLOAD_URL`), validation type/taille.
+- Utilisation dans `MarkdownEditor` (insertion markdown), bannière dans ArticleDraft.
+
+## 6) Contrib (référence rapide)
+
+- Lint/typage obligatoires (`npm run lint`, `npm run type-check`).
+- Pas de tests/analytics ajoutés.
+- Pas de fallback implicite, pas de `ts-ignore`/`any` non justifié.
+- Accessibilité : ARIA/clavier/contraste.
+- Documentation : corrections dans `fixKnowledge/`, features dans `features/`.
diff --git a/fixKnowledge/2025-12-22-lint-type-fixes.md b/fixKnowledge/2025-12-22-lint-type-fixes.md
new file mode 100644
index 0000000..aa4bd9e
--- /dev/null
+++ b/fixKnowledge/2025-12-22-lint-type-fixes.md
@@ -0,0 +1,20 @@
+# Lint & type fixes (exactOptionalPropertyTypes)
+
+**Problem:** TypeScript strict optional props and nostr-tools API differences caused `npm run type-check` and `next lint` to fail.
+
+**Impact:** Build blocked (exact optional props errors, nostr-tools signing types), lint blocked (`max-lines-per-function`, `prefer-const`).
+
+**Root cause:** Code was written against older nostr-tools typings and passed `undefined` into optional props under `exactOptionalPropertyTypes`.
+
+**Corrections (code):**
+- Normalized optional props handling (conditional spreads) across editor, fields, notifications, storage, markdown rendering.
+- Added safe category handling for article drafts; tightened defaults and guards.
+- Aligned signing with nostr-tools 1.17.0 (explicit `pubkey`/`created_at`, hash/sign helpers).
+- Fixed async return contracts, removed unused params, and satisfied lint structure rules.
+- Kept storage and notification payloads free of `undefined` fields; guarded link rendering.
+
+**Modifications (files):** `components/ArticleEditor*.tsx`, `components/ArticleField.tsx`, `components/UserArticles.tsx`, `components/CategorySelect.tsx`, `lib/{nostr,nostrRemoteSigner,nostrconnect,articlePublisher,articleStorage,markdownRenderer,notifications,zapVerification}.ts`, `hooks/useArticles.ts`, `hooks/useUserArticles.ts`, `types/nostr-tools-extended.ts`, `package.json` (nostr-tools 1.17.0).
+
+**Deployment:** No special steps; run `npm run lint && npm run type-check` (already clean).
+
+**Analysis:** Ensured strict optional handling without fallbacks, restored compatibility with stable nostr-tools API, and kept functions within lint boundaries.
diff --git a/fixKnowledge/2025-12-23-optional-props-lint-types.md b/fixKnowledge/2025-12-23-optional-props-lint-types.md
new file mode 100644
index 0000000..04478bc
--- /dev/null
+++ b/fixKnowledge/2025-12-23-optional-props-lint-types.md
@@ -0,0 +1,27 @@
+## Problème
+Erreurs TypeScript `exactOptionalPropertyTypes` et avertissements ESLint `max-lines-per-function` sur les composants de sélection de séries (ArticleEditorForm, Profile, UserArticles).
+
+## Impacts
+- Blocage du `tsc --noEmit` et du lint, empêchant le déploiement.
+- Risque de régressions UI (sélection de série) si les props optionnelles sont mal typées.
+
+## Cause
+Propagation de props optionnelles (`seriesOptions`, `onSelectSeries`, `selectedSeriesId`) sans inclure explicitement `undefined` dans les signatures avec `exactOptionalPropertyTypes: true`.
+
+## Root cause
+Contrats de composants non alignés sur les exigences strictes TypeScript (exactOptionalPropertyTypes) et fonctions trop longues (>40 lignes) dans ArticleEditorForm et UserArticles.
+
+## Corrections
+- Typage explicite des props optionnelles avec `| undefined` et propagation conditionnelle des props.
+- Refactoring des fonctions longues : extraction des handlers (SeriesSelect) et découpage de `createLayoutProps`.
+
+## Modifications
+- `components/ArticleEditor.tsx`, `components/ArticleEditorForm.tsx`, `components/ProfileArticlesSection.tsx`, `components/ProfileSeriesBlock.tsx`, `components/SeriesSection.tsx`, `components/SeriesList.tsx`, `components/SeriesCard.tsx`, `components/UserArticles.tsx`, `components/UserArticlesList.tsx`, `components/ProfileView.tsx`.
+- Ajustements de typage et réduction des fonctions >40 lignes.
+
+## Modalités de déploiement
+Pas d’action spécifique : lint et type-check passent (`npm run lint`, `npm run type-check`). Déployer via la pipeline habituelle.
+
+## Modalités d'analyse
+- Vérifier `npm run lint` et `npm run type-check`.
+- Tester la sélection/filtrage de série dans l’éditeur d’articles et sur la page profil (navigation série).
diff --git a/hooks/useArticleEditing.ts b/hooks/useArticleEditing.ts
new file mode 100644
index 0000000..eae5baa
--- /dev/null
+++ b/hooks/useArticleEditing.ts
@@ -0,0 +1,114 @@
+import { useState } from 'react'
+import type { ArticleDraft } from '@/lib/articlePublisher'
+import type { ArticleUpdateResult } from '@/lib/articleMutations'
+import { publishArticleUpdate, deleteArticleEvent, getStoredContent } from '@/lib/articleMutations'
+import { nostrService } from '@/lib/nostr'
+import type { Article } from '@/types/nostr'
+
+interface EditState {
+ draft: ArticleDraft | null
+ articleId: string | null
+}
+
+export function useArticleEditing(authorPubkey: string | null) {
+ const [state, setState] = useState({ draft: null, articleId: null })
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const updateDraft = (draft: ArticleDraft | null) => {
+ setState((prev) => ({ ...prev, draft }))
+ }
+
+ const startEditing = async (article: Article) => {
+ if (!authorPubkey) {
+ setError('Connect your Nostr wallet to edit')
+ return
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const stored = await getStoredContent(article.id)
+ if (!stored) {
+ setError('Private content not available locally. Please republish from original device.')
+ setLoading(false)
+ return
+ }
+ setState({
+ articleId: article.id,
+ draft: {
+ title: article.title,
+ preview: article.preview,
+ content: stored.content,
+ zapAmount: article.zapAmount,
+ ...(article.category === 'science-fiction' || article.category === 'scientific-research'
+ ? { category: article.category }
+ : {}),
+ },
+ })
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Failed to load draft')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const cancelEditing = () => {
+ setState({ draft: null, articleId: null })
+ setError(null)
+ }
+
+ const submitEdit = async (): Promise => {
+ if (!authorPubkey || !state.articleId || !state.draft) {
+ setError('Missing data for update')
+ return null
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const privateKey = nostrService.getPrivateKey() ?? undefined
+ const result = await publishArticleUpdate(state.articleId, state.draft, authorPubkey, privateKey)
+ if (!result.success) {
+ setError(result.error ?? 'Update failed')
+ return null
+ }
+ return result
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Update failed')
+ return null
+ } finally {
+ setLoading(false)
+ setState({ draft: null, articleId: null })
+ }
+ }
+
+ const deleteArticle = async (articleId: string): Promise => {
+ if (!authorPubkey) {
+ setError('Connect your Nostr wallet to delete')
+ return false
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const privateKey = nostrService.getPrivateKey() ?? undefined
+ await deleteArticleEvent(articleId, authorPubkey, privateKey)
+ return true
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Delete failed')
+ return false
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return {
+ editingDraft: state.draft,
+ editingArticleId: state.articleId,
+ loading,
+ error,
+ startEditing,
+ cancelEditing,
+ submitEdit,
+ deleteArticle,
+ updateDraft,
+ }
+}
diff --git a/hooks/useArticles.ts b/hooks/useArticles.ts
index 63afc6e..b63c9dd 100644
--- a/hooks/useArticles.ts
+++ b/hooks/useArticles.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
@@ -8,45 +8,36 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ const hasArticlesRef = useRef(false)
useEffect(() => {
setLoading(true)
setError(null)
- let unsubscribe: (() => void) | null = null
-
- nostrService.subscribeToArticles(
+ const unsubscribe = nostrService.subscribeToArticles(
(article) => {
setArticles((prev) => {
- // Avoid duplicates
if (prev.some((a) => a.id === article.id)) {
return prev
}
- return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
+ const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
+ hasArticlesRef.current = next.length > 0
+ return next
})
setLoading(false)
},
50
- ).then((unsub) => {
- unsubscribe = unsub
- }).catch((e) => {
- console.error('Error subscribing to articles:', e)
- setError('Failed to load articles')
- setLoading(false)
- })
+ )
- // Timeout after 10 seconds
const timeout = setTimeout(() => {
setLoading(false)
- if (articles.length === 0) {
+ if (!hasArticlesRef.current) {
setError('No articles found')
}
}, 10000)
return () => {
- if (unsubscribe) {
- unsubscribe()
- }
+ unsubscribe()
clearTimeout(timeout)
}
}, [])
@@ -77,19 +68,21 @@ export function useArticles(searchQuery: string = '', filters: ArticleFilters |
// Apply filters and sorting
const filteredArticles = useMemo(() => {
- if (!filters) {
- // If no filters, just apply search
- if (!searchQuery.trim()) {
- return articles
- }
- return applyFiltersAndSort(articles, searchQuery, {
+ const effectiveFilters =
+ filters ??
+ ({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
- })
+ category: 'all',
+ } as const)
+
+ if (!filters && !searchQuery.trim()) {
+ return articles
}
- return applyFiltersAndSort(articles, searchQuery, filters)
+
+ return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters])
return {
diff --git a/hooks/useUserArticles.ts b/hooks/useUserArticles.ts
index 06ed851..fd8c127 100644
--- a/hooks/useUserArticles.ts
+++ b/hooks/useUserArticles.ts
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { nostrService } from '@/lib/nostr'
import type { Article } from '@/types/nostr'
import { applyFiltersAndSort } from '@/lib/articleFiltering'
@@ -15,6 +15,7 @@ export function useUserArticles(
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ const hasArticlesRef = useRef(false)
useEffect(() => {
if (!userPubkey) {
@@ -25,34 +26,22 @@ export function useUserArticles(
setLoading(true)
setError(null)
- let unsubscribe: (() => void) | null = null
-
- // Subscribe to articles from this specific author
- nostrService
- .subscribeToArticles(
- (article) => {
- // Only include articles from this user
- if (article.pubkey === userPubkey) {
- setArticles((prev) => {
- // Avoid duplicates
- if (prev.some((a) => a.id === article.id)) {
- return prev
- }
- return [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
- })
- setLoading(false)
- }
- },
- 100
- )
- .then((unsub) => {
- unsubscribe = unsub
- })
- .catch((e) => {
- console.error('Error subscribing to user articles:', e)
- setError('Failed to load articles')
- setLoading(false)
- })
+ const unsubscribe = nostrService.subscribeToArticles(
+ (article) => {
+ if (article.pubkey === userPubkey) {
+ setArticles((prev) => {
+ if (prev.some((a) => a.id === article.id)) {
+ return prev
+ }
+ const next = [article, ...prev].sort((a, b) => b.createdAt - a.createdAt)
+ hasArticlesRef.current = next.length > 0
+ return next
+ })
+ setLoading(false)
+ }
+ },
+ 100
+ )
// Timeout after 10 seconds
const timeout = setTimeout(() => {
@@ -60,28 +49,28 @@ export function useUserArticles(
}, 10000)
return () => {
- if (unsubscribe) {
- unsubscribe()
- }
+ unsubscribe()
clearTimeout(timeout)
}
}, [userPubkey])
// Apply filters and sorting
const filteredArticles = useMemo(() => {
- if (!filters) {
- // If no filters, just apply search
- if (!searchQuery.trim()) {
- return articles
- }
- return applyFiltersAndSort(articles, searchQuery, {
+ const effectiveFilters =
+ filters ??
+ ({
authorPubkey: null,
minPrice: null,
maxPrice: null,
sortBy: 'newest',
- })
+ category: 'all',
+ } as const)
+
+ if (!filters && !searchQuery.trim()) {
+ return articles
}
- return applyFiltersAndSort(articles, searchQuery, filters)
+
+ return applyFiltersAndSort(articles, searchQuery, effectiveFilters)
}, [articles, searchQuery, filters])
const loadArticleContent = async (articleId: string, authorPubkey: string) => {
diff --git a/lib/articleInvoice.ts b/lib/articleInvoice.ts
index ea19992..0b9f3dd 100644
--- a/lib/articleInvoice.ts
+++ b/lib/articleInvoice.ts
@@ -25,31 +25,15 @@ export async function createArticleInvoice(draft: ArticleDraft): Promise 0) {
+ base.push(...extraTags)
+ }
+ return base
+}
diff --git a/lib/articleMutations.ts b/lib/articleMutations.ts
new file mode 100644
index 0000000..08a5c64
--- /dev/null
+++ b/lib/articleMutations.ts
@@ -0,0 +1,226 @@
+import { nostrService } from './nostr'
+import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
+import { storePrivateContent, getStoredPrivateContent } from './articleStorage'
+import { buildReviewTags, buildSeriesTags } from './nostrTags'
+import type { ArticleDraft, PublishedArticle } from './articlePublisher'
+import type { AlbyInvoice } from '@/types/alby'
+import type { Review, Series } from '@/types/nostr'
+
+export interface ArticleUpdateResult extends PublishedArticle {
+ originalArticleId: string
+}
+
+const SITE_TAG = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
+
+function ensureKeys(authorPubkey: string, authorPrivateKey?: string): void {
+ nostrService.setPublicKey(authorPubkey)
+ if (authorPrivateKey) {
+ nostrService.setPrivateKey(authorPrivateKey)
+ } else if (!nostrService.getPrivateKey()) {
+ throw new Error('Private key required for signing. Connect a wallet that can sign.')
+ }
+}
+
+function requireCategory(category?: ArticleDraft['category']): asserts category is NonNullable {
+ if (category !== 'science-fiction' && category !== 'scientific-research') {
+ throw new Error('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
+ }
+}
+
+async function ensurePresentation(authorPubkey: string): Promise {
+ const presentation = await articlePublisher.getAuthorPresentation(authorPubkey)
+ if (!presentation) {
+ throw new Error('Vous devez créer un article de présentation avant de publier des articles.')
+ }
+ return presentation.id
+}
+
+async function publishPreviewWithInvoice(
+ draft: ArticleDraft,
+ invoice: AlbyInvoice,
+ presentationId: string,
+ extraTags?: string[][]
+): Promise {
+ const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
+ const publishedEvent = await nostrService.publishEvent(previewEvent)
+ return publishedEvent ?? null
+}
+
+export async function publishSeries(params: {
+ title: string
+ description: string
+ preview?: string
+ coverUrl?: string
+ category: ArticleDraft['category']
+ authorPubkey: string
+ authorPrivateKey?: string
+}): Promise {
+ ensureKeys(params.authorPubkey, params.authorPrivateKey)
+ const category = params.category
+ requireCategory(category)
+ const event = buildSeriesEvent(params, category)
+ const published = await nostrService.publishEvent(event)
+ if (!published) {
+ throw new Error('Failed to publish series')
+ }
+ return {
+ id: published.id,
+ pubkey: params.authorPubkey,
+ title: params.title,
+ description: params.description,
+ preview: params.preview ?? params.description.substring(0, 200),
+ category,
+ ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
+ kindType: 'series',
+ }
+}
+
+function buildSeriesEvent(
+ params: {
+ title: string
+ description: string
+ preview?: string
+ coverUrl?: string
+ authorPubkey: string
+ },
+ category: NonNullable
+) {
+ return {
+ kind: 1,
+ created_at: Math.floor(Date.now() / 1000),
+ content: params.preview ?? params.description.substring(0, 200),
+ tags: buildSeriesTags({
+ site: SITE_TAG,
+ category,
+ author: params.authorPubkey,
+ seriesId: 'pending',
+ title: params.title,
+ description: params.description,
+ ...(params.preview ? { preview: params.preview } : { preview: params.description.substring(0, 200) }),
+ ...(params.coverUrl ? { coverUrl: params.coverUrl } : {}),
+ kindType: 'series',
+ }),
+ }
+}
+
+export async function publishReview(params: {
+ articleId: string
+ seriesId: string
+ category: ArticleDraft['category']
+ authorPubkey: string
+ reviewerPubkey: string
+ content: string
+ title?: string
+ authorPrivateKey?: string
+}): Promise {
+ ensureKeys(params.reviewerPubkey, params.authorPrivateKey)
+ const category = params.category
+ requireCategory(category)
+ const event = buildReviewEvent(params, category)
+ const published = await nostrService.publishEvent(event)
+ if (!published) {
+ throw new Error('Failed to publish review')
+ }
+ return {
+ id: published.id,
+ articleId: params.articleId,
+ authorPubkey: params.authorPubkey,
+ reviewerPubkey: params.reviewerPubkey,
+ content: params.content,
+ createdAt: published.created_at,
+ ...(params.title ? { title: params.title } : {}),
+ kindType: 'review',
+ }
+}
+
+function buildReviewEvent(
+ params: {
+ articleId: string
+ seriesId: string
+ authorPubkey: string
+ reviewerPubkey: string
+ content: string
+ title?: string
+ },
+ category: NonNullable
+) {
+ return {
+ kind: 1,
+ created_at: Math.floor(Date.now() / 1000),
+ content: params.content,
+ tags: buildReviewTags({
+ site: SITE_TAG,
+ category,
+ author: params.authorPubkey,
+ seriesId: params.seriesId,
+ articleId: params.articleId,
+ reviewer: params.reviewerPubkey,
+ ...(params.title ? { title: params.title } : {}),
+ kindType: 'review',
+ }),
+ }
+}
+
+export async function publishArticleUpdate(
+ originalArticleId: string,
+ draft: ArticleDraft,
+ authorPubkey: string,
+ authorPrivateKey?: string
+): Promise {
+ try {
+ ensureKeys(authorPubkey, authorPrivateKey)
+ const category = draft.category
+ requireCategory(category)
+ const presentationId = await ensurePresentation(authorPubkey)
+ const invoice = await createArticleInvoice(draft)
+ const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, [
+ ['e', originalArticleId],
+ ['replace', 'article-update'],
+ ['kind_type', 'article'],
+ ['site', SITE_TAG],
+ ['category', category],
+ ...(draft.seriesId ? [['series', draft.seriesId]] : []),
+ ])
+ if (!publishedEvent) {
+ return updateFailure(originalArticleId, 'Failed to publish article update')
+ }
+ await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
+ return {
+ articleId: publishedEvent.id,
+ previewEventId: publishedEvent.id,
+ invoice,
+ success: true,
+ originalArticleId,
+ }
+ } catch (error) {
+ return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
+ }
+}
+
+function updateFailure(originalArticleId: string, error?: string): ArticleUpdateResult {
+ return {
+ articleId: '',
+ previewEventId: '',
+ success: false,
+ originalArticleId,
+ ...(error ? { error } : {}),
+ }
+}
+
+export async function deleteArticleEvent(articleId: string, authorPubkey: string, authorPrivateKey?: string): Promise {
+ ensureKeys(authorPubkey, authorPrivateKey)
+ const deleteEvent = {
+ kind: 5,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['e', articleId]] as string[][],
+ content: 'deleted',
+ } as const
+ const published = await nostrService.publishEvent(deleteEvent)
+ if (!published) {
+ throw new Error('Failed to publish delete event')
+ }
+}
+
+// Re-export for convenience to avoid circular imports in hooks
+import { articlePublisher } from './articlePublisher'
+export const getStoredContent = getStoredPrivateContent
diff --git a/lib/articlePublisher.ts b/lib/articlePublisher.ts
index 05dd3d8..6f9eb35 100644
--- a/lib/articlePublisher.ts
+++ b/lib/articlePublisher.ts
@@ -1,6 +1,7 @@
import { nostrService } from './nostr'
import type { AlbyInvoice } from '@/types/alby'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import type { MediaRef } from '@/types/nostr'
import {
storePrivateContent,
getStoredPrivateContent,
@@ -16,6 +17,9 @@ export interface ArticleDraft {
content: string // Full content that will be sent as private message after payment
zapAmount: number
category?: 'science-fiction' | 'scientific-research'
+ seriesId?: string
+ bannerUrl?: string
+ media?: MediaRef[]
}
export interface AuthorPresentationDraft {
@@ -40,13 +44,15 @@ export interface PublishedArticle {
* Handles publishing preview (public note), creating invoice, and storing full content for later private message
*/
export class ArticlePublisher {
+ private readonly siteTag = process.env.NEXT_PUBLIC_SITE_TAG ?? 'zapwall4science'
+
private buildFailure(error?: string): PublishedArticle {
- return {
+ const base: PublishedArticle = {
articleId: '',
previewEventId: '',
success: false,
- error,
}
+ return error ? { ...base, error } : base
}
private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
@@ -69,20 +75,41 @@ export class ArticlePublisher {
return { success: true }
}
- private isValidCategory(category?: ArticleDraft['category']): category is ArticleDraft['category'] {
+ private isValidCategory(category?: ArticleDraft['category']): category is NonNullable {
return category === 'science-fiction' || category === 'scientific-research'
}
private async publishPreview(
draft: ArticleDraft,
invoice: AlbyInvoice,
- presentationId: string
+ presentationId: string,
+ extraTags?: string[][]
): Promise {
- const previewEvent = createPreviewEvent(draft, invoice, presentationId)
+ const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags)
const publishedEvent = await nostrService.publishEvent(previewEvent)
return publishedEvent ?? null
}
+ private buildArticleExtraTags(draft: ArticleDraft, category: NonNullable): string[][] {
+ const extraTags: string[][] = [
+ ['kind_type', 'article'],
+ ['site', this.siteTag],
+ ['category', category],
+ ]
+ if (draft.seriesId) {
+ extraTags.push(['series', draft.seriesId])
+ }
+ if (draft.bannerUrl) {
+ extraTags.push(['banner', draft.bannerUrl])
+ }
+ if (draft.media && draft.media.length > 0) {
+ draft.media.forEach((m) => {
+ extraTags.push(['media', m.url, m.type])
+ })
+ }
+ return extraTags
+ }
+
/**
* Publish an article preview as a public note (kind:1)
* Creates a Lightning invoice for the article
@@ -107,9 +134,11 @@ export class ArticlePublisher {
if (!this.isValidCategory(draft.category)) {
return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
}
+ const category = draft.category
const invoice = await createArticleInvoice(draft)
- const publishedEvent = await this.publishPreview(draft, invoice, presentation.id)
+ const extraTags = this.buildArticleExtraTags(draft, category)
+ const publishedEvent = await this.publishPreview(draft, invoice, presentation.id, extraTags)
if (!publishedEvent) {
return this.buildFailure('Failed to publish article')
}
@@ -123,6 +152,9 @@ export class ArticlePublisher {
}
}
+ /**
+ * Update an existing article by publishing a new event that references the original
+ */
/**
* Get stored private content for an article
*/
@@ -147,7 +179,6 @@ export class ArticlePublisher {
async sendPrivateContent(
articleId: string,
recipientPubkey: string,
- authorPubkey: string,
authorPrivateKey: string
): Promise {
try {
@@ -202,16 +233,13 @@ export class ArticlePublisher {
}
}
- /**
- * Get author presentation article by pubkey
- */
- getAuthorPresentation(pubkey: string): Promise {
+ async getAuthorPresentation(pubkey: string): Promise {
try {
const pool = nostrService.getPool()
if (!pool) {
return null
}
- return fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
+ return await fetchAuthorPresentationFromPool(pool as SimplePoolWithSub, pubkey)
} catch (error) {
console.error('Error getting author presentation:', error)
return null
diff --git a/lib/articleQueries.ts b/lib/articleQueries.ts
new file mode 100644
index 0000000..74957e9
--- /dev/null
+++ b/lib/articleQueries.ts
@@ -0,0 +1,47 @@
+import type { Event } from 'nostr-tools'
+import { nostrService } from './nostr'
+import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import type { Article } from '@/types/nostr'
+import { parseArticleFromEvent } from './nostrEventParsing'
+
+const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+
+export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const filters = [
+ {
+ kinds: [1],
+ '#series': [seriesId],
+ limit,
+ },
+ ]
+
+ return new Promise((resolve) => {
+ const results: Article[] = []
+ const sub = poolWithSub.sub([RELAY_URL], filters)
+ let finished = false
+
+ const done = () => {
+ if (finished) {
+ return
+ }
+ finished = true
+ sub.unsub()
+ resolve(results)
+ }
+
+ sub.on('event', (event: Event) => {
+ const parsed = parseArticleFromEvent(event)
+ if (parsed) {
+ results.push(parsed)
+ }
+ })
+
+ sub.on('eose', () => done())
+ setTimeout(() => done(), timeoutMs).unref?.()
+ })
+}
diff --git a/lib/articleStorage.ts b/lib/articleStorage.ts
index d404853..ac5ec44 100644
--- a/lib/articleStorage.ts
+++ b/lib/articleStorage.ts
@@ -16,6 +16,30 @@ interface StoredArticleData {
// Default expiration: 30 days in milliseconds
const DEFAULT_EXPIRATION = 30 * 24 * 60 * 60 * 1000
+const MASTER_KEY_STORAGE_KEY = 'article_storage_master_key'
+
+function getOrCreateMasterKey(): string {
+ if (typeof window === 'undefined') {
+ throw new Error('Storage encryption requires browser environment')
+ }
+ const existing = localStorage.getItem(MASTER_KEY_STORAGE_KEY)
+ if (existing) {
+ return existing
+ }
+ const keyBytes = crypto.getRandomValues(new Uint8Array(32))
+ let binary = ''
+ keyBytes.forEach((b) => {
+ binary += String.fromCharCode(b)
+ })
+ const key = btoa(binary)
+ localStorage.setItem(MASTER_KEY_STORAGE_KEY, key)
+ return key
+}
+
+function deriveSecret(articleId: string): string {
+ const masterKey = getOrCreateMasterKey()
+ return `${masterKey}:${articleId}`
+}
/**
* Store private content temporarily until payment is confirmed
@@ -31,6 +55,7 @@ export async function storePrivateContent(
): Promise {
try {
const key = `article_private_content_${articleId}`
+ const secret = deriveSecret(articleId)
const data: StoredArticleData = {
content,
authorPubkey,
@@ -47,7 +72,7 @@ export async function storePrivateContent(
}
// Store with expiration (30 days)
- await storageService.set(key, data, DEFAULT_EXPIRATION)
+ await storageService.set(key, data, secret, DEFAULT_EXPIRATION)
} catch (error) {
console.error('Error storing private content:', error)
}
@@ -64,7 +89,8 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
} | null> {
try {
const key = `article_private_content_${articleId}`
- const data = await storageService.get(key)
+ const secret = deriveSecret(articleId)
+ const data = await storageService.get(key, secret)
if (!data) {
return null
@@ -73,14 +99,16 @@ export async function getStoredPrivateContent(articleId: string): Promise<{
return {
content: data.content,
authorPubkey: data.authorPubkey,
- invoice: data.invoice
+ ...(data.invoice
? {
- invoice: data.invoice.invoice,
- paymentHash: data.invoice.paymentHash,
- amount: data.invoice.amount,
- expiresAt: data.invoice.expiresAt,
+ invoice: {
+ invoice: data.invoice.invoice,
+ paymentHash: data.invoice.paymentHash,
+ amount: data.invoice.amount,
+ expiresAt: data.invoice.expiresAt,
+ } as AlbyInvoice,
}
- : undefined,
+ : {}),
}
} catch (error) {
console.error('Error retrieving private content:', error)
diff --git a/lib/markdownRenderer.tsx b/lib/markdownRenderer.tsx
index 4711a0c..0d981fb 100644
--- a/lib/markdownRenderer.tsx
+++ b/lib/markdownRenderer.tsx
@@ -102,13 +102,16 @@ function renderParagraphOrBreak(line: string, index: number, elements: JSX.Eleme
elements.push({line}
)
return
}
- if (elements.length > 0 && elements[elements.length - 1].type !== 'br') {
- elements.push( )
+ if (elements.length > 0) {
+ const last = elements[elements.length - 1] as { type?: unknown }
+ if (last?.type !== 'br') {
+ elements.push( )
+ }
}
}
function handleCodeBlock(
- line: string,
+ _line: string,
index: number,
state: RenderState,
elements: JSX.Element[]
@@ -172,12 +175,17 @@ function renderLink(line: string, index: number, elements: JSX.Element[]): void
let match
while ((match = linkRegex.exec(line)) !== null) {
- if (match.index > lastIndex) {
+ if (!match[2]) {
+ continue
+ }
+ if (!match[1] || match.index > lastIndex) {
parts.push(line.substring(lastIndex, match.index))
}
const href = match[2]
const isExternal = href.startsWith('http')
- parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
+ if (match[1]) {
+ parts.push(createLinkElement(match[1], href, `link-${index}-${match.index}`, isExternal))
+ }
lastIndex = match.index + match[0].length
}
diff --git a/lib/nip95.ts b/lib/nip95.ts
new file mode 100644
index 0000000..d48bbce
--- /dev/null
+++ b/lib/nip95.ts
@@ -0,0 +1,63 @@
+import type { MediaRef } from '@/types/nostr'
+
+const MAX_IMAGE_BYTES = 5 * 1024 * 1024
+const MAX_VIDEO_BYTES = 45 * 1024 * 1024
+const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']
+const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']
+
+function assertBrowser(): void {
+ if (typeof window === 'undefined') {
+ throw new Error('NIP-95 upload is only available in the browser')
+ }
+}
+
+function validateFile(file: File): MediaRef['type'] {
+ if (IMAGE_TYPES.includes(file.type)) {
+ if (file.size > MAX_IMAGE_BYTES) {
+ throw new Error('Image exceeds 5MB limit')
+ }
+ return 'image'
+ }
+ if (VIDEO_TYPES.includes(file.type)) {
+ if (file.size > MAX_VIDEO_BYTES) {
+ throw new Error('Video exceeds 45MB limit')
+ }
+ return 'video'
+ }
+ throw new Error('Unsupported media type')
+}
+
+/**
+ * Upload media via NIP-95.
+ * This implementation validates size/type then delegates to a pluggable uploader.
+ * The actual upload endpoint must be provided via env/config; otherwise an error is thrown.
+ */
+export async function uploadNip95Media(file: File): Promise {
+ assertBrowser()
+ const mediaType = validateFile(file)
+
+ const endpoint = process.env.NEXT_PUBLIC_NIP95_UPLOAD_URL
+ if (!endpoint) {
+ throw new Error('NIP-95 upload endpoint is not configured')
+ }
+
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const message = await response.text().catch(() => 'Upload failed')
+ throw new Error(message || 'Upload failed')
+ }
+
+ const result = (await response.json()) as { url?: string }
+ if (!result.url) {
+ throw new Error('Upload response missing URL')
+ }
+
+ return { url: result.url, type: mediaType }
+}
diff --git a/lib/nostr.ts b/lib/nostr.ts
index 7759e0f..fa8552c 100644
--- a/lib/nostr.ts
+++ b/lib/nostr.ts
@@ -60,10 +60,16 @@ class NostrService {
throw new Error('Private key not set or pool not initialized')
}
- const event = {
+ const unsignedEvent = {
+ pubkey: this.publicKey ?? '',
...eventTemplate,
- id: getEventHash(eventTemplate),
- sig: signEvent(eventTemplate, this.privateKey),
+ created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
+ }
+
+ const event = {
+ ...unsignedEvent,
+ id: getEventHash(unsignedEvent),
+ sig: signEvent(unsignedEvent, this.privateKey),
} as Event
try {
@@ -190,7 +196,7 @@ class NostrService {
userPubkey?: string
): Promise {
if (!this.publicKey || !this.pool) {
- return false
+ return Promise.resolve(false)
}
// Use provided userPubkey or fall back to current public key
diff --git a/lib/nostrEventParsing.ts b/lib/nostrEventParsing.ts
index befd239..1d2b41c 100644
--- a/lib/nostrEventParsing.ts
+++ b/lib/nostrEventParsing.ts
@@ -1,5 +1,5 @@
import type { Event } from 'nostr-tools'
-import type { Article } from '@/types/nostr'
+import type { Article, KindType, MediaRef, Review, Series } from '@/types/nostr'
/**
* Parse article metadata from Nostr event
@@ -7,6 +7,9 @@ import type { Article } from '@/types/nostr'
export function parseArticleFromEvent(event: Event): Article | null {
try {
const tags = extractTags(event)
+ if (tags.kindType && tags.kindType !== 'article') {
+ return null
+ }
const { previewContent } = getPreviewContent(event.content, tags.preview)
return buildArticle(event, tags, previewContent)
} catch (e) {
@@ -15,11 +18,83 @@ export function parseArticleFromEvent(event: Event): Article | null {
}
}
+export function parseSeriesFromEvent(event: Event): Series | null {
+ try {
+ const tags = extractTags(event)
+ if (tags.kindType && tags.kindType !== 'series') {
+ return null
+ }
+ if (!tags.title || !tags.description) {
+ return null
+ }
+ const series: Series = {
+ id: event.id,
+ pubkey: event.pubkey,
+ title: tags.title,
+ description: tags.description,
+ preview: tags.preview ?? event.content.substring(0, 200),
+ ...(tags.category ? { category: tags.category } : { category: 'science-fiction' }),
+ ...(tags.coverUrl ? { coverUrl: tags.coverUrl } : {}),
+ }
+ if (tags.kindType) {
+ series.kindType = tags.kindType
+ }
+ return series
+ } catch (e) {
+ console.error('Error parsing series:', e)
+ return null
+ }
+}
+
+export function parseReviewFromEvent(event: Event): Review | null {
+ try {
+ const tags = extractTags(event)
+ if (tags.kindType && tags.kindType !== 'review') {
+ return null
+ }
+ const articleId = tags.articleId
+ const reviewer = tags.reviewerPubkey
+ if (!articleId || !reviewer) {
+ return null
+ }
+ const review: Review = {
+ id: event.id,
+ articleId,
+ authorPubkey: tags.author ?? event.pubkey,
+ reviewerPubkey: reviewer,
+ content: event.content,
+ createdAt: event.created_at,
+ ...(tags.title ? { title: tags.title } : {}),
+ }
+ if (tags.kindType) {
+ review.kindType = tags.kindType
+ }
+ return review
+ } catch (e) {
+ console.error('Error parsing review:', e)
+ return null
+ }
+}
+
function extractTags(event: Event) {
const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
+ const mediaTags = event.tags.filter((tag) => tag[0] === 'media')
+ const media: MediaRef[] =
+ mediaTags
+ .map((tag) => {
+ const url = tag[1]
+ const type = tag[2] === 'video' ? 'video' : 'image'
+ if (!url) {
+ return null
+ }
+ return { url, type }
+ })
+ .filter(Boolean) as MediaRef[]
+
return {
title: findTag('title') ?? 'Untitled',
preview: findTag('preview'),
+ description: findTag('description'),
zapAmount: parseInt(findTag('zap') ?? '800', 10),
invoice: findTag('invoice'),
paymentHash: findTag('payment_hash'),
@@ -28,6 +103,14 @@ function extractTags(event: Event) {
mainnetAddress: findTag('mainnet_address'),
totalSponsoring: parseInt(findTag('total_sponsoring') ?? '0', 10),
authorPresentationId: findTag('author_presentation_id'),
+ seriesId: findTag('series'),
+ bannerUrl: findTag('banner'),
+ coverUrl: findTag('cover'),
+ media,
+ kindType: findTag('kind_type') as KindType | undefined,
+ articleId: findTag('article'),
+ reviewerPubkey: findTag('reviewer'),
+ author: findTag('author'),
}
}
@@ -47,12 +130,16 @@ function buildArticle(event: Event, tags: ReturnType, previe
createdAt: event.created_at,
zapAmount: tags.zapAmount,
paid: false,
- invoice: tags.invoice,
- paymentHash: tags.paymentHash,
- category: tags.category,
- isPresentation: tags.isPresentation,
- mainnetAddress: tags.mainnetAddress,
- totalSponsoring: tags.totalSponsoring,
- authorPresentationId: tags.authorPresentationId,
+ ...(tags.invoice ? { invoice: tags.invoice } : {}),
+ ...(tags.paymentHash ? { paymentHash: tags.paymentHash } : {}),
+ ...(tags.category ? { category: tags.category } : {}),
+ ...(tags.isPresentation ? { isPresentation: tags.isPresentation } : {}),
+ ...(tags.mainnetAddress ? { mainnetAddress: tags.mainnetAddress } : {}),
+ ...(tags.totalSponsoring ? { totalSponsoring: tags.totalSponsoring } : {}),
+ ...(tags.authorPresentationId ? { authorPresentationId: tags.authorPresentationId } : {}),
+ ...(tags.seriesId ? { seriesId: tags.seriesId } : {}),
+ ...(tags.bannerUrl ? { bannerUrl: tags.bannerUrl } : {}),
+ ...(tags.media.length ? { media: tags.media } : {}),
+ ...(tags.kindType ? { kindType: tags.kindType } : {}),
}
}
diff --git a/lib/nostrRemoteSigner.ts b/lib/nostrRemoteSigner.ts
index 9a9edf9..3447471 100644
--- a/lib/nostrRemoteSigner.ts
+++ b/lib/nostrRemoteSigner.ts
@@ -14,7 +14,17 @@ export class NostrRemoteSigner {
*/
signEvent(eventTemplate: EventTemplate): Event | null {
// Get the event hash first
- const eventId = getEventHash(eventTemplate)
+ const pubkey = nostrService.getPublicKey()
+ if (!pubkey) {
+ throw new Error('Public key required for signing. Please connect a Nostr wallet.')
+ }
+
+ const unsignedEvent = {
+ pubkey,
+ ...eventTemplate,
+ created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
+ }
+ const eventId = getEventHash(unsignedEvent)
// Try to get private key from nostrService (if available from NostrConnect)
const privateKey = nostrService.getPrivateKey()
@@ -28,9 +38,9 @@ export class NostrRemoteSigner {
}
const event = {
- ...eventTemplate,
+ ...unsignedEvent,
id: eventId,
- sig: signEvent(eventTemplate, privateKey),
+ sig: signEvent(unsignedEvent, privateKey),
} as Event
return event
diff --git a/lib/nostrTags.ts b/lib/nostrTags.ts
new file mode 100644
index 0000000..b9873e2
--- /dev/null
+++ b/lib/nostrTags.ts
@@ -0,0 +1,102 @@
+import type { ArticleCategory, KindType, MediaRef } from '@/types/nostr'
+
+export interface SeriesTags {
+ site?: string
+ category: ArticleCategory
+ author: string
+ seriesId: string
+ title: string
+ description: string
+ preview?: string
+ coverUrl?: string
+ kindType: KindType
+}
+
+export interface ArticleTags {
+ site?: string
+ category: ArticleCategory
+ author: string
+ seriesId?: string
+ articleId: string
+ title: string
+ preview: string
+ bannerUrl?: string
+ media?: MediaRef[]
+ kindType: KindType
+}
+
+export interface ReviewTags {
+ site?: string
+ category: ArticleCategory
+ author: string
+ seriesId: string
+ articleId: string
+ reviewer: string
+ title?: string
+ kindType: KindType
+}
+
+export function buildSeriesTags(tags: SeriesTags): string[][] {
+ const result: string[][] = [
+ ['kind_type', tags.kindType],
+ ['category', tags.category],
+ ['author', tags.author],
+ ['series', tags.seriesId],
+ ['title', tags.title],
+ ['description', tags.description],
+ ]
+ if (tags.site) {
+ result.push(['site', tags.site])
+ }
+ if (tags.preview) {
+ result.push(['preview', tags.preview])
+ }
+ if (tags.coverUrl) {
+ result.push(['cover', tags.coverUrl])
+ }
+ return result
+}
+
+export function buildArticleTags(tags: ArticleTags): string[][] {
+ const result: string[][] = [
+ ['kind_type', tags.kindType],
+ ['category', tags.category],
+ ['author', tags.author],
+ ['article', tags.articleId],
+ ['title', tags.title],
+ ['preview', tags.preview],
+ ]
+ if (tags.site) {
+ result.push(['site', tags.site])
+ }
+ if (tags.seriesId) {
+ result.push(['series', tags.seriesId])
+ }
+ if (tags.bannerUrl) {
+ result.push(['banner', tags.bannerUrl])
+ }
+ if (tags.media && tags.media.length > 0) {
+ tags.media.forEach((m) => {
+ result.push(['media', m.url, m.type])
+ })
+ }
+ return result
+}
+
+export function buildReviewTags(tags: ReviewTags): string[][] {
+ const result: string[][] = [
+ ['kind_type', tags.kindType],
+ ['category', tags.category],
+ ['author', tags.author],
+ ['series', tags.seriesId],
+ ['article', tags.articleId],
+ ['reviewer', tags.reviewer],
+ ]
+ if (tags.site) {
+ result.push(['site', tags.site])
+ }
+ if (tags.title) {
+ result.push(['title', tags.title])
+ }
+ return result
+}
diff --git a/lib/nostrZapVerification.ts b/lib/nostrZapVerification.ts
index 7b7dde5..3bcc6bd 100644
--- a/lib/nostrZapVerification.ts
+++ b/lib/nostrZapVerification.ts
@@ -38,7 +38,7 @@ export function checkZapReceipt(
userPubkey: string
): Promise {
if (!pool) {
- return false
+ return Promise.resolve(false)
}
return new Promise((resolve) => {
diff --git a/lib/nostrconnect.ts b/lib/nostrconnect.ts
index 6440ca7..c1ed1ec 100644
--- a/lib/nostrconnect.ts
+++ b/lib/nostrconnect.ts
@@ -49,7 +49,7 @@ export class NostrConnectService {
}
private cleanupPopup(popup: Window | null, checkClosed: number, messageHandler: (event: MessageEvent) => void) {
- clearInterval(checkClosed)
+ window.clearInterval(checkClosed)
window.removeEventListener('message', messageHandler)
if (popup && !popup.closed) {
popup.close()
@@ -100,8 +100,7 @@ export class NostrConnectService {
return
}
- const messageHandler: (event: MessageEvent) => void
- const checkClosed = setInterval(() => {
+ const checkClosed = window.setInterval(() => {
if (popup.closed) {
this.cleanupPopup(popup, checkClosed, messageHandler)
if (!this.state.connected) {
@@ -110,17 +109,14 @@ export class NostrConnectService {
}
}, 1000)
- const cleanup = () => {
- this.cleanupPopup(popup, checkClosed, messageHandler)
- }
-
- messageHandler = this.createMessageHandler(resolve, reject, cleanup)
+ const cleanup = () => this.cleanupPopup(popup, checkClosed, messageHandler)
+ const messageHandler = this.createMessageHandler(resolve, reject, cleanup)
window.addEventListener('message', messageHandler)
})
}
- disconnect(): Promise {
+ disconnect(): void {
this.state = {
connected: false,
pubkey: null,
diff --git a/lib/notifications.ts b/lib/notifications.ts
index 3b35caa..b6f7108 100644
--- a/lib/notifications.ts
+++ b/lib/notifications.ts
@@ -41,8 +41,8 @@ async function buildPaymentNotification(event: Event, userPubkey: string): Promi
: `You received ${paymentInfo.amount} sats`,
timestamp: event.created_at,
read: false,
- articleId: paymentInfo.articleId ?? undefined,
- articleTitle,
+ ...(paymentInfo.articleId ? { articleId: paymentInfo.articleId } : {}),
+ ...(articleTitle ? { articleTitle } : {}),
amount: paymentInfo.amount,
fromPubkey: paymentInfo.payer,
}
diff --git a/lib/payment.ts b/lib/payment.ts
index a2e90c5..a6eec14 100644
--- a/lib/payment.ts
+++ b/lib/payment.ts
@@ -53,7 +53,7 @@ export class PaymentService {
* Check if payment for an article has been completed
*/
async checkArticlePayment(
- paymentHash: string,
+ _paymentHash: string,
articleId: string,
articlePubkey: string,
amount: number,
diff --git a/lib/paymentPolling.ts b/lib/paymentPolling.ts
index 5da4654..26b4869 100644
--- a/lib/paymentPolling.ts
+++ b/lib/paymentPolling.ts
@@ -69,12 +69,7 @@ async function sendPrivateContentAfterPayment(
const authorPrivateKey = nostrService.getPrivateKey()
if (authorPrivateKey) {
- const sent = await articlePublisher.sendPrivateContent(
- articleId,
- recipientPubkey,
- storedContent.authorPubkey,
- authorPrivateKey
- )
+ const sent = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
if (sent) {
// Private content sent successfully
diff --git a/lib/reviewAggregation.ts b/lib/reviewAggregation.ts
new file mode 100644
index 0000000..78974a8
--- /dev/null
+++ b/lib/reviewAggregation.ts
@@ -0,0 +1,12 @@
+import { aggregateZapSats } from './zapAggregation'
+
+export function getReviewTipsForArticle(params: {
+ authorPubkey: string
+ articleId: string
+}): Promise {
+ return aggregateZapSats({
+ authorPubkey: params.authorPubkey,
+ articleId: params.articleId,
+ kindType: 'review_tip',
+ })
+}
diff --git a/lib/reviews.ts b/lib/reviews.ts
new file mode 100644
index 0000000..88fb494
--- /dev/null
+++ b/lib/reviews.ts
@@ -0,0 +1,47 @@
+import type { Event } from 'nostr-tools'
+import { nostrService } from './nostr'
+import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import type { Review } from '@/types/nostr'
+import { parseReviewFromEvent } from './nostrEventParsing'
+
+const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+
+export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const filters = [
+ {
+ kinds: [1],
+ '#article': [articleId],
+ '#kind_type': ['review'],
+ },
+ ]
+
+ return new Promise((resolve) => {
+ const results: Review[] = []
+ const sub = poolWithSub.sub([RELAY_URL], filters)
+ let finished = false
+
+ const done = () => {
+ if (finished) {
+ return
+ }
+ finished = true
+ sub.unsub()
+ resolve(results)
+ }
+
+ sub.on('event', (event: Event) => {
+ const parsed = parseReviewFromEvent(event)
+ if (parsed) {
+ results.push(parsed)
+ }
+ })
+
+ sub.on('eose', () => done())
+ setTimeout(() => done(), timeoutMs).unref?.()
+ })
+}
diff --git a/lib/seriesAggregation.ts b/lib/seriesAggregation.ts
new file mode 100644
index 0000000..9c1abb1
--- /dev/null
+++ b/lib/seriesAggregation.ts
@@ -0,0 +1,20 @@
+import { aggregateZapSats } from './zapAggregation'
+
+export interface SeriesAggregates {
+ sponsoring: number
+ purchases: number
+ reviewTips: number
+}
+
+export async function getSeriesAggregates(params: {
+ authorPubkey: string
+ seriesId: string
+}): Promise {
+ const [sponsoring, purchases, reviewTips] = await Promise.all([
+ aggregateZapSats({ authorPubkey: params.authorPubkey, seriesId: params.seriesId, kindType: 'sponsoring' }),
+ aggregateZapSats({ authorPubkey: params.authorPubkey, seriesId: params.seriesId, kindType: 'purchase' }),
+ aggregateZapSats({ authorPubkey: params.authorPubkey, seriesId: params.seriesId, kindType: 'review_tip' }),
+ ])
+
+ return { sponsoring, purchases, reviewTips }
+}
diff --git a/lib/seriesQueries.ts b/lib/seriesQueries.ts
new file mode 100644
index 0000000..d1f71bf
--- /dev/null
+++ b/lib/seriesQueries.ts
@@ -0,0 +1,86 @@
+import type { Event } from 'nostr-tools'
+import { nostrService } from './nostr'
+import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import type { Series } from '@/types/nostr'
+import { parseSeriesFromEvent } from './nostrEventParsing'
+
+const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+
+export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const filters = [
+ {
+ kinds: [1],
+ authors: [authorPubkey],
+ '#kind_type': ['series'],
+ },
+ ]
+
+ return new Promise((resolve) => {
+ const results: Series[] = []
+ const sub = poolWithSub.sub([RELAY_URL], filters)
+ let finished = false
+
+ const done = () => {
+ if (finished) {
+ return
+ }
+ finished = true
+ sub.unsub()
+ resolve(results)
+ }
+
+ sub.on('event', (event: Event) => {
+ const parsed = parseSeriesFromEvent(event)
+ if (parsed) {
+ results.push(parsed)
+ }
+ })
+
+ sub.on('eose', () => done())
+ setTimeout(() => done(), timeoutMs).unref?.()
+ })
+}
+
+export function getSeriesById(seriesId: string, timeoutMs: number = 5000): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const filters = [
+ {
+ kinds: [1],
+ ids: [seriesId],
+ '#kind_type': ['series'],
+ },
+ ]
+
+ return new Promise((resolve) => {
+ const sub = poolWithSub.sub([RELAY_URL], filters)
+ let finished = false
+
+ const done = (value: Series | null) => {
+ if (finished) {
+ return
+ }
+ finished = true
+ sub.unsub()
+ resolve(value)
+ }
+
+ sub.on('event', (event: Event) => {
+ const parsed = parseSeriesFromEvent(event)
+ if (parsed) {
+ done(parsed)
+ }
+ })
+
+ sub.on('eose', () => done(null))
+ setTimeout(() => done(null), timeoutMs).unref?.()
+ })
+}
diff --git a/lib/storage/cryptoHelpers.ts b/lib/storage/cryptoHelpers.ts
new file mode 100644
index 0000000..be22791
--- /dev/null
+++ b/lib/storage/cryptoHelpers.ts
@@ -0,0 +1,53 @@
+const IV_LENGTH = 12
+
+function toBase64(bytes: Uint8Array): string {
+ let binary = ''
+ bytes.forEach((b) => {
+ binary += String.fromCharCode(b)
+ })
+ return btoa(binary)
+}
+
+function fromBase64(value: string): Uint8Array {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes
+}
+
+async function importKey(secret: string): Promise {
+ const encoder = new TextEncoder()
+ const keyMaterial = encoder.encode(secret)
+ const hash = await crypto.subtle.digest('SHA-256', keyMaterial)
+ return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
+}
+
+export interface EncryptedPayload {
+ iv: string
+ ciphertext: string
+}
+
+export async function encryptPayload(secret: string, value: unknown): Promise {
+ const key = await importKey(secret)
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
+ const encoder = new TextEncoder()
+ const encoded = encoder.encode(JSON.stringify(value))
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
+ return {
+ iv: toBase64(iv),
+ ciphertext: toBase64(new Uint8Array(ciphertext)),
+ }
+}
+
+export async function decryptPayload(secret: string, payload: EncryptedPayload): Promise {
+ const key = await importKey(secret)
+ const ivBytes = fromBase64(payload.iv)
+ const cipherBytes = fromBase64(payload.ciphertext)
+ const ivBuffer = ivBytes.buffer as ArrayBuffer
+ const cipherBuffer = cipherBytes.buffer as ArrayBuffer
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, cipherBuffer)
+ const decoder = new TextDecoder()
+ return JSON.parse(decoder.decode(decrypted)) as T
+}
diff --git a/lib/storage/indexedDB.ts b/lib/storage/indexedDB.ts
index f8f62b3..40b9448 100644
--- a/lib/storage/indexedDB.ts
+++ b/lib/storage/indexedDB.ts
@@ -1,10 +1,12 @@
+import { decryptPayload, encryptPayload, type EncryptedPayload } from './cryptoHelpers'
+
const DB_NAME = 'nostr_paywall'
const DB_VERSION = 1
const STORE_NAME = 'article_content'
interface DBData {
id: string
- data: unknown
+ data: EncryptedPayload
createdAt: number
expiresAt?: number
}
@@ -72,7 +74,7 @@ export class IndexedDBStorage {
/**
* Store data in IndexedDB
*/
- async set(key: string, value: unknown, expiresIn?: number): Promise {
+ async set(key: string, value: unknown, secret: string, expiresIn?: number): Promise {
try {
await this.init()
@@ -80,12 +82,13 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
+ const encrypted = await encryptPayload(secret, value)
const now = Date.now()
const data: DBData = {
id: key,
- data: value,
+ data: encrypted,
createdAt: now,
- expiresAt: expiresIn ? now + expiresIn : undefined,
+ ...(expiresIn ? { expiresAt: now + expiresIn } : {}),
}
const db = this.db
@@ -110,7 +113,7 @@ export class IndexedDBStorage {
/**
* Get data from IndexedDB
*/
- async get(key: string): Promise {
+ async get(key: string, secret: string): Promise {
try {
await this.init()
@@ -118,14 +121,14 @@ export class IndexedDBStorage {
throw new Error('Database not initialized')
}
- return this.readValue(key)
+ return this.readValue(key, secret)
} catch (error) {
console.error('Error getting from IndexedDB:', error)
return null
}
}
- private readValue(key: string): Promise {
+ private readValue(key: string, secret: string): Promise {
const db = this.db
if (!db) {
throw new Error('Database not initialized')
@@ -150,7 +153,12 @@ export class IndexedDBStorage {
return
}
- resolve(result.data as T)
+ decryptPayload(secret, result.data)
+ .then((value) => resolve(value))
+ .catch((error) => {
+ console.error('Error decrypting from IndexedDB:', error)
+ resolve(null)
+ })
}
request.onerror = () => reject(new Error(`Failed to get data: ${request.error}`))
diff --git a/lib/zapAggregation.ts b/lib/zapAggregation.ts
new file mode 100644
index 0000000..83be29d
--- /dev/null
+++ b/lib/zapAggregation.ts
@@ -0,0 +1,92 @@
+import type { Event } from 'nostr-tools'
+import { nostrService } from './nostr'
+import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+
+const DEFAULT_RELAY = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+
+interface ZapAggregationFilter {
+ authorPubkey: string
+ seriesId?: string
+ articleId?: string
+ kindType: 'sponsoring' | 'purchase' | 'review_tip'
+ reviewerPubkey?: string
+ timeoutMs?: number
+}
+
+function buildFilters(params: ZapAggregationFilter) {
+ const filters = [
+ {
+ kinds: [9735],
+ '#p': [params.authorPubkey],
+ '#kind_type': [params.kindType],
+ ...(params.seriesId ? { '#series': [params.seriesId] } : {}),
+ ...(params.articleId ? { '#article': [params.articleId] } : {}),
+ ...(params.reviewerPubkey ? { '#reviewer': [params.reviewerPubkey] } : {}),
+ },
+ ]
+ return filters
+}
+
+function millisatsToSats(value: string | undefined): number {
+ if (!value) {
+ return 0
+ }
+ const parsed = parseInt(value, 10)
+ if (Number.isNaN(parsed)) {
+ return 0
+ }
+ return Math.floor(parsed / 1000)
+}
+
+export function aggregateZapSats(params: ZapAggregationFilter): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Nostr pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const filters = buildFilters(params)
+ const relay = DEFAULT_RELAY
+ const timeout = params.timeoutMs ?? 5000
+
+ const sub = poolWithSub.sub([relay], filters)
+ return collectZap(sub, timeout)
+}
+
+function collectZap(
+ sub: ReturnType,
+ timeout: number
+): Promise {
+ return new Promise((resolve, reject) => {
+ let total = 0
+ let finished = false
+
+ const done = () => {
+ if (finished) {
+ return
+ }
+ finished = true
+ sub.unsub()
+ resolve(total)
+ }
+
+ const onError = (err: unknown) => {
+ if (finished) {
+ return
+ }
+ finished = true
+ sub.unsub()
+ reject(err instanceof Error ? err : new Error('Unknown zap aggregation error'))
+ }
+
+ sub.on('event', (event: Event) => {
+ const amountTag = event.tags.find((tag) => tag[0] === 'amount')
+ total += millisatsToSats(amountTag?.[1])
+ })
+ sub.on('eose', () => done())
+ setTimeout(() => done(), timeout).unref?.()
+ if (typeof (sub as unknown as { on?: unknown }).on === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ;(sub as any).on('error', onError)
+ }
+ })
+}
diff --git a/lib/zapVerification.ts b/lib/zapVerification.ts
index 4076011..e92c2c6 100644
--- a/lib/zapVerification.ts
+++ b/lib/zapVerification.ts
@@ -1,4 +1,4 @@
-import { Event, verifyEvent } from 'nostr-tools'
+import { Event, validateEvent, verifySignature } from 'nostr-tools'
/**
* Service for verifying zap receipts and their signatures
@@ -9,7 +9,7 @@ export class ZapVerificationService {
*/
verifyZapReceiptSignature(event: Event): boolean {
try {
- return verifyEvent(event)
+ return validateEvent(event) && verifySignature(event)
} catch (error) {
console.error('Error verifying zap receipt signature:', error)
return false
@@ -23,7 +23,7 @@ export class ZapVerificationService {
zapReceipt: Event,
articleId: string,
articlePubkey: string,
- userPubkey: string,
+ _userPubkey: string,
expectedAmount: number
): boolean {
if (!this.verifyZapReceiptSignature(zapReceipt)) {
@@ -62,7 +62,7 @@ export class ZapVerificationService {
return {
amount: amountInSats,
- recipient: recipientTag[1],
+ recipient: recipientTag?.[1] ?? '',
articleId: eventTag?.[1] ?? null,
payer: zapReceipt.pubkey,
}
diff --git a/package-lock.json b/package-lock.json
index d5868a4..a6fde6c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"next": "^14.0.4",
- "nostr-tools": "^2.3.4",
+ "nostr-tools": "1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-qr-code": "^2.0.18"
@@ -434,33 +434,21 @@
}
},
"node_modules/@noble/ciphers": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
- "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
+ "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
- "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
+ "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
- "@noble/hashes": "1.3.2"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
- "node_modules/@noble/curves/node_modules/@noble/hashes": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
- "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 16"
+ "@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@@ -577,18 +565,6 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/@scure/bip32/node_modules/@noble/curves": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
- "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
- "license": "MIT",
- "dependencies": {
- "@noble/hashes": "1.3.1"
- },
- "funding": {
- "url": "https://paulmillr.com/funding/"
- }
- },
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
@@ -4238,18 +4214,17 @@
}
},
"node_modules/nostr-tools": {
- "version": "2.19.4",
- "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
- "integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
+ "version": "1.17.0",
+ "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz",
+ "integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
"license": "Unlicense",
"dependencies": {
- "@noble/ciphers": "^0.5.1",
- "@noble/curves": "1.2.0",
+ "@noble/ciphers": "0.2.0",
+ "@noble/curves": "1.1.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
- "@scure/bip39": "1.2.1",
- "nostr-wasm": "0.1.0"
+ "@scure/bip39": "1.2.1"
},
"peerDependencies": {
"typescript": ">=5.0.0"
@@ -4260,12 +4235,6 @@
}
}
},
- "node_modules/nostr-wasm": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
- "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
- "license": "MIT"
- },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
diff --git a/package.json b/package.json
index 058798d..f9e0f43 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
},
"dependencies": {
"next": "^14.0.4",
- "nostr-tools": "^2.3.4",
+ "nostr-tools": "1.17.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-qr-code": "^2.0.18"
diff --git a/pages/profile.tsx b/pages/profile.tsx
index 0a96084..f56faf9 100644
--- a/pages/profile.tsx
+++ b/pages/profile.tsx
@@ -18,10 +18,6 @@ function useUserProfileData(currentPubkey: string | null) {
const createMinimalProfile = (): NostrProfile => ({
pubkey: currentPubkey,
- name: undefined,
- about: undefined,
- picture: undefined,
- nip05: undefined,
})
const load = async () => {
@@ -62,6 +58,7 @@ function useProfileController() {
sortBy: 'newest',
category: 'all',
})
+ const [selectedSeriesId, setSelectedSeriesId] = useState(undefined)
useRedirectWhenDisconnected(connected, currentPubkey ?? null)
const { profile, loadingProfile } = useUserProfileData(currentPubkey ?? null)
@@ -86,6 +83,8 @@ function useProfileController() {
loadArticleContent,
profile,
loadingProfile,
+ selectedSeriesId,
+ onSelectSeries: setSelectedSeriesId,
}
}
diff --git a/pages/publish.tsx b/pages/publish.tsx
index 1945c25..1bf94fe 100644
--- a/pages/publish.tsx
+++ b/pages/publish.tsx
@@ -2,6 +2,9 @@ import Head from 'next/head'
import { useRouter } from 'next/router'
import { ConnectButton } from '@/components/ConnectButton'
import { ArticleEditor } from '@/components/ArticleEditor'
+import { useEffect, useState } from 'react'
+import { useNostrConnect } from '@/hooks/useNostrConnect'
+import { getSeriesByAuthor } from '@/lib/seriesQueries'
function PublishHeader() {
return (
@@ -30,6 +33,8 @@ function PublishHero({ onBack }: { onBack: () => void }) {
export default function PublishPage() {
const router = useRouter()
+ const { pubkey } = useNostrConnect()
+ const [seriesOptions, setSeriesOptions] = useState<{ id: string; title: string }[]>([])
const handlePublishSuccess = () => {
setTimeout(() => {
@@ -37,28 +42,54 @@ export default function PublishPage() {
}, 2000)
}
+ useEffect(() => {
+ if (!pubkey) {
+ setSeriesOptions([])
+ return
+ }
+ const load = async () => {
+ const items = await getSeriesByAuthor(pubkey)
+ setSeriesOptions(items.map((s) => ({ id: s.id, title: s.title })))
+ }
+ void load()
+ }, [pubkey])
+
return (
<>
-
-
-
-
-
zapwall4Science
-
-
-
-
-
-
{
- void router.push('/')
- }}
- />
-
-
-
-
+ {
+ void router.push('/')
+ }}
+ onPublishSuccess={handlePublishSuccess}
+ seriesOptions={seriesOptions}
+ />
>
)
}
+
+function PublishLayout({
+ onBack,
+ onPublishSuccess,
+ seriesOptions,
+}: {
+ onBack: () => void
+ onPublishSuccess: () => void
+ seriesOptions: { id: string; title: string }[]
+}) {
+ return (
+
+
+
+
zapwall4Science
+
+
+
+
+
+
+ )
+}
diff --git a/pages/series/[id].tsx b/pages/series/[id].tsx
new file mode 100644
index 0000000..51eacad
--- /dev/null
+++ b/pages/series/[id].tsx
@@ -0,0 +1,126 @@
+import { useRouter } from 'next/router'
+import Head from 'next/head'
+import { useEffect, useState } from 'react'
+import { getSeriesById } from '@/lib/seriesQueries'
+import { getSeriesAggregates } from '@/lib/seriesAggregation'
+import { getArticlesBySeries } from '@/lib/articleQueries'
+import type { Series, Article } from '@/types/nostr'
+import { SeriesStats } from '@/components/SeriesStats'
+import { ArticleCard } from '@/components/ArticleCard'
+import Image from 'next/image'
+import { ArticleReviews } from '@/components/ArticleReviews'
+
+function SeriesHeader({ series }: { series: Series }) {
+ return (
+
+ {series.coverUrl && (
+
+
+
+ )}
+
{series.title}
+
{series.description}
+
Catégorie : {series.category === 'science-fiction' ? 'Science-fiction' : 'Recherche scientifique'}
+
+ )
+}
+
+export default function SeriesPage() {
+ const router = useRouter()
+ const { id } = router.query
+ const seriesId = typeof id === 'string' ? id : ''
+ const { series, articles, aggregates, loading, error } = useSeriesPageData(seriesId)
+
+ if (!seriesId) {
+ return null
+ }
+ return (
+ <>
+
+ Série - zapwall4Science
+
+
+
+ {loading &&
Chargement...
}
+ {error &&
{error}
}
+ {series && (
+ <>
+
+
+
+ >
+ )}
+
+
+ >
+ )
+}
+
+function SeriesArticles({ articles }: { articles: Article[] }) {
+ if (articles.length === 0) {
+ return Aucun article pour cette série.
+ }
+ return (
+
+
Articles de la série
+
+ {articles.map((a) => (
+
+ ))}
+
+
+ )
+}
+
+function useSeriesPageData(seriesId: string) {
+ const [series, setSeries] = useState(null)
+ const [articles, setArticles] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [aggregates, setAggregates] = useState<{ sponsoring: number; purchases: number; reviewTips: number } | null>(null)
+
+ useEffect(() => {
+ if (!seriesId) {
+ return
+ }
+ const load = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const s = await getSeriesById(seriesId)
+ if (!s) {
+ setError('Série introuvable')
+ setLoading(false)
+ return
+ }
+ setSeries(s)
+ const [agg, seriesArticles] = await Promise.all([
+ getSeriesAggregates({ authorPubkey: s.pubkey, seriesId: s.id }),
+ getArticlesBySeries(s.id),
+ ])
+ setAggregates(agg)
+ setArticles(seriesArticles)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Erreur lors du chargement de la série')
+ } finally {
+ setLoading(false)
+ }
+ }
+ void load()
+ }, [seriesId])
+
+ return { series, articles, aggregates, loading, error }
+}
diff --git a/types/nostr-tools-extended.ts b/types/nostr-tools-extended.ts
index 9394d69..408404d 100644
--- a/types/nostr-tools-extended.ts
+++ b/types/nostr-tools-extended.ts
@@ -1,21 +1,13 @@
-import type { Event, Filter } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
/**
- * Extended SimplePool interface that includes the sub method
- * The sub method exists in nostr-tools but is not properly typed in the TypeScript definitions
+ * Alias for SimplePool with typed sub method from nostr-tools definitions.
+ * Using the existing type avoids compatibility issues while keeping explicit intent.
*/
export interface SimplePoolWithSub extends SimplePool {
- sub(relays: string[], filters: Filter[]): {
- on(event: 'event', callback: (event: Event) => void): void
- on(event: 'eose', callback: () => void): void
- unsub(): void
- }
+ sub: SimplePool['sub']
}
-/**
- * Type guard to check if a SimplePool has the sub method
- */
export function hasSubMethod(pool: SimplePool): pool is SimplePoolWithSub {
return typeof (pool as SimplePoolWithSub).sub === 'function'
}
diff --git a/types/nostr.ts b/types/nostr.ts
index afff2a2..babdcd8 100644
--- a/types/nostr.ts
+++ b/types/nostr.ts
@@ -1,4 +1,4 @@
-import { Event, EventTemplate } from 'nostr-tools'
+import type { Event } from 'nostr-tools'
export interface NostrProfile {
pubkey: string
@@ -10,6 +10,19 @@ export interface NostrProfile {
export type ArticleCategory = 'science-fiction' | 'scientific-research' | 'author-presentation'
+export type KindType =
+ | 'article'
+ | 'series'
+ | 'review'
+ | 'purchase'
+ | 'review_tip'
+ | 'sponsoring'
+
+export interface MediaRef {
+ url: string
+ type: 'image' | 'video'
+}
+
export interface Article {
id: string
pubkey: string
@@ -26,6 +39,10 @@ export interface Article {
mainnetAddress?: string // Bitcoin mainnet address for sponsoring (presentation articles only)
totalSponsoring?: number // Total sponsoring received in sats (presentation articles only)
authorPresentationId?: string // ID of the author's presentation article (for standard articles)
+ seriesId?: string // Series event id
+ bannerUrl?: string // NIP-95 banner
+ media?: MediaRef[] // Embedded media (NIP-95)
+ kindType?: KindType
}
export interface AuthorPresentationArticle extends Article {
@@ -35,6 +52,32 @@ export interface AuthorPresentationArticle extends Article {
totalSponsoring: number
}
+export interface Series {
+ id: string
+ pubkey: string
+ title: string
+ description: string
+ preview: string
+ coverUrl?: string
+ category: ArticleCategory
+ totalSponsoring?: number
+ totalPayments?: number
+ kindType?: KindType
+}
+
+export interface Review {
+ id: string
+ articleId: string
+ authorPubkey: string
+ reviewerPubkey: string
+ content: string
+ createdAt: number
+ title?: string
+ rewarded?: boolean
+ rewardAmount?: number
+ kindType?: KindType
+}
+
export interface ZapRequest {
event: Event
amount: number