From 6e3b7cb55ee0889e528e166d124a230489a1f7fe Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 31 May 2026 09:20:39 +0200 Subject: [PATCH] fix menus --- package-lock.json | 10 +- package.json | 2 +- .../AdvancedEventLabMarkupToolbar.tsx | 12 +- src/components/BookmarkButton/index.tsx | 83 --- .../ConnectedRelaysSidebarStrip.tsx | 6 +- src/components/HelpAndAccountMenu.tsx | 2 +- src/components/KindFilter/index.tsx | 2 +- src/components/NoteOptions/DesktopMenu.tsx | 54 +- .../NoteOptions/EditOrCloneEventDialog.tsx | 8 +- src/components/NoteOptions/MobileMenu.tsx | 27 +- .../NoteOptions/NoteOptionsMetaHeader.tsx | 69 +-- src/components/NoteOptions/index.tsx | 2 + src/components/NoteOptions/useMenuActions.tsx | 502 +++++++++++------- .../NoteStats/NoteStatsCountHover.tsx | 4 +- src/components/NoteStats/index.tsx | 12 - src/components/PostEditor/Mentions.tsx | 2 +- src/components/PostEditor/PostContent.tsx | 9 +- .../PostEditor/PostRelaySelector.tsx | 9 +- .../PostTextarea/Emoji/EmojiList.tsx | 2 +- .../Mention/MentionAndEventToolbarButtons.tsx | 7 +- .../PostTextarea/Mention/MentionList.tsx | 2 +- src/components/Profile/index.tsx | 2 +- src/components/ProfileOptions/index.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 39 +- src/components/ui/hover-card.tsx | 12 +- src/components/ui/popover.tsx | 12 +- src/components/ui/select.tsx | 19 +- src/constants.ts | 6 +- src/i18n/locales/cs.ts | 1 + src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 4 + src/i18n/locales/es.ts | 1 + src/i18n/locales/fr.ts | 1 + src/i18n/locales/nl.ts | 1 + src/i18n/locales/pl.ts | 1 + src/i18n/locales/ru.ts | 1 + src/i18n/locales/tr.ts | 1 + src/i18n/locales/zh.ts | 1 + src/lib/menu-popover-layout.ts | 29 + 39 files changed, 542 insertions(+), 418 deletions(-) delete mode 100644 src/components/BookmarkButton/index.tsx create mode 100644 src/lib/menu-popover-layout.ts diff --git a/package-lock.json b/package-lock.json index c909df4b..f654018a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.16.1", + "version": "23.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.16.1", + "version": "23.17.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", @@ -15865,9 +15865,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "license": "MIT", "optional": true, "engines": { diff --git a/package.json b/package.json index ab78c749..f17fda16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.16.1", + "version": "23.17.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx index 82abb4a2..c9f3b886 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx @@ -176,7 +176,7 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb citationsHint')} {LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => ( openCitationPicker(type)}> @@ -239,7 +239,7 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb headings hint')} {( [ @@ -573,7 +573,7 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb mathIntro')} @@ -733,7 +733,7 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb adocTitlesHint')} @@ -1070,7 +1070,7 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb adocStructureHint')} @@ -1212,7 +1212,7 @@ export function AdvancedEventLabMarkupToolbar({ - + {t('Advanced lab tb adocStemHint')} run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))} diff --git a/src/components/BookmarkButton/index.tsx b/src/components/BookmarkButton/index.tsx deleted file mode 100644 index 439626dc..00000000 --- a/src/components/BookmarkButton/index.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton' -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { NostrContext } from '@/providers/nostr-context' -import { useBookmarksOptional } from '@/providers/bookmarks-context' -import { BookmarkIcon } from 'lucide-react' -import { Event } from 'nostr-tools' -import { useContext, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' - -export default function BookmarkButton({ event }: { event: Event }) { - const { t } = useTranslation() - const nostrContext = useContext(NostrContext) - const bookmarksContext = useBookmarksOptional() - const accountPubkey = nostrContext?.pubkey ?? null - const bookmarkListEvent = nostrContext?.bookmarkListEvent ?? null - const checkLogin = nostrContext?.checkLogin ?? (async () => {}) - const { addBookmark, removeBookmark } = bookmarksContext ?? { - addBookmark: async () => {}, - removeBookmark: async () => false, - removeBookmarkByBech32: async () => false - } - const [updating, setUpdating] = useState(false) - const isBookmarked = useMemo(() => { - const isReplaceable = isReplaceableEvent(event.kind) - const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id - - return bookmarkListEvent?.tags.some((tag) => - isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey - ) - }, [bookmarkListEvent, event]) - - if (!bookmarksContext || !accountPubkey) return null - - const handleBookmark = async (e: React.MouseEvent) => { - e.stopPropagation() - checkLogin(async () => { - if (isBookmarked) return - - setUpdating(true) - try { - await addBookmark(event) - } catch (error) { - toast.error(t('Bookmark failed') + ': ' + (error as Error).message) - } finally { - setUpdating(false) - } - }) - } - - const handleRemoveBookmark = async (e: React.MouseEvent) => { - e.stopPropagation() - checkLogin(async () => { - if (!isBookmarked) return - - setUpdating(true) - try { - await removeBookmark(event) - } catch (error) { - toast.error(t('Remove bookmark failed') + ': ' + (error as Error).message) - } finally { - setUpdating(false) - } - }) - } - - return ( - - ) -} diff --git a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx index 0ee5e1e9..cd6fb01c 100644 --- a/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx +++ b/src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx @@ -88,7 +88,11 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string +{overflow} - + {t('More relays', { count: overflow })} diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 6c85b0bd..639a595f 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -27,7 +27,7 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' const titlebarAccountMenuContentClassName = - 'z-[220] max-h-[min(85dvh,32rem)] w-72 overflow-y-auto overscroll-contain' + 'z-[220] w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain' export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar' diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 75d72f6a..2ba66e73 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -379,7 +379,7 @@ export default function KindFilter({ {trigger} {action.label} - + {action.subMenuSearchable ? (
) : null} -
+
{filtered.length === 0 ? (
{t('Language list filter empty')} @@ -81,15 +82,44 @@ const SubMenuPanel = memo( filtered.map((subAction, subIndex) => (
{subAction.separator && subIndex > 0 && } - - {subAction.label} - + {subAction.subMenu?.length ? ( + + + {subAction.label} + + + {subAction.subMenu.map((nested, nestedIndex) => ( +
+ {nested.separator && nestedIndex > 0 && } + + {nested.label} + +
+ ))} +
+
+ ) : ( + + {subAction.label} + + )}
)) )} @@ -150,7 +180,7 @@ export function DesktopMenu({ menuActions, trigger, header, open, onOpenChange } }} > {trigger} - + {header}
{ if (isCreate && parsedCreateKind === null) return diff --git a/src/components/NoteOptions/MobileMenu.tsx b/src/components/NoteOptions/MobileMenu.tsx index 7a6f7472..d5ed6d0d 100644 --- a/src/components/NoteOptions/MobileMenu.tsx +++ b/src/components/NoteOptions/MobileMenu.tsx @@ -8,7 +8,7 @@ import { import { cn } from '@/lib/utils' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { ArrowLeft } from 'lucide-react' -import { MenuAction, SubMenuAction } from './useMenuActions' +import { MenuAction, ShowSubMenuOptions, SubMenuAction } from './useMenuActions' import { useMemo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -24,6 +24,11 @@ interface MobileMenuProps { subMenuSearchable: boolean closeDrawer: () => void goBackToMainMenu: () => void + showSubMenuActions: ( + subMenu: SubMenuAction[], + title: string, + options?: ShowSubMenuOptions + ) => void } function filterSubMenuRows( @@ -70,7 +75,8 @@ export function MobileMenu({ subMenuTitle, subMenuSearchable, closeDrawer, - goBackToMainMenu + goBackToMainMenu, + showSubMenuActions }: MobileMenuProps) { const { t } = useTranslation() const [subMenuFilter, setSubMenuFilter] = useState('') @@ -106,7 +112,15 @@ export function MobileMenu({ icon={Icon} label={action.label} className={action.className} - onClick={action.onClick} + onClick={ + action.onClick ?? + (action.subMenu?.length + ? () => + showSubMenuActions(action.subMenu!, action.label, { + subMenuSearchable: action.subMenuSearchable + }) + : undefined) + } /> ) })} @@ -140,7 +154,12 @@ export function MobileMenu({ filteredSubMenu.map((subAction, index) => ( - - ) - }) - return ( -
+

{t('Note kind label line', { kind: event.kind, description })}

- {relays.length > 0 ? ( -
-

- {t('Seen on')} -

- {inDropdown ? ( -
{relayRows}
- ) : ( -
    {relayRows}
- )} -
- ) : null}
) } diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index 02b75106..2fe4e385 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -109,6 +109,7 @@ export default function NoteOptions({ setIsRawEventDialogOpen, setIsReportDialogOpen, isSmallScreen, + seenOnAllowlist, onOpenPublicMessage, onOpenCallInvite, onOpenEditOrClone: (mode) => { @@ -160,6 +161,7 @@ export default function NoteOptions({ subMenuSearchable={subMenuSearchable} closeDrawer={closeDrawer} goBackToMainMenu={goBackToMainMenu} + showSubMenuActions={showSubMenuActions} /> ) : ( void + /** When set (home favorites feed), "Seen on" in Advanced matches the feed allowlist. */ + seenOnAllowlist?: readonly string[] } export function useMenuActions({ @@ -140,13 +148,31 @@ export function useMenuActions({ onOpenCallInvite, onOpenEditOrClone, pinned: _pinnedInFeed = false, - onViewAttestation + onViewAttestation, + seenOnAllowlist }: UseMenuActionsProps) { const { t } = useTranslation() + const { push } = useSecondaryPage() + const seenOnRelays = useSeenOnRelays(event.id, seenOnAllowlist) // Use useContext directly to avoid error if provider is not available const primaryPageContext = useContext(PrimaryPageContext) const currentPrimaryPage = primaryPageContext?.current ?? null - const { pubkey, profile, attemptDelete, publish, account, relayList } = useNostr() + const { + pubkey, + profile, + attemptDelete, + publish, + account, + relayList, + bookmarkListEvent, + checkLogin + } = useNostr() + const bookmarksContext = useBookmarksOptional() + const { addBookmark, removeBookmark } = bookmarksContext ?? { + addBookmark: async () => {}, + removeBookmark: async () => false + } + const [bookmarkUpdating, setBookmarkUpdating] = useState(false) const canSignEvents = account != null && account.signerType !== 'npub' const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() @@ -184,6 +210,15 @@ export function useMenuActions({ const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() const isMuted = useMemo(() => muteSetHas(mutePubkeySet, event.pubkey), [mutePubkeySet, event]) + const isBookmarked = useMemo(() => { + if (!bookmarkListEvent) return false + const isReplaceable = isReplaceableEvent(event.kind) + const eventKey = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id + return bookmarkListEvent.tags.some((tag) => + isReplaceable ? tag[0] === 'a' && tag[1] === eventKey : tag[0] === 'e' && tag[1] === eventKey + ) + }, [bookmarkListEvent, event]) + const noteTranslationFromMenu = useSyncExternalStore( subscribeNoteTranslations, () => getNoteTranslation(event.id), @@ -932,122 +967,133 @@ export function useMenuActions({ ] : [] - const actions: MenuAction[] = [ - { - icon: Copy, - label: t('Copy event ID'), - onClick: () => { - navigator.clipboard.writeText(getNoteBech32Id(event)) - closeDrawer() - } - }, - { - icon: Copy, - label: t('Copy user ID'), - onClick: () => { - navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') - closeDrawer() - } - }, - ...(READ_ALOUD_KINDS.includes(event.kind) - ? [ - { - icon: Volume2, - label: t('Read this note aloud'), - onClick: () => { - closeDrawer() - void speakNoteReadAloud(event).then((result) => { - if (result === 'unsupported') { - toast.error(t('Read-aloud is not supported in this browser')) - } else if (result === 'empty') { - toast.error(t('Nothing to read aloud')) - } else if (result === 'error') { - toast.error(t('Read-aloud failed')) - } - }) - } - } as MenuAction - ] - : []), - ...(noteSupportsTranslateMenu - ? [ - { - icon: Languages, - label: t('Translate note'), - onClick: isSmallScreen - ? () => - showSubMenuActions(translateTargetSubmenu, t('Translate note'), { - subMenuSearchable: true - }) - : undefined, - subMenu: isSmallScreen ? undefined : translateTargetSubmenu, - subMenuSearchable: true - } as MenuAction - ] - : []), + const pushSubMenuParent = ( + target: MenuAction[], + icon: MenuAction['icon'], + title: string, + subMenu: SubMenuAction[], + options?: { separator?: boolean; subMenuSearchable?: boolean; className?: string } + ) => { + if (subMenu.length === 0) return + target.push({ + icon, + label: title, + separator: options?.separator, + className: options?.className, + subMenuSearchable: options?.subMenuSearchable, + onClick: isSmallScreen + ? () => + showSubMenuActions(subMenu, title, { + subMenuSearchable: options?.subMenuSearchable + }) + : undefined, + subMenu: isSmallScreen ? undefined : subMenu + }) + } + + const connectionsSubMenu: SubMenuAction[] = [ ...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage ? [ { - icon: MessageCircle, label: t('Send public message'), onClick: () => { closeDrawer() onOpenPublicMessage(event.pubkey) } - } as MenuAction + } ] : []), { - icon: Link, label: t('Share with Imwald'), + separator: pubkey != null && event.pubkey !== pubkey && !!onOpenPublicMessage, onClick: () => { const noteId = getNoteBech32Id(event) - // Contextual URL when on Spells (e.g. discussions faux-spell); plain /notes/{id} otherwise const path = currentPrimaryPage === 'spells' ? `/spells/notes/${noteId}` : currentPrimaryPage === 'rss' ? `/rss/notes/${noteId}` : `/notes/${noteId}` - const appShareUrl = `https://jumble.imwald.eu${path}` - navigator.clipboard.writeText(appShareUrl) + navigator.clipboard.writeText(`https://jumble.imwald.eu${path}`) closeDrawer() } }, { - icon: BookOpen, label: t('Share with Alexandria'), onClick: () => { navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event))) closeDrawer() } - }, + } + ] + + if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { + connectionsSubMenu.push({ + label: t('View on Alexandria'), + separator: true, + onClick: () => { + closeDrawer() + window.open( + 'https://next-alexandria.gitcitadel.eu/profile/notifications', + '_blank', + 'noopener,noreferrer' + ) + } + }) + } + + if (isArticleType) { + if (event.kind === kinds.LongFormArticle) { + if (naddr) { + connectionsSubMenu.push({ + label: t('View on Alexandria'), + separator: connectionsSubMenu.length > 0, + onClick: handleViewOnAlexandria + }) + } + if (dTag && authorNpubForDecentNewsroom) { + connectionsSubMenu.push({ + label: t('View on DecentNewsroom'), + onClick: handleViewOnDecentNewsroom + }) + } + } else if ( + event.kind === ExtendedKind.PUBLICATION_CONTENT || + event.kind === ExtendedKind.PUBLICATION || + event.kind === ExtendedKind.WIKI_ARTICLE || + event.kind === ExtendedKind.NOSTR_SPECIFICATION + ) { + if (naddr) { + connectionsSubMenu.push({ + label: t('View on Alexandria'), + separator: connectionsSubMenu.length > 0, + onClick: handleViewOnAlexandria + }) + } + } + } + + const callsSubMenu: SubMenuAction[] = [ { - icon: Video, label: t('Start call about this'), - separator: true, onClick: () => { closeDrawer() const roomId = `imwald-note-${event.id}` - const url = buildHiveTalkJoinUrl({ room: roomId }) - window.open(url, '_blank', 'noopener,noreferrer') + window.open(buildHiveTalkJoinUrl({ room: roomId }), '_blank', 'noopener,noreferrer') } }, { - icon: Copy, label: t('Copy call invite link'), onClick: () => { closeDrawer() const roomId = `imwald-note-${event.id}` - const url = buildHiveTalkJoinUrl({ room: roomId }) - navigator.clipboard.writeText(url) + navigator.clipboard.writeText(buildHiveTalkJoinUrl({ room: roomId })) toast.success(t('Copied to clipboard')) } }, ...(onOpenCallInvite ? [ { - icon: Send, label: t('Send call invite'), onClick: () => { closeDrawer() @@ -1055,161 +1101,207 @@ export function useMenuActions({ const url = buildHiveTalkJoinUrl({ room: roomId }) onOpenCallInvite(`${t('Join the video call')}: ${url}`) } - } as MenuAction + } ] : []) ] - // Add "View on Alexandria" menu item for public messages (PMs) - if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { - actions.push({ - icon: Globe, - label: t('View on Alexandria'), + const isProtected = isProtectedEvent(event) + const isDiscussion = event.kind === ExtendedKind.DISCUSSION + const showRepublish = + broadcastSubMenu.length > 0 && + (!isProtected || event.pubkey === pubkey) && + !isDiscussion && + !isReplyToDiscussion + + const advancedSubMenu: SubMenuAction[] = [ + { + label: t('Copy event ID'), onClick: () => { + navigator.clipboard.writeText(getNoteBech32Id(event)) closeDrawer() - window.open('https://next-alexandria.gitcitadel.eu/profile/notifications', '_blank', 'noopener,noreferrer') - }, - separator: true + } + }, + { + label: t('Copy user ID'), + onClick: () => { + navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') + closeDrawer() + } + } + ] + + if (pubkey && event.pubkey !== pubkey) { + advancedSubMenu.push({ + label: t('Report'), + className: 'text-destructive focus:text-destructive', + separator: true, + onClick: () => { + closeDrawer() + setIsReportDialogOpen(true) + } }) } if (canSignEvents && pubkey && onOpenEditOrClone) { - const isOwn = event.pubkey === pubkey - actions.push({ - icon: isOwn ? PencilLine : GitFork, - label: isOwn ? t('Edit this event') : t('Clone or fork this event'), + advancedSubMenu.push({ + label: t('Edit or fork this event'), + separator: advancedSubMenu.length > 2, onClick: () => { closeDrawer() - onOpenEditOrClone(isOwn ? 'edit' : 'clone') - }, - separator: true + onOpenEditOrClone(event.pubkey === pubkey ? 'edit' : 'clone') + } }) } - actions.push({ - icon: Code, + advancedSubMenu.push({ label: t('View raw event'), + separator: true, onClick: () => { closeDrawer() setIsRawEventDialogOpen(true) - }, - separator: !onViewAttestation + } }) if (onViewAttestation) { - actions.push({ - icon: Sparkles, + advancedSubMenu.push({ label: t('View attestation'), onClick: () => { closeDrawer() onViewAttestation() - }, - separator: true + } }) } - // Add export options for article-type events + if (showRepublish) { + advancedSubMenu.push({ + label: t('Republish to ...'), + separator: true, + onClick: isSmallScreen + ? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...')) + : () => {}, + subMenu: isSmallScreen ? undefined : broadcastSubMenu + } as SubMenuAction) + } + if (isArticleType) { - const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION - const isAsciidocFormat = event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT - + const isMarkdownFormat = + event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION + const isAsciidocFormat = + event.kind === ExtendedKind.WIKI_ARTICLE || + event.kind === ExtendedKind.PUBLICATION || + event.kind === ExtendedKind.PUBLICATION_CONTENT + if (isMarkdownFormat) { - actions.push({ - icon: FileDown, + advancedSubMenu.push({ label: t('Export as Markdown'), onClick: () => { closeDrawer() exportAsMarkdown() - }, - separator: true + } }) } - if (isAsciidocFormat) { - actions.push({ - icon: FileDown, + advancedSubMenu.push({ label: t('Export as AsciiDoc'), onClick: () => { closeDrawer() exportAsAsciidoc() - }, - separator: true + } }) } - - // Add view options based on event kind - if (event.kind === kinds.LongFormArticle) { - // For LongFormArticle (30023): Alexandria and DecentNewsroom - if (naddr) { - actions.push({ - icon: BookOpen, - label: t('View on Alexandria'), - onClick: handleViewOnAlexandria - }) - } - if (dTag && authorNpubForDecentNewsroom) { - actions.push({ - icon: Globe, - label: t('View on DecentNewsroom'), - onClick: handleViewOnDecentNewsroom - }) - } - } else if ( - event.kind === ExtendedKind.PUBLICATION_CONTENT || - event.kind === ExtendedKind.PUBLICATION || - event.kind === ExtendedKind.WIKI_ARTICLE || - event.kind === ExtendedKind.NOSTR_SPECIFICATION - ) { - // For 30041, 30040, 30818, 30817: Alexandria - if (naddr) { - actions.push({ - icon: BookOpen, - label: t('View on Alexandria'), - onClick: handleViewOnAlexandria - }) - } - } - - if (event.kind === ExtendedKind.PUBLICATION) { - actions.push({ - icon: SatelliteDish, + if (event.kind === ExtendedKind.PUBLICATION && publicationBroadcastSubMenu.length > 0) { + advancedSubMenu.push({ label: t('Rebroadcast entire publication'), + separator: true, onClick: isSmallScreen - ? () => showSubMenuActions(publicationBroadcastSubMenu, t('Rebroadcast entire publication to ...')) - : undefined, - subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu, - separator: true - }) + ? () => + showSubMenuActions( + publicationBroadcastSubMenu, + t('Rebroadcast entire publication to ...') + ) + : () => {}, + subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu + } as SubMenuAction) } } - const isProtected = isProtectedEvent(event) - const isDiscussion = event.kind === ExtendedKind.DISCUSSION - if ((!isProtected || event.pubkey === pubkey) && !isDiscussion && !isReplyToDiscussion) { - actions.push({ - icon: SatelliteDish, - label: t('Republish to ...'), - onClick: isSmallScreen - ? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...')) - : undefined, - subMenu: isSmallScreen ? undefined : broadcastSubMenu, + if (seenOnRelays.length > 0) { + advancedSubMenu.push({ + label: ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {seenOnRelays.map((relay) => ( + + ))} +
+ ), + onClick: () => {}, separator: true }) } - if (pubkey && event.pubkey !== pubkey) { + const actions: MenuAction[] = [] + + if (READ_ALOUD_KINDS.includes(event.kind)) { actions.push({ - icon: TriangleAlert, - label: t('Report'), - className: 'text-destructive focus:text-destructive', + icon: Volume2, + label: t('Read this note aloud'), onClick: () => { closeDrawer() - setIsReportDialogOpen(true) - }, - separator: true + void speakNoteReadAloud(event).then((result) => { + if (result === 'unsupported') { + toast.error(t('Read-aloud is not supported in this browser')) + } else if (result === 'empty') { + toast.error(t('Nothing to read aloud')) + } else if (result === 'error') { + toast.error(t('Read-aloud failed')) + } + }) + } + }) + } + + if (noteSupportsTranslateMenu) { + actions.push({ + icon: Languages, + label: t('Translate note'), + onClick: isSmallScreen + ? () => + showSubMenuActions(translateTargetSubmenu, t('Translate note'), { + subMenuSearchable: true + }) + : undefined, + subMenu: isSmallScreen ? undefined : translateTargetSubmenu, + subMenuSearchable: true }) } + pushSubMenuParent(actions, Share2, t('Connections'), connectionsSubMenu, { + separator: actions.length > 0 + }) + pushSubMenuParent(actions, Video, t('Calls'), callsSubMenu) + pushSubMenuParent(actions, Settings, t('Advanced'), advancedSubMenu, { + separator: actions.length > 0 + }) + if (pubkey && event.pubkey !== pubkey) { if (isMuted) { actions.push({ @@ -1232,7 +1324,7 @@ export function useMenuActions({ mutePubkeyPrivately(event.pubkey) }, className: 'text-destructive focus:text-destructive', - separator: true + separator: actions.length > 0 }, { icon: BellOff, @@ -1247,8 +1339,7 @@ export function useMenuActions({ } } - // Pin / unpin only against the signed-in user's list (not another profile's pinned section). - if (pubkey) { + if (pubkey && event.pubkey === pubkey) { actions.push({ icon: Pin, label: isPinnedInMyList ? t('Unpin note') : t('Pin note'), @@ -1257,6 +1348,34 @@ export function useMenuActions({ }, separator: true }) + } else if (pubkey && event.pubkey !== pubkey && bookmarksContext) { + actions.push({ + icon: Bookmark, + label: isBookmarked ? t('Remove bookmark') : t('Bookmark'), + onClick: () => { + closeDrawer() + void checkLogin(async () => { + if (bookmarkUpdating) return + setBookmarkUpdating(true) + try { + if (isBookmarked) { + await removeBookmark(event) + } else { + await addBookmark(event) + } + } catch (error) { + toast.error( + (isBookmarked ? t('Remove bookmark failed') : t('Bookmark failed')) + + ': ' + + (error as Error).message + ) + } finally { + setBookmarkUpdating(false) + } + }) + }, + separator: true + }) } // Delete only when signed in as the author with a signing key (not read-only npub) @@ -1290,6 +1409,13 @@ export function useMenuActions({ attemptDelete, isPinnedInMyList, handlePinNote, + bookmarkListEvent, + bookmarksContext, + isBookmarked, + bookmarkUpdating, + addBookmark, + removeBookmark, + checkLogin, isArticleType, articleMetadata, dTag, @@ -1302,7 +1428,11 @@ export function useMenuActions({ profile, noteTranslationFromMenu, translateMenuOptions, - onViewAttestation + onViewAttestation, + seenOnRelays, + push, + currentPrimaryPage, + isReplyToDiscussion ]) return menuActions diff --git a/src/components/NoteStats/NoteStatsCountHover.tsx b/src/components/NoteStats/NoteStatsCountHover.tsx index d6a14814..45bd3ea8 100644 --- a/src/components/NoteStats/NoteStatsCountHover.tsx +++ b/src/components/NoteStats/NoteStatsCountHover.tsx @@ -213,7 +213,7 @@ export function NoteStatsCountHover({ return ( {trigger} - + {panel} @@ -223,7 +223,7 @@ export function NoteStatsCountHover({ return ( {trigger} - + {panel} diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index b2840af1..3078efe7 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -10,9 +10,7 @@ import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot import { normalizeAnyRelayUrl } from '@/lib/url' import { Event } from 'nostr-tools' import { useEffect, useRef, useState, type ReactNode } from 'react' -import BookmarkButton from '../BookmarkButton' import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons' -import { useBookmarksOptional } from '@/providers/bookmarks-context' import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' import { LikeButtonWithStats } from './LikeButton' import { ReplyButtonWithStats } from './ReplyButton' @@ -143,9 +141,7 @@ export default function NoteStats({ ]) const watch = useNotificationThreadWatchOptional() - const bookmarksContext = useBookmarksOptional() const showThreadWatchButtons = Boolean(watch && pubkey) - const showBookmarkButton = Boolean(bookmarksContext && pubkey) /** Kind 11 / 1111 under a discussion: up+down votes need more width than a single like button. */ const isDiscussionBar = isDiscussion || isReplyToDiscussion const compactBarItem = isDiscussionBar ? 'shrink-0 flex-none basis-auto' : undefined @@ -194,14 +190,6 @@ export default function NoteStats({ ) } - if (!isRssArticleRoot && showBookmarkButton) { - barItems.push( - - - - ) - } - return (
0 && `(${mentions.length}/${potentialMentions.length})`} - +
{potentialMentions.map((_, index) => { const pubkey = potentialMentions[potentialMentions.length - 1 - index] diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index c7eec73d..7fbbf86b 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -2409,7 +2409,12 @@ export default function PostContent({ - +

{t('Suggested topics')}

{allAvailableTopics.map((topic, index) => { @@ -3109,7 +3114,7 @@ export default function PostContent({ - + {t('Note type')} diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index c01eace1..4a4ee352 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -423,7 +423,12 @@ export default function PostRelaySelector({ - +
{t('Select relays')} @@ -431,7 +436,7 @@ export default function PostRelaySelector({
{capHintEl}
-
+
{content}
diff --git a/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx b/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx index 27b836cd..b112c780 100644 --- a/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx +++ b/src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx @@ -83,7 +83,7 @@ export const EmojiList = forwardRef((props, re return ( e.stopPropagation()} onTouchMove={(e) => e.stopPropagation()} > diff --git a/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx b/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx index 41fc8bd5..608130e8 100644 --- a/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx +++ b/src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx @@ -64,7 +64,12 @@ export function MentionAndEventToolbarButtons({ - + ((props, ref) return (
e.stopPropagation()} diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 49a5f78a..13606f9a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -434,7 +434,7 @@ export default function Profile({ - + {profileEvent && ( <> setOpenSelfReply(true)}> diff --git a/src/components/ProfileOptions/index.tsx b/src/components/ProfileOptions/index.tsx index 11e4d751..b1bf9348 100644 --- a/src/components/ProfileOptions/index.tsx +++ b/src/components/ProfileOptions/index.tsx @@ -206,7 +206,7 @@ export default function ProfileOptions({ - + {eventToUse && ( <> setOpenReply(true)}> diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index ab9e1e3a..d6ba6f16 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -3,8 +3,17 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { Check, ChevronDown, ChevronRight, ChevronUp, Circle } from 'lucide-react' import { DialogContext } from '@/components/ui/dialog' +import { + dropdownMenuMaxHeightClass, + floatingPanelMaxWidthClass, + floatingPanelScrollClass, + menuItemLargeTextClass +} from '@/lib/menu-popover-layout' import { cn } from '@/lib/utils' +/** @deprecated Use {@link dropdownMenuMaxHeightClass} from `@/lib/menu-popover-layout`. */ +export const dropdownMenuScrollMaxHeightClass = dropdownMenuMaxHeightClass + /** Radix `MenuSubContentProps` omits `side` / `align`; Popper still accepts them at runtime. */ type DropdownMenuSubContentPositionProps = Partial< Pick, 'side' | 'align'> @@ -37,7 +46,7 @@ const DropdownMenuSubTrigger = React.forwardRef< { - if (showScrollButtons) { - checkScrollability() - } - }} + onAnimationEnd={checkScrollability} collisionPadding={16} {...props} > @@ -144,7 +149,10 @@ const DropdownMenuSubContent = React.forwardRef<
{ - if (showScrollButtons) { - checkScrollability() - } - }} + onAnimationEnd={checkScrollability} collisionPadding={16} {...props} > @@ -249,7 +254,10 @@ const DropdownMenuContent = React.forwardRef<
{children} @@ -113,6 +125,7 @@ const SelectItem = React.forwardRef< ref={ref} className={cn( 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', + menuItemLargeTextClass, className )} {...props} diff --git a/src/constants.ts b/src/constants.ts index fbe19dd4..82e09198 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -459,7 +459,6 @@ export const NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS = ['wss://nostr.wine'] as cons export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', 'wss://profiles.nostr1.com', - 'wss://purplepag.es', 'wss://relay.nsec.app', 'wss://bucket.coracle.social', 'wss://spatia-arcana.com', @@ -521,10 +520,11 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550 export const PROFILE_RELAY_URLS = [ 'wss://profiles.nostr1.com', - 'wss://purplepag.es', 'wss://profiles.nostrver.se/', 'wss://indexer.coracle.social/', - 'wss://thecitadel.nostr1.com' + 'wss://thecitadel.nostr1.com', + 'wss://relay.damus.io', + 'wss://relay.primal.net' ] export const FOLLOWS_HISTORY_RELAY_URLS = [ diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 91d0b2c5..4c87e117 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 0028138a..795043ba 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -88,6 +88,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Dieses Event bearbeiten', 'Clone or fork this event': 'Event klonen oder forken', + 'Edit or fork this event': 'Event bearbeiten oder forken', 'Event kind': 'Event-Kind', 'Note content': 'Inhalt', Publish: 'Veröffentlichen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5041429d..0decc601 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -85,6 +85,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', @@ -289,6 +290,9 @@ export default { 'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.', 'Tag value': 'Tag value', 'Saving…': 'Saving…', + Connections: 'Connections', + Calls: 'Calls', + Advanced: 'Advanced', 'Share with Imwald': 'Share with Imwald', 'Share with Alexandria': 'Share with Alexandria', 'Start video call': 'Start video call', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 44c59500..0e31b3ea 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 1c50b1de..00ca81a3 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index 37e4abaa..defc81d5 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 6552a664..2f8b924c 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index d883fb83..b874dfb3 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index da902559..d2a177ef 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 598d9fb1..fb12ba54 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -87,6 +87,7 @@ export default { 'Raw Event': 'Raw Event', 'Edit this event': 'Edit this event', 'Clone or fork this event': 'Clone or fork this event', + 'Edit or fork this event': 'Edit or fork this event', 'Event kind': 'Event kind', 'Note content': 'Note content', Publish: 'Publish', diff --git a/src/lib/menu-popover-layout.ts b/src/lib/menu-popover-layout.ts new file mode 100644 index 00000000..ce3319e8 --- /dev/null +++ b/src/lib/menu-popover-layout.ts @@ -0,0 +1,29 @@ +/** + * Shared Tailwind classes for menus, popovers, and selects. + * Uses Radix collision CSS variables so lists fit the viewport (mobile + large font). + */ + +/** Dropdown / menu list vertical bound */ +export const dropdownMenuMaxHeightClass = + 'max-h-[min(85dvh,var(--radix-dropdown-menu-content-available-height,100dvh))]' + +/** Popover panel vertical bound */ +export const popoverMaxHeightClass = + 'max-h-[min(85dvh,var(--radix-popover-content-available-height,100dvh))]' + +/** Select viewport vertical bound */ +export const selectViewportMaxHeightClass = + 'max-h-[min(85dvh,var(--radix-select-content-available-height,80dvh))]' + +/** Hover card vertical bound */ +export const hoverCardMaxHeightClass = + 'max-h-[min(85dvh,var(--radix-hover-card-content-available-height,100dvh))]' + +/** Keep panels inside the screen horizontally */ +export const floatingPanelMaxWidthClass = 'max-w-[min(calc(100vw-1.5rem),28rem)]' + +export const floatingPanelScrollClass = + 'popover-scroll-y min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain' + +/** Menu rows: wrap when root font-size is large */ +export const menuItemLargeTextClass = 'min-w-0 whitespace-normal'