Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.1.0",
"@nostr-dev-kit/ndk": "^2.18.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
Expand Down
61 changes: 58 additions & 3 deletions src/components/ghostNote/GhostNoteCompose.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Send, Loader2, Copy, Check, X } from 'lucide-react'
import { Send, Loader2, Copy, Check, X, Zap } from 'lucide-react'
import { GhostNoteIcon } from '@/components/common/GhostNoteIcon'
import {
Dialog,
Expand All @@ -23,9 +23,11 @@ import {
type SearchProfile,
} from '@/services/profileSearchService'
import { toast } from '@/hooks/useToast'
import { useWalletStore } from '@/stores/walletStore'
import { EXPIRATION_OPTIONS, DEFAULT_EXPIRATION_SECONDS } from '@/types/ghostNote'

const MAX_CONTENT_LENGTH = 500
const MAX_PAID_CONTENT_LENGTH = 10000

interface GhostNoteComposeProps {
open: boolean
Expand All @@ -40,6 +42,7 @@ export function GhostNoteCompose({
}: GhostNoteComposeProps) {
const { user } = useAuthStore()
const { addGhostNote, settings } = useGhostNoteStore()
const walletStatus = useWalletStore((s) => s.status)

const [recipientNpub, setRecipientNpub] = useState('')
const [selectedProfile, setSelectedProfile] = useState<SearchProfile | null>(
Expand All @@ -50,6 +53,8 @@ export function GhostNoteCompose({
settings.defaultExpiration || DEFAULT_EXPIRATION_SECONDS
)
const [isSending, setIsSending] = useState(false)
const [isPaid, setIsPaid] = useState(false)
const [priceSats, setPriceSats] = useState('')

// Success state
const [showSuccess, setShowSuccess] = useState(false)
Expand All @@ -64,6 +69,8 @@ export function GhostNoteCompose({
setShowSuccess(false)
setSuccessLink('')
setCopiedLink(false)
setIsPaid(false)
setPriceSats('')

if (initialRecipient) {
setSelectedProfile(initialRecipient)
Expand Down Expand Up @@ -122,11 +129,14 @@ export function GhostNoteCompose({
throw new Error('Invalid recipient')
}

const price = isPaid && priceSats ? parseInt(priceSats) : undefined

const result = await createGhostNote({
recipientPubkey,
content: content.trim(),
expirationSeconds,
notifyOnRead: true, // Always notify sender when read
priceSats: price && price > 0 ? price : undefined,
})

if (result.success && result.ghostNote) {
Expand Down Expand Up @@ -187,7 +197,8 @@ export function GhostNoteCompose({
return option?.label || `${Math.floor(seconds / 3600)} hours`
}

const remainingChars = MAX_CONTENT_LENGTH - content.length
const maxLength = isPaid ? MAX_PAID_CONTENT_LENGTH : MAX_CONTENT_LENGTH
const remainingChars = maxLength - content.length
const isOverLimit = remainingChars < 0

// Success view
Expand Down Expand Up @@ -326,7 +337,7 @@ export function GhostNoteCompose({
placeholder="Type your secret message..."
rows={4}
className="resize-none"
maxLength={MAX_CONTENT_LENGTH + 50} // Allow some overage for visual feedback
maxLength={maxLength + 50} // Allow some overage for visual feedback
/>
</div>

Expand Down Expand Up @@ -360,6 +371,50 @@ export function GhostNoteCompose({
</RadioGroup>
</div>

{/* Lightning Payment Option */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-yellow-500" />
Require Payment
</Label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={isPaid}
onChange={(e) => setIsPaid(e.target.checked)}
className="sr-only peer"
disabled={walletStatus !== 'connected'}
/>
<div className="w-9 h-5 bg-muted rounded-full peer peer-checked:bg-yellow-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full" />
</label>
</div>
{walletStatus !== 'connected' && (
<p className="text-xs text-muted-foreground">
Set up your Lightning wallet in Settings to enable paid Ghost Notes
</p>
)}
{isPaid && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="number"
value={priceSats}
onChange={(e) => setPriceSats(e.target.value)}
placeholder="Amount in sats"
min="1"
max="1000000"
className="flex-1 px-3 py-2 text-sm rounded-md border bg-background"
/>
<span className="text-sm text-muted-foreground">sats</span>
</div>
<p className="text-xs text-muted-foreground">
Payment goes directly to your self-custodial Spark wallet. No limit on message length for paid notes.
</p>
</div>
)}
</div>

<p className="text-xs text-muted-foreground">
You'll be notified when the recipient reads this message. You can manually revoke access anytime before expiration.
</p>
Expand Down
26 changes: 25 additions & 1 deletion src/components/ghostNote/GhostNoteViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
import { fetchProfile, getDisplayName } from '@/services/profileSearchService'
import { toast } from '@/hooks/useToast'
import type { GhostNote } from '@/types/ghostNote'
import { PaymentGate } from '@/components/lightning/PaymentGate'
import { hasPaymentProof } from '@/lib/lightning/paymentVerifier'
import { v4 as uuidv4 } from 'uuid'

export function GhostNoteViewer() {
Expand All @@ -35,6 +37,7 @@ export function GhostNoteViewer() {
const [decryptedMessage, setDecryptedMessage] = useState<string | null>(null)
const [copiedContent, setCopiedContent] = useState(false)
const [viewTimer, setViewTimer] = useState<number>(0)
const [paymentConfirmed, setPaymentConfirmed] = useState(false)

const VIEW_TIMEOUT_SECONDS = 60

Expand Down Expand Up @@ -107,6 +110,15 @@ export function GhostNoteViewer() {
return
}

// Extract Lightning payment fields
const priceTag = event.tags.find((t: string[]) => t[0] === 'price')
const bolt11Tag = event.tags.find((t: string[]) => t[0] === 'bolt11')
const paymentHashTag = event.tags.find((t: string[]) => t[0] === 'payment_hash')

const priceSats = priceTag ? parseInt(priceTag[1] || '') : undefined
const bolt11 = bolt11Tag?.[1]
const paymentHash = paymentHashTag?.[1]

// Create ghost note object
const note: GhostNote = {
id: uuidv4(),
Expand All @@ -124,6 +136,9 @@ export function GhostNoteViewer() {
revokedAt: null,
relayUrls: [],
notificationEnabled: false,
priceSats,
bolt11,
paymentHash,
}

// Add to store
Expand Down Expand Up @@ -352,7 +367,16 @@ export function GhostNoteViewer() {
</div>
</div>

{!decryptedMessage ? (
{/* Payment gate for paid Ghost Notes */}
{!decryptedMessage && ghostNote.priceSats && ghostNote.bolt11 && ghostNote.paymentHash && !paymentConfirmed && !hasPaymentProof(ghostNote.dTag) ? (
<PaymentGate
ghostNoteDTag={ghostNote.dTag}
amountSats={ghostNote.priceSats}
bolt11={ghostNote.bolt11}
paymentHash={ghostNote.paymentHash}
onPaymentConfirmed={() => setPaymentConfirmed(true)}
/>
) : !decryptedMessage ? (
// Pre-decrypt view
<>
{isExpired ? (
Expand Down
56 changes: 56 additions & 0 deletions src/components/lightning/InvoiceDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* InvoiceDisplay - Shows a Lightning invoice with copy button
* Used in wallet management and payment confirmation screens
*/

import { useState } from "react";
import { Copy, Check, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/useToast";

interface InvoiceDisplayProps {
bolt11: string;
amountSats: number;
label?: string;
}

export function InvoiceDisplay({ bolt11, amountSats, label }: InvoiceDisplayProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(bolt11);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ title: "Copy failed", variant: "destructive" });
}
};

return (
<div className="space-y-2">
{label && (
<p className="text-sm font-medium flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-yellow-500" />
{label}
</p>
)}
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<div className="flex-1 min-w-0">
<p className="text-xs font-mono truncate">{bolt11}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{amountSats.toLocaleString()} sats
</p>
</div>
<Button variant="ghost" size="icon" onClick={handleCopy} className="flex-shrink-0">
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{/* TODO: Add QR code rendering */}
</div>
);
}
Loading