From 2d99d69d02c5a4bcad97c7b1237bcf1b1036d533 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:09:38 +0200 Subject: [PATCH 1/2] Update README for current LAYER.systems product (#70) * Initial plan * Rewrite README for LAYER.systems Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- README.md | 313 +++++++++++++++++------------------------------------- 1 file changed, 97 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index db47200..71328e7 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,123 @@ -# MKStack +# LAYER.systems -**The Complete Framework for Building Nostr Clients with AI** +LAYER.systems is a Nostr client designed like a desktop operating system. Instead of +navigating between pages, you open apps in windows that can be moved, resized, stacked, +minimized, and kept open side by side. This makes it possible to read a thread, inspect a +profile, and keep your feed available at the same time. -MKStack is an AI-powered framework for building Nostr applications with React 18.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify. Build powerful Nostr applications with AI-first development - from social feeds to private messaging, MKStack provides everything you need to create decentralized apps on the Nostr protocol. +The app is for people who use Nostr and want a focused, multitasking-friendly interface, +as well as contributors interested in building Nostr applications with React and +TypeScript. -## πŸš€ Quick Start +## What you can do -Build your Nostr app in 3 simple steps: +- Browse a Following or Global feed, publish notes, and open notes with their replies. +- View profiles, follow or unfollow people, and search notes, replies, hashtags, and users. +- Read NIP-23 long-form articles, save notes and articles, and create web bookmarks. +- Discover picture posts and view live events with their NIP-53 chat. +- Browse Nostr calendar events by month. +- Manage relay connections, inspect connection latency, and configure relays and Blossom + media servers. +- Sign in with a Nostr account, switch between accounts, and use NIP-19 identifiers such + as `npub`, `note`, `nevent`, `nprofile`, and `naddr` as links. +- Save and share reusable Nostr queries with Spells. -### 1. Install & Create -```bash -npm install -g @getstacks/stacks -stacks mkstack -``` +LAYER.systems reads and writes Nostr events through relays. It does not provide a +centralized social database; your relays and signer remain the source of your Nostr data. +Some features require a signed-in account, and availability of content depends on the +relays you use. -### 2. Build with AI -```bash -stacks agent -# Tell Dork AI what you want: "Build a group chat application" -``` +## Run locally -### 3. Deploy Instantly -```bash -npm run deploy -# βœ… App deployed to NostrDeploy.com! -``` +### Requirements -## ✨ What Makes MKStack Special +- Node.js 22 or newer +- npm -- **πŸ€– AI-First Development**: Build complete Nostr apps with just one prompt using Dork AI agent -- **⚑ 8 Minutes Average**: From idea to deployed application in minutes, not months -- **πŸ”— 50+ NIPs Supported**: Comprehensive Nostr protocol implementation -- **🎨 Beautiful UI**: 48+ shadcn/ui components with light/dark theme support -- **πŸ” Built-in Security**: NIP-07 browser signing, NIP-44 encryption, event validation -- **πŸ’° Payments Ready**: Lightning zaps (NIP-57), Cashu wallets (NIP-60), Wallet Connect (NIP-47) -- **πŸ“± Production Ready**: TypeScript, testing, deployment, and responsive design included - -## πŸ›  Technology Stack - -- **React 18.x**: Stable version with hooks, concurrent rendering, and improved performance -- **TailwindCSS 3.x**: Utility-first CSS framework for styling -- **Vite**: Fast build tool and development server -- **shadcn/ui**: 48+ unstyled, accessible UI components built with Radix UI -- **Nostrify**: Nostr protocol framework for Deno and web -- **React Router**: Client-side routing with BrowserRouter -- **TanStack Query**: Data fetching, caching, and state management -- **TypeScript**: Type-safe JavaScript development - -## 🎯 Real-World Examples - -### Built with One Prompt - -Each of these applications was created with just a single prompt to Dork AI: - -- **Group Chat Application**: `"Build me a group chat application"` - - [Live Demo](https://groupchat-74z9j26wq-mks-projects-1f1254c4.vercel.app/) - -- **Decentralized Goodreads**: `"Build a decentralized goodreads alternative. Use OpenLibrary API for book data."` - - [Live Demo](https://bookstr123-87phkwjcy-mks-projects-1f1254c4.vercel.app/) - -- **Chess Game**: `"Build a chess game with NIP 64"` - - [Live Demo](https://chess-l0d7ms7m3-mks-projects-1f1254c4.vercel.app/chess) - -### Production Apps - -Real Nostr applications built using MKStack: - -- **[Chorus](https://chorus.community/)**: Facebook-style groups on Nostr with built-in eCash wallet -- **[Blobbi](https://www.blobbi.pet/)**: Digital pet companions that live forever on the decentralized web -- **[Treasures](https://treasures.to/)**: Decentralized geocaching adventure powered by Nostr - -[Browse more apps made with MKStack β†’](https://nostrhub.io/apps/t/mkstack/) - -## πŸ”§ Core Features - -### Authentication & Users -- `LoginArea` component with account switching -- `useCurrentUser` hook for authentication state -- `useAuthor` hook for fetching user profiles -- NIP-07 browser signing support -- Multi-account management - -### Nostr Protocol Support -- **Social Features**: User profiles (NIP-01), follow lists (NIP-02), reactions (NIP-25), reposts (NIP-18) -- **Messaging**: Private DMs (NIP-17), public chat (NIP-28), group chat (NIP-29), encryption (NIP-44) -- **Payments**: Lightning zaps (NIP-57), Cashu wallets (NIP-60), Nutzaps (NIP-61), Wallet Connect (NIP-47) -- **Content**: Long-form articles (NIP-23), file metadata (NIP-94), live events (NIP-53), calendars (NIP-52) - -### Data Management -- `useNostr` hook for querying and publishing -- `useNostrPublish` hook with automatic client tagging -- Event validation and filtering -- Infinite scroll with TanStack Query -- Multi-relay support - -### UI Components -- 48+ shadcn/ui components (buttons, forms, dialogs, etc.) -- `NoteContent` component for rich text rendering -- `EditProfileForm` for profile management -- `RelaySelector` for relay switching -- `CommentsSection` for threaded discussions -- Light/dark theme system - -### Media & Files -- `useUploadFile` hook with Blossom server integration -- NIP-94 compatible file metadata -- Image and video support -- File attachment to events - -### Advanced Features -- NIP-19 identifier routing (`npub1`, `note1`, `nevent1`, `naddr1`) -- Cryptographic operations (encryption/decryption) -- Lightning payments and zaps -- Real-time event subscriptions -- Responsive design with mobile support - -## πŸ€– AI Development with Dork - -MKStack includes Dork, a built-in AI agent that understands your codebase and Nostr protocols: - -### Supported AI Providers - -Configure your AI provider with `stacks configure`: - -- **OpenRouter** ([openrouter.ai](https://openrouter.ai/)): Enter your API key from settings -- **Routstr** ([routstr.com](https://www.routstr.com/)): Use Cashu tokens for payment -- **PayPerQ** ([ppq.ai](https://ppq.ai/)): OpenAI-compatible API - -### How Dork Works - -- **Context-Aware**: Understands your entire codebase and project structure -- **Nostr Expert**: Built-in knowledge of 50+ NIPs and best practices -- **Instant Implementation**: Makes changes directly to your code following React/TypeScript best practices - -Example prompts: -```bash -"Add user profiles with avatars and bio" -"Implement NIP-17 private messaging" -"Add a dark mode toggle" -"Create a marketplace with NIP-15" -``` - -## πŸ“ Project Structure - -``` -src/ -β”œβ”€β”€ components/ # UI components -β”‚ β”œβ”€β”€ ui/ # shadcn/ui components (48+ available) -β”‚ β”œβ”€β”€ auth/ # Authentication components -β”‚ └── comments/ # Comment system components -β”œβ”€β”€ hooks/ # Custom React hooks -β”‚ β”œβ”€β”€ useNostr # Core Nostr integration -β”‚ β”œβ”€β”€ useAuthor # User profile data -β”‚ β”œβ”€β”€ useCurrentUser # Authentication state -β”‚ β”œβ”€β”€ useNostrPublish # Event publishing -β”‚ β”œβ”€β”€ useUploadFile # File uploads -β”‚ └── useZaps # Lightning payments -β”œβ”€β”€ pages/ # Page components -β”œβ”€β”€ lib/ # Utility functions -β”œβ”€β”€ contexts/ # React context providers -└── test/ # Testing utilities -``` - -## 🎨 UI Components - -MKStack includes 48+ shadcn/ui components: - -**Layout**: Card, Separator, Sheet, Sidebar, ScrollArea, Resizable -**Navigation**: Breadcrumb, NavigationMenu, Menubar, Tabs, Pagination -**Forms**: Button, Input, Textarea, Select, Checkbox, RadioGroup, Switch, Slider -**Feedback**: Alert, AlertDialog, Toast, Progress, Skeleton -**Overlay**: Dialog, Popover, HoverCard, Tooltip, ContextMenu, DropdownMenu -**Data Display**: Table, Avatar, Badge, Calendar, Chart, Carousel -**And many more... - -## πŸ” Security & Best Practices - -- **Never use `any` type**: Always use proper TypeScript types -- **Event validation**: Filter events through validator functions for custom kinds -- **Efficient queries**: Minimize separate queries to avoid rate limiting -- **Proper error handling**: Graceful handling of invalid NIP-19 identifiers -- **Secure authentication**: Use signer interface, never request private keys directly - -## πŸ“± Responsive Design - -- Mobile-first approach with Tailwind breakpoints -- `useIsMobile` hook for responsive behavior -- Touch-friendly interactions -- Optimized for all screen sizes - -## πŸ§ͺ Testing - -- Vitest with jsdom environment -- React Testing Library with jest-dom matchers -- `TestApp` component provides all necessary context providers -- Mocked browser APIs (matchMedia, scrollTo, IntersectionObserver, ResizeObserver) - -## πŸš€ Deployment - -Built-in deployment to NostrDeploy.com: +Clone the repository, install dependencies, and start the Vite development server: ```bash -npm run deploy +git clone https://github.com/layer-systems/website.git +cd website +npm install +npm run dev ``` -Your app goes live instantly with: -- Automatic builds -- CDN distribution -- HTTPS support -- Custom domains available +The development server listens on . The app has no required +environment variables; relay and media-server settings are managed in the app. -## πŸ“š Documentation +Useful commands: -For detailed documentation on building Nostr applications with MKStack: +```bash +npm run test # Type-check, lint, run Vitest, and create a production build +npm run build # Create the production bundle in dist/ +``` -- [Tutorial](https://soapbox.pub/blog/mkstack-tutorial) -- [Nostr Protocol Documentation](https://nostr.com) -- [shadcn/ui Components](https://ui.shadcn.com) +`npm run build` also creates `dist/404.html`, which supports client-side routes when the +build is deployed as a static site. -## 🀝 Contributing +## Production deployment -MKStack is open source and welcomes contributions. The framework is designed to be: +The repository includes a GitHub Actions workflow in +[`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml). It runs on pushes to +`main` (or manually from the Actions tab), builds the app with Node.js 22, and deploys +`dist/` to GitHub Pages. -- **Extensible**: Easy to add new NIPs and features -- **Maintainable**: Clean architecture with TypeScript -- **Testable**: Comprehensive testing setup included -- **Documented**: Clear patterns and examples +For another static host, run `npm run build` and publish the generated `dist/` directory. +Configure the host to serve `dist/404.html` for unknown paths so direct NIP-19 links +continue to load the client application. -## πŸ“„ License +## Technology -MKStack is dedicated to the **public domain**. +- [React 19](https://react.dev/) and [TypeScript](https://www.typescriptlang.org/) +- [Vite](https://vite.dev/) for development and production builds +- [Tailwind CSS](https://tailwindcss.com/) and [shadcn/ui](https://ui.shadcn.com/) for UI +- [Nostrify](https://www.npmjs.com/package/@nostrify/nostrify) and + [nostr-tools](https://github.com/nbd-wtf/nostr-tools) for Nostr integration +- [React Router](https://reactrouter.com/) for the root and NIP-19 routes +- [TanStack Query](https://tanstack.com/query) for fetching and caching +- [Vitest](https://vitest.dev/) and + [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) + for tests -To the extent possible under law, the authors have waived all copyright and related or neighboring rights to MKStack. You are free to copy, modify, distribute, and use this software for any purpose, commercial or non-commercial, without asking permission and without attribution. +The application is organized into three main layers: -Build amazing Nostr applications and help grow the decentralized web! +```text +src/os/ Window manager, persistence, layout, and keyboard shortcuts +src/components/os/ Desktop shell, menu bar, windows, and mobile app shell +src/apps/ Nostr and system apps rendered inside windows +src/components/ Shared UI, authentication, and Nostr components +src/hooks/ Data access and application hooks +``` ---- +Apps are registered in [`src/os/registry.ts`](./src/os/registry.ts). Each app is lazy +loaded and receives a small window-oriented contract, so adding an app normally does not +require changing the router. Read [the app guide](./docs/apps.md) before adding one. -**"Vibed with MKStack"** - [Learn more about MKStack](https://soapbox.pub/mkstack) +## Documentation -*Build your Nostr app in minutes, not months. Start with AI, deploy instantly.* \ No newline at end of file +- [Documentation index](./docs/README.md) +- [Window manager](./docs/window-manager.md) +- [App development](./docs/apps.md) +- [Nostr data access and safety](./docs/nostr.md) +- [Style guide](./docs/styleguide.md) +- [Project architecture and design decisions](./PLAN.md) + +For protocol reference, see the [Nostr protocol documentation](https://nostr.com/). + +## Contributing + +1. Create a branch for your change. +2. Install dependencies with `npm install`. +3. Make the smallest focused change that fits the existing architecture. +4. Run `npm run test`. +5. Open a pull request describing the behavior you changed and how you verified it. + +When working with Nostr content, treat events, URLs, and profile metadata as untrusted +input. Follow the validation and URL-sanitization patterns in +[`docs/nostr.md`](./docs/nostr.md), and do not use `any` in TypeScript. + +## License + +LAYER.systems is dedicated to the public domain. You are free to copy, modify, +distribute, and use it for any purpose, commercial or non-commercial. From 8d594d7d6968c3d175eb440c3ccb82c9fb284140 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:16:10 +0200 Subject: [PATCH 2/2] Add Lightning zaps with optional Nostr Wallet Connect (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Lightning zaps with optional Nostr Wallet Connect Signed-in users can zap a note or reply, and see its zap total, from the feed, a thread view, and replies. Zapping opens a dialog to pick an amount (presets or custom) and an optional comment, then: - Builds and signs a NIP-57 zap request and fetches an invoice from the recipient's LNURL/lud16 callback (rejecting non-https endpoints). - If a wallet is connected via Nostr Wallet Connect (NIP-47, Settings > Lightning wallet), pays the invoice automatically and only reports success once the wallet returns a payment preimage. - Otherwise shows the invoice as a QR code plus a copy/`lightning:` link for the user's own wallet, and polls for a matching zap receipt to confirm payment without ever asserting success it can't verify. Zap totals sum kind-9735 receipts defensively: a receipt only counts if it carries a bolt11 amount and a description whose embedded zap request is a well-formed, signature-valid event, so a malformed or forged receipt can't inflate the total. The NWC connection secret is stored only in this browser, scoped to the signed-in pubkey, and is used solely to sign/send payment requests to the wallet's own relay β€” never published, logged, or shown besides a truncated pubkey once connected. Closes #54 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * Address PR #62 review feedback: NWC relay security, N+1 zap queries, manual-payment confirmation Addresses Copilot review comments on PR #62: - nwc.ts: reject plaintext ws:// Wallet Connect relays, requiring wss://. Every request is signed by the connection secret (a private key); even with an encrypted payload, an unencrypted transport still leaks metadata about the connection and admits tampering. - ZapButton/useZaps: fixed the N+1 query pattern β€” a feed page mounted one unconditional zap-receipts query (limit 500) per rendered note. Added a `revealed` gate so the query only fires once a note is actually hovered or focused (the same interaction that already reveals the action row via CSS), confirmed live: 0 queries fired across 49 mounted notes before any interaction, exactly 1 after hovering one. - ZapDialog: manual-payment confirmation compared the note's total receipt *count* against a baseline, so anyone else zapping the same note while the dialog waited would falsely confirm the viewer's own unpaid invoice. Added `hasValidReceiptForInvoice` to match against the specific invoice instead, with unit tests covering the exact race the review described. - useZaps.ts: corrected a docstring overclaiming that receipt validation prevents "forged" receipts from inflating totals β€” it only rules out structurally invalid data; NIP-57 receipts are vouched for by the recipient's own LNURL server, so trusting one is inherent to the protocol, not something client-side validation can prove. Investigated but did not change: the review's claim that `nip04.decrypt(...)` needs an `await` because it returns a Promise. Not correct for this project's actual `nostr-tools` dependency β€” confirmed by running the real encrypt/decrypt round trip, `decrypt` is synchronous and returns the plaintext string directly, so the existing `JSON.parse` call already worked. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto * Merge main and resolve action conflicts Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: highperfocused Co-authored-by: Claude Sonnet 5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- package-lock.json | 11 -- src/apps/notes/index.tsx | 2 + src/apps/settings/index.tsx | 80 +++++++++- src/components/nostr/NoteCard.tsx | 9 ++ src/components/nostr/ZapButton.tsx | 69 +++++++++ src/components/nostr/ZapDialog.tsx | 231 +++++++++++++++++++++++++++++ src/hooks/useNwc.ts | 46 ++++++ src/hooks/useZaps.test.ts | 112 ++++++++++++++ src/hooks/useZaps.ts | 162 ++++++++++++++++++++ src/lib/nwc.test.ts | 53 +++++++ src/lib/nwc.ts | 111 ++++++++++++++ 11 files changed, 874 insertions(+), 12 deletions(-) create mode 100644 src/components/nostr/ZapButton.tsx create mode 100644 src/components/nostr/ZapDialog.tsx create mode 100644 src/hooks/useNwc.ts create mode 100644 src/hooks/useZaps.test.ts create mode 100644 src/hooks/useZaps.ts create mode 100644 src/lib/nwc.test.ts create mode 100644 src/lib/nwc.ts diff --git a/package-lock.json b/package-lock.json index 36fc8ce..55ebf24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6069,7 +6069,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6090,7 +6089,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6111,7 +6109,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6132,7 +6129,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6153,7 +6149,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6174,7 +6169,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6195,7 +6189,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6216,7 +6209,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6237,7 +6229,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6258,7 +6249,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6279,7 +6269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/apps/notes/index.tsx b/src/apps/notes/index.tsx index a88872d..c706c25 100644 --- a/src/apps/notes/index.tsx +++ b/src/apps/notes/index.tsx @@ -7,6 +7,7 @@ import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppC import { AuthorLine } from '@/components/nostr/AuthorLine'; import { NoteContent } from '@/components/nostr/NoteContent'; import { NoteCard } from '@/components/nostr/NoteCard'; +import { ZapButton } from '@/components/nostr/ZapButton'; import { ReactionButton } from '@/components/nostr/ReactionButton'; import { Composer } from '@/apps/feed/Composer'; import { DraftNote } from './Draft'; @@ -124,6 +125,7 @@ export default function NotesApp({ params, setTitle, setParams }: AppProps) {

{absoluteTime(event.created_at)}

+
diff --git a/src/apps/settings/index.tsx b/src/apps/settings/index.tsx index e5bcdd4..6192bbb 100644 --- a/src/apps/settings/index.tsx +++ b/src/apps/settings/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Check, Plus, Trash2 } from 'lucide-react'; +import { Check, Plus, Trash2, Zap } from 'lucide-react'; import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,6 +10,7 @@ import { LoginArea } from '@/components/auth/LoginArea'; import { useAppContext } from '@/hooks/useAppContext'; import { useTheme } from '@/hooks/useTheme'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNwcConnection } from '@/hooks/useNwc'; import { useWindowManager } from '@/os/useWindowManager'; import { desktopApps } from '@/os/registry'; import { useIconLayout } from '@/os/useIconLayout'; @@ -44,6 +45,8 @@ export default function SettingsApp({ setTitle }: AppProps) { + + @@ -304,6 +307,81 @@ function MediaSection() { ); } +function WalletSection() { + const { user } = useCurrentUser(); + const { connection, connect, disconnect } = useNwcConnection(); + const { toast } = useToast(); + const [draft, setDraft] = useState(''); + + const handleConnect = () => { + try { + connect(draft); + setDraft(''); + toast({ title: 'Wallet connected' }); + } catch (error) { + toast({ + title: 'Could not connect that wallet', + description: error instanceof Error ? error.message : undefined, + variant: 'destructive', + }); + } + }; + + const handleDisconnect = () => { + disconnect(); + toast({ title: 'Wallet disconnected' }); + }; + + return ( +
+ {!user ? ( +

+ Sign in to connect a wallet. +

+ ) : connection ? ( +
+
+ +
+

Connected

+

+ {connection.pubkey.slice(0, 12)}… via {connection.relay.replace(/^wss:\/\//, '')} +

+
+
+ +
+ ) : ( +
+
+ setDraft(event.target.value)} + onKeyDown={(event) => event.key === 'Enter' && handleConnect()} + placeholder="nostr+walletconnect://…" + className="font-mono text-xs" + aria-label="Nostr Wallet Connect link" + /> + +
+

+ Paste a connection link from your wallet (e.g. Alby, Mutiny). It is stored only in this browser and used + solely to sign and send payment requests to your wallet's relay β€” never published or shared elsewhere. + Without a connected wallet, zapping still works: you'll pay each invoice manually. +

+
+ )} +
+ ); +} + function SessionSection() { const { resetSession } = useWindowManager(); const { toast } = useToast(); diff --git a/src/components/nostr/NoteCard.tsx b/src/components/nostr/NoteCard.tsx index 59e746b..7fb138d 100644 --- a/src/components/nostr/NoteCard.tsx +++ b/src/components/nostr/NoteCard.tsx @@ -1,9 +1,11 @@ +import { useState } from 'react'; import { MessageSquare, Repeat2 } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; import { AuthorLine } from './AuthorLine'; import { NoteContent } from './NoteContent'; import { BookmarkButton } from './BookmarkButton'; +import { ZapButton } from './ZapButton'; import { ReactionButton } from './ReactionButton'; import { Button } from '@/components/ui/button'; import { useWindowManager } from '@/os/useWindowManager'; @@ -26,6 +28,10 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { const { openApp } = useWindowManager(); const { toast } = useToast(); const hints = useRelayHints(); + // Tracks the same interaction that reveals the action row via CSS + // (`group-hover`/`focus-within`), so `ZapButton` can defer its relay query + // until this note is actually looked at instead of firing on every mount. + const [revealed, setRevealed] = useState(false); const copyLink = async () => { try { @@ -45,6 +51,8 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { 'group border-b border-border px-4 py-3 transition-colors last:border-b-0 hover:bg-muted/40', className, )} + onMouseEnter={() => setRevealed(true)} + onFocus={() => setRevealed(true)} > @@ -71,6 +79,7 @@ export function NoteCard({ event, compact, className }: NoteCardProps) { Copy link + diff --git a/src/components/nostr/ZapButton.tsx b/src/components/nostr/ZapButton.tsx new file mode 100644 index 0000000..0579289 --- /dev/null +++ b/src/components/nostr/ZapButton.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Zap } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import AuthDialog from '@/components/auth/AuthDialog'; +import { ZapDialog } from './ZapDialog'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useZapReceipts, summarizeZapReceipts, formatSats } from '@/hooks/useZaps'; +import { cn } from '@/lib/utils'; + +/** + * Shows a note's zap total and opens the zap flow (NIP-57), from the feed or + * a thread. + * + * `revealed` gates the zap-receipts query itself, not just the total's + * visibility: a feed page mounts one of these per note, and firing every + * one's relay query unconditionally on mount turns a page of notes into a + * page of concurrent queries before anyone's looked at any of them. Callers + * in a list (`NoteCard`) pass `revealed` once the row is actually hovered or + * focused β€” the same interaction that already reveals the row via CSS β€” so + * off-screen or never-looked-at notes never fetch. A caller showing the + * button on its own (the thread root) can just leave it `true`. + */ +export function ZapButton({ target, className, revealed = true }: { target: NostrEvent; className?: string; revealed?: boolean }) { + const { user } = useCurrentUser(); + const [authOpen, setAuthOpen] = useState(false); + const [zapOpen, setZapOpen] = useState(false); + const receipts = useZapReceipts(target.id, { enabled: revealed || zapOpen }); + const recipient = useAuthor(target.pubkey); + + const { totalSats } = summarizeZapReceipts(receipts.data); + + const handleClick = () => { + if (!user) { + setAuthOpen(true); + return; + } + setZapOpen(true); + }; + + return ( + <> + + + setAuthOpen(false)} /> + + {zapOpen && ( + setZapOpen(false)} + target={target} + recipientMetadata={recipient.data?.event} + recipientMetadataLoading={recipient.isLoading} + /> + )} + + ); +} diff --git a/src/components/nostr/ZapDialog.tsx b/src/components/nostr/ZapDialog.tsx new file mode 100644 index 0000000..f6a5e85 --- /dev/null +++ b/src/components/nostr/ZapDialog.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { CheckCircle2, Copy, Loader2, XCircle, Zap } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { QRCodeCanvas } from '@/components/ui/qrcode'; +import { useToast } from '@/hooks/useToast'; +import { useCreateZapInvoice, useZapReceipts, hasValidReceiptForInvoice, formatSats } from '@/hooks/useZaps'; +import { useNwcConnection, usePayWithNwc } from '@/hooks/useNwc'; +import { cn } from '@/lib/utils'; + +const PRESET_AMOUNTS = [21, 100, 500, 1_000, 5_000, 21_000]; + +type Stage = 'amount' | 'requesting' | 'paying' | 'manual' | 'paid' | 'failed'; + +interface ZapDialogProps { + open: boolean; + onClose: () => void; + /** The note or reply being zapped. */ + target: NostrEvent; + /** The recipient's kind-0 event, so a zap endpoint can be resolved. */ + recipientMetadata: NostrEvent | undefined; + recipientMetadataLoading: boolean; +} + +export function ZapDialog({ open, onClose, target, recipientMetadata, recipientMetadataLoading }: ZapDialogProps) { + const { toast } = useToast(); + const { connection } = useNwcConnection(); + const createInvoice = useCreateZapInvoice(); + const payWithNwc = usePayWithNwc(); + + const [stage, setStage] = useState('amount'); + const [amount, setAmount] = useState(21); + const [customAmount, setCustomAmount] = useState(''); + const [comment, setComment] = useState(''); + const [invoice, setInvoice] = useState(null); + const [amountSats, setAmountSats] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + + // Re-fetched on an interval only while this dialog is showing an unpaid + // manual invoice. Whether that now means "paid" is derived at render time + // below instead of copied into state, so there is nothing to keep in sync + // by hand. Matched against the specific invoice, not just a receipt-count + // increase β€” someone else zapping the same note while this dialog waits + // must not be mistaken for this payment completing. + const receipts = useZapReceipts(target.id, { refetchInterval: stage === 'manual' ? 4_000 : false }); + const manualPaymentConfirmed = stage === 'manual' && invoice !== null && hasValidReceiptForInvoice(receipts.data, invoice); + const effectiveStage: Stage = manualPaymentConfirmed ? 'paid' : stage; + + const resolvedAmount = customAmount.trim() ? Number(customAmount) : amount; + const canSubmit = Boolean(recipientMetadata) && Number.isFinite(resolvedAmount) && (resolvedAmount ?? 0) > 0; + + const handleSubmit = async () => { + if (!recipientMetadata || !resolvedAmount || resolvedAmount <= 0) return; + setStage('requesting'); + setErrorMessage(''); + + try { + const result = await createInvoice.mutateAsync({ + target, + recipientMetadata, + amountSats: resolvedAmount, + comment: comment.trim() || undefined, + }); + setInvoice(result.invoice); + setAmountSats(result.amountSats); + + if (connection) { + setStage('paying'); + try { + await payWithNwc.mutateAsync({ connection, invoice: result.invoice }); + setStage('paid'); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Your wallet did not complete the payment.'); + setStage('manual'); + } + } else { + setStage('manual'); + } + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Could not request an invoice.'); + setStage('failed'); + } + }; + + const copyInvoice = async () => { + if (!invoice) return; + try { + await navigator.clipboard.writeText(invoice); + toast({ title: 'Invoice copied' }); + } catch { + toast({ title: 'Could not copy the invoice', variant: 'destructive' }); + } + }; + + return ( + !next && onClose()}> + + + + + Zap this note + + Send a Lightning zap for this note. + + + {effectiveStage === 'amount' && ( +
+
+ {PRESET_AMOUNTS.map((preset) => ( + + ))} +
+ +
+ + setCustomAmount(event.target.value)} + placeholder="1000" + /> +
+ +
+ +