From 6b94d2147cb63d420771d507ad5a6ec841d259fb Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 20 May 2026 09:44:11 +0200 Subject: [PATCH] update zap modal --- src/App.tsx | 4 +- src/PageManager.tsx | 14 ++ src/assets/payto_logos/Bitcoin.svg | 7 + src/components/ErrorBoundary.tsx | 1 + src/components/HelpAndAccountMenu.tsx | 44 ++++-- .../PaymentMethodsSection/index.tsx | 40 ++++- src/components/PaytoLink/index.tsx | 4 +- src/components/Profile/index.tsx | 52 +++++-- .../ZapDialog/TipPublicMessagePrompt.tsx | 95 +++++++++--- src/components/ZapDialog/index.tsx | 115 +++++++++++--- src/contexts/cache-browser-context.tsx | 6 +- src/data/payto-types.json | 15 +- src/hooks/useFetchProfile.tsx | 27 ++++ src/hooks/useRecipientAlternativePayments.ts | 31 +++- src/i18n/locales/de.ts | 3 + src/i18n/locales/en.ts | 3 + src/lib/merge-payment-methods.test.ts | 59 ++++++- src/lib/merge-payment-methods.ts | 146 ++++++++++++++++-- src/lib/payto-editor-hints.test.ts | 14 +- src/lib/payto-registry.ts | 4 +- .../secondary/ProfileEditorPage/index.tsx | 17 +- src/services/lightning.service.ts | 19 ++- 22 files changed, 596 insertions(+), 124 deletions(-) create mode 100644 src/assets/payto_logos/Bitcoin.svg diff --git a/src/App.tsx b/src/App.tsx index 9f1ad477..2103d8a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ export default function App(): JSX.Element { +
@@ -59,9 +60,7 @@ export default function App(): JSX.Element { - - @@ -81,6 +80,7 @@ export default function App(): JSX.Element {
+
diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 4acaecd0..a3b351e9 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -1124,6 +1124,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const [secondaryStack, setSecondaryStack] = useState([]) /** Latest stack for popstate / pop() — avoids stale length when history and React state race. */ const secondaryStackRef = useRef([]) + /** Suppress duplicate pushSecondaryPage calls (e.g. React Strict Mode) within a short window. */ + const recentSecondaryPushRef = useRef<{ url: string; at: number } | null>(null) useLayoutEffect(() => { secondaryStackRef.current = secondaryStack }, [secondaryStack]) @@ -1963,6 +1965,18 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const pushSecondaryPage = (url: string, index?: number) => { logger.component('PageManager', 'pushSecondaryPage called', { url }) + const now = Date.now() + const recent = recentSecondaryPushRef.current + if (recent?.url === url && now - recent.at < 400) { + logger.component('PageManager', 'pushSecondaryPage skipped (recent duplicate)', { url }) + return + } + if (isCurrentPage(secondaryStackRef.current, url)) { + logger.component('PageManager', 'pushSecondaryPage skipped (already on stack)', { url }) + return + } + recentSecondaryPushRef.current = { url, at: now } + // Small screens render either the primary overlay OR the secondary stack — not both. // Clear overlays (e.g. full-screen note) so pushes from Seen-on, settings deep links, etc. show the target page. if (isSmallScreen && primaryNoteView) { diff --git a/src/assets/payto_logos/Bitcoin.svg b/src/assets/payto_logos/Bitcoin.svg new file mode 100644 index 00000000..b3fd9e3b --- /dev/null +++ b/src/assets/payto_logos/Bitcoin.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 0557357a..7294b040 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -18,6 +18,7 @@ function isLikelyBrokenReactContextFromHmr(message: string): boolean { message.includes('useNostr must be used within') || message.includes('useContentPolicy must be used within') || message.includes('useInterestList must be used within') || + message.includes('useCacheBrowser must be used within') || (message.includes('useContext') && message.includes('null')) ) } diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 76df8e22..d48b9f36 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -14,7 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { isVideo } from '@/lib/url' import { cn } from '@/lib/utils' -import { useCacheBrowser } from '../contexts/cache-browser-context' +import { useCacheBrowserOptional } from '../contexts/cache-browser-context' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useFetchProfile } from '@/hooks/useFetchProfile' import { useNostr } from '@/providers/NostrProvider' @@ -26,14 +26,15 @@ export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar' function AccountDropdownItems({ onSwitchAccount, - onLogoutClick + onLogoutClick, + onBrowseCache }: { onSwitchAccount: () => void onLogoutClick: () => void + onBrowseCache?: () => void }) { const { t } = useTranslation() const { navigate } = usePrimaryPage() - const { openBrowseCache } = useCacheBrowser() return ( <> @@ -45,14 +46,12 @@ function AccountDropdownItems({ {t('Settings')} - { - openBrowseCache() - }} - > - - {t('Browse Cache')} - + {onBrowseCache ? ( + + + {t('Browse Cache')} + + ) : null} @@ -68,10 +67,12 @@ function AccountDropdownItems({ function SidebarAccountMenu({ onSwitchAccount, - onLogoutClick + onLogoutClick, + onBrowseCache }: { onSwitchAccount: () => void onLogoutClick: () => void + onBrowseCache?: () => void }) { const { t } = useTranslation() const { account, profile } = useNostr() @@ -118,7 +119,11 @@ function SidebarAccountMenu({ - + ) @@ -126,10 +131,12 @@ function SidebarAccountMenu({ function TitlebarAccountMenu({ onSwitchAccount, - onLogoutClick + onLogoutClick, + onBrowseCache }: { onSwitchAccount: () => void onLogoutClick: () => void + onBrowseCache?: () => void }) { const { t } = useTranslation() const { account, profile } = useNostr() @@ -172,7 +179,11 @@ function TitlebarAccountMenu({ - + ) @@ -182,6 +193,7 @@ function TitlebarAccountMenu({ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccountMenuVariant }) { const { t } = useTranslation() const { pubkey, checkLogin } = useNostr() + const onBrowseCache = useCacheBrowserOptional()?.openBrowseCache const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) @@ -192,11 +204,13 @@ export default function HelpAndAccountMenu({ variant }: { variant: HelpAndAccoun setLoginDialogOpen(true)} onLogoutClick={() => setLogoutDialogOpen(true)} + onBrowseCache={onBrowseCache} /> ) : ( setLoginDialogOpen(true)} onLogoutClick={() => setLogoutDialogOpen(true)} + onBrowseCache={onBrowseCache} /> ) } else if (variant === 'sidebar') { diff --git a/src/components/PaymentMethodsSection/index.tsx b/src/components/PaymentMethodsSection/index.tsx index 1e9222c7..84bd7ed2 100644 --- a/src/components/PaymentMethodsSection/index.tsx +++ b/src/components/PaymentMethodsSection/index.tsx @@ -1,5 +1,7 @@ import PaytoLink from '@/components/PaytoLink' import type { PaymentMethodGroup } from '@/lib/merge-payment-methods' +import { isLightningPaytoType } from '@/lib/payto' +import { cn } from '@/lib/utils' import { Copy } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -9,14 +11,17 @@ export default function PaymentMethodsSection({ recipientPubkey, onOpenZap, title, - className + className, + headerHelpText }: { groups: PaymentMethodGroup[] recipientPubkey?: string - /** When set, lightning rows can open the zap flow for this profile. */ - onOpenZap?: () => void + /** When set, lightning rows open the zap flow with that address as the default. */ + onOpenZap?: (lightningAuthority: string) => void title?: string className?: string + /** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */ + headerHelpText?: string }) { const { t } = useTranslation() @@ -27,10 +32,27 @@ export default function PaymentMethodsSection({
{title ?? t('Payment Methods')}
+ {headerHelpText ? ( +

+ {headerHelpText} +

+ ) : null}
{groups.map((group, groupIdx) => ( -
-
{group.displayType}
+
+
+ {group.displayType} +
{group.methods.map((method, idx) => (
@@ -40,8 +62,12 @@ export default function PaymentMethodsSection({ type={method.type} authority={method.authority} paytoUri={method.payto} - pubkey={method.type === 'lightning' ? recipientPubkey : undefined} - onOpenZap={method.type === 'lightning' ? onOpenZap : undefined} + pubkey={isLightningPaytoType(method.type) ? recipientPubkey : undefined} + onOpenZap={ + isLightningPaytoType(method.type) && onOpenZap + ? (_pk, authority) => onOpenZap(authority) + : undefined + } className="hover:underline break-all min-w-0 text-primary flex-1" > {method.authority} diff --git a/src/components/PaytoLink/index.tsx b/src/components/PaytoLink/index.tsx index 2a94ee00..7c49bbcb 100644 --- a/src/components/PaytoLink/index.tsx +++ b/src/components/PaytoLink/index.tsx @@ -32,7 +32,7 @@ export default function PaytoLink({ authority?: string /** When set with lightning type, clicking can open Zap dialog via onOpenZap */ pubkey?: string - onOpenZap?: (pubkey: string) => void + onOpenZap?: (pubkey: string, lightningAuthority: string) => void className?: string children?: React.ReactNode linkTitle?: string @@ -64,7 +64,7 @@ export default function PaytoLink({ e.preventDefault() e.stopPropagation() if (canZap) { - onOpenZap(pubkey!) + onOpenZap(pubkey!, authority) return } if (!known) { diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index ecc3bc2e..2c70895a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -86,11 +86,11 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { nip66Service } from '@/services/nip66.service' import PaymentMethodsSection from '@/components/PaymentMethodsSection' import { - getAlternativePaymentMethods, groupPaymentMethodsByDisplayType, mergePaymentMethods, sortMergedPaymentMethods } from '@/lib/merge-payment-methods' +import { isLightningPaytoType } from '@/lib/payto' export default function Profile({ id, @@ -118,6 +118,7 @@ export default function Profile({ 'posts' | 'media' | 'publications' | 'reports' | 'wall' | 'liked' >('posts') const profilePubkeyRef = useRef(null) + const pendingReportsRefreshRef = useRef(false) const { profile, isFetching } = useFetchProfile(id) profilePubkeyRef.current = profile?.pubkey ?? null @@ -125,6 +126,7 @@ export default function Profile({ const [paymentInfo, setPaymentInfo] = useState | null>(null) const [profileEvent, setProfileEvent] = useState(undefined) const [openZapDialog, setOpenZapDialog] = useState(false) + const [zapLightningDefault, setZapLightningDefault] = useState(null) const [openPublicMessageTo, setOpenPublicMessageTo] = useState(null) const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false) @@ -145,13 +147,8 @@ export default function Profile({ [mergedPaymentMethods] ) - const alternativePaymentGroups = useMemo(() => { - const alts = getAlternativePaymentMethods(mergedPaymentMethods, profile?.lightningAddress) - return groupPaymentMethodsByDisplayType(alts) - }, [mergedPaymentMethods, profile?.lightningAddress]) - const hasLightningForZap = useMemo( - () => paymentMethodsByType.some((g) => g.methods.some((m) => m.type === 'lightning')), + () => paymentMethodsByType.some((g) => g.methods.some((m) => isLightningPaytoType(m.type))), [paymentMethodsByType] ) @@ -177,6 +174,11 @@ export default function Profile({ void syncAuthorReplaceablesFromCache(profile.pubkey) }, [profile?.pubkey, syncAuthorReplaceablesFromCache]) + const refreshAuthorReplaceables = useCallback(async (pubkey: string) => { + await client.forceRefreshProfileAndPaymentInfoCache(pubkey) + await syncAuthorReplaceablesFromCache(pubkey) + }, [syncAuthorReplaceablesFromCache]) + useEffect(() => { if (!profile?.pubkey) return void client.refreshAuthorPublishedReplaceablesOnProfileView(profile.pubkey) @@ -276,19 +278,23 @@ export default function Profile({ postsFeedRef.current?.refresh() mediaFeedRef.current?.refresh() publicationsFeedRef.current?.refresh() - reportsFeedRef.current?.refresh() wallFeedRef.current?.refresh() likedFeedRef.current?.refresh() const pk = profilePubkeyRef.current + if (reportsFeedRef.current) { + reportsFeedRef.current.refresh() + } else { + pendingReportsRefreshRef.current = true + } if (pk) { - void client.refreshAuthorPublishedReplaceablesOnProfileView(pk) + void refreshAuthorReplaceables(pk) } } } return () => { m.current = null } - }, []) + }, [refreshAuthorReplaceables]) useEffect(() => { if (!profile?.pubkey) return @@ -312,6 +318,9 @@ export default function Profile({ } else if (profileFeedTab === 'publications') { publicationsFeedRef.current?.refresh() } else if (profileFeedTab === 'reports') { + if (pendingReportsRefreshRef.current) { + pendingReportsRefreshRef.current = false + } reportsFeedRef.current?.refresh() } else if (profileFeedTab === 'wall') { wallFeedRef.current?.refresh() @@ -514,7 +523,15 @@ export default function Profile({ {!isSelf ? ( <> {hasLightningForZap && ( - + { + if (open) setZapLightningDefault(null) + setOpenZapDialog(open) + if (!open) setZapLightningDefault(null) + }} + /> )} @@ -577,15 +594,22 @@ export default function Profile({ setOpenZapDialog(true)} + onOpenZap={(lightningAuthority) => { + setZapLightningDefault(lightningAuthority) + setOpenZapDialog(true) + }} className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0" /> )} { + const willOpen = typeof next === 'function' ? next(openZapDialog) : next + setOpenZapDialog(willOpen) + if (!willOpen) setZapLightningDefault(null) + }} pubkey={pubkey} - alternativePaymentGroups={alternativePaymentGroups} + defaultLightningAddress={zapLightningDefault} />
diff --git a/src/components/ZapDialog/TipPublicMessagePrompt.tsx b/src/components/ZapDialog/TipPublicMessagePrompt.tsx index 6fa7f88a..c8a8b90a 100644 --- a/src/components/ZapDialog/TipPublicMessagePrompt.tsx +++ b/src/components/ZapDialog/TipPublicMessagePrompt.tsx @@ -14,20 +14,30 @@ import { DrawerHeader, DrawerTitle } from '@/components/ui/drawer' +import { Textarea } from '@/components/ui/textarea' +import { ExtendedKind } from '@/constants' import { createPublicMessageDraftEvent } from '@/lib/draft-event' +import { createFakeEvent } from '@/lib/event' import { showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { LoginRequiredError } from '@/lib/nostr-errors' import { pubkeyToNpub } from '@/lib/pubkey' +import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' -import { LoginRequiredError } from '@/lib/nostr-errors' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import UserAvatar from '../UserAvatar' import Username from '../Username' const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!' +function defaultTipNoticeMessage(recipientPubkey: string, tipText: string): string { + const npub = pubkeyToNpub(recipientPubkey) + return `nostr:${npub} ${tipText}` +} + export default function TipPublicMessagePrompt({ open, onOpenChange, @@ -41,22 +51,43 @@ export default function TipPublicMessagePrompt({ const { isSmallScreen } = useScreenSize() const { publish, checkLogin, pubkey: selfPubkey } = useNostr() const [sending, setSending] = useState(false) + const [message, setMessage] = useState('') const cancelRef = useRef(null) + const textareaRef = useRef(null) const tipText = t(TIP_NOTICE_DEFAULT_KEY) - const npub = recipientPubkey ? pubkeyToNpub(recipientPubkey) : null - const previewContent = npub ? `nostr:${npub} ${tipText}` : tipText + + useEffect(() => { + if (!open || !recipientPubkey) return + setMessage(defaultTipNoticeMessage(recipientPubkey, tipText)) + }, [open, recipientPubkey, tipText]) useEffect(() => { if (!open) return const id = requestAnimationFrame(() => { - cancelRef.current?.focus() + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange( + textareaRef.current.value.length, + textareaRef.current.value.length + ) }) return () => cancelAnimationFrame(id) }, [open]) + const previewEvent = useMemo(() => { + if (!recipientPubkey) return null + return createFakeEvent({ + kind: ExtendedKind.PUBLIC_MESSAGE, + pubkey: selfPubkey ?? '', + content: message, + tags: [['p', recipientPubkey]] + }) + }, [message, recipientPubkey, selfPubkey]) + const handleSend = () => { if (!recipientPubkey) return + const trimmed = message.trim() + if (!trimmed) return checkLogin(async () => { if (selfPubkey === recipientPubkey) { onOpenChange(false) @@ -64,7 +95,7 @@ export default function TipPublicMessagePrompt({ } setSending(true) try { - const draft = await createPublicMessageDraftEvent(previewContent, [recipientPubkey], { + const draft = await createPublicMessageDraftEvent(trimmed, [recipientPubkey], { addClientTag: true }) await publish(draft, { disableFallbacks: true }) @@ -84,11 +115,31 @@ export default function TipPublicMessagePrompt({ } const body = ( - <> +

{t('Tip notice success only note')}

-

{t('Tip notice prompt description')}

-

{previewContent}

- +