Browse Source

fix menus

imwald
Silberengel 2 weeks ago
parent
commit
6e3b7cb55e
  1. 10
      package-lock.json
  2. 2
      package.json
  3. 12
      src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx
  4. 83
      src/components/BookmarkButton/index.tsx
  5. 6
      src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
  6. 2
      src/components/HelpAndAccountMenu.tsx
  7. 2
      src/components/KindFilter/index.tsx
  8. 36
      src/components/NoteOptions/DesktopMenu.tsx
  9. 6
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  10. 27
      src/components/NoteOptions/MobileMenu.tsx
  11. 69
      src/components/NoteOptions/NoteOptionsMetaHeader.tsx
  12. 2
      src/components/NoteOptions/index.tsx
  13. 484
      src/components/NoteOptions/useMenuActions.tsx
  14. 4
      src/components/NoteStats/NoteStatsCountHover.tsx
  15. 12
      src/components/NoteStats/index.tsx
  16. 2
      src/components/PostEditor/Mentions.tsx
  17. 9
      src/components/PostEditor/PostContent.tsx
  18. 9
      src/components/PostEditor/PostRelaySelector.tsx
  19. 2
      src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
  20. 7
      src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx
  21. 2
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  22. 2
      src/components/Profile/index.tsx
  23. 2
      src/components/ProfileOptions/index.tsx
  24. 39
      src/components/ui/dropdown-menu.tsx
  25. 12
      src/components/ui/hover-card.tsx
  26. 12
      src/components/ui/popover.tsx
  27. 19
      src/components/ui/select.tsx
  28. 6
      src/constants.ts
  29. 1
      src/i18n/locales/cs.ts
  30. 1
      src/i18n/locales/de.ts
  31. 4
      src/i18n/locales/en.ts
  32. 1
      src/i18n/locales/es.ts
  33. 1
      src/i18n/locales/fr.ts
  34. 1
      src/i18n/locales/nl.ts
  35. 1
      src/i18n/locales/pl.ts
  36. 1
      src/i18n/locales/ru.ts
  37. 1
      src/i18n/locales/tr.ts
  38. 1
      src/i18n/locales/zh.ts
  39. 29
      src/lib/menu-popover-layout.ts

10
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.16.1", "version": "23.17.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.16.1", "version": "23.17.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -15865,9 +15865,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

12
src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx

