13 changed files with 535 additions and 49 deletions
@ -0,0 +1,155 @@
@@ -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 @@
@@ -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