diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index ea2f0c9e..253a5079 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -90,6 +90,12 @@ export default function Superchat({ showAt className="min-w-0 font-medium text-foreground/85 hover:text-foreground" /> + ) : ( <> @@ -115,19 +121,21 @@ export default function Superchat({ )} ) : null} -
- - {t('Superchat')} -
+ {!isProfileWall ? ( +
+ + {t('Superchat')} +
+ ) : null} {comment ? ( ) : null} diff --git a/src/components/Note/SuperchatPaymentMethodLabel.tsx b/src/components/Note/SuperchatPaymentMethodLabel.tsx index 55d0455a..8b321cc5 100644 --- a/src/components/Note/SuperchatPaymentMethodLabel.tsx +++ b/src/components/Note/SuperchatPaymentMethodLabel.tsx @@ -5,26 +5,32 @@ import { cn } from '@/lib/utils' export default function SuperchatPaymentMethodLabel({ paytoType, className, - imgClassName + imgClassName, + iconOnly = false }: { /** Canonical or alias payto type (`lightning`, `monero`, `geyser`, …). */ paytoType: string className?: string imgClassName?: string + /** Profile wall: icon only (label in `title` for hover). */ + iconOnly?: boolean }) { const canonical = getCanonicalPaytoType(paytoType) const label = getPaytoEditorTypeLabel(canonical) return ( - {label} + {iconOnly ? null : {label}} ) } diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 2d2b43b1..53acce6c 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -100,6 +100,17 @@ export default function Zap({ showAt className="min-w-0 font-medium text-foreground/85 hover:text-foreground" /> + {amount != null ? ( + + {formatAmount(amount)} {t('sats')} + + ) : null} + ) : ( <> @@ -129,24 +140,26 @@ export default function Zap({ )} ) : null} -
- - {t('Superchat')} - {amount != null ? ( - - {formatAmount(amount)} {t('sats')} - - ) : null} -
+ {!isProfileWall ? ( +
+ + {t('Superchat')} + {amount != null ? ( + + {formatAmount(amount)} {t('sats')} + + ) : null} +
+ ) : null} {comment ? ( ) : null} diff --git a/src/components/Profile/ProfileWallSuperchats.tsx b/src/components/Profile/ProfileWallSuperchats.tsx index 778a5a15..a51f5107 100644 --- a/src/components/Profile/ProfileWallSuperchats.tsx +++ b/src/components/Profile/ProfileWallSuperchats.tsx @@ -1,10 +1,16 @@ import Superchat from '@/components/Note/Superchat' import Zap from '@/components/Note/Zap' import { ExtendedKind } from '@/constants' +import { cn } from '@/lib/utils' import { Skeleton } from '@/components/ui/skeleton' import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' +/** Roughly five profile-wall superchat rows before scrolling. */ +const PROFILE_WALL_SUPERCHAT_SCROLL_MAX_HEIGHT = 'max-h-[28rem]' + +const PROFILE_WALL_SUPERCHAT_VISIBLE_CAP = 5 + export default function ProfileWallSuperchats({ superchats, isLoading @@ -24,12 +30,20 @@ export default function ProfileWallSuperchats({ if (superchats.length === 0) return null + const scrollable = superchats.length > PROFILE_WALL_SUPERCHAT_VISIBLE_CAP + return (
-

+

{t('Superchats')}

