Files
eranos/src/components/NoteMoreMenu.tsx
T
filemon f6b209949a Fix post menu preview: keep NoteContent with working line-clamp
The previous attempt replaced NoteContent with plain text, losing rich
rendering of hashtags, mentions, custom emoji, and nostr identifiers.
Reverting to NoteContent while keeping the overflow fix needed two
changes to make line-clamp-3 work:

- Render NoteContent as a <span> (as="span") so it participates in
  the parent's -webkit-box line counting. The default <div> wrapper
  with overflow-hidden created a separate block formatting context
  that defeated line-clamp entirely.
- Add disableNoteEmbeds to prevent block-level EmbeddedNote/
  EmbeddedNaddr cards from appearing inside the compact preview.

The outer container keeps overflow-wrap-anywhere so long URLs break
safely within the clamped area.

Regression-of: fc950865
2026-05-19 12:17:27 -03:00

641 lines
21 KiB
TypeScript

import { useMemo, useState } from 'react';
import { nip19 } from 'nostr-tools';
import { useNavigate } from 'react-router-dom';
import {
Bookmark,
BellOff,
VolumeX,
Flag,
Pin,
FileJson,
Trash2,
StickyNote,
ListPlus,
PanelLeft,
Copy,
Check,
Radio,
ShieldBan,
Ban,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogHeader,
} 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 { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { NoteContent } from '@/components/NoteContent';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ReportDialog } from '@/components/ReportDialog';
import { CommunityReportDialog } from '@/components/CommunityReportDialog';
import { AddToListDialog } from '@/components/AddToListDialog';
import { useNostr } from '@nostrify/react';
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 { useFeedSettings } from '@/hooks/useFeedSettings';
import { useOrganizers } from '@/hooks/useOrganizers';
import { usePinnedPosts } from '@/hooks/usePinnedPosts';
import { useCountryFeed } from '@/contexts/CountryFeedContext';
import { useCommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { type CommunityMenuContext, canBanTarget, getViewerAuthority } from '@/lib/communityUtils';
// NOTE: `CommunityMenuContext` is derived automatically from
// `useCommunityModerationContext()`. Parents install a
// `CommunityModerationContext.Provider` to enable community-aware menu items.
import { isAdmin } from '@/lib/admins';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { toast } from '@/hooks/useToast';
import { impactLight } from '@/lib/haptics';
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={(e) => {
e.stopPropagation();
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>
);
}
/** Encode the NIP-19 identifier for an event — naddr for addressable events, nevent otherwise. */
function encodeEventNip19(event: NostrEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (dTag) {
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}
}
return nip19.neventEncode({ id: event.id, author: event.pubkey });
}
interface EventJsonDialogProps {
event: NostrEvent;
nip19Id: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: `${label} copied to clipboard` });
setTimeout(() => setCopied(false), 2000);
};
return (
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="size-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</Button>
);
}
function EventJsonDialog({ event, nip19Id, open, onOpenChange }: EventJsonDialogProps) {
const { nostr } = useNostr();
const [broadcasting, setBroadcasting] = useState(false);
const jsonText = JSON.stringify(event, null, 2);
const handleBroadcast = async () => {
setBroadcasting(true);
try {
await nostr.event(event, { signal: AbortSignal.timeout(5000) });
toast({ title: 'Event broadcast to relays' });
} catch {
toast({ title: 'Failed to broadcast event', variant: 'destructive' });
} finally {
setBroadcasting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85dvh] flex flex-col gap-0 p-0 rounded-2xl overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3 shrink-0">
<DialogTitle className="text-base font-semibold">Event Details</DialogTitle>
</DialogHeader>
<div className="px-5 pb-3 shrink-0">
<p className="text-xs font-medium text-muted-foreground mb-1">Event ID</p>
<div className="relative flex items-center bg-muted rounded-lg px-3 py-2">
<p className="font-mono text-xs break-all text-foreground/80 flex-1 pr-2 select-all">
{nip19Id}
</p>
<CopyButton text={nip19Id} label="Event ID" />
</div>
</div>
<div className="px-5 pb-5 flex flex-col flex-1 min-h-0">
<p className="text-xs font-medium text-muted-foreground mb-1">Raw JSON</p>
<div className="relative flex-1 min-h-0 overflow-auto rounded-lg bg-muted border border-border">
<div className="sticky top-2 right-2 float-right mr-2">
<CopyButton text={jsonText} label="Event JSON" />
</div>
<pre className="p-4 text-xs font-mono text-foreground/80 whitespace-pre leading-relaxed">
{jsonText}
</pre>
</div>
</div>
<div className="px-5 pb-5 shrink-0">
<Button
variant="outline"
className="w-full gap-2"
onClick={handleBroadcast}
disabled={broadcasting}
>
<Radio className="size-4" />
{broadcasting ? 'Broadcasting...' : 'Broadcast Event'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
// These states live here (not in NoteMoreMenuContent) so they persist after the menu closes
const [reportOpen, setReportOpen] = useState(false);
const [banContentOpen, setBanContentOpen] = useState(false);
const [banMemberOpen, setBanMemberOpen] = useState(false);
const [addToListOpen, setAddToListOpen] = useState(false);
const [eventJsonOpen, setEventJsonOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
// Resolve community context from the React Context. Parents install a
// `CommunityModerationContext.Provider` (activity feed, community detail
// page, post detail page) to enable community-aware menu items.
const { user } = useCurrentUser();
const communityModCtx = useCommunityModerationContext();
const communityContext = useMemo<CommunityMenuContext | undefined>(() => {
if (!communityModCtx || !user) return undefined;
const viewerMember = getViewerAuthority(user.pubkey, communityModCtx.rankMap, communityModCtx.moderation);
if (!viewerMember) return undefined;
return {
communityATag: communityModCtx.communityATag,
canBan: canBanTarget(viewerMember, communityModCtx.rankMap.get(event.pubkey)),
};
}, [communityModCtx, user, event.pubkey]);
const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent();
const nip19Id = encodeEventNip19(event);
const handleDelete = () => {
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
deleteEvent(
{ eventId: event.id, eventKind: event.kind, eventPubkey: event.pubkey, eventDTag: dTag },
{
onSuccess: () => {
setDeleteConfirmOpen(false);
toast({ title: 'Post deleted' });
},
onError: () => {
toast({ title: 'Failed to delete post', variant: 'destructive' });
},
},
);
};
return (
<>
{open && (
<NoteMoreMenuContent
event={event}
open={open}
onOpenChange={onOpenChange}
communityContext={communityContext}
onReport={() => {
onOpenChange(false);
setTimeout(() => setReportOpen(true), 150);
}}
onBanContent={() => {
onOpenChange(false);
setTimeout(() => setBanContentOpen(true), 150);
}}
onBanMember={() => {
onOpenChange(false);
setTimeout(() => setBanMemberOpen(true), 150);
}}
onAddToList={() => {
onOpenChange(false);
setTimeout(() => setAddToListOpen(true), 150);
}}
onViewEventJson={() => {
onOpenChange(false);
setTimeout(() => setEventJsonOpen(true), 150);
}}
onDelete={() => {
onOpenChange(false);
setTimeout(() => setDeleteConfirmOpen(true), 150);
}}
/>
)}
{communityContext ? (
<CommunityReportDialog
event={event}
communityATag={communityContext.communityATag}
open={reportOpen}
onOpenChange={setReportOpen}
/>
) : (
<ReportDialog event={event} open={reportOpen} onOpenChange={setReportOpen} />
)}
{communityContext?.canBan && (
<>
<BanConfirmDialog
mode="content"
eventId={event.id}
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banContentOpen}
onOpenChange={setBanContentOpen}
/>
<BanConfirmDialog
mode="member"
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banMemberOpen}
onOpenChange={setBanMemberOpen}
/>
</>
)}
<AddToListDialog
pubkey={event.pubkey}
open={addToListOpen}
onOpenChange={setAddToListOpen}
/>
<EventJsonDialog
event={event}
nip19Id={nip19Id}
open={eventJsonOpen}
onOpenChange={setEventJsonOpen}
/>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<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={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
/** Resolved community context (authored upstream from `CommunityModerationContext`). */
communityContext?: CommunityMenuContext;
onReport: () => void;
onBanContent: () => void;
onBanMember: () => void;
onAddToList: () => void;
onViewEventJson: () => void;
onDelete: () => void;
}
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onBanMember, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
const navigate = useNavigate();
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;
// Country-feed pin/unpin context (organizer/admin action). `useCountryFeed`
// returns null outside of a country page; we only enable usePinnedPosts when
// the viewer is actually authorized to pin so we avoid extra relay traffic
// for read-only viewers.
const countryFeed = useCountryFeed();
const countryCode = countryFeed?.countryCode;
const userIsAdmin = !!user && isAdmin(user.pubkey);
const { isOrganizer } = useOrganizers();
const userIsOrganizerHere =
!!user && !!countryCode && isOrganizer(user.pubkey, countryCode);
const canPinHere = userIsAdmin || userIsOrganizerHere;
const {
isPinned: isPinnedInCountry,
pinPost,
unpinPost,
} = usePinnedPosts(canPinHere ? countryCode : undefined);
const postIsPinnedInCountry = !!countryCode && isPinnedInCountry(event.id);
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 { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
const nip19Id = encodeEventNip19(event);
const nostrUri = `nostr:${nip19Id}`;
const isInSidebar = orderedItems.includes(nostrUri);
const close = () => onOpenChange(false);
const handleViewPostDetails = () => {
navigate(`/${nip19Id}`);
close();
};
const handleBookmark = () => {
impactLight();
toggleBookmark.mutate(event.id);
close();
};
const handleToggleSidebar = () => {
if (isInSidebar) {
removeFromSidebar(nostrUri);
toast({ title: 'Removed from sidebar' });
} else {
addToSidebar(nostrUri);
toast({ title: 'Added to sidebar' });
}
close();
};
const handleTogglePin = () => {
impactLight();
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 handleToggleCountryPin = () => {
if (!countryCode) return;
impactLight();
const mutation = postIsPinnedInCountry ? unpinPost : pinPost;
mutation.mutate(
{ eventId: event.id, countryCode },
{
onSuccess: () => {
toast({
title: postIsPinnedInCountry
? 'Unpinned from country feed'
: 'Pinned to country feed',
});
},
onError: () => {
toast({
title: postIsPinnedInCountry
? 'Failed to unpin from country feed'
: 'Failed to pin to country feed',
variant: 'destructive',
});
},
},
);
close();
};
const handleMuteConversation = () => {
impactLight();
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 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();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[85dvh] p-0 gap-0 rounded-2xl overflow-y-auto [&>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 overflow-wrap-anywhere">
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
<span className="italic">Encrypted content</span>
) : (
<NoteContent event={event} className="text-sm leading-snug whitespace-normal" disableEmbeds disableNoteEmbeds as="span" />
)}
</div>
</div>
</div>
</div>
<Separator />
<div className="py-1">
<MenuItem
icon={<StickyNote className="size-5" />}
label="View post details"
onClick={handleViewPostDetails}
/>
<MenuItem
icon={<FileJson className="size-5" />}
label="View Event JSON"
onClick={onViewEventJson}
/>
<MenuItem
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
label={bookmarked ? 'Remove Bookmark' : 'Bookmark'}
onClick={handleBookmark}
/>
{user && (
<MenuItem
icon={<ListPlus className="size-5" />}
label="Add to list"
onClick={() => { onAddToList(); }}
/>
)}
<MenuItem
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
label={isInSidebar ? 'Remove from sidebar' : 'Add to sidebar'}
onClick={handleToggleSidebar}
/>
{isOwnPost && (
<MenuItem
icon={<Pin className={cn("size-5", pinned && "fill-current")} />}
label={pinned ? 'Unpin from profile' : 'Pin on profile'}
onClick={handleTogglePin}
/>
)}
{canPinHere && (
<MenuItem
icon={<Pin className={cn('size-5', postIsPinnedInCountry && 'fill-current')} />}
label={postIsPinnedInCountry ? 'Unpin from country feed' : 'Pin to country feed'}
onClick={handleToggleCountryPin}
/>
)}
{!isOwnPost && (
<MenuItem
icon={<BellOff className="size-5" />}
label="Mute Conversation"
onClick={handleMuteConversation}
/>
)}
{!isOwnPost && (
<MenuItem
icon={<VolumeX className="size-5" />}
label={userMuted ? `Unmute @${displayName}` : `Mute @${displayName}`}
onClick={handleMuteUser}
/>
)}
{!isOwnPost && (
<MenuItem
icon={<Flag className="size-5" />}
label={communityContext ? 'Report post to community' : `Report @${displayName}`}
onClick={onReport}
destructive
/>
)}
{!isOwnPost && communityContext?.canBan && (
<>
<MenuItem
icon={<ShieldBan className="size-5" />}
label="Remove from community"
onClick={onBanContent}
destructive
/>
<MenuItem
icon={<Ban className="size-5" />}
label={`Ban @${displayName} from community`}
onClick={onBanMember}
destructive
/>
</>
)}
{isOwnPost && (
<MenuItem
icon={<Trash2 className="size-5" />}
label="Delete post"
onClick={onDelete}
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>
</Dialog>
);
}