Refactor goal components: deduplicate GoalCard/GoalContent, fix staleness

- Unify GoalCard and GoalContent into a single component with variant prop
- Extract useGoalDisplay hook for shared display logic (author, progress,
  community link, deadline, image)
- Add useNow(60s) interval so deadline labels refresh automatically
- Add generic parseATagCoordinate utility to nostrEvents.ts
- Replace DOM-mutating image onError with React state
- Remove dead isGoalFunded export and redundant created_at in publish
- Delete GoalContent.tsx (-144 net lines)
This commit is contained in:
lemon
2026-04-27 12:33:13 -07:00
parent 07ea1f94d1
commit 27b60b2a6f
8 changed files with 367 additions and 387 deletions
-1
View File
@@ -103,7 +103,6 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
kind: ZAP_GOAL_KIND,
content: title.trim(),
tags,
created_at: Math.floor(Date.now() / 1000),
});
// Refresh the fundraising tab and the community activity feed
+215 -186
View File
@@ -1,224 +1,253 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Clock, Target, Users, Zap } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { ZapDialog } from '@/components/ZapDialog';
import { useAddrEvent } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { useGoalProgress } from '@/hooks/useGoalProgress';
import { useGoalDisplay } from '@/hooks/useGoalDisplay';
import { useOpenPost } from '@/hooks/useOpenPost';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { canZap } from '@/lib/canZap';
import { formatSats, isGoalExpired, parseCommunityATag, type ParsedGoal } from '@/lib/goalUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { formatSats, parseGoalEvent, type ParsedGoal } from '@/lib/goalUtils';
import { cn } from '@/lib/utils';
// ── Public API ────────────────────────────────────────────────────────────────
interface GoalCardProps {
event: NostrEvent;
goal: ParsedGoal;
/** Pre-parsed goal. When omitted the component parses the event itself. */
goal?: ParsedGoal;
/**
* `card` — standalone clickable card with border, stats row, and zap button
* (community fundraising tab).
* `compact` — inline content block for NoteCard / PostDetailPage.
*/
variant?: 'card' | 'compact';
}
export function GoalCard({ event, goal }: GoalCardProps) {
const expired = isGoalExpired(goal);
const { currentSats, percentage, contributors, zapCount, isLoading: progressLoading } =
useGoalProgress(event.id, goal.amountMsat, goal.closedAt);
/**
* Renders a NIP-75 zap goal.
*
* - `variant="card"` (default): standalone card used in the community
* fundraising tab. Includes a clickable wrapper, stats row, and Contribute button.
* - `variant="compact"`: inline renderer used inside NoteCard feeds and
* PostDetailPage.
*/
export function GoalCard({ event, goal: goalProp, variant = 'card' }: GoalCardProps) {
const goal = useMemo(() => goalProp ?? parseGoalEvent(event), [goalProp, event]);
if (!goal) return null;
return <GoalCardInner event={event} goal={goal} variant={variant} />;
}
const funded = percentage >= 100;
// ── Inner renderer (hooks are safe here) ──────────────────────────────────────
// Navigation to post detail
function GoalCardInner({
event,
goal,
variant,
}: {
event: NostrEvent;
goal: ParsedGoal;
variant: 'card' | 'compact';
}) {
const isCard = variant === 'card';
const d = useGoalDisplay(event, goal);
const [imgError, setImgError] = useState(false);
// Navigation (card variant only)
const postPath = useMemo(
() => `/${nip19.neventEncode({ id: event.id, author: event.pubkey })}`,
[event.id, event.pubkey],
);
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(postPath);
// Recipient info
const author = useAuthor(goal.beneficiary);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
const avatarShape = getAvatarShape(metadata);
const profileUrl = useProfileUrl(goal.beneficiary, metadata);
const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
// ── Shared sub-sections ───────────────────────────────────────────────────
// Deadline display
const deadlineLabel = useMemo(() => {
if (!goal.closedAt) return null;
const now = Math.floor(Date.now() / 1000);
const diff = goal.closedAt - now;
if (diff <= 0) return 'Ended';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h left`;
const mins = Math.floor((diff % 3600) / 60);
return hours > 0 ? `${hours}h ${mins}m left` : `${mins}m left`;
}, [goal.closedAt]);
const imageSection = d.image && !imgError && (
<div className={cn('overflow-hidden', isCard ? 'aspect-[21/9] w-full' : '-mx-4 aspect-[21/9]')}>
<img
src={d.image}
alt={goal.title}
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
</div>
);
const image = goal.image ? sanitizeUrl(goal.image) : undefined;
// Community link
const communityAddr = useMemo(() => goal.communityATag ? parseCommunityATag(goal.communityATag) : undefined, [goal.communityATag]);
const { data: communityEvent } = useAddrEvent(communityAddr);
const communityName = communityEvent?.tags.find(([n]) => n === 'name')?.[1]
|| communityEvent?.tags.find(([n]) => n === 'd')?.[1];
const communityUrl = useMemo(() => {
if (!communityAddr) return undefined;
try {
return `/${nip19.naddrEncode({ kind: communityAddr.kind, pubkey: communityAddr.pubkey, identifier: communityAddr.identifier })}`;
} catch {
return undefined;
}
}, [communityAddr]);
return (
<div
className="rounded-xl border border-border overflow-hidden bg-card transition-all duration-300 hover:shadow-md hover:border-primary/20 cursor-pointer"
onClick={openPost}
onAuxClick={auxOpenPost}
>
{/* Goal image */}
{image && (
<div className="aspect-[21/9] w-full overflow-hidden">
<img
src={image}
alt={goal.title}
className="w-full h-full object-cover"
onError={(e) => { (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; }}
/>
</div>
)}
<div className="p-4 space-y-4">
{/* Header: title + community link / status badge */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Target className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight truncate">{goal.title}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
{communityUrl && communityName && (
<Link
to={communityUrl}
className="flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Users className="size-3" />
{communityName}
</Link>
)}
{funded ? (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
Funded
</Badge>
) : expired ? (
<Badge variant="secondary">Ended</Badge>
) : null}
</div>
</div>
{/* Summary */}
{goal.summary && (
<p className="text-sm text-muted-foreground leading-relaxed">{goal.summary}</p>
)}
{/* Progress bar */}
<div className="space-y-2">
<Progress
value={percentage}
className={cn('h-3', funded && '[&>div]:bg-emerald-500')}
/>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{progressLoading ? (
<Skeleton className="h-4 w-20 inline-block" />
) : (
<>{formatSats(currentSats)} sats</>
)}
</span>
<span className="text-muted-foreground">
of {formatSats(goal.amountSats)} sats ({percentage}%)
</span>
</div>
</div>
{/* Stats row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{zapCount > 0 && (
<span className="flex items-center gap-1">
<Zap className="size-3" />
{zapCount} zap{zapCount !== 1 ? 's' : ''}
</span>
)}
{contributors.length > 0 && (
<span className="flex items-center gap-1">
<Users className="size-3" />
{contributors.length} contributor{contributors.length !== 1 ? 's' : ''}
</span>
)}
{deadlineLabel && (
<span className="flex items-center gap-1">
<Clock className="size-3" />
{deadlineLabel}
</span>
)}
</div>
{/* Recipient info — who is receiving the zaps */}
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-3 py-2.5">
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-9 ring-2 ring-background">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
const headerSection = (
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Target className="size-4 text-primary shrink-0" />
<h3 className={cn('font-semibold text-[15px] leading-tight', isCard && 'truncate')}>
{goal.title}
</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
{d.communityUrl && d.communityName && (
<Link
to={d.communityUrl}
className="flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Users className="size-3" />
{d.communityName}
</Link>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">Receiving zaps</p>
<Link
to={profileUrl}
className="text-sm font-medium truncate block hover:underline"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
{lightningAddress && (
<p className="text-xs text-muted-foreground truncate" title={lightningAddress}>
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
{lightningAddress}
</p>
)}
</div>
</div>
)}
{d.funded ? (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
Funded
</Badge>
) : d.expired ? (
<Badge variant="secondary">Ended</Badge>
) : !isCard && d.deadlineLabel ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" />
{d.deadlineLabel}
</span>
) : null}
</div>
</div>
);
{/* Zap button */}
{!expired && canZap(metadata) && (
<div onClick={(e) => e.stopPropagation()}>
<ZapDialog target={event}>
<button
className={cn(
'w-full flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
'bg-primary text-primary-foreground hover:bg-primary/90',
)}
>
<Zap className="size-4" />
Contribute
</button>
</ZapDialog>
</div>
const summarySection = goal.summary && (
<p className={cn('text-sm text-muted-foreground leading-relaxed', !isCard && 'line-clamp-2')}>
{goal.summary}
</p>
);
const progressSection = (
<div className={cn('space-y-2', !isCard && 'space-y-1.5')}>
<Progress
value={d.percentage}
className={cn(isCard ? 'h-3' : 'h-2.5', d.funded && '[&>div]:bg-emerald-500')}
/>
<div className={cn('flex items-center justify-between', isCard ? 'text-sm' : 'text-xs text-muted-foreground')}>
<span className={cn('font-medium', !isCard && 'text-foreground')}>
{d.progressLoading ? (
<Skeleton className={cn('inline-block', isCard ? 'h-4 w-20' : 'h-3.5 w-16')} />
) : (
<>{formatSats(d.currentSats)} sats</>
)}
</span>
<span className={isCard ? 'text-muted-foreground' : undefined}>
of {formatSats(goal.amountSats)} sats ({d.percentage}%)
</span>
</div>
</div>
);
const recipientSection = (
<div className={cn(
'flex items-center rounded-lg bg-muted/50 px-3',
isCard ? 'gap-3 py-2.5' : 'gap-2.5 py-2',
)}>
<Link to={d.profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={d.avatarShape} className={cn('ring-2 ring-background', isCard ? 'size-9' : 'size-8')}>
<AvatarImage src={d.metadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
{d.displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">Receiving zaps</p>
<Link
to={d.profileUrl}
className="text-sm font-medium truncate block hover:underline"
onClick={(e) => e.stopPropagation()}
>
{d.displayName}
</Link>
{d.lightningAddress && (
<p className="text-xs text-muted-foreground truncate" title={d.lightningAddress}>
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
{d.lightningAddress}
</p>
)}
</div>
</div>
);
// ── Card variant ──────────────────────────────────────────────────────────
if (isCard) {
return (
<div
className="rounded-xl border border-border overflow-hidden bg-card transition-all duration-300 hover:shadow-md hover:border-primary/20 cursor-pointer"
onClick={openPost}
onAuxClick={auxOpenPost}
>
{imageSection}
<div className="p-4 space-y-4">
{headerSection}
{summarySection}
{progressSection}
{/* Stats row (card only) */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{d.zapCount > 0 && (
<span className="flex items-center gap-1">
<Zap className="size-3" />
{d.zapCount} zap{d.zapCount !== 1 ? 's' : ''}
</span>
)}
{d.contributors.length > 0 && (
<span className="flex items-center gap-1">
<Users className="size-3" />
{d.contributors.length} contributor{d.contributors.length !== 1 ? 's' : ''}
</span>
)}
{d.deadlineLabel && (
<span className="flex items-center gap-1">
<Clock className="size-3" />
{d.deadlineLabel}
</span>
)}
</div>
{recipientSection}
{/* Zap button (card only) */}
{!d.expired && canZap(d.metadata) && (
<div onClick={(e) => e.stopPropagation()}>
<ZapDialog target={event}>
<button
className={cn(
'w-full flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
'bg-primary text-primary-foreground hover:bg-primary/90',
)}
>
<Zap className="size-4" />
Contribute
</button>
</ZapDialog>
</div>
)}
</div>
</div>
);
}
// ── Compact variant ───────────────────────────────────────────────────────
return (
<div className="mt-3 space-y-3">
{imageSection}
{headerSection}
{summarySection}
{progressSection}
{recipientSection}
</div>
);
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
/** Skeleton placeholder for loading goal cards. */
export function GoalCardSkeleton() {
return (
-181
View File
@@ -1,181 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Clock, Target, Users, Zap } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { useAddrEvent } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { useGoalProgress } from '@/hooks/useGoalProgress';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { formatSats, isGoalExpired, parseGoalEvent, parseCommunityATag } from '@/lib/goalUtils';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
/**
* Compact goal renderer for NoteCard feed views.
* Shows goal title, progress bar, recipient profile + lightning address, and deadline.
*/
export function GoalContent({ event }: { event: NostrEvent }) {
const goal = useMemo(() => parseGoalEvent(event), [event]);
if (!goal) return null;
return <GoalContentInner event={event} goal={goal} />;
}
function GoalContentInner({
event,
goal,
}: {
event: NostrEvent;
goal: NonNullable<ReturnType<typeof parseGoalEvent>>;
}) {
const expired = isGoalExpired(goal);
const { currentSats, percentage, isLoading: progressLoading } =
useGoalProgress(event.id, goal.amountMsat, goal.closedAt);
const funded = percentage >= 100;
// Recipient info
const author = useAuthor(goal.beneficiary);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
const avatarShape = getAvatarShape(metadata);
const profileUrl = useProfileUrl(goal.beneficiary, metadata);
const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
// Deadline display
const deadlineLabel = useMemo(() => {
if (!goal.closedAt) return null;
const now = Math.floor(Date.now() / 1000);
const diff = goal.closedAt - now;
if (diff <= 0) return 'Ended';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h left`;
const mins = Math.floor((diff % 3600) / 60);
return hours > 0 ? `${hours}h ${mins}m left` : `${mins}m left`;
}, [goal.closedAt]);
const image = goal.image ? sanitizeUrl(goal.image) : undefined;
// Community link
const communityAddr = useMemo(() => goal.communityATag ? parseCommunityATag(goal.communityATag) : undefined, [goal.communityATag]);
const { data: communityEvent } = useAddrEvent(communityAddr);
const communityName = communityEvent?.tags.find(([n]) => n === 'name')?.[1]
|| communityEvent?.tags.find(([n]) => n === 'd')?.[1];
const communityUrl = useMemo(() => {
if (!communityAddr) return undefined;
try {
return `/${nip19.naddrEncode({ kind: communityAddr.kind, pubkey: communityAddr.pubkey, identifier: communityAddr.identifier })}`;
} catch {
return undefined;
}
}, [communityAddr]);
return (
<div className="mt-3 space-y-3">
{/* Goal image */}
{image && (
<div className="-mx-4 aspect-[21/9] overflow-hidden">
<img
src={image}
alt={goal.title}
className="w-full h-full object-cover"
onError={(e) => { (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; }}
/>
</div>
)}
{/* Title + community link / status */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Target className="size-4 text-primary shrink-0" />
<h3 className="font-semibold text-[15px] leading-tight">{goal.title}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
{communityUrl && communityName && (
<Link
to={communityUrl}
className="flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Users className="size-3" />
{communityName}
</Link>
)}
{funded ? (
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
Funded
</Badge>
) : expired ? (
<Badge variant="secondary">Ended</Badge>
) : deadlineLabel ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" />
{deadlineLabel}
</span>
) : null}
</div>
</div>
{/* Summary */}
{goal.summary && (
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">{goal.summary}</p>
)}
{/* Progress bar */}
<div className="space-y-1.5">
<Progress
value={percentage}
className={cn('h-2.5', funded && '[&>div]:bg-emerald-500')}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{progressLoading ? (
<Skeleton className="h-3.5 w-16 inline-block" />
) : (
<>{formatSats(currentSats)} sats</>
)}
</span>
<span>of {formatSats(goal.amountSats)} sats ({percentage}%)</span>
</div>
</div>
{/* Recipient — who is receiving the zaps */}
<div className="flex items-center gap-2.5 rounded-lg bg-muted/50 px-3 py-2">
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-8 ring-2 ring-background">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
{displayName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="text-xs text-muted-foreground">Receiving zaps</p>
</div>
<Link
to={profileUrl}
className="text-sm font-medium truncate block hover:underline"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
{lightningAddress && (
<p className="text-xs text-muted-foreground truncate" title={lightningAddress}>
<Zap className="size-3 inline-block mr-0.5 -mt-0.5" />
{lightningAddress}
</p>
)}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -49,7 +49,7 @@ import { FollowPackContent } from "@/components/FollowPackContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
import { GitRepoCard } from "@/components/GitRepoCard";
import { GoalContent } from "@/components/GoalContent";
import { GoalCard } from "@/components/GoalCard";
import { NsiteCard } from "@/components/NsiteCard";
import { ImageGallery } from "@/components/ImageGallery";
import { CardsIcon } from "@/components/icons/CardsIcon";
@@ -606,7 +606,7 @@ export const NoteCard = memo(function NoteCard({
) : isCommunity ? (
<CommunityContent event={event} />
) : isZapGoal ? (
<GoalContent event={event} />
<GoalCard event={event} />
) : isVoiceMessage ? (
<VoiceMessagePlayer event={event} />
) : isCalendarEvent ? (
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useMemo, useState } from 'react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { getAvatarShape } from '@/lib/avatarShape';
import { isGoalExpired, parseCommunityATag, type ParsedGoal } from '@/lib/goalUtils';
import { genUserName } from '@/lib/genUserName';
import { useAddrEvent } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { useGoalProgress } from '@/hooks/useGoalProgress';
import { useProfileUrl } from '@/hooks/useProfileUrl';
/** Re-renders every `intervalMs` so time-dependent values stay fresh. */
function useNow(intervalMs: number): number {
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
export interface GoalDisplayData {
// Status
expired: boolean;
funded: boolean;
// Progress
currentSats: number;
percentage: number;
contributors: string[];
zapCount: number;
progressLoading: boolean;
// Recipient
metadata: NostrMetadata | undefined;
displayName: string;
avatarShape: string | undefined;
profileUrl: string;
lightningAddress: string | undefined;
// Deadline
deadlineLabel: string | null;
// Community link
communityName: string | undefined;
communityUrl: string | undefined;
// Image (already sanitized at parse time)
image: string | undefined;
}
/**
* Consolidates all display-related hooks and derived state for a goal event.
* Used by both the standalone GoalCard and the compact NoteCard/detail renderers.
*/
export function useGoalDisplay(event: NostrEvent, goal: ParsedGoal): GoalDisplayData {
const now = useNow(60_000);
const expired = isGoalExpired(goal);
const { currentSats, percentage, contributors, zapCount, isLoading: progressLoading } =
useGoalProgress(event.id, goal.amountMsat, goal.closedAt);
const funded = percentage >= 100;
// Recipient info
const author = useAuthor(goal.beneficiary);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(goal.beneficiary);
const avatarShape = getAvatarShape(metadata);
const profileUrl = useProfileUrl(goal.beneficiary, metadata);
const lightningAddress = metadata?.lud16 || metadata?.lud06 || undefined;
// Deadline label — `now` dependency ensures it refreshes every minute
const deadlineLabel = useMemo(() => {
if (!goal.closedAt) return null;
const diff = goal.closedAt - now;
if (diff <= 0) return 'Ended';
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
if (days > 0) return `${days}d ${hours}h left`;
const mins = Math.floor((diff % 3600) / 60);
return hours > 0 ? `${hours}h ${mins}m left` : `${mins}m left`;
}, [goal.closedAt, now]);
// Community link
const communityAddr = useMemo(
() => (goal.communityATag ? parseCommunityATag(goal.communityATag) : undefined),
[goal.communityATag],
);
const { data: communityEvent } = useAddrEvent(communityAddr);
const communityName =
communityEvent?.tags.find(([n]) => n === 'name')?.[1] ||
communityEvent?.tags.find(([n]) => n === 'd')?.[1];
const communityUrl = useMemo(() => {
if (!communityAddr) return undefined;
try {
return `/${nip19.naddrEncode({
kind: communityAddr.kind,
pubkey: communityAddr.pubkey,
identifier: communityAddr.identifier,
})}`;
} catch {
return undefined;
}
}, [communityAddr]);
return {
expired,
funded,
currentSats,
percentage,
contributors,
zapCount,
progressLoading,
metadata,
displayName,
avatarShape,
profileUrl,
lightningAddress,
deadlineLabel,
communityName,
communityUrl,
image: goal.image,
};
}
+6 -14
View File
@@ -1,5 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { parseATagCoordinate } from '@/lib/nostrEvents';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Kind constant ─────────────────────────────────────────────────────────────
@@ -83,11 +84,6 @@ export function isGoalExpired(goal: ParsedGoal): boolean {
return Math.floor(Date.now() / 1000) > goal.closedAt;
}
/** Check whether a goal has been fully funded (current >= target). */
export function isGoalFunded(currentMsat: number, goal: ParsedGoal): boolean {
return currentMsat >= goal.amountMsat;
}
/** Format satoshis with locale-aware separators. */
export function formatSats(sats: number): string {
return sats.toLocaleString();
@@ -95,15 +91,11 @@ export function formatSats(sats: number): string {
/**
* Parse a community `a` tag coordinate (`34550:<pubkey>:<d-tag>`) into its
* constituent parts. Returns `undefined` if the format is invalid.
* constituent parts. Returns `undefined` if the format is invalid or the kind
* is not 34550.
*/
export function parseCommunityATag(aTag: string): { kind: number; pubkey: string; identifier: string } | undefined {
const parts = aTag.split(':');
if (parts.length < 3) return undefined;
const kind = parseInt(parts[0], 10);
if (kind !== 34550) return undefined;
const pubkey = parts[1];
if (!pubkey) return undefined;
const identifier = parts.slice(2).join(':');
return { kind, pubkey, identifier };
const addr = parseATagCoordinate(aTag);
if (!addr || addr.kind !== 34550) return undefined;
return addr;
}
+18
View File
@@ -78,3 +78,21 @@ function getParentEventTag(event: NostrEvent): string[] | undefined {
// Deprecated positional scheme: last non-mention e-tag is the reply target
return eTags[eTags.length - 1];
}
/**
* Parse a Nostr `a`-tag coordinate string (`kind:pubkey:identifier`) into its
* constituent parts. Returns `undefined` if the format is invalid.
*
* Handles d-tags that contain colons by joining everything after the second
* colon back together.
*/
export function parseATagCoordinate(aTag: string): { kind: number; pubkey: string; identifier: string } | undefined {
const parts = aTag.split(':');
if (parts.length < 3) return undefined;
const kind = parseInt(parts[0], 10);
if (isNaN(kind) || kind < 0) return undefined;
const pubkey = parts[1];
if (!pubkey) return undefined;
const identifier = parts.slice(2).join(':');
return { kind, pubkey, identifier };
}
+2 -3
View File
@@ -39,7 +39,7 @@ import {
const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard })));
import { FileMetadataContent } from "@/components/FileMetadataContent";
import { FollowPackContent } from "@/components/FollowPackContent";
import { GoalContent } from "@/components/GoalContent";
import { GoalCard } from "@/components/GoalCard";
import { FollowPackDetailContent } from "@/components/FollowPackDetailContent";
import { FoundLogContent } from "@/components/FoundLogContent";
import { GeocacheContent } from "@/components/GeocacheContent";
@@ -421,7 +421,6 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
);
}
/** NoteCard + NIP-22 comments section for kind 10008/30008 profile badges detail page. */
function ProfileBadgesDetailView({ event }: { event: NostrEvent }) {
const { muteItems } = useMuteList();
@@ -2143,7 +2142,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
) : isLetter ? (
<EncryptedLetterContent event={event} />
) : isZapGoal ? (
<GoalContent event={event} />
<GoalCard event={event} variant="compact" />
) : isVine ||
isPoll ||
isGeocache ||