13 changed files with 535 additions and 49 deletions
@ -0,0 +1,155 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogDescription, |
||||||
|
DialogFooter, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { |
||||||
|
Drawer, |
||||||
|
DrawerContent, |
||||||
|
DrawerFooter, |
||||||
|
DrawerHeader, |
||||||
|
DrawerTitle |
||||||
|
} from '@/components/ui/drawer' |
||||||
|
import { createPublicMessageDraftEvent } from '@/lib/draft-event' |
||||||
|
import { showSimplePublishSuccess } from '@/lib/publishing-feedback' |
||||||
|
import { pubkeyToNpub } from '@/lib/pubkey' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { LoginRequiredError } from '@/lib/nostr-errors' |
||||||
|
import { useEffect, useRef, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { toast } from 'sonner' |
||||||
|
import UserAvatar from '../UserAvatar' |
||||||
|
import Username from '../Username' |
||||||
|
|
||||||
|
const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!' |
||||||
|
|
||||||
|
export default function TipPublicMessagePrompt({ |
||||||
|
open, |
||||||
|
onOpenChange, |
||||||
|
recipientPubkey |
||||||
|
}: { |
||||||
|
open: boolean |
||||||
|
onOpenChange: (open: boolean) => void |
||||||
|
recipientPubkey: string | null |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const { publish, checkLogin, pubkey: selfPubkey } = useNostr() |
||||||
|
const [sending, setSending] = useState(false) |
||||||
|
const cancelRef = useRef<HTMLButtonElement>(null) |
||||||
|
|
||||||
|
const tipText = t(TIP_NOTICE_DEFAULT_KEY) |
||||||
|
const npub = recipientPubkey ? pubkeyToNpub(recipientPubkey) : null |
||||||
|
const previewContent = npub ? `nostr:${npub} ${tipText}` : tipText |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!open) return |
||||||
|
const id = requestAnimationFrame(() => { |
||||||
|
cancelRef.current?.focus() |
||||||
|
}) |
||||||
|
return () => cancelAnimationFrame(id) |
||||||
|
}, [open]) |
||||||
|
|
||||||
|
const handleSend = () => { |
||||||
|
if (!recipientPubkey) return |
||||||
|
checkLogin(async () => { |
||||||
|
if (selfPubkey === recipientPubkey) { |
||||||
|
onOpenChange(false) |
||||||
|
return |
||||||
|
} |
||||||
|
setSending(true) |
||||||
|
try { |
||||||
|
const draft = await createPublicMessageDraftEvent(previewContent, [recipientPubkey], { |
||||||
|
addClientTag: true |
||||||
|
}) |
||||||
|
await publish(draft, { disableFallbacks: true }) |
||||||
|
showSimplePublishSuccess(t('Tip notice sent')) |
||||||
|
onOpenChange(false) |
||||||
|
} catch (error) { |
||||||
|
if (error instanceof LoginRequiredError) return |
||||||
|
toast.error( |
||||||
|
t('Failed to send tip notice', { |
||||||
|
error: error instanceof Error ? error.message : String(error) |
||||||
|
}) |
||||||
|
) |
||||||
|
} finally { |
||||||
|
setSending(false) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const body = ( |
||||||
|
<> |
||||||
|
<p className="text-sm font-medium text-foreground">{t('Tip notice success only note')}</p> |
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{t('Tip notice prompt description')}</p> |
||||||
|
<p className="mt-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm break-words">{previewContent}</p> |
||||||
|
</> |
||||||
|
) |
||||||
|
|
||||||
|
const actions = ( |
||||||
|
<> |
||||||
|
<Button |
||||||
|
ref={cancelRef} |
||||||
|
type="button" |
||||||
|
variant="default" |
||||||
|
onClick={() => onOpenChange(false)} |
||||||
|
disabled={sending} |
||||||
|
> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="outline" |
||||||
|
onClick={handleSend} |
||||||
|
disabled={sending || !recipientPubkey} |
||||||
|
> |
||||||
|
{t('Send')} |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
) |
||||||
|
|
||||||
|
if (!recipientPubkey) return null |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}> |
||||||
|
<DrawerContent className="px-4 pb-6" onOpenAutoFocus={(e) => e.preventDefault()}> |
||||||
|
<DrawerHeader> |
||||||
|
<DrawerTitle className="flex items-center gap-2"> |
||||||
|
{t('Tip notice prompt title')} |
||||||
|
<UserAvatar size="small" userId={recipientPubkey} /> |
||||||
|
<Username userId={recipientPubkey} className="truncate" /> |
||||||
|
</DrawerTitle> |
||||||
|
</DrawerHeader> |
||||||
|
<div className="px-0 pb-4">{body}</div> |
||||||
|
<DrawerFooter className="flex-row justify-end gap-2 pt-2">{actions}</DrawerFooter> |
||||||
|
</DrawerContent> |
||||||
|
</Drawer> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}> |
||||||
|
<DialogContent |
||||||
|
className="sm:max-w-md" |
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()} |
||||||
|
> |
||||||
|
<DialogHeader> |
||||||
|
<DialogTitle className="flex items-center gap-2"> |
||||||
|
{t('Tip notice prompt title')} |
||||||
|
<UserAvatar size="small" userId={recipientPubkey} /> |
||||||
|
<Username userId={recipientPubkey} className="truncate" /> |
||||||
|
</DialogTitle> |
||||||
|
<DialogDescription>{t('Tip notice prompt description')}</DialogDescription> |
||||||
|
</DialogHeader> |
||||||
|
{body} |
||||||
|
<DialogFooter className="gap-2 sm:gap-2">{actions}</DialogFooter> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
import { |
||||||
|
getAlternativePaymentMethods, |
||||||
|
groupPaymentMethodsByDisplayType, |
||||||
|
mergePaymentMethods, |
||||||
|
sortMergedPaymentMethods, |
||||||
|
type PaymentMethodGroup |
||||||
|
} from '@/lib/merge-payment-methods' |
||||||
|
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata' |
||||||
|
import client, { replaceableEventService } from '@/services/client.service' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
import type { TPaymentInfo } from '@/types' |
||||||
|
import type { TProfile } from '@/types' |
||||||
|
|
||||||
|
/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */ |
||||||
|
export function useRecipientAlternativePayments( |
||||||
|
recipientPubkey: string | undefined, |
||||||
|
enabled: boolean |
||||||
|
): PaymentMethodGroup[] { |
||||||
|
const [paymentInfo, setPaymentInfo] = useState<TPaymentInfo | null>(null) |
||||||
|
const [profile, setProfile] = useState<TProfile | null>(null) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!enabled || !recipientPubkey) { |
||||||
|
setPaymentInfo(null) |
||||||
|
setProfile(null) |
||||||
|
return |
||||||
|
} |
||||||
|
let cancelled = false |
||||||
|
void (async () => { |
||||||
|
try { |
||||||
|
const [paymentEvent, metaEvent] = await Promise.all([ |
||||||
|
client.fetchPaymentInfoEvent(recipientPubkey), |
||||||
|
replaceableEventService.fetchReplaceableEvent(recipientPubkey, kinds.Metadata) |
||||||
|
]) |
||||||
|
if (cancelled) return |
||||||
|
setPaymentInfo(paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null) |
||||||
|
setProfile(metaEvent ? getProfileFromEvent(metaEvent) : null) |
||||||
|
} catch { |
||||||
|
if (!cancelled) { |
||||||
|
setPaymentInfo(null) |
||||||
|
setProfile(null) |
||||||
|
} |
||||||
|
} |
||||||
|
})() |
||||||
|
return () => { |
||||||
|
cancelled = true |
||||||
|
} |
||||||
|
}, [recipientPubkey, enabled]) |
||||||
|
|
||||||
|
return useMemo(() => { |
||||||
|
if (!recipientPubkey) return [] |
||||||
|
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile)) |
||||||
|
const alts = getAlternativePaymentMethods(merged, profile?.lightningAddress) |
||||||
|
return groupPaymentMethodsByDisplayType(alts) |
||||||
|
}, [recipientPubkey, paymentInfo, profile]) |
||||||
|
} |
||||||
Loading…
Reference in new issue