mirror of
https://github.com/layer-systems/website.git
synced 2026-09-15 07:47:34 +02:00
Adds a ModerationMenu (mute/block/report) to notes and profiles, backed by a NIP-51 mute list (kind 10000, private by default via NIP-44) and NIP-56 reports (kind 1984). Blocking mutes and also removes the account from the follow list. Muted accounts are filtered out of the feed and notifications, and a new Settings section lists them with an Unmute control to reverse either choice. Closes #51. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto
225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
import { useEffect, useMemo } from 'react';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Check, Copy, Globe, Loader2, UserMinus, UserPlus } from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome';
|
|
import { ModerationMenu } from '@/components/nostr/ModerationMenu';
|
|
import { NoteCard } from '@/components/nostr/NoteCard';
|
|
import { NoteContent } from '@/components/nostr/NoteContent';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useMyFollows } from '@/hooks/useFollows';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { decodeRelayHints, displayName, isReply, npubOf, sanitizeUrl } from '@/lib/nostrUtils';
|
|
import type { AppProps } from '@/os/types';
|
|
|
|
/**
|
|
* Replies are excluded here for the same reason as the Feed: without their
|
|
* parent for context, a reply on a profile's timeline reads as an orphaned
|
|
* root post rather than what it is.
|
|
*/
|
|
function useAuthorNotes(pubkey: string | undefined, relays: string[] | undefined) {
|
|
const { nostr } = useNostr();
|
|
|
|
return useQuery<NostrEvent[]>({
|
|
queryKey: ['nostr', 'author-notes', pubkey ?? '', relays?.join(',') ?? ''],
|
|
enabled: Boolean(pubkey),
|
|
queryFn: async ({ signal }) => {
|
|
const events = await nostr.query(
|
|
[{ kinds: [1], authors: [pubkey!], limit: 40 }],
|
|
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]), relays },
|
|
);
|
|
return events
|
|
.filter((event) => event.content.trim().length > 0 && !isReply(event))
|
|
.sort((a, b) => b.created_at - a.created_at);
|
|
},
|
|
staleTime: 60_000,
|
|
});
|
|
}
|
|
|
|
export default function ProfileApp({ params, setTitle }: AppProps) {
|
|
const { user } = useCurrentUser();
|
|
const pubkey = params.pubkey || user?.pubkey;
|
|
|
|
const author = useAuthor(pubkey);
|
|
const notes = useAuthorNotes(pubkey, decodeRelayHints(params.relays));
|
|
const name = pubkey ? displayName(pubkey, author.data?.metadata) : '';
|
|
|
|
useEffect(() => {
|
|
setTitle(name ? `Profile — ${name}` : 'Profile');
|
|
}, [name, setTitle]);
|
|
|
|
if (!pubkey) {
|
|
return (
|
|
<EmptyState
|
|
title="No profile selected"
|
|
hint="Open a profile from a note, or sign in to see your own."
|
|
/>
|
|
);
|
|
}
|
|
|
|
const metadata = author.data?.metadata;
|
|
const banner = sanitizeUrl(metadata?.banner);
|
|
const picture = sanitizeUrl(metadata?.picture);
|
|
const website = sanitizeUrl(metadata?.website);
|
|
|
|
return (
|
|
<AppLayout>
|
|
<AppToolbar>
|
|
<span className="truncate text-[13px] font-medium">{name}</span>
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
<CopyNpubButton pubkey={pubkey} />
|
|
<FollowButton pubkey={pubkey} />
|
|
<ModerationMenu pubkey={pubkey} />
|
|
</div>
|
|
</AppToolbar>
|
|
|
|
<AppBody>
|
|
<div
|
|
className="h-28 w-full bg-gradient-to-br from-primary/25 via-primary/10 to-transparent bg-cover bg-center"
|
|
style={banner ? { backgroundImage: `url(${JSON.stringify(banner)})` } : undefined}
|
|
aria-hidden
|
|
/>
|
|
|
|
<div className="px-4 pb-4">
|
|
<Avatar className="-mt-9 size-18 border-4 border-background">
|
|
{picture && <AvatarImage src={picture} alt="" />}
|
|
<AvatarFallback className="text-lg">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="mt-3 space-y-1">
|
|
<h1 className="text-xl font-semibold tracking-tight">{name}</h1>
|
|
{metadata?.nip05 && (
|
|
<p className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
<Check className="size-3.5 text-primary" aria-hidden />
|
|
{metadata.nip05}
|
|
</p>
|
|
)}
|
|
<p className="font-mono text-xs text-muted-foreground break-all">{npubOf(pubkey)}</p>
|
|
</div>
|
|
|
|
{metadata?.about && (
|
|
<div className="mt-3">
|
|
<NoteContent content={metadata.about} className="text-sm" />
|
|
</div>
|
|
)}
|
|
|
|
{website && (
|
|
<a
|
|
href={website}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
className="mt-3 inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
|
>
|
|
<Globe className="size-3.5" aria-hidden />
|
|
{website.replace(/^https?:\/\//, '')}
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border">
|
|
<h2 className="px-4 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Notes
|
|
</h2>
|
|
{notes.isLoading ? (
|
|
<div className="space-y-3 p-4">
|
|
<Skeleton className="h-4 w-3/4" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-2/3" />
|
|
</div>
|
|
) : notes.data && notes.data.length > 0 ? (
|
|
notes.data.map((event) => <NoteCard key={event.id} event={event} />)
|
|
) : (
|
|
<EmptyState title="No notes found" hint="This person has not posted, or your relays do not carry them." />
|
|
)}
|
|
</div>
|
|
</AppBody>
|
|
</AppLayout>
|
|
);
|
|
}
|
|
|
|
function CopyNpubButton({ pubkey }: { pubkey: string }) {
|
|
const { toast } = useToast();
|
|
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1.5 px-2 text-xs"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(npubOf(pubkey));
|
|
toast({ title: 'npub copied' });
|
|
} catch {
|
|
toast({ title: 'Could not copy', variant: 'destructive' });
|
|
}
|
|
}}
|
|
>
|
|
<Copy className="size-3.5" aria-hidden />
|
|
npub
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Follows are a whole-list replacement (kind 3), so the current list has to be
|
|
* read back before writing or the edit would silently drop everyone else.
|
|
*/
|
|
function FollowButton({ pubkey }: { pubkey: string }) {
|
|
const { user } = useCurrentUser();
|
|
const myFollows = useMyFollows();
|
|
const publish = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
|
|
const follows = useMemo(() => myFollows.data ?? [], [myFollows.data]);
|
|
const isFollowing = follows.includes(pubkey);
|
|
const isSelf = user?.pubkey === pubkey;
|
|
|
|
if (!user || isSelf) return null;
|
|
|
|
const toggle = async () => {
|
|
const next = isFollowing ? follows.filter((key) => key !== pubkey) : [...follows, pubkey];
|
|
|
|
try {
|
|
await publish.mutateAsync({
|
|
kind: 3,
|
|
content: '',
|
|
tags: next.map((key) => ['p', key]),
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: ['nostr', 'follows'] });
|
|
toast({ title: isFollowing ? 'Unfollowed' : 'Following' });
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Could not update your follow list',
|
|
description: error instanceof Error ? error.message : undefined,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant={isFollowing ? 'outline' : 'default'}
|
|
className="h-7 gap-1.5 px-2.5 text-xs"
|
|
onClick={toggle}
|
|
disabled={publish.isPending || myFollows.isLoading}
|
|
>
|
|
{publish.isPending ? (
|
|
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
|
) : isFollowing ? (
|
|
<UserMinus className="size-3.5" aria-hidden />
|
|
) : (
|
|
<UserPlus className="size-3.5" aria-hidden />
|
|
)}
|
|
{isFollowing ? 'Following' : 'Follow'}
|
|
</Button>
|
|
);
|
|
}
|