Files
eranos/src/components/TeamSoapboxCard.tsx
T
Alex Gleason 4cceaf652d Add a Mute All dropdown next to every Follow All button
Splits the existing Follow All button on people-list, follow-pack, follow-set, badge, and Team Soapbox detail views into a primary Follow All + a caret dropdown whose lone option is Mute All. Mute All opens an AlertDialog and, on confirm, merges every pubkey in the list into the viewer's NIP-51 kind 10000 mute list. Lets you treat any people list as a mute source as well as a follow source.

To make mute meaningful for users who already followed someone before muting, follow-scoped feed queries now subtract muted pubkeys from the authors filter at query time, via a shared useMutedAuthorFilter hook that builds the muted-pubkey Set once per mute-list change and exposes a single excludeMuted helper. The hook replaces ten inline copies of the same filter — including two that allocated a new Set per follow-list element inside a .filter() callback. Render-layer mute filtering stays in place as a second line of defense.

Also adds reusable hooks for the bulk operations (useFollowActions.followMany, useMuteList.muteManyPubkeys) and replaces four duplicated inline Follow All implementations with a single FollowAllSplitButton component.
2026-05-13 14:54:51 -05:00

244 lines
8.7 KiB
TypeScript

import { useMemo, useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Users, UserPlus, Check, Loader2, Heart } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useNostr } from '@nostrify/react';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { useAuthors } from '@/hooks/useAuthors';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList } from '@/hooks/useFollowActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { TEAM_SOAPBOX_PACK } from '@/lib/helpContent';
/**
* A card that displays the "Team Soapbox" follow pack with a help-oriented CTA.
* Fetches the pack event from relays, shows member avatars, and provides
* a "Follow All" action. Designed for the Help page but reusable anywhere.
*/
export function TeamSoapboxCard({ className }: { className?: string }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const [event, setEvent] = useState<NostrEvent | null>(null);
const [loading, setLoading] = useState(true);
const [isFollowingAll, setIsFollowingAll] = useState(false);
// Fetch the pack event
useEffect(() => {
let cancelled = false;
const fetchPack = async () => {
try {
const events = await nostr.query(
[{
kinds: [TEAM_SOAPBOX_PACK.kind],
authors: [TEAM_SOAPBOX_PACK.pubkey],
'#d': [TEAM_SOAPBOX_PACK.identifier],
limit: 1,
}],
{ signal: AbortSignal.timeout(8000) },
);
if (!cancelled && events.length > 0) {
setEvent(events[0]);
}
} catch (error) {
console.warn('Failed to fetch Team Soapbox pack:', error);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchPack();
return () => { cancelled = true; };
}, [nostr]);
const pubkeys = useMemo(
() => event?.tags.filter(([n]) => n === 'p').map(([, pk]) => pk) ?? [],
[event],
);
const previewPubkeys = useMemo(() => pubkeys.slice(0, 8), [pubkeys]);
const { data: membersMap } = useAuthors(previewPubkeys);
const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]);
const newPubkeys = useMemo(
() => pubkeys.filter((pk) => !followedPubkeys.has(pk)),
[pubkeys, followedPubkeys],
);
const naddrLink = useMemo(() => {
if (!event) return undefined;
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return `/${nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag })}`;
}, [event]);
const handleFollowAll = useCallback(async () => {
if (!user || !event) return;
setIsFollowingAll(true);
try {
// 1. Fetch freshest kind 3 from relays (not cache)
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
// 3. Merge: add new pubkeys that aren't already followed
const newPTags = pubkeys
.filter((pk) => !existingPubkeys.has(pk))
.map((pk) => ['p', pk]);
const added = newPTags.length;
// 4. Publish with prev for published_at preservation
await publishEvent({
kind: 3,
content: prev?.content ?? '',
tags: [...nonPTags, ...existingPTags, ...newPTags],
prev: prev ?? undefined,
});
toast({
title: 'Following Team Soapbox!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: 'You were already following everyone on the team.',
});
} catch (error) {
console.error('Failed to follow all:', error);
toast({
title: 'Failed to follow',
description: 'There was an error updating your follow list.',
variant: 'destructive',
});
} finally {
setIsFollowingAll(false);
}
}, [user, event, pubkeys, nostr, publishEvent, toast]);
if (loading) {
return <TeamSoapboxCardSkeleton className={className} />;
}
if (!event) return null;
return (
<div className={className}>
<div className="rounded-2xl border border-primary/20 bg-primary/5 overflow-hidden">
<div className="px-5 pt-5 pb-4 space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<div className="flex items-center justify-center size-10 shrink-0 rounded-full bg-primary/15">
<Heart className="size-5 text-primary" />
</div>
<div className="min-w-0">
<h3 className="text-base font-bold leading-snug">Need help? Meet Team Soapbox</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
These are real people who built this platform and are happy to help. Follow them and don't be shy — ask anything!
</p>
</div>
</div>
{/* Avatar stack + member count */}
{pubkeys.length > 0 && (
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{previewPubkeys.map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || genUserName(pk);
return (
<Avatar key={pk} className="size-8 ring-2 ring-background">
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
<Users className="size-3.5" />
{pubkeys.length} member{pubkeys.length !== 1 ? 's' : ''}
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
className="gap-2 flex-1"
onClick={handleFollowAll}
disabled={isFollowingAll || !user}
>
{isFollowingAll ? (
<>
<Loader2 className="size-4 animate-spin" />
Following...
</>
) : newPubkeys.length === 0 && user ? (
<>
<Check className="size-4" />
Already following all
</>
) : (
<>
<UserPlus className="size-4" />
Follow All ({pubkeys.length})
</>
)}
</Button>
{naddrLink && (
<Button variant="outline" asChild>
<Link to={naddrLink}>View Pack</Link>
</Button>
)}
</div>
</div>
</div>
</div>
);
}
function TeamSoapboxCardSkeleton({ className }: { className?: string }) {
return (
<div className={className}>
<div className="rounded-2xl border border-primary/20 bg-primary/5 overflow-hidden">
<div className="px-5 pt-5 pb-4 space-y-4">
<div className="flex items-start gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="size-8 rounded-full ring-2 ring-background" />
))}
</div>
<Skeleton className="h-4 w-20" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 flex-1 rounded-md" />
<Skeleton className="h-10 w-24 rounded-md" />
</div>
</div>
</div>
</div>
);
}