2a89242d25
Adds two large namespaces:
noteCard.* — the action labels surfaced under reaction / repost / poll
vote actor rows (reacted / reposted / voted), the Read more / Show less
truncation toggle, the live-stream status badges (LIVE / ENDED /
PLANNED / UNKNOWN), the Bot account avatar title, the donation prefix
on synthetic zap cards (Donated X to / Donated to), and the entire
KIND_HEADER_MAP — every 'X did a Y' header for the 24 kinds Agora
renders an action header for (photos, encrypted messages, letters,
treasures + finds, decks, emoji packs, groups, campaigns, badges,
streams, Zapstore app/release/asset, generic apps, git repos / patches
/ PRs, NIPs, nsites, zaps, pledges, follow packs, follow sets).
The KIND_HEADER_MAP refactor pulls the same trick as profileSettings
presets: each entry stores i18n keys instead of English strings, and
EventActionHeader runs them through t() at render time. publishedAtKey
is just publishedAtAction returning keys instead of phrases. The map
itself stays a module-level constant.
noteMoreMenu.* — the entire 'more' overflow menu hanging off every
NoteCard: every menu item (View post details, View Event JSON,
Bookmark, Add to list, Add/Remove from sidebar, Pin/Unpin to profile,
Pin/Unpin to country feed, Mute Conversation / mute @user / Report /
Remove from group / Delete post), the Encrypted content fallback in
the post preview, every toast (success + failure for each mutation),
the Delete confirmation alert dialog, and the Event JSON dialog (title,
Event ID label, Raw JSON label, Broadcast Event button + Broadcasting
state, plus the {{label}} copied toast).
ar / es / zh / fa / ps / km / sn translations drafted inline; km, sn,
ps, fa still pending native-speaker review.
621 lines
22 KiB
TypeScript
621 lines
22 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
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,
|
|
} 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 { t } = useTranslation();
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCopy = () => {
|
|
navigator.clipboard.writeText(text);
|
|
setCopied(true);
|
|
toast({ title: t('noteMoreMenu.toast.copied', { label }) });
|
|
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 { t } = useTranslation();
|
|
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: t('noteMoreMenu.toast.eventBroadcast') });
|
|
} catch {
|
|
toast({ title: t('noteMoreMenu.toast.broadcastFailed'), 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">{t('noteMoreMenu.jsonDialog.title')}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="px-5 pb-3 shrink-0">
|
|
<p className="text-xs font-medium text-muted-foreground mb-1">{t('noteMoreMenu.jsonDialog.eventId')}</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={t('noteMoreMenu.jsonDialog.eventIdLabel')} />
|
|
</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">{t('noteMoreMenu.jsonDialog.rawJson')}</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={t('noteMoreMenu.jsonDialog.eventJsonLabel')} />
|
|
</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 ? t('noteMoreMenu.jsonDialog.broadcasting') : t('noteMoreMenu.jsonDialog.broadcast')}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
|
const { t } = useTranslation();
|
|
// 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 [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: t('noteMoreMenu.toast.postDeleted') });
|
|
},
|
|
onError: () => {
|
|
toast({ title: t('noteMoreMenu.toast.deleteFailed'), 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);
|
|
}}
|
|
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
|
|
eventId={event.id}
|
|
targetPubkey={event.pubkey}
|
|
communityATag={communityContext.communityATag}
|
|
open={banContentOpen}
|
|
onOpenChange={setBanContentOpen}
|
|
/>
|
|
)}
|
|
|
|
<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>{t('noteMoreMenu.deleteDialog.title')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('noteMoreMenu.deleteDialog.description')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>{t('noteMoreMenu.deleteDialog.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleDelete();
|
|
}}
|
|
disabled={isDeleting}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{isDeleting ? t('noteMoreMenu.deleteDialog.deleting') : t('noteMoreMenu.deleteDialog.delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
|
|
/** Resolved community context (authored upstream from `CommunityModerationContext`). */
|
|
communityContext?: CommunityMenuContext;
|
|
onReport: () => void;
|
|
onBanContent: () => void;
|
|
onAddToList: () => void;
|
|
onViewEventJson: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
|
|
const { t } = useTranslation();
|
|
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: t('noteMoreMenu.toast.removedFromSidebar') });
|
|
} else {
|
|
addToSidebar(nostrUri);
|
|
toast({ title: t('noteMoreMenu.toast.addedToSidebar') });
|
|
}
|
|
close();
|
|
};
|
|
|
|
const handleTogglePin = () => {
|
|
impactLight();
|
|
togglePin.mutate(event.id, {
|
|
onSuccess: () => {
|
|
toast({ title: pinned ? t('noteMoreMenu.toast.unpinned') : t('noteMoreMenu.toast.pinned') });
|
|
},
|
|
onError: () => {
|
|
toast({ title: t('noteMoreMenu.toast.pinFailed'), variant: 'destructive' });
|
|
},
|
|
});
|
|
close();
|
|
};
|
|
|
|
const handleToggleCountryPin = () => {
|
|
if (!countryCode) return;
|
|
impactLight();
|
|
const mutation = postIsPinnedInCountry ? unpinPost : pinPost;
|
|
mutation.mutate(
|
|
{ eventId: event.id, countryCode },
|
|
{
|
|
onSuccess: () => {
|
|
toast({
|
|
title: postIsPinnedInCountry
|
|
? t('noteMoreMenu.toast.countryUnpinned')
|
|
: t('noteMoreMenu.toast.countryPinned'),
|
|
});
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: postIsPinnedInCountry
|
|
? t('noteMoreMenu.toast.countryUnpinFailed')
|
|
: t('noteMoreMenu.toast.countryPinFailed'),
|
|
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: t('noteMoreMenu.toast.convoMuted') });
|
|
},
|
|
onError: () => {
|
|
toast({ title: t('noteMoreMenu.toast.convoMuteFailed'), 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 ? t('noteMoreMenu.toast.unmuted', { name: displayName }) : t('noteMoreMenu.toast.muted', { name: displayName }) });
|
|
},
|
|
onError: () => {
|
|
toast({ title: userMuted ? t('noteMoreMenu.toast.unmuteFailed') : t('noteMoreMenu.toast.muteFailed'), 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">{t('noteMoreMenu.title')}</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">{t('noteMoreMenu.encryptedContent')}</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={t('noteMoreMenu.viewPost')}
|
|
onClick={handleViewPostDetails}
|
|
/>
|
|
<MenuItem
|
|
icon={<FileJson className="size-5" />}
|
|
label={t('noteMoreMenu.viewEventJson')}
|
|
onClick={onViewEventJson}
|
|
/>
|
|
<MenuItem
|
|
icon={<Bookmark className={cn("size-5", bookmarked && "fill-current")} />}
|
|
label={bookmarked ? t('noteMoreMenu.removeBookmark') : t('noteMoreMenu.bookmark')}
|
|
onClick={handleBookmark}
|
|
/>
|
|
{user && (
|
|
<MenuItem
|
|
icon={<ListPlus className="size-5" />}
|
|
label={t('noteMoreMenu.addToList')}
|
|
onClick={() => { onAddToList(); }}
|
|
/>
|
|
)}
|
|
<MenuItem
|
|
icon={isInSidebar ? <Trash2 className="size-5" /> : <PanelLeft className="size-5" />}
|
|
label={isInSidebar ? t('noteMoreMenu.removeFromSidebar') : t('noteMoreMenu.addToSidebar')}
|
|
onClick={handleToggleSidebar}
|
|
/>
|
|
{isOwnPost && (
|
|
<MenuItem
|
|
icon={<Pin className={cn("size-5", pinned && "fill-current")} />}
|
|
label={pinned ? t('noteMoreMenu.unpinProfile') : t('noteMoreMenu.pinProfile')}
|
|
onClick={handleTogglePin}
|
|
/>
|
|
)}
|
|
{canPinHere && (
|
|
<MenuItem
|
|
icon={<Pin className={cn('size-5', postIsPinnedInCountry && 'fill-current')} />}
|
|
label={postIsPinnedInCountry ? t('noteMoreMenu.unpinCountry') : t('noteMoreMenu.pinCountry')}
|
|
onClick={handleToggleCountryPin}
|
|
/>
|
|
)}
|
|
{!isOwnPost && (
|
|
<MenuItem
|
|
icon={<BellOff className="size-5" />}
|
|
label={t('noteMoreMenu.muteConversation')}
|
|
onClick={handleMuteConversation}
|
|
/>
|
|
)}
|
|
{!isOwnPost && (
|
|
<MenuItem
|
|
icon={<VolumeX className="size-5" />}
|
|
label={userMuted ? t('noteMoreMenu.unmute', { name: displayName }) : t('noteMoreMenu.mute', { name: displayName })}
|
|
onClick={handleMuteUser}
|
|
/>
|
|
)}
|
|
{!isOwnPost && (
|
|
<MenuItem
|
|
icon={<Flag className="size-5" />}
|
|
label={communityContext ? t('noteMoreMenu.reportToGroup') : t('noteMoreMenu.report', { name: displayName })}
|
|
onClick={onReport}
|
|
destructive
|
|
/>
|
|
)}
|
|
{!isOwnPost && communityContext?.canBan && (
|
|
<MenuItem
|
|
icon={<ShieldBan className="size-5" />}
|
|
label={t('noteMoreMenu.removeFromGroup')}
|
|
onClick={onBanContent}
|
|
destructive
|
|
/>
|
|
)}
|
|
{isOwnPost && (
|
|
<MenuItem
|
|
icon={<Trash2 className="size-5" />}
|
|
label={t('noteMoreMenu.delete')}
|
|
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}
|
|
>
|
|
{t('noteMoreMenu.close')}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|