Files
eranos/src/components/CommunityContent.tsx
T
2026-03-02 13:19:42 -06:00

216 lines
7.8 KiB
TypeScript

import { useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Users, Share2, Globe } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
// --- Helpers ---
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
function parseCommunityEvent(event: NostrEvent) {
const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Community';
const description = getTag(event.tags, 'description') || '';
const image = getTag(event.tags, 'image');
// Extract moderators from p tags with "moderator" role
const moderators = event.tags
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
.map(([, pubkey]) => pubkey)
.filter(Boolean);
// Extract relays
const relays = event.tags
.filter(([n]) => n === 'relay')
.map(([, url, marker]) => ({ url, marker }))
.filter((r) => !!r.url);
return { name, description, image, moderators, relays };
}
// --- Sub-components ---
function ModeratorRow({ pubkey }: { pubkey: string }) {
const { data } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
const profileUrl = useProfileUrl(pubkey, metadata);
return (
<Link to={profileUrl} className="flex items-center gap-3 group py-1.5">
<Avatar className="size-9 ring-2 ring-background">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground text-xs">
{name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate group-hover:underline">{name}</p>
{metadata?.nip05 && (
<p className="text-xs text-muted-foreground truncate">{metadata.nip05}</p>
)}
</div>
<Badge variant="secondary" className="text-xs shrink-0">Moderator</Badge>
</Link>
);
}
// --- Main Component ---
export function CommunityContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { name, description, image, moderators, relays } = useMemo(
() => parseCommunityEvent(event),
[event],
);
// Batch-fetch moderator profiles
useAuthors(moderators);
// Owner
const ownerAuthor = useAuthor(event.pubkey);
const ownerMetadata = ownerAuthor.data?.metadata;
const ownerName = ownerMetadata?.display_name || ownerMetadata?.name || genUserName(event.pubkey);
const ownerProfileUrl = useProfileUrl(event.pubkey, ownerMetadata);
// Extract website URL from description if present
const descriptionUrl = useMemo(() => {
const urlMatch = description.match(/https?:\/\/[^\s]+/);
return urlMatch?.[0];
}, [description]);
// Description text without trailing URL (if the URL is the last thing)
const descriptionText = useMemo(() => {
if (!descriptionUrl) return description;
return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim();
}, [description, descriptionUrl]);
const handleShare = useCallback(async () => {
const d = getTag(event.tags, 'd') ?? '';
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: d,
});
const url = `${window.location.origin}/${naddr}`;
try {
await navigator.clipboard.writeText(url);
toast({ title: 'Link copied to clipboard' });
} catch {
toast({ title: 'Failed to copy link', variant: 'destructive' });
}
}, [event, toast]);
return (
<div className="mt-3 space-y-5">
{/* Community hero image */}
{image ? (
<div className="relative -mx-4 aspect-[21/9] overflow-hidden">
<img
src={image}
alt={name}
className="w-full h-full object-cover"
/>
{/* Gradient overlay for text readability */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Community name overlaid on image */}
<div className="absolute bottom-0 left-0 right-0 px-4 pb-4">
<h1 className="text-2xl font-bold text-white leading-tight drop-shadow-lg">{name}</h1>
</div>
</div>
) : (
<div className="relative -mx-4 aspect-[21/9] bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Users className="size-16 text-primary/20" />
<div className="absolute bottom-0 left-0 right-0 px-4 pb-4">
<h1 className="text-2xl font-bold leading-tight">{name}</h1>
</div>
</div>
)}
{/* Quick stats + share */}
<div className="flex items-center gap-3">
<Badge variant="outline" className="gap-1.5">
<Users className="size-3.5" />
{moderators.length} moderator{moderators.length !== 1 ? 's' : ''}
</Badge>
{relays.length > 0 && (
<Badge variant="outline" className="gap-1.5">
<Globe className="size-3.5" />
{relays.length} relay{relays.length !== 1 ? 's' : ''}
</Badge>
)}
<Button variant="outline" size="icon" className="ml-auto size-8 shrink-0" onClick={handleShare}>
<Share2 className="size-3.5" />
</Button>
</div>
{/* Description */}
{descriptionText && (
<p className="text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap">{descriptionText}</p>
)}
{/* Website link */}
{descriptionUrl && (
<a
href={descriptionUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<Globe className="size-3.5" />
{descriptionUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</a>
)}
{/* Owner */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Created by</p>
<Link to={ownerProfileUrl} className="flex items-center gap-3 group">
<Avatar className={cn('size-10 ring-2 ring-background')}>
<AvatarImage src={ownerMetadata?.picture} />
<AvatarFallback className="bg-muted text-muted-foreground">
{ownerName.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-medium truncate group-hover:underline">{ownerName}</p>
{ownerMetadata?.nip05 && (
<p className="text-xs text-muted-foreground truncate">{ownerMetadata.nip05}</p>
)}
</div>
</Link>
</div>
<Separator />
{/* Moderators */}
{moderators.length > 0 && (
<section>
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 flex items-center gap-2">
<Users className="size-3.5" />
Moderators
</h2>
<div className="space-y-1">
{moderators.map((pk) => (
<ModeratorRow key={pk} pubkey={pk} />
))}
</div>
</section>
)}
</div>
);
}