ea0f01da98
- Defer bookmark/pin queries in NoteMoreMenu until menu is opened - Move RightSidebar outside Suspense to prevent default sidebar queries on profile pages - Split profile metadata: kind 0 piggybacked on feed query, kind 3+10001 fetched separately - Share single feed query across posts/replies/media tabs (filter client-side) - Reduce feed page size from 60 to 15 to match main feed - Fix pagination using raw event count instead of filtered count - Remove duplicate pinned events query from usePinnedNotes - Add staleTime to useMuteList to prevent refetch on every navigation - Add skeleton loading for banner, avatar, name, and pinned posts - Stabilize streak calculation to use raw feed data across all tabs
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import { nip19 } from 'nostr-tools';
|
|
|
|
import {
|
|
Bookmark,
|
|
ClipboardCopy,
|
|
ExternalLink,
|
|
AtSign,
|
|
BellOff,
|
|
VolumeX,
|
|
Flag,
|
|
Pin,
|
|
FileJson,
|
|
FileDigit,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Button } from '@/components/ui/button';
|
|
import { NoteContent } from '@/components/NoteContent';
|
|
import { EmojifiedText } from '@/components/CustomEmoji';
|
|
import { useBookmarks } from '@/hooks/useBookmarks';
|
|
import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useMuteList } from '@/hooks/useMuteList';
|
|
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { timeAgo } from '@/lib/timeAgo';
|
|
import { toast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
interface NoteMoreMenuProps {
|
|
event: NostrEvent;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
interface MenuItemProps {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
destructive?: boolean;
|
|
}
|
|
|
|
function MenuItem({ icon, label, onClick, destructive }: MenuItemProps) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
'flex items-center gap-4 w-full px-5 py-3 text-[15px] transition-colors hover:bg-secondary/60',
|
|
destructive ? 'text-destructive' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
<span className="shrink-0">{icon}</span>
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
|
if (!open) return null;
|
|
return <NoteMoreMenuContent event={event} open={open} onOpenChange={onOpenChange} />;
|
|
}
|
|
|
|
function NoteMoreMenuContent({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
|
const { user } = useCurrentUser();
|
|
const { isBookmarked, toggleBookmark } = useBookmarks();
|
|
const bookmarked = isBookmarked(event.id);
|
|
const { isPinned, togglePin } = usePinnedNotes(user?.pubkey);
|
|
const pinned = isPinned(event.id);
|
|
const isOwnPost = user?.pubkey === event.pubkey;
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name || genUserName(event.pubkey);
|
|
const { addMute, removeMute, isMuted } = useMuteList();
|
|
const userMuted = isMuted('pubkey', event.pubkey);
|
|
const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent();
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
|
|
const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey });
|
|
|
|
const close = () => onOpenChange(false);
|
|
|
|
const handleCopyLink = () => {
|
|
const url = `${window.location.origin}/${neventId}`;
|
|
navigator.clipboard.writeText(url);
|
|
toast({ title: 'Link copied to clipboard' });
|
|
close();
|
|
};
|
|
|
|
const handleViewOnNjump = () => {
|
|
window.open(`https://njump.me/${neventId}`, '_blank', 'noopener,noreferrer');
|
|
close();
|
|
};
|
|
|
|
const handleBookmark = () => {
|
|
toggleBookmark.mutate(event.id);
|
|
close();
|
|
};
|
|
|
|
const handleTogglePin = () => {
|
|
togglePin.mutate(event.id, {
|
|
onSuccess: () => {
|
|
toast({ title: pinned ? 'Unpinned from profile' : 'Pinned to profile' });
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to update pinned posts', variant: 'destructive' });
|
|
},
|
|
});
|
|
close();
|
|
};
|
|
|
|
const handleCopyEventId = () => {
|
|
navigator.clipboard.writeText(event.id);
|
|
toast({ title: 'Event ID copied to clipboard' });
|
|
close();
|
|
};
|
|
|
|
const handleCopyEventJson = () => {
|
|
navigator.clipboard.writeText(JSON.stringify(event, null, 2));
|
|
toast({ title: 'Event JSON copied to clipboard' });
|
|
close();
|
|
};
|
|
|
|
const handleMuteConversation = () => {
|
|
// Mute the root event of the thread, or this event if it's the root
|
|
const rootTag = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
|
const threadId = rootTag?.[1] ?? event.id;
|
|
addMute.mutate(
|
|
{ type: 'thread', value: threadId },
|
|
{
|
|
onSuccess: () => {
|
|
toast({ title: 'Conversation muted' });
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to mute conversation', variant: 'destructive' });
|
|
},
|
|
},
|
|
);
|
|
close();
|
|
};
|
|
|
|
const handleMention = () => {
|
|
toast({ title: 'Mention is not yet implemented' });
|
|
close();
|
|
};
|
|
|
|
const handleMuteUser = () => {
|
|
const muteItem = { type: 'pubkey' as const, value: event.pubkey };
|
|
const mutation = userMuted ? removeMute : addMute;
|
|
mutation.mutate(muteItem, {
|
|
onSuccess: () => {
|
|
toast({ title: userMuted ? `Unmuted @${displayName}` : `Muted @${displayName}` });
|
|
},
|
|
onError: () => {
|
|
toast({ title: userMuted ? 'Failed to unmute user' : 'Failed to mute user', variant: 'destructive' });
|
|
},
|
|
});
|
|
close();
|
|
};
|
|
|
|
const handleReport = () => {
|
|
toast({ title: 'Report is not yet implemented' });
|
|
close();
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
deleteEvent(
|
|
{ eventId: event.id, eventKind: event.kind },
|
|
{
|
|
onSuccess: () => {
|
|
toast({ title: 'Post deleted' });
|
|
close();
|
|
},
|
|
onError: () => {
|
|
toast({ title: 'Failed to delete post', variant: 'destructive' });
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden">
|
|
<DialogTitle className="sr-only">Post options</DialogTitle>
|
|
|
|
{/* Post preview */}
|
|
<div className="px-4 pt-4 pb-3">
|
|
<div className="flex gap-3">
|
|
<Avatar className="size-10 shrink-0">
|
|
<AvatarImage src={metadata?.picture} alt={displayName} />
|
|
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
|
{displayName[0].toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 text-sm">
|
|
<span className="font-bold truncate">
|
|
{author.data?.event ? (
|
|
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
|
) : displayName}
|
|
</span>
|
|
<span className="text-muted-foreground shrink-0">·</span>
|
|
<span className="text-muted-foreground shrink-0 text-xs">{timeAgo(event.created_at)}</span>
|
|
</div>
|
|
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3">
|
|
<NoteContent event={event} className="text-sm leading-relaxed" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="py-1">
|
|
<MenuItem
|
|
icon={<ClipboardCopy className="size-5" />}
|
|
label="Copy Link to Post"
|
|
onClick={handleCopyLink}
|
|
/>
|
|
<MenuItem
|
|
icon={<FileDigit className="size-5" />}
|
|
label="Copy Event ID"
|
|
onClick={handleCopyEventId}
|
|
/>
|
|
<MenuItem
|
|
icon={<FileJson className="size-5" />}
|
|
label="Copy Event JSON"
|
|
onClick={handleCopyEventJson}
|
|
/>
|
|
<MenuItem
|
|
icon={<ExternalLink className="size-5" />}
|
|
label="View post on njump.me"
|
|
onClick={handleViewOnNjump}
|
|
/>
|
|
<MenuItem
|
|
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
|
|
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
|
|
onClick={handleBookmark}
|
|
/>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="py-1">
|
|
{!isOwnPost && (
|
|
<MenuItem
|
|
icon={<BellOff className="size-5" />}
|
|
label="Mute Conversation"
|
|
onClick={handleMuteConversation}
|
|
/>
|
|
)}
|
|
{isOwnPost && (
|
|
<MenuItem
|
|
icon={<Pin className={cn("size-5", pinned && "fill-current")} />}
|
|
label={pinned ? 'Unpin from profile' : 'Pin on profile'}
|
|
onClick={handleTogglePin}
|
|
/>
|
|
)}
|
|
{isOwnPost && (
|
|
<MenuItem
|
|
icon={<Trash2 className="size-5" />}
|
|
label="Delete post"
|
|
onClick={() => setDeleteConfirmOpen(true)}
|
|
destructive
|
|
/>
|
|
)}
|
|
{!isOwnPost && (
|
|
<MenuItem
|
|
icon={<AtSign className="size-5" />}
|
|
label={`Mention @${displayName}`}
|
|
onClick={handleMention}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{!isOwnPost && (
|
|
<>
|
|
<Separator />
|
|
|
|
<div className="py-1">
|
|
<MenuItem
|
|
icon={<VolumeX className="size-5" />}
|
|
label={userMuted ? `Unmute @${displayName}` : `Mute @${displayName}`}
|
|
onClick={handleMuteUser}
|
|
/>
|
|
<MenuItem
|
|
icon={<Flag className="size-5" />}
|
|
label={`Report @${displayName}`}
|
|
onClick={handleReport}
|
|
destructive
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<div className="py-1">
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full h-auto py-3 text-[15px] font-medium text-muted-foreground hover:bg-secondary/60 rounded-none"
|
|
onClick={close}
|
|
>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
|
|
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete post?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will request deletion from relays. Some relays may still keep a copy of the original event. This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Dialog>
|
|
);
|
|
}
|