-
+
{superchats.map((event) => event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( diff --git a/src/components/Titlebar/index.tsx b/src/components/Titlebar/index.tsx index 50802256..dbb661d5 100644 --- a/src/components/Titlebar/index.tsx +++ b/src/components/Titlebar/index.tsx @@ -12,7 +12,7 @@ export function Titlebar({ return (
(null) @@ -67,7 +70,7 @@ export default function SuperchatRequestForm({ referencedEvent: paymentContext?.referencedEvent, addClientTag: true }) - await publish(draft, { disableFallbacks: true }) + await publish(draft, { disableFallbacks: true, minPow }) showSimplePublishSuccess(t('Superchat request sent')) onDone() } catch (error) { @@ -103,6 +106,17 @@ export default function SuperchatRequestForm({ aria-label={t('Superchat message')} placeholder={t('Superchat message placeholder')} /> +
+ + setMinPow(pow)} + max={28} + step={1} + disabled={sending} + /> +
{previewEvent && message.trim() ? (

{t('Preview')}

diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx index ba6e81c6..749549d1 100644 --- a/src/hooks/useProfileWall.tsx +++ b/src/hooks/useProfileWall.tsx @@ -127,6 +127,130 @@ async function hydrateProfileWallSuperchatTargets( return [...byId.values()] } +function normalizeProfileEventId(profileEventId: string | undefined): string | undefined { + const id = profileEventId?.trim().toLowerCase() + return id && /^[0-9a-f]{64}$/.test(id) ? id : undefined +} + +function buildProfileWallSuperchatFilters(pkNorm: string, profileId: string | undefined): Filter[] { + const filters: Filter[] = [ + { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 }, + { kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 }, + { kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 } + ] + if (profileId) { + const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '') + filters.unshift( + { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 }, + { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 } + ) + filters.push( + { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 }, + { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 }, + { kinds: [kinds.Zap], '#e': [profileId], limit: 200 } + ) + } + return filters +} + +/** IndexedDB + session only — no relay REQ (profile wall must paint immediately when cached). */ +async function hydrateProfileWallSuperchatsFromLocalCache( + pkNorm: string, + profileEventId: string | undefined, + isEventDeleted: (event: Event) => boolean +): Promise { + if (!isValidPubkey(pkNorm)) return [] + + const profileId = normalizeProfileEventId(profileEventId) + const pool = new Map() + + try { + for (const e of await indexedDb.getPaymentNotificationsForRecipient(pkNorm, 200)) { + pool.set(e.id, e) + } + } catch { + /* optional */ + } + + if (profileId) { + try { + for (const e of await indexedDb.getPaymentNotificationsForReferencedEvent(profileId, 200)) { + pool.set(e.id, e) + } + } catch { + /* optional */ + } + try { + const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '') + for (const e of await indexedDb.getPaymentNotificationsForReferencedCoordinate(profileCoord, 200)) { + pool.set(e.id, e) + } + } catch { + /* optional */ + } + } + + try { + for (const e of await indexedDb.getPaymentAttestationsForAuthor(pkNorm, 500)) { + pool.set(e.id, e) + } + } catch { + /* optional */ + } + + const filters = buildProfileWallSuperchatFilters(pkNorm, profileId) + try { + for (const e of await indexedDb.getPaymentSuperchatEventsMatchingFilters(filters, 800)) { + pool.set(e.id, e) + } + } catch { + /* optional */ + } + try { + const localMatches = await client.getLocalFeedEvents( + filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })), + { maxMatches: 800 } + ) + for (const e of localMatches) pool.set(e.id, e) + } catch { + /* optional */ + } + + const attestations = [...pool.values()].filter((e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION) + const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) + for (const e of await hydrateProfileWallSuperchatTargets(attestedIds, [])) { + pool.set(e.id, e) + } + + const paymentEvents = [...pool.values()].filter( + (e) => + (e.kind === ExtendedKind.PAYMENT_NOTIFICATION || + e.kind === kinds.Zap || + e.kind === ExtendedKind.ZAP_RECEIPT) && + !isEventDeleted(e) + ) + + return filterAttestedProfileWallSuperchats( + paymentEvents, + attestations, + pkNorm, + profileId, + attestedIds + ) +} + +async function hydrateProfileWallFromLocalCache( + pkNorm: string, + profileEventId: string | undefined, + isEventDeleted: (event: Event) => boolean +): Promise<{ badges: ResolvedProfileBadge[]; superchats: Event[] }> { + const [badges, superchats] = await Promise.all([ + hydrateProfileBadgesFromLocalCache(pkNorm), + hydrateProfileWallSuperchatsFromLocalCache(pkNorm, profileEventId, isEventDeleted) + ]) + return { badges, superchats } +} + const wallCacheByKey = new Map< string, { badges: ResolvedProfileBadge[]; comments: Event[]; superchats: Event[]; lastUpdated: number } @@ -181,6 +305,29 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const [superchats, setSuperchats] = useState(hasUsefulWallCache ? (cached!.superchats ?? []) : []) const [isLoading, setIsLoading] = useState(!hasUsefulWallCache) const [refreshToken, setRefreshToken] = useState(0) + const badgesRef = useRef(badges) + const superchatsRef = useRef(superchats) + badgesRef.current = badges + superchatsRef.current = superchats + + const setLoadingUnlessWallVisible = useCallback(() => { + setIsLoading(badgesRef.current.length === 0 && superchatsRef.current.length === 0) + }, []) + + const applyLocalWallHydrate = useCallback( + (local: { badges: ResolvedProfileBadge[]; superchats: Event[] }) => { + if (local.badges.length > 0) { + setBadges((prev) => (prev.length > 0 ? prev : local.badges)) + } + if (local.superchats.length > 0) { + setSuperchats((prev) => (prev.length > 0 ? prev : local.superchats)) + } + if (local.badges.length > 0 || local.superchats.length > 0) { + setIsLoading(false) + } + }, + [] + ) const relayListsKey = useMemo( () => relayListsContentKey(favoriteRelays, blockedRelays), @@ -199,10 +346,10 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine const bumpWallRefetch = useCallback(() => { wallCacheByKey.delete(cacheKey) queueMicrotask(() => { - setIsLoading(true) + setLoadingUnlessWallVisible() setRefreshToken((t) => t + 1) }) - }, [cacheKey]) + }, [cacheKey, setLoadingUnlessWallVisible]) const scheduleManualWallRefetch = useCallback(() => { if (manualRefreshBumpScheduledRef.current) return @@ -210,23 +357,26 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine wallCacheByKey.delete(cacheKey) queueMicrotask(() => { manualRefreshBumpScheduledRef.current = false - setIsLoading(true) + setLoadingUnlessWallVisible() setRefreshToken((t) => t + 1) }) - }, [cacheKey]) + }, [cacheKey, setLoadingUnlessWallVisible]) useEffect(() => { if (!isValidPubkey(pkNormForHydrate)) return let cancelled = false - void hydrateProfileBadgesFromLocalCache(pkNormForHydrate).then((local) => { - if (cancelled || local.length === 0) return - setBadges((prev) => (prev.length > 0 ? prev : local)) - setIsLoading(false) + void hydrateProfileWallFromLocalCache( + pkNormForHydrate, + profileEventId, + isEventDeletedRef.current + ).then((local) => { + if (cancelled) return + applyLocalWallHydrate(local) }) return () => { cancelled = true } - }, [pkNormForHydrate]) + }, [pkNormForHydrate, profileEventId, applyLocalWallHydrate]) useEffect(() => { const pk = normalizeWallRefreshPubkey(pkNormForHydrate) @@ -309,6 +459,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine try { const pkNorm = userIdToPubkey(pubkey) || pubkey if (!isValidPubkey(pkNorm)) { + if (!cancelled && runGen === runGenRef.current) setIsLoading(false) return } @@ -331,22 +482,26 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine useGlobalRelayBootstrapRef.current ) - const localBadges = await hydrateProfileBadgesFromLocalCache(pkNorm) - if (!cancelled && localBadges.length > 0) { - setBadges(localBadges) - setIsLoading(false) - } else if (!cancelled) { - setIsLoading(true) + const localWall = await hydrateProfileWallFromLocalCache( + pkNorm, + profileEventId, + isEventDeletedRef.current + ) + if (!cancelled) { + applyLocalWallHydrate(localWall) + if (localWall.badges.length === 0 && localWall.superchats.length === 0) { + setIsLoading(true) + } } - // --- Badges (NIP-58): show cache first; relay refresh may upgrade list/definitions --- + // --- Badges (NIP-58): IndexedDB first; relay refresh may upgrade list/definitions --- let listEvent = await fetchProfileBadgesListEvent(pkNorm, relayUrls, { foreground: true, - cacheFirst: false + cacheFirst: true }) if (!listEvent || !isNip58ProfileBadgesListEvent(listEvent)) { const legacy = await fetchLegacyProfileBadgesListEvent(pkNorm, relayUrls, { - cacheFirst: false + cacheFirst: true }) if (legacy && isNip58ProfileBadgesListEvent(legacy)) listEvent = legacy } @@ -366,52 +521,50 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine ) if (cancelled) return - if (resolvedBadges.length > 0 || localBadges.length === 0) { + if (resolvedBadges.length > 0 || localWall.badges.length === 0) { setBadges(resolvedBadges) } - setIsLoading(false) // --- Wall comments (kind 1111) and attested superchats (9735 / 9740 + 9741) --- let wallComments: Event[] = [] - let wallSuperchats: Event[] = [] - const profileId = - profileEventId?.trim().toLowerCase() && /^[0-9a-f]{64}$/.test(profileEventId.trim()) - ? profileEventId.trim().toLowerCase() - : undefined - if (relayUrls.length > 0) { - const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '') - const filters: Filter[] = [ - { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 }, - { kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 }, - { kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 } - ] - if (profileId) { - filters.unshift( - { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 }, - { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 } - ) - filters.push( - { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 }, - { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 }, - { kinds: [kinds.Zap], '#e': [profileId], limit: 200 } - ) + let wallSuperchats: Event[] = localWall.superchats + const profileId = normalizeProfileEventId(profileEventId) + const filters = buildProfileWallSuperchatFilters(pkNorm, profileId) + const pool = new Map() + + for (const e of localWall.superchats) pool.set(e.id, e) + try { + for (const e of await indexedDb.getPaymentNotificationsForRecipient(pkNorm, 200)) { + pool.set(e.id, e) } - const pool = new Map() - try { - const idbPayments = await indexedDb.getPaymentNotificationsForRecipient(pkNorm, 200) - for (const e of idbPayments) pool.set(e.id, e) - } catch { - /* optional */ + } catch { + /* optional */ + } + try { + for (const e of await indexedDb.getPaymentAttestationsForAuthor(pkNorm, 500)) { + pool.set(e.id, e) } - try { - const localMatches = await client.getLocalFeedEvents( - filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })), - { maxMatches: 800 } - ) - for (const e of localMatches) pool.set(e.id, e) - } catch { - /* ignore */ + } catch { + /* optional */ + } + try { + for (const e of await indexedDb.getPaymentSuperchatEventsMatchingFilters(filters, 800)) { + pool.set(e.id, e) } + } catch { + /* optional */ + } + try { + const localMatches = await client.getLocalFeedEvents( + filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })), + { maxMatches: 800 } + ) + for (const e of localMatches) pool.set(e.id, e) + } catch { + /* optional */ + } + + if (relayUrls.length > 0) { try { const rows = await Promise.all( filters.map((filter) => @@ -427,43 +580,44 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine for (const e of batch) pool.set(e.id, e) } } catch { - /* ignore */ + /* optional */ } + } - const attestations = [...pool.values()].filter( - (e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION - ) - const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) - const hydratedTargets = await hydrateProfileWallSuperchatTargets(attestedIds, relayUrls) - for (const e of hydratedTargets) pool.set(e.id, e) - - if (profileId) { - wallComments = [...pool.values()] - .filter( - (e) => - e.kind === ExtendedKind.COMMENT && - !isEventDeletedRef.current(e) && - isDirectProfileWallComment(e, profileId, pkNorm) - ) - .sort((a, b) => b.created_at - a.created_at) - } + const attestations = [...pool.values()].filter( + (e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION + ) + const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations) + for (const e of await hydrateProfileWallSuperchatTargets(attestedIds, relayUrls)) { + pool.set(e.id, e) + } - const paymentEvents = [...pool.values()].filter( - (e) => - (e.kind === ExtendedKind.PAYMENT_NOTIFICATION || - e.kind === kinds.Zap || - e.kind === ExtendedKind.ZAP_RECEIPT) && - !isEventDeletedRef.current(e) - ) - wallSuperchats = filterAttestedProfileWallSuperchats( - paymentEvents, - attestations, - pkNorm, - profileId, - attestedIds - ) + if (profileId) { + wallComments = [...pool.values()] + .filter( + (e) => + e.kind === ExtendedKind.COMMENT && + !isEventDeletedRef.current(e) && + isDirectProfileWallComment(e, profileId, pkNorm) + ) + .sort((a, b) => b.created_at - a.created_at) } + const paymentEvents = [...pool.values()].filter( + (e) => + (e.kind === ExtendedKind.PAYMENT_NOTIFICATION || + e.kind === kinds.Zap || + e.kind === ExtendedKind.ZAP_RECEIPT) && + !isEventDeletedRef.current(e) + ) + wallSuperchats = filterAttestedProfileWallSuperchats( + paymentEvents, + attestations, + pkNorm, + profileId, + attestedIds + ) + if (cancelled) return setComments(wallComments) setSuperchats(wallSuperchats) @@ -487,7 +641,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine return () => { cancelled = true } - }, [pubkey, profileEventId, cacheKey, refreshToken]) + }, [pubkey, profileEventId, cacheKey, refreshToken, applyLocalWallHydrate]) const refresh = useCallback(() => { scheduleManualWallRefetch() diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index c2c53df7..e3568920 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -176,37 +176,41 @@ function SecondaryPageTitlebar({ if (titlebar) { return ( - -
{titlebar}
- {isSmallScreen ? : null} +
+ +
{titlebar}
+ {isSmallScreen ? : null} +
) } return ( - -
- {hideBackButton ? ( - title ? ( -
- {title} +
+ +
+ {hideBackButton ? ( + title ? ( +
+ {title} +
+ ) : null + ) : ( +
+ {title ?? t('back')}
- ) : null - ) : ( -
- {title ?? t('back')} + )} +
+ {controls}
- )} -
- {controls} - {isSmallScreen ? : null}
+ {isSmallScreen ? : null}
)