Skaters can now request to join a Lab collective (studio/build team), mirroring the crew join-request flow: - New addressable kind 36387 (collective join-request), documented in NIP.md - useCollectiveJoinRequests: send / incoming / "already requested" hooks - Owner-only notification surfaced in the bell (All + Crews tabs) via CollectiveJoinRequestCard, reusing the synced dismissal set - "Request to join this collective" button on collective Lab profiles (RequestToJoinCollective), hidden for the owner and existing members Trust model preserved: only the owner can edit their own profile roster, so the card links them to the member editor — no forged membership possible. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
|
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
|
import { CollectiveJoinRequestCard } from '@/components/CollectiveJoinRequestCard';
|
|
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
|
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
|
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
|
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { SKATE_CREW_KIND, type MemberVisibility, type SkateCrew } from '@/lib/types';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* A pending-confirm crew: this user has been LISTED in a crew's member list but
|
|
* hasn't published their own attestation yet. Rendered like an invite so they
|
|
* can confirm (with a visibility choice) right from the bell.
|
|
*/
|
|
function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?: () => void }) {
|
|
const isPending = useIsPendingMember(crew);
|
|
const founder = useAuthor(crew.pubkey);
|
|
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
|
|
const founderPic = founder.data?.metadata?.picture;
|
|
const { toast } = useToast();
|
|
const { mutateAsync: confirm, isPending: confirming } = useConfirmMembership();
|
|
const [visibility, setVisibility] = useState<MemberVisibility>('public');
|
|
const [done, setDone] = useState(false);
|
|
|
|
// Only actionable while the member is genuinely pending confirmation.
|
|
if (!isPending || done) return null;
|
|
|
|
const handleConfirm = async () => {
|
|
try {
|
|
await confirm({
|
|
kind: SKATE_CREW_KIND,
|
|
founderPubkey: crew.pubkey,
|
|
identifier: crew.identifier,
|
|
collectiveName: crew.name,
|
|
visibility,
|
|
});
|
|
setDone(true);
|
|
toast({ title: `You're in ${crew.name}! 🛹`, description: 'Membership confirmed.' });
|
|
onResolved?.();
|
|
} catch (err) {
|
|
toast({
|
|
title: 'Could not confirm',
|
|
description: err instanceof Error ? err.message : 'Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-xl border border-teal-500/25 bg-gradient-to-br from-teal-500/[0.08] to-transparent p-3">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-teal-500/15">
|
|
<Users className="h-4 w-4 text-teal-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm leading-snug text-gray-200">
|
|
<span className="inline-flex items-center gap-1 font-bold text-white">
|
|
<Avatar className="h-4 w-4">
|
|
<AvatarImage src={founderPic} alt={founderName} />
|
|
<AvatarFallback className="text-[8px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
{founderName}
|
|
</span>{' '}
|
|
added you to{' '}
|
|
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="font-black text-teal-300 hover:underline">
|
|
{crew.name}
|
|
</Link>
|
|
. Confirm to appear as a member.
|
|
</p>
|
|
|
|
{/* Visibility toggle */}
|
|
<div className="mt-2 grid grid-cols-2 gap-1.5">
|
|
{([
|
|
{ key: 'public', label: 'Public', icon: Globe },
|
|
{ key: 'crew', label: 'Crew only', icon: EyeOff },
|
|
] as const).map(({ key, label, icon: Icon }) => {
|
|
const active = visibility === key;
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
onClick={() => setVisibility(key)}
|
|
className={cn(
|
|
'flex items-center justify-center gap-1 rounded-lg border px-2 py-1.5 text-xs font-bold transition-all active:scale-95',
|
|
active
|
|
? 'border-transparent bg-teal-500 text-black'
|
|
: 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white',
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
onClick={handleConfirm}
|
|
disabled={confirming}
|
|
className="mt-2.5 h-8 w-full gap-1.5 bg-teal-500 font-bold text-black hover:bg-teal-600"
|
|
>
|
|
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
|
Confirm membership
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ label }: { label: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
|
|
<Bell className="h-5 w-5 text-gray-600" />
|
|
</div>
|
|
<p className="text-sm font-bold text-gray-400">You're all caught up</p>
|
|
<p className="max-w-[220px] text-xs text-gray-600">{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Header notification bell — the user's whole activity center, not just crews:
|
|
*
|
|
* SOCIAL: reactions, reposts, zaps, mentions and replies that reference them.
|
|
* CREWS: structured invites, "pending confirmation" crews, and (for crew
|
|
* leaders) incoming join-requests to approve.
|
|
*
|
|
* Unread state is tracked by a per-account "last read" timestamp; opening the
|
|
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
|
|
*/
|
|
export function NotificationBell({ className }: { className?: string }) {
|
|
const [open, setOpen] = useState(false);
|
|
const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } =
|
|
useCrewNotifications();
|
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
|
const { lastRead, markAllRead } = useNotificationsRead();
|
|
|
|
const crewTotal = useMemo(
|
|
() => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length,
|
|
[invites, listedCrews, joinRequests, collectiveRequests],
|
|
);
|
|
|
|
// Unread social items = activity newer than the last time the bell was opened.
|
|
const unreadActivity = useMemo(
|
|
() => activity.filter((n) => n.createdAt > lastRead).length,
|
|
[activity, lastRead],
|
|
);
|
|
|
|
// Crew items are always "actionable" so they always count toward the badge.
|
|
const badgeTotal = crewTotal + unreadActivity;
|
|
|
|
// Opening the bell marks everything as read.
|
|
useEffect(() => {
|
|
if (open) markAllRead();
|
|
}, [open, markAllRead]);
|
|
|
|
const isLoading = crewLoading || activityLoading;
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
|
|
title="Notifications"
|
|
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
|
|
>
|
|
<Bell className="h-5 w-5" />
|
|
{badgeTotal > 0 && (
|
|
<span className="absolute -right-0.5 -top-0.5 flex min-w-[18px] items-center justify-center rounded-full bg-teal-500 px-1 text-[10px] font-black leading-[18px] text-black">
|
|
{badgeTotal > 9 ? '9+' : badgeTotal}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-[380px] max-w-[calc(100vw-1.5rem)] p-0" sideOffset={8}>
|
|
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
|
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
|
<Bell className="h-4 w-4 text-teal-400" /> Notifications
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={markAllRead}
|
|
className="flex items-center gap-1 text-[10px] font-black uppercase tracking-wide text-gray-500 transition-colors hover:text-teal-400"
|
|
>
|
|
<CheckCheck className="h-3.5 w-3.5" /> Mark read
|
|
</button>
|
|
</div>
|
|
|
|
<Tabs defaultValue="all" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-3 rounded-none border-b border-white/10 bg-transparent p-0">
|
|
<TabsTrigger
|
|
value="all"
|
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
|
>
|
|
All
|
|
{badgeTotal > 0 && <span className="ml-1 text-teal-400">{badgeTotal}</span>}
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="social"
|
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
|
>
|
|
<Heart className="mr-1 h-3 w-3" /> Social
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="crews"
|
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
|
>
|
|
<Users className="mr-1 h-3 w-3" /> Crews
|
|
{crewTotal > 0 && <span className="ml-1 text-teal-400">{crewTotal}</span>}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<ScrollArea className="max-h-[65vh]">
|
|
{/* ─── ALL ─────────────────────────────────────────────────── */}
|
|
<TabsContent value="all" className="m-0 p-3">
|
|
{isLoading && badgeTotal === 0 && (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
|
</div>
|
|
)}
|
|
{!isLoading && crewTotal === 0 && activity.length === 0 && (
|
|
<EmptyState label="Zaps, reactions, replies and crew activity will show up here." />
|
|
)}
|
|
<div className="space-y-2.5">
|
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
|
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
|
))}
|
|
{collectiveRequests.map((request) => (
|
|
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
|
))}
|
|
{invites.map((invite) => (
|
|
<CrewInviteCard key={invite.id} invite={invite} compact />
|
|
))}
|
|
{listedCrews.map((crew) => (
|
|
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
|
))}
|
|
{activity.map((n) => (
|
|
<ActivityNotificationCard
|
|
key={n.id}
|
|
notification={n}
|
|
unread={n.createdAt > lastRead}
|
|
onNavigate={() => setOpen(false)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ─── SOCIAL ──────────────────────────────────────────────── */}
|
|
<TabsContent value="social" className="m-0 p-3">
|
|
{activityLoading && activity.length === 0 && (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
|
</div>
|
|
)}
|
|
{!activityLoading && activity.length === 0 && (
|
|
<EmptyState label="When people zap, react, repost, mention or reply to you, it lands here." />
|
|
)}
|
|
<div className="space-y-2.5">
|
|
{activity.map((n) => (
|
|
<ActivityNotificationCard
|
|
key={n.id}
|
|
notification={n}
|
|
unread={n.createdAt > lastRead}
|
|
onNavigate={() => setOpen(false)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ─── CREWS ───────────────────────────────────────────────── */}
|
|
<TabsContent value="crews" className="m-0 p-3">
|
|
{crewLoading && crewTotal === 0 && (
|
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
|
</div>
|
|
)}
|
|
{!crewLoading && crewTotal === 0 && (
|
|
<EmptyState label="Crew invites, collective requests and membership requests will show up here." />
|
|
)}
|
|
<div className="space-y-2.5">
|
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
|
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
|
))}
|
|
{collectiveRequests.map((request) => (
|
|
<CollectiveJoinRequestCard key={request.id} request={request} compact />
|
|
))}
|
|
{invites.map((invite) => (
|
|
<CrewInviteCard key={invite.id} invite={invite} compact />
|
|
))}
|
|
{listedCrews.map((crew) => (
|
|
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
</ScrollArea>
|
|
</Tabs>
|
|
|
|
<div className="border-t border-white/10 p-2">
|
|
<Button
|
|
asChild
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full text-xs font-bold text-gray-400 hover:text-teal-400"
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<Link to="/crews">Browse all crews</Link>
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|