efe5d3db1c
Bookmarking a kind 34550 community now writes to the NIP-51 Communities list (kind 10004) keyed by the addressable coordinate, so the reference stays valid across community updates. My Communities merges bookmarked communities as a third discovery source alongside founded and member-of, with Founder/Member/Bookmarked badges on each card. Bookmark toasts live on the mutation itself so they survive the more-menu dialog unmounting between .mutate() and publish resolution.
675 lines
23 KiB
TypeScript
675 lines
23 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 { getAvatarShape } from '@/lib/avatarShape';
|
|
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 { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
|
|
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, COMMUNITY_DEFINITION_KIND } 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 {
|
|
isBookmarked: isCommunityBookmarked,
|
|
toggleBookmark: toggleCommunityBookmark,
|
|
} = useCommunityBookmarks();
|
|
|
|
// Community events (kind 34550) are bookmarked via NIP-51 kind 10004
|
|
// using their addressable `a` tag coordinate, so the reference stays
|
|
// valid across community updates. Non-community events use the standard
|
|
// kind 10003 bookmark list keyed by event id.
|
|
const isCommunityEvent = event.kind === COMMUNITY_DEFINITION_KIND;
|
|
const communityATag = useMemo(() => {
|
|
if (!isCommunityEvent) return undefined;
|
|
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
|
if (!dTag) return undefined;
|
|
return `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`;
|
|
}, [isCommunityEvent, event.pubkey, event.tags]);
|
|
|
|
const bookmarked = isCommunityEvent
|
|
? !!communityATag && isCommunityBookmarked(communityATag)
|
|
: 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 avatarShape = getAvatarShape(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();
|
|
if (isCommunityEvent) {
|
|
if (!communityATag) return;
|
|
// Success/error toasts are fired from the mutation itself in
|
|
// useCommunityBookmarks so they survive this dialog unmounting
|
|
// before the publish resolves.
|
|
toggleCommunityBookmark.mutate({ aTag: communityATag });
|
|
} else {
|
|
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 shape={avatarShape} 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 max-h-[4.5em] overflow-hidden">
|
|
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
|
|
<span className="italic">Encrypted content</span>
|
|
) : (
|
|
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
|
|
)}
|
|
</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={
|
|
isCommunityEvent
|
|
? (bookmarked ? 'Remove community bookmark' : 'Bookmark community')
|
|
: (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>
|
|
);
|
|
}
|