@ -176,7 +176,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" /> <ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(20rem,92vw)] max-h-80 overflow-y-auto"> <DropdownMenuContent align="start" className="z-[280] w-[min(20rem,92vw)] max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height,100dvh))] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb citationsHint')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Advanced lab tb citationsHint')}</DropdownMenuLabel>
{LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => ( {LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => (
<DropdownMenuItem key={type} onSelect={() => openCitationPicker(type)}> <DropdownMenuItem key={type} onSelect={() => openCitationPicker(type)}>
@ -239,7 +239,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" /> <ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] max-h-80 overflow-y-auto w-56"> <DropdownMenuContent align="start" className="z-[280] max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height,100dvh))] overflow-y-auto w-56">
<DropdownMenuLabel>{t('Advanced lab tb headings hint')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Advanced lab tb headings hint')}</DropdownMenuLabel>
{( {(
[ [
@ -573,7 +573,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" /> <ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(24rem,92vw)] max-h-[min(70vh,28rem)] overflow-y-auto"> <DropdownMenuContent align="start" className="z-[280] w-[min(24rem,92vw)] max-h-[min(28rem,70dvh,var(--radix-dropdown-menu-content-available-height,100dvh))] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb mathIntro')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Advanced lab tb mathIntro')}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
@ -733,7 +733,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" /> <ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(22rem,92vw)] max-h-[min(80vh,32rem)] overflow-y-auto"> <DropdownMenuContent align="start" className="z-[280] w-[min(22rem,92vw)] max-h-[min(32rem,80dvh,var(--radix-dropdown-menu-content-available-height,100dvh))] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb adocTitlesHint')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Advanced lab tb adocTitlesHint')}</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onSelect={() =>
@ -1070,7 +1070,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" /> <ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(22rem,92vw)] max-h-[min(70vh,28rem)] overflow-y-auto"> <DropdownMenuContent align="start" className="z-[280] w-[min(22rem,92vw)] max-h-[min(28rem,70dvh,var(--radix-dropdown-menu-content-available-height,100dvh))] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb adocStructureHint')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Advanced lab tb adocStructureHint')}</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onSelect={() =>
@ -1212,7 +1212,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" /> <ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(24rem,92vw)] max-h-[min(70vh,28rem)] overflow-y-auto"> <DropdownMenuContent align="start" className="z-[280] w-[min(24rem,92vw)] max-h-[min(28rem,70dvh,var(--radix-dropdown-menu-content-available-height,100dvh))] overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb adocStemHint')}</DropdownMenuLabel> <DropdownMenuLabel>{t('Advanced lab tb adocStemHint')}</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))} onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))}

83
src/components/BookmarkButton/index.tsx

@ -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 (
<button
className={`flex items-center gap-1 ${
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
} enabled:hover:text-rose-400 px-1.5 h-full`}
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
disabled={updating}
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
>
{updating ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
)}
</button>
)
}

6
src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx

@ -88,7 +88,11 @@ export function ConnectedRelaysSidebarStrip({ className }: { className?: string
+{overflow} +{overflow}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="max-h-[min(70vh,24rem)] w-72 overflow-y-auto"> <DropdownMenuContent
align="start"
side="right"
className="w-[min(18rem,calc(100vw-1.5rem))]"
>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('More relays', { count: overflow })} {t('More relays', { count: overflow })}
</DropdownMenuLabel> </DropdownMenuLabel>

2
src/components/HelpAndAccountMenu.tsx

@ -27,7 +27,7 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const titlebarAccountMenuContentClassName = 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' export type HelpAndAccountMenuVariant = 'sidebar' | 'titlebar'

2
src/components/KindFilter/index.tsx

@ -379,7 +379,7 @@ export default function KindFilter({
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger> <PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent <PopoverContent
className="flex w-96 max-h-[min(85dvh,calc(100dvh-6rem))] flex-col gap-0 overflow-hidden p-0" className="flex w-[min(24rem,calc(100vw-1.5rem))] max-w-none flex-col gap-0 overflow-hidden p-0"
collisionPadding={{ top: 80, bottom: 20, left: 16, right: 16 }} collisionPadding={{ top: 80, bottom: 20, left: 16, right: 16 }}
side="bottom" side="bottom"
align="end" align="end"

36
src/components/NoteOptions/DesktopMenu.tsx

@ -9,6 +9,7 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { dropdownMenuMaxHeightClass, floatingPanelScrollClass } from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { MenuAction, SubMenuAction } from './useMenuActions' import { MenuAction, SubMenuAction } from './useMenuActions'
import { memo, useMemo, useState } from 'react' import { memo, useMemo, useState } from 'react'
@ -56,7 +57,7 @@ const SubMenuPanel = memo(
<Icon /> <Icon />
{action.label} {action.label}
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-[min(28rem,calc(100vw-2rem))] max-w-[28rem] min-w-[18rem] p-0"> <DropdownMenuSubContent className="min-w-[min(12rem,calc(100vw-2rem))] p-0">
{action.subMenuSearchable ? ( {action.subMenuSearchable ? (
<div <div
className="border-b border-border bg-popover p-2" className="border-b border-border bg-popover p-2"
@ -72,7 +73,7 @@ const SubMenuPanel = memo(
/> />
</div> </div>
) : null} ) : null}
<div className="max-h-[min(50vh,22rem)] overflow-y-auto py-1"> <div className={cn(floatingPanelScrollClass, dropdownMenuMaxHeightClass, 'py-1')}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground"> <div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t('Language list filter empty')} {t('Language list filter empty')}
@ -81,6 +82,34 @@ const SubMenuPanel = memo(
filtered.map((subAction, subIndex) => ( filtered.map((subAction, subIndex) => (
<div key={subIndex}> <div key={subIndex}>
{subAction.separator && subIndex > 0 && <DropdownMenuSeparator />} {subAction.separator && subIndex > 0 && <DropdownMenuSeparator />}
{subAction.subMenu?.length ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger
className={cn(
'min-w-0 max-w-none whitespace-normal',
subAction.className
)}
>
{subAction.label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="p-1">
{subAction.subMenu.map((nested, nestedIndex) => (
<div key={nestedIndex}>
{nested.separator && nestedIndex > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={nested.onClick}
className={cn(
'min-w-0 max-w-none whitespace-normal',
nested.className
)}
>
{nested.label}
</DropdownMenuItem>
</div>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem <DropdownMenuItem
onClick={subAction.onClick} onClick={subAction.onClick}
className={cn( className={cn(
@ -90,6 +119,7 @@ const SubMenuPanel = memo(
> >
{subAction.label} {subAction.label}
</DropdownMenuItem> </DropdownMenuItem>
)}
</div> </div>
)) ))
)} )}
@ -150,7 +180,7 @@ export function DesktopMenu({ menuActions, trigger, header, open, onOpenChange }
}} }}
> >
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[50vh] overflow-y-auto p-0"> <DropdownMenuContent showScrollButtons className="p-0">
{header} {header}
<div className="py-1"> <div className="py-1">
<MenuContent <MenuContent

6
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -368,10 +368,8 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
} }
const title = const title =
mode === 'edit' mode === 'edit' || mode === 'clone'
? t('Edit this event') ? t('Edit or fork this event')
: mode === 'clone'
? t('Clone or fork this event')
: t('Create custom event') : t('Create custom event')
const openAdvancedLab = useCallback(() => { const openAdvancedLab = useCallback(() => {

27
src/components/NoteOptions/MobileMenu.tsx

@ -8,7 +8,7 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { MenuAction, SubMenuAction } from './useMenuActions' import { MenuAction, ShowSubMenuOptions, SubMenuAction } from './useMenuActions'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -24,6 +24,11 @@ interface MobileMenuProps {
subMenuSearchable: boolean subMenuSearchable: boolean
closeDrawer: () => void closeDrawer: () => void
goBackToMainMenu: () => void goBackToMainMenu: () => void
showSubMenuActions: (
subMenu: SubMenuAction[],
title: string,
options?: ShowSubMenuOptions
) => void
} }
function filterSubMenuRows( function filterSubMenuRows(
@ -70,7 +75,8 @@ export function MobileMenu({
subMenuTitle, subMenuTitle,
subMenuSearchable, subMenuSearchable,
closeDrawer, closeDrawer,
goBackToMainMenu goBackToMainMenu,
showSubMenuActions
}: MobileMenuProps) { }: MobileMenuProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [subMenuFilter, setSubMenuFilter] = useState('') const [subMenuFilter, setSubMenuFilter] = useState('')
@ -106,7 +112,15 @@ export function MobileMenu({
icon={Icon} icon={Icon}
label={action.label} label={action.label}
className={action.className} 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) => ( filteredSubMenu.map((subAction, index) => (
<Button <Button
key={index} key={index}
onClick={subAction.onClick} onClick={
subAction.subMenu?.length
? () =>
showSubMenuActions(subAction.subMenu!, String(subAction.label))
: subAction.onClick
}
className={cn(drawerMenuButtonClassName, subAction.className)} className={cn(drawerMenuButtonClassName, subAction.className)}
variant="ghost" variant="ghost"
> >

69
src/components/NoteOptions/NoteOptionsMetaHeader.tsx

@ -1,85 +1,26 @@
import RelayIcon from '@/components/RelayIcon'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useSecondaryPage } from '@/PageManager'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function NoteOptionsMetaHeader({ export default function NoteOptionsMetaHeader({
event, event
allowedRelays,
onNavigate,
inDropdown = false
}: { }: {
event: Event event: Event
/** @deprecated Seen-on relays moved to Advanced submenu. */
allowedRelays?: readonly string[] allowedRelays?: readonly string[]
/** @deprecated */
onNavigate?: () => void onNavigate?: () => void
/** @deprecated */
inDropdown?: boolean inDropdown?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage()
const relays = useSeenOnRelays(event.id, allowedRelays)
const { description } = getKindDescription(event.kind, event) const { description } = getKindDescription(event.kind, event)
const relayRows = relays.map((relay) => {
const label = (
<>
<RelayIcon url={relay} className="size-4 shrink-0" />
<span className="min-w-0 truncate">{simplifyUrl(relay)}</span>
</>
)
if (inDropdown) {
return (
<DropdownMenuItem
key={relay}
className="min-w-0 gap-2"
onSelect={() => {
onNavigate?.()
push(toRelay(relay))
}}
>
{label}
</DropdownMenuItem>
)
}
return ( return (
<li key={relay}> <div className="border-b border-border px-3 py-2.5">
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-1 py-1 text-left text-sm text-foreground hover:bg-muted"
onClick={() => {
onNavigate?.()
push(toRelay(relay))
}}
>
{label}
</button>
</li>
)
})
return (
<div className="space-y-2 border-b border-border px-3 py-2.5">
<p className="text-xs leading-snug text-muted-foreground/80" data-note-kind-label> <p className="text-xs leading-snug text-muted-foreground/80" data-note-kind-label>
{t('Note kind label line', { kind: event.kind, description })} {t('Note kind label line', { kind: event.kind, description })}
</p> </p>
{relays.length > 0 ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t('Seen on')}
</p>
{inDropdown ? (
<div className="space-y-0.5">{relayRows}</div>
) : (
<ul className="max-h-32 space-y-0.5 overflow-y-auto overscroll-y-contain">{relayRows}</ul>
)}
</div>
) : null}
</div> </div>
) )
} }

2
src/components/NoteOptions/index.tsx

@ -109,6 +109,7 @@ export default function NoteOptions({
setIsRawEventDialogOpen, setIsRawEventDialogOpen,
setIsReportDialogOpen, setIsReportDialogOpen,
isSmallScreen, isSmallScreen,
seenOnAllowlist,
onOpenPublicMessage, onOpenPublicMessage,
onOpenCallInvite, onOpenCallInvite,
onOpenEditOrClone: (mode) => { onOpenEditOrClone: (mode) => {
@ -160,6 +161,7 @@ export default function NoteOptions({
subMenuSearchable={subMenuSearchable} subMenuSearchable={subMenuSearchable}
closeDrawer={closeDrawer} closeDrawer={closeDrawer}
goBackToMainMenu={goBackToMainMenu} goBackToMainMenu={goBackToMainMenu}
showSubMenuActions={showSubMenuActions}
/> />
) : ( ) : (
<DesktopMenu <DesktopMenu

484
src/components/NoteOptions/useMenuActions.tsx

@ -1,8 +1,19 @@
import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants' import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants'
import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import {
getNoteBech32Id,
getReplaceableCoordinateFromEvent,
isProtectedEvent,
isReplaceableEvent,
getRootEventHexId
} from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' import { buildHiveTalkJoinUrl } from '@/lib/hivetalk'
import { toAlexandria, encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr } from '@/lib/link' import {
toAlexandria,
encodeArticleLikePublicationNaddr,
openAlexandriaPublicationFromNaddr,
toRelay
} from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { pubkeyToNpub } from '@/lib/pubkey' import { pubkeyToNpub } from '@/lib/pubkey'
import { import {
@ -35,6 +46,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { eventService } from '@/services/client.service' import { eventService } from '@/services/client.service'
@ -42,21 +54,11 @@ import { nip66Service } from '@/services/nip66.service'
import { import {
Bell, Bell,
BellOff, BellOff,
BookOpen, Bookmark,
Code,
Copy,
FileDown,
GitFork,
Globe,
Link,
MessageCircle,
PencilLine,
Pin, Pin,
SatelliteDish, Settings,
Send, Share2,
Sparkles,
Trash2, Trash2,
TriangleAlert,
Video, Video,
Volume2, Volume2,
Languages Languages
@ -84,6 +86,8 @@ import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { useSecondaryPage } from '@/PageManager'
import { PrimaryPageContext } from '@/contexts/primary-page-context' import { PrimaryPageContext } from '@/contexts/primary-page-context'
import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback' import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback'
import type { TEditOrCloneMode } from './EditOrCloneEventDialog' import type { TEditOrCloneMode } from './EditOrCloneEventDialog'
@ -95,6 +99,8 @@ export interface SubMenuAction {
separator?: boolean separator?: boolean
/** Lowercase haystack for submenu filter when the parent sets {@link MenuAction.subMenuSearchable}. */ /** Lowercase haystack for submenu filter when the parent sets {@link MenuAction.subMenuSearchable}. */
filterHaystack?: string filterHaystack?: string
/** Nested submenu (desktop dropdown only). */
subMenu?: SubMenuAction[]
} }
export interface MenuAction { export interface MenuAction {
@ -127,6 +133,8 @@ interface UseMenuActionsProps {
pinned?: boolean pinned?: boolean
/** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */ /** Opens JSON viewer for the kind 9741 attestation of this payment or zap receipt. */
onViewAttestation?: () => void onViewAttestation?: () => void
/** When set (home favorites feed), "Seen on" in Advanced matches the feed allowlist. */
seenOnAllowlist?: readonly string[]
} }
export function useMenuActions({ export function useMenuActions({
@ -140,13 +148,31 @@ export function useMenuActions({
onOpenCallInvite, onOpenCallInvite,
onOpenEditOrClone, onOpenEditOrClone,
pinned: _pinnedInFeed = false, pinned: _pinnedInFeed = false,
onViewAttestation onViewAttestation,
seenOnAllowlist
}: UseMenuActionsProps) { }: UseMenuActionsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage()
const seenOnRelays = useSeenOnRelays(event.id, seenOnAllowlist)
// Use useContext directly to avoid error if provider is not available // Use useContext directly to avoid error if provider is not available
const primaryPageContext = useContext(PrimaryPageContext) const primaryPageContext = useContext(PrimaryPageContext)
const currentPrimaryPage = primaryPageContext?.current ?? null 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 canSignEvents = account != null && account.signerType !== 'npub'
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
@ -184,6 +210,15 @@ export function useMenuActions({
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, event.pubkey), [mutePubkeySet, event]) 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( const noteTranslationFromMenu = useSyncExternalStore(
subscribeNoteTranslations, subscribeNoteTranslations,
() => getNoteTranslation(event.id), () => getNoteTranslation(event.id),
@ -932,122 +967,133 @@ export function useMenuActions({
] ]
: [] : []
const actions: MenuAction[] = [ const pushSubMenuParent = (
{ target: MenuAction[],
icon: Copy, icon: MenuAction['icon'],
label: t('Copy event ID'), title: string,
onClick: () => { subMenu: SubMenuAction[],
navigator.clipboard.writeText(getNoteBech32Id(event)) options?: { separator?: boolean; subMenuSearchable?: boolean; className?: string }
closeDrawer() ) => {
} if (subMenu.length === 0) return
}, target.push({
{ icon,
icon: Copy, label: title,
label: t('Copy user ID'), separator: options?.separator,
onClick: () => { className: options?.className,
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '') subMenuSearchable: options?.subMenuSearchable,
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 onClick: isSmallScreen
? () => ? () =>
showSubMenuActions(translateTargetSubmenu, t('Translate note'), { showSubMenuActions(subMenu, title, {
subMenuSearchable: true subMenuSearchable: options?.subMenuSearchable
}) })
: undefined, : undefined,
subMenu: isSmallScreen ? undefined : translateTargetSubmenu, subMenu: isSmallScreen ? undefined : subMenu
subMenuSearchable: true })
} as MenuAction }
]
: []), const connectionsSubMenu: SubMenuAction[] = [
...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage ...(pubkey && event.pubkey !== pubkey && onOpenPublicMessage
? [ ? [
{ {
icon: MessageCircle,
label: t('Send public message'), label: t('Send public message'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
onOpenPublicMessage(event.pubkey) onOpenPublicMessage(event.pubkey)
} }
} as MenuAction }
] ]
: []), : []),
{ {
icon: Link,
label: t('Share with Imwald'), label: t('Share with Imwald'),
separator: pubkey != null && event.pubkey !== pubkey && !!onOpenPublicMessage,
onClick: () => { onClick: () => {
const noteId = getNoteBech32Id(event) const noteId = getNoteBech32Id(event)
// Contextual URL when on Spells (e.g. discussions faux-spell); plain /notes/{id} otherwise
const path = const path =
currentPrimaryPage === 'spells' currentPrimaryPage === 'spells'
? `/spells/notes/${noteId}` ? `/spells/notes/${noteId}`
: currentPrimaryPage === 'rss' : currentPrimaryPage === 'rss'
? `/rss/notes/${noteId}` ? `/rss/notes/${noteId}`
: `/notes/${noteId}` : `/notes/${noteId}`
const appShareUrl = `https://jumble.imwald.eu${path}` navigator.clipboard.writeText(`https://jumble.imwald.eu${path}`)
navigator.clipboard.writeText(appShareUrl)
closeDrawer() closeDrawer()
} }
}, },
{ {
icon: BookOpen,
label: t('Share with Alexandria'), label: t('Share with Alexandria'),
onClick: () => { onClick: () => {
navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event))) navigator.clipboard.writeText(toAlexandria(getNoteBech32Id(event)))
closeDrawer() 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'), label: t('Start call about this'),
separator: true,
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
const roomId = `imwald-note-${event.id}` const roomId = `imwald-note-${event.id}`
const url = buildHiveTalkJoinUrl({ room: roomId }) window.open(buildHiveTalkJoinUrl({ room: roomId }), '_blank', 'noopener,noreferrer')
window.open(url, '_blank', 'noopener,noreferrer')
} }
}, },
{ {
icon: Copy,
label: t('Copy call invite link'), label: t('Copy call invite link'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
const roomId = `imwald-note-${event.id}` const roomId = `imwald-note-${event.id}`
const url = buildHiveTalkJoinUrl({ room: roomId }) navigator.clipboard.writeText(buildHiveTalkJoinUrl({ room: roomId }))
navigator.clipboard.writeText(url)
toast.success(t('Copied to clipboard')) toast.success(t('Copied to clipboard'))
} }
}, },
...(onOpenCallInvite ...(onOpenCallInvite
? [ ? [
{ {
icon: Send,
label: t('Send call invite'), label: t('Send call invite'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
@ -1055,160 +1101,206 @@ export function useMenuActions({
const url = buildHiveTalkJoinUrl({ room: roomId }) const url = buildHiveTalkJoinUrl({ room: roomId })
onOpenCallInvite(`${t('Join the video call')}: ${url}`) onOpenCallInvite(`${t('Join the video call')}: ${url}`)
} }
} as MenuAction }
] ]
: []) : [])
] ]
// Add "View on Alexandria" menu item for public messages (PMs) const isProtected = isProtectedEvent(event)
if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { const isDiscussion = event.kind === ExtendedKind.DISCUSSION
actions.push({ const showRepublish =
icon: Globe, broadcastSubMenu.length > 0 &&
label: t('View on Alexandria'), (!isProtected || event.pubkey === pubkey) &&
!isDiscussion &&
!isReplyToDiscussion
const advancedSubMenu: SubMenuAction[] = [
{
label: t('Copy event ID'),
onClick: () => { onClick: () => {
navigator.clipboard.writeText(getNoteBech32Id(event))
closeDrawer() 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) { if (canSignEvents && pubkey && onOpenEditOrClone) {
const isOwn = event.pubkey === pubkey advancedSubMenu.push({
actions.push({ label: t('Edit or fork this event'),
icon: isOwn ? PencilLine : GitFork, separator: advancedSubMenu.length > 2,
label: isOwn ? t('Edit this event') : t('Clone or fork this event'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
onOpenEditOrClone(isOwn ? 'edit' : 'clone') onOpenEditOrClone(event.pubkey === pubkey ? 'edit' : 'clone')
}, }
separator: true
}) })
} }
actions.push({ advancedSubMenu.push({
icon: Code,
label: t('View raw event'), label: t('View raw event'),
separator: true,
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
setIsRawEventDialogOpen(true) setIsRawEventDialogOpen(true)
}, }
separator: !onViewAttestation
}) })
if (onViewAttestation) { if (onViewAttestation) {
actions.push({ advancedSubMenu.push({
icon: Sparkles,
label: t('View attestation'), label: t('View attestation'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
onViewAttestation() 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) { if (isArticleType) {
const isMarkdownFormat = event.kind === kinds.LongFormArticle || event.kind === ExtendedKind.NOSTR_SPECIFICATION const isMarkdownFormat =
const isAsciidocFormat = event.kind === ExtendedKind.WIKI_ARTICLE || event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT 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) { if (isMarkdownFormat) {
actions.push({ advancedSubMenu.push({
icon: FileDown,
label: t('Export as Markdown'), label: t('Export as Markdown'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
exportAsMarkdown() exportAsMarkdown()
}, }
separator: true
}) })
} }
if (isAsciidocFormat) { if (isAsciidocFormat) {
actions.push({ advancedSubMenu.push({
icon: FileDown,
label: t('Export as AsciiDoc'), label: t('Export as AsciiDoc'),
onClick: () => { onClick: () => {
closeDrawer() closeDrawer()
exportAsAsciidoc() 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) { if (event.kind === ExtendedKind.PUBLICATION && publicationBroadcastSubMenu.length > 0) {
actions.push({ advancedSubMenu.push({
icon: Globe, label: t('Rebroadcast entire publication'),
label: t('View on DecentNewsroom'), separator: true,
onClick: handleViewOnDecentNewsroom onClick: isSmallScreen
}) ? () =>
showSubMenuActions(
publicationBroadcastSubMenu,
t('Rebroadcast entire publication to ...')
)
: () => {},
subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu
} as SubMenuAction)
} }
} 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 (seenOnRelays.length > 0) {
advancedSubMenu.push({
label: (
<div
className="flex flex-wrap gap-2 py-0.5"
role="group"
aria-label={t('Seen on')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{seenOnRelays.map((relay) => (
<button
key={relay}
type="button"
title={simplifyUrl(relay)}
className="rounded-md p-1 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
e.stopPropagation()
closeDrawer()
push(toRelay(relay))
}}
>
<RelayIcon url={relay} className="size-8 shrink-0" />
</button>
))}
</div>
),
onClick: () => {},
separator: true
})
} }
if (event.kind === ExtendedKind.PUBLICATION) { const actions: MenuAction[] = []
if (READ_ALOUD_KINDS.includes(event.kind)) {
actions.push({ actions.push({
icon: SatelliteDish, icon: Volume2,
label: t('Rebroadcast entire publication'), label: t('Read this note aloud'),
onClick: isSmallScreen onClick: () => {
? () => showSubMenuActions(publicationBroadcastSubMenu, t('Rebroadcast entire publication to ...')) closeDrawer()
: undefined, void speakNoteReadAloud(event).then((result) => {
subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu, if (result === 'unsupported') {
separator: true 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'))
}
}) })
} }
})
} }
const isProtected = isProtectedEvent(event) if (noteSupportsTranslateMenu) {
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
if ((!isProtected || event.pubkey === pubkey) && !isDiscussion && !isReplyToDiscussion) {
actions.push({ actions.push({
icon: SatelliteDish, icon: Languages,
label: t('Republish to ...'), label: t('Translate note'),
onClick: isSmallScreen onClick: isSmallScreen
? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...')) ? () =>
showSubMenuActions(translateTargetSubmenu, t('Translate note'), {
subMenuSearchable: true
})
: undefined, : undefined,
subMenu: isSmallScreen ? undefined : broadcastSubMenu, subMenu: isSmallScreen ? undefined : translateTargetSubmenu,
separator: true subMenuSearchable: true
}) })
} }
if (pubkey && event.pubkey !== pubkey) { pushSubMenuParent(actions, Share2, t('Connections'), connectionsSubMenu, {
actions.push({ separator: actions.length > 0
icon: TriangleAlert, })
label: t('Report'), pushSubMenuParent(actions, Video, t('Calls'), callsSubMenu)
className: 'text-destructive focus:text-destructive', pushSubMenuParent(actions, Settings, t('Advanced'), advancedSubMenu, {
onClick: () => { separator: actions.length > 0
closeDrawer()
setIsReportDialogOpen(true)
},
separator: true
}) })
}
if (pubkey && event.pubkey !== pubkey) { if (pubkey && event.pubkey !== pubkey) {
if (isMuted) { if (isMuted) {
@ -1232,7 +1324,7 @@ export function useMenuActions({
mutePubkeyPrivately(event.pubkey) mutePubkeyPrivately(event.pubkey)
}, },
className: 'text-destructive focus:text-destructive', className: 'text-destructive focus:text-destructive',
separator: true separator: actions.length > 0
}, },
{ {
icon: BellOff, 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 && event.pubkey === pubkey) {
if (pubkey) {
actions.push({ actions.push({
icon: Pin, icon: Pin,
label: isPinnedInMyList ? t('Unpin note') : t('Pin note'), label: isPinnedInMyList ? t('Unpin note') : t('Pin note'),
@ -1257,6 +1348,34 @@ export function useMenuActions({
}, },
separator: true 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) // Delete only when signed in as the author with a signing key (not read-only npub)
@ -1290,6 +1409,13 @@ export function useMenuActions({
attemptDelete, attemptDelete,
isPinnedInMyList, isPinnedInMyList,
handlePinNote, handlePinNote,
bookmarkListEvent,
bookmarksContext,
isBookmarked,
bookmarkUpdating,
addBookmark,
removeBookmark,
checkLogin,
isArticleType, isArticleType,
articleMetadata, articleMetadata,
dTag, dTag,
@ -1302,7 +1428,11 @@ export function useMenuActions({
profile, profile,
noteTranslationFromMenu, noteTranslationFromMenu,
translateMenuOptions, translateMenuOptions,
onViewAttestation onViewAttestation,
seenOnRelays,
push,
currentPrimaryPage,
isReplyToDiscussion
]) ])
return menuActions return menuActions

4
src/components/NoteStats/NoteStatsCountHover.tsx

@ -213,7 +213,7 @@ export function NoteStatsCountHover({
return ( return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger> <PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent side="top" align="center" className="z-[100] w-72 p-3"> <PopoverContent side="top" align="center" className="z-[100] w-[min(18rem,calc(100vw-1.5rem))] max-w-none p-3">
{panel} {panel}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -223,7 +223,7 @@ export function NoteStatsCountHover({
return ( return (
<HoverCard openDelay={220} closeDelay={80}> <HoverCard openDelay={220} closeDelay={80}>
<HoverCardTrigger asChild>{trigger}</HoverCardTrigger> <HoverCardTrigger asChild>{trigger}</HoverCardTrigger>
<HoverCardContent side="top" align="center" className="z-[100] w-72 p-3"> <HoverCardContent side="top" align="center" className="z-[100] w-[min(18rem,calc(100vw-1.5rem))] max-w-none p-3">
{panel} {panel}
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>

12
src/components/NoteStats/index.tsx

@ -10,9 +10,7 @@ import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import BookmarkButton from '../BookmarkButton'
import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons' import NotificationThreadWatchButtons from '../NotificationThreadWatchButtons'
import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { LikeButtonWithStats } from './LikeButton' import { LikeButtonWithStats } from './LikeButton'
import { ReplyButtonWithStats } from './ReplyButton' import { ReplyButtonWithStats } from './ReplyButton'
@ -143,9 +141,7 @@ export default function NoteStats({
]) ])
const watch = useNotificationThreadWatchOptional() const watch = useNotificationThreadWatchOptional()
const bookmarksContext = useBookmarksOptional()
const showThreadWatchButtons = Boolean(watch && pubkey) 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. */ /** Kind 11 / 1111 under a discussion: up+down votes need more width than a single like button. */
const isDiscussionBar = isDiscussion || isReplyToDiscussion const isDiscussionBar = isDiscussion || isReplyToDiscussion
const compactBarItem = isDiscussionBar ? 'shrink-0 flex-none basis-auto' : undefined const compactBarItem = isDiscussionBar ? 'shrink-0 flex-none basis-auto' : undefined
@ -194,14 +190,6 @@ export default function NoteStats({
) )
} }
if (!isRssArticleRoot && showBookmarkButton) {
barItems.push(
<NoteStatsBarItem key="bookmark" className={compactBarItem}>
<BookmarkButton event={event} />
</NoteStatsBarItem>
)
}
return ( return (
<div <div
ref={containerRef} ref={containerRef}

2
src/components/PostEditor/Mentions.tsx

@ -79,7 +79,7 @@ export default function Mentions({
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`} {potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-52 p-0 py-1"> <PopoverContent className="w-[min(13rem,calc(100vw-1.5rem))] max-w-none p-0 py-1">
<div className="space-y-1"> <div className="space-y-1">
{potentialMentions.map((_, index) => { {potentialMentions.map((_, index) => {
const pubkey = potentialMentions[potentialMentions.length - 1 - index] const pubkey = potentialMentions[potentialMentions.length - 1 - index]

9
src/components/PostEditor/PostContent.tsx

@ -2409,7 +2409,12 @@ export default function PostContent({
<ChevronDown className="h-4 w-4 opacity-70" /> <ChevronDown className="h-4 w-4 opacity-70" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="z-[10000] w-72 p-2" align="end" side="bottom" sideOffset={4}> <PopoverContent
className="z-[10000] w-[min(18rem,calc(100vw-1.5rem))] max-w-none p-2"
align="end"
side="bottom"
sideOffset={4}
>
<p className="text-muted-foreground mb-2 px-1 text-xs font-medium">{t('Suggested topics')}</p> <p className="text-muted-foreground mb-2 px-1 text-xs font-medium">{t('Suggested topics')}</p>
<div className="max-h-60 overflow-y-auto"> <div className="max-h-60 overflow-y-auto">
{allAvailableTopics.map((topic, index) => { {allAvailableTopics.map((topic, index) => {
@ -3109,7 +3114,7 @@ export default function PostContent({
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64"> <DropdownMenuContent align="end" className="w-[min(16rem,calc(100vw-1.5rem))]">
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1"> <DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1">
{t('Note type')} {t('Note type')}
</DropdownMenuLabel> </DropdownMenuLabel>

9
src/components/PostEditor/PostRelaySelector.tsx

@ -423,7 +423,12 @@ export default function PostRelaySelector({
<ChevronDown className="w-3 h-3 shrink-0" /> <ChevronDown className="w-3 h-3 shrink-0" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[90vw] max-w-md p-0 max-h-[40vh] flex flex-col overflow-hidden" align="start" side="bottom" sideOffset={8}> <PopoverContent
className="w-[min(calc(100vw-1.5rem),28rem)] max-w-none p-0 flex flex-col overflow-hidden"
align="start"
side="bottom"
sideOffset={8}
>
<div className="p-3 border-b flex flex-col gap-1 shrink-0"> <div className="p-3 border-b flex flex-col gap-1 shrink-0">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{t('Select relays')}</span> <span className="text-sm font-medium">{t('Select relays')}</span>
@ -431,7 +436,7 @@ export default function PostRelaySelector({
</div> </div>
{capHintEl} {capHintEl}
</div> </div>
<div className="max-h-[35vh] min-h-0 overflow-y-scroll overflow-x-hidden p-3"> <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain p-3 popover-scroll-y">
{content} {content}
</div> </div>
</PopoverContent> </PopoverContent>

2
src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx

@ -83,7 +83,7 @@ export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, re
return ( return (
<ScrollArea <ScrollArea
className="border rounded-lg bg-background z-[110] pointer-events-auto flex flex-col max-h-80 overflow-y-auto" className="border rounded-lg bg-background z-[110] pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y"
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()} onTouchMove={(e) => e.stopPropagation()}
> >

7
src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx

@ -64,7 +64,12 @@ export function MentionAndEventToolbarButtons({
<AtSign className="h-4 w-4" /> <AtSign className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80 p-2 z-[10000]" align="start" side="bottom" sideOffset={4}> <PopoverContent
className="w-[min(20rem,calc(100vw-1.5rem))] max-w-none p-2 z-[10000]"
align="start"
side="bottom"
sideOffset={4}
>
<Input <Input
placeholder={t('Search for user…')} placeholder={t('Search for user…')}
value={mentionQuery} value={mentionQuery}

2
src/components/PostEditor/PostTextarea/Mention/MentionList.tsx

@ -102,7 +102,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
return ( return (
<div <div
className={cn( className={cn(
'border rounded-lg bg-background pointer-events-auto flex flex-col max-h-80 min-h-0 overflow-y-scroll overflow-x-hidden', 'border rounded-lg bg-background pointer-events-auto flex flex-col min-h-0 max-h-[min(85dvh,calc(100dvh-6rem))] max-w-[min(calc(100vw-1.5rem),28rem)] overflow-x-hidden overflow-y-auto overscroll-contain popover-scroll-y',
inDialog ? 'z-[290]' : 'z-[110]' inDialog ? 'z-[290]' : 'z-[110]'
)} )}
onWheel={(e: React.WheelEvent) => e.stopPropagation()} onWheel={(e: React.WheelEvent) => e.stopPropagation()}

2
src/components/Profile/index.tsx

@ -434,7 +434,7 @@ export default function Profile({
<Ellipsis /> <Ellipsis />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" showScrollButtons>
{profileEvent && ( {profileEvent && (
<> <>
<DropdownMenuItem onClick={() => setOpenSelfReply(true)}> <DropdownMenuItem onClick={() => setOpenSelfReply(true)}>

2
src/components/ProfileOptions/index.tsx

@ -206,7 +206,7 @@ export default function ProfileOptions({
<Ellipsis /> <Ellipsis />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent showScrollButtons className="w-[min(20rem,calc(100vw-1.5rem))]">
{eventToUse && ( {eventToUse && (
<> <>
<DropdownMenuItem onClick={() => setOpenReply(true)}> <DropdownMenuItem onClick={() => setOpenReply(true)}>

39
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 { Check, ChevronDown, ChevronRight, ChevronUp, Circle } from 'lucide-react'
import { DialogContext } from '@/components/ui/dialog' import { DialogContext } from '@/components/ui/dialog'
import {
dropdownMenuMaxHeightClass,
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
menuItemLargeTextClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils' 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. */ /** Radix `MenuSubContentProps` omits `side` / `align`; Popper still accepts them at runtime. */
type DropdownMenuSubContentPositionProps = Partial< type DropdownMenuSubContentPositionProps = Partial<
Pick<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>, 'side' | 'align'> Pick<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>, 'side' | 'align'>
@ -37,7 +46,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'flex min-w-0 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent whitespace-normal [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8', inset && 'pl-8',
className className
)} )}
@ -117,14 +126,10 @@ const DropdownMenuSubContent = React.forwardRef<
align={align} align={align}
className={cn( className={cn(
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
submenuBelow && 'max-w-[min(100vw-1.5rem,24rem)]', floatingPanelMaxWidthClass,
inDialog ? 'z-[290]' : 'z-[100]' inDialog ? 'z-[290]' : 'z-[100]'
)} )}
onAnimationEnd={() => { onAnimationEnd={checkScrollability}
if (showScrollButtons) {
checkScrollability()
}
}}
collisionPadding={16} collisionPadding={16}
{...props} {...props}
> >
@ -144,7 +149,10 @@ const DropdownMenuSubContent = React.forwardRef<
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
className={cn( className={cn(
'p-1 popover-scroll-y max-h-[min(85dvh,calc(100dvh-3rem))] min-h-0 overflow-x-hidden', 'p-1',
floatingPanelScrollClass,
dropdownMenuMaxHeightClass,
floatingPanelMaxWidthClass,
className className
)} )}
onScroll={checkScrollability} onScroll={checkScrollability}
@ -223,13 +231,10 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
floatingPanelMaxWidthClass,
inDialog ? 'z-[290]' : 'z-[100]' inDialog ? 'z-[290]' : 'z-[100]'
)} )}
onAnimationEnd={() => { onAnimationEnd={checkScrollability}
if (showScrollButtons) {
checkScrollability()
}
}}
collisionPadding={16} collisionPadding={16}
{...props} {...props}
> >
@ -249,7 +254,10 @@ const DropdownMenuContent = React.forwardRef<
<div <div
ref={scrollAreaRef} ref={scrollAreaRef}
className={cn( className={cn(
'p-1 popover-scroll-y max-h-[min(85dvh,calc(100dvh-3rem))] min-h-0 overflow-x-hidden', 'p-1',
floatingPanelScrollClass,
dropdownMenuMaxHeightClass,
floatingPanelMaxWidthClass,
className className
)} )}
onScroll={checkScrollability} onScroll={checkScrollability}
@ -284,7 +292,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md', 'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
menuItemLargeTextClass,
inset && 'pl-8', inset && 'pl-8',
className className
)} )}

12
src/components/ui/hover-card.tsx

@ -1,6 +1,11 @@
import * as React from 'react' import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card' import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import {
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
hoverCardMaxHeightClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const HoverCard = HoverCardPrimitive.Root const HoverCard = HoverCardPrimitive.Root
@ -15,9 +20,12 @@ const HoverCardContent = React.forwardRef<
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
collisionPadding={10} collisionPadding={16}
className={cn( className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'z-50 w-[min(16rem,calc(100vw-1.5rem))] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
floatingPanelScrollClass,
hoverCardMaxHeightClass,
floatingPanelMaxWidthClass,
className className
)} )}
{...props} {...props}

12
src/components/ui/popover.tsx

@ -2,6 +2,11 @@ import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover' import * as PopoverPrimitive from '@radix-ui/react-popover'
import { DialogContext } from '@/components/ui/dialog' import { DialogContext } from '@/components/ui/dialog'
import {
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
popoverMaxHeightClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root const Popover = PopoverPrimitive.Root
@ -21,9 +26,12 @@ const PopoverContent = React.forwardRef<
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
collisionPadding={10} collisionPadding={16}
className={cn( className={cn(
'w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'w-[min(18rem,calc(100vw-1.5rem))] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
floatingPanelScrollClass,
popoverMaxHeightClass,
floatingPanelMaxWidthClass,
inDialog ? 'z-[290]' : 'z-[110]', inDialog ? 'z-[290]' : 'z-[110]',
className className
)} )}

19
src/components/ui/select.tsx

@ -3,6 +3,12 @@ import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react' import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { DialogContext } from '@/components/ui/dialog' import { DialogContext } from '@/components/ui/dialog'
import {
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
menuItemLargeTextClass,
selectViewportMaxHeightClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root
@ -68,8 +74,10 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
collisionPadding={16}
className={cn( className={cn(
'relative max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 'relative min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
floatingPanelMaxWidthClass,
inDialog ? 'z-[290]' : 'z-[110]', inDialog ? 'z-[290]' : 'z-[110]',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
@ -80,9 +88,13 @@ const SelectContent = React.forwardRef<
> >
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
'p-1 popover-scroll-y', 'p-1',
floatingPanelScrollClass,
position === 'popper' && position === 'popper' &&
'max-h-[min(24rem,var(--radix-select-content-available-height,80vh))] w-full min-w-[var(--radix-select-trigger-width)]' cn(
selectViewportMaxHeightClass,
'w-full min-w-[var(--radix-select-trigger-width)]'
)
)} )}
> >
{children} {children}
@ -113,6 +125,7 @@ const SelectItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( 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', '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 className
)} )}
{...props} {...props}

6
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 = [ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es',
'wss://relay.nsec.app', 'wss://relay.nsec.app',
'wss://bucket.coracle.social', 'wss://bucket.coracle.social',
'wss://spatia-arcana.com', 'wss://spatia-arcana.com',
@ -521,10 +520,11 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://purplepag.es',
'wss://profiles.nostrver.se/', 'wss://profiles.nostrver.se/',
'wss://indexer.coracle.social/', '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 = [ export const FOLLOWS_HISTORY_RELAY_URLS = [

1
src/i18n/locales/cs.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/de.ts

@ -88,6 +88,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Dieses Event bearbeiten', 'Edit this event': 'Dieses Event bearbeiten',
'Clone or fork this event': 'Event klonen oder forken', 'Clone or fork this event': 'Event klonen oder forken',
'Edit or fork this event': 'Event bearbeiten oder forken',
'Event kind': 'Event-Kind', 'Event kind': 'Event-Kind',
'Note content': 'Inhalt', 'Note content': 'Inhalt',
Publish: 'Veröffentlichen', Publish: 'Veröffentlichen',

4
src/i18n/locales/en.ts

@ -85,6 +85,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',
@ -289,6 +290,9 @@ export default {
'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.', 'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.',
'Tag value': 'Tag value', 'Tag value': 'Tag value',
'Saving…': 'Saving…', 'Saving…': 'Saving…',
Connections: 'Connections',
Calls: 'Calls',
Advanced: 'Advanced',
'Share with Imwald': 'Share with Imwald', 'Share with Imwald': 'Share with Imwald',
'Share with Alexandria': 'Share with Alexandria', 'Share with Alexandria': 'Share with Alexandria',
'Start video call': 'Start video call', 'Start video call': 'Start video call',

1
src/i18n/locales/es.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/fr.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/nl.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/pl.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/ru.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/tr.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

1
src/i18n/locales/zh.ts

@ -87,6 +87,7 @@ export default {
'Raw Event': 'Raw Event', 'Raw Event': 'Raw Event',
'Edit this event': 'Edit this event', 'Edit this event': 'Edit this event',
'Clone or fork this event': 'Clone or fork 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', 'Event kind': 'Event kind',
'Note content': 'Note content', 'Note content': 'Note content',
Publish: 'Publish', Publish: 'Publish',

29
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'
Loading…
Cancel
Save