Compare commits

..

18 Commits

  1. 6
      nip66-cron/index.mjs
  2. 16
      package-lock.json
  3. 3
      package.json
  4. 50
      src/PageManager.tsx
  5. 85
      src/components/AccountManager/GenerateNewAccount.tsx
  6. 83
      src/components/AccountManager/index.tsx
  7. 12
      src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx
  8. 83
      src/components/BookmarkButton/index.tsx
  9. 40
      src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx
  10. 106
      src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx
  11. 113
      src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx
  12. 13
      src/components/ConnectedRelays/active-relays-display.ts
  13. 31
      src/components/CreateWalletGuideToast/index.tsx
  14. 26
      src/components/Embedded/EmbeddedNote.tsx
  15. 45
      src/components/FeedRelaysIconRow/index.tsx
  16. 88
      src/components/GifPicker/index.tsx
  17. 26
      src/components/HelpAndAccountMenu.tsx
  18. 2
      src/components/KindFilter/index.tsx
  19. 13
      src/components/MailboxSetting/RelayCountWarning.tsx
  20. 40
      src/components/MetadataRelaysOnlySetting/index.tsx
  21. 54
      src/components/Nip05AffiliationBadges/index.tsx
  22. 2
      src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx
  23. 4
      src/components/NormalFeed/index.tsx
  24. 50
      src/components/Note/index.tsx
  25. 38
      src/components/NoteAuthorMetaLine/index.tsx
  26. 72
      src/components/NoteList/index.tsx
  27. 36
      src/components/NoteOptions/DesktopMenu.tsx
  28. 6
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  29. 27
      src/components/NoteOptions/MobileMenu.tsx
  30. 33
      src/components/NoteOptions/NoteOptionsMetaHeader.tsx
  31. 1
      src/components/NoteOptions/index.tsx
  32. 550
      src/components/NoteOptions/useMenuActions.tsx
  33. 4
      src/components/NoteStats/NoteStatsCountHover.tsx
  34. 13
      src/components/NoteStats/SeenOnButton.tsx
  35. 40
      src/components/NoteStats/index.tsx
  36. 17
      src/components/PostEditor/Mentions.tsx
  37. 23
      src/components/PostEditor/PollEditor.tsx
  38. 139
      src/components/PostEditor/PostContent.tsx
  39. 189
      src/components/PostEditor/PostEditorAdvancedPanel.tsx
  40. 30
      src/components/PostEditor/PostEditorFormatToolbar.tsx
  41. 84
      src/components/PostEditor/PostOptions.tsx
  42. 29
      src/components/PostEditor/PostRelaySelector.tsx
  43. 2
      src/components/PostEditor/PostTextarea/Emoji/EmojiList.tsx
  44. 7
      src/components/PostEditor/PostTextarea/Mention/MentionAndEventToolbarButtons.tsx
  45. 2
      src/components/PostEditor/PostTextarea/Mention/MentionList.tsx
  46. 10
      src/components/PostEditor/PostTextarea/index.tsx
  47. 49
      src/components/PostSignupBackupRedirect/index.tsx
  48. 226
      src/components/PrivateKeyRecoverySetting/index.tsx
  49. 10
      src/components/Profile/index.tsx
  50. 87
      src/components/ProfileList/index.tsx
  51. 2
      src/components/ProfileOptions/index.tsx
  52. 34
      src/components/Relay/index.tsx
  53. 27
      src/components/ReplyNote/index.tsx
  54. 41
      src/components/Settings/SettingsMenuBody.tsx
  55. 2
      src/components/Sidebar/index.tsx
  56. 8
      src/components/TooManyRelaysAlertDialog/index.tsx
  57. 10
      src/components/Username/index.tsx
  58. 16
      src/components/ui/avatar.tsx
  59. 39
      src/components/ui/dropdown-menu.tsx
  60. 12
      src/components/ui/hover-card.tsx
  61. 12
      src/components/ui/popover.tsx
  62. 19
      src/components/ui/select.tsx
  63. 62
      src/constants.ts
  64. 2
      src/hooks/index.tsx
  65. 13
      src/hooks/useBypassMetadataRelaysOnlyPolicy.ts
  66. 25
      src/hooks/useFetchProfile.tsx
  67. 10
      src/hooks/useRelayPageFeedPolicy.ts
  68. 57
      src/hooks/useThreadNotificationMenuState.ts
  69. 51
      src/hooks/useVerifiedNip05Affiliations.ts
  70. 8
      src/i18n/locales/cs.ts
  71. 30
      src/i18n/locales/de.ts
  72. 68
      src/i18n/locales/en.ts
  73. 8
      src/i18n/locales/es.ts
  74. 8
      src/i18n/locales/fr.ts
  75. 8
      src/i18n/locales/nl.ts
  76. 8
      src/i18n/locales/pl.ts
  77. 8
      src/i18n/locales/ru.ts
  78. 8
      src/i18n/locales/tr.ts
  79. 8
      src/i18n/locales/zh.ts
  80. 8
      src/lib/account-list-relay-urls.ts
  81. 74
      src/lib/event-ingest-filter.test.ts
  82. 54
      src/lib/event-ingest-filter.ts
  83. 12
      src/lib/favorites-feed-relays.ts
  84. 21
      src/lib/feed-relay-urls.test.ts
  85. 67
      src/lib/feed-relay-urls.ts
  86. 7
      src/lib/home-feed-relays.ts
  87. 4
      src/lib/live-activities.ts
  88. 29
      src/lib/menu-popover-layout.ts
  89. 21
      src/lib/metadata-policy-curated-relays.test.ts
  90. 65
      src/lib/metadata-policy-curated-relays.ts
  91. 186
      src/lib/new-user-template-broadcast.ts
  92. 124
      src/lib/new-user-template.test.ts
  93. 122
      src/lib/new-user-template.ts
  94. 51
      src/lib/nip05-affiliation.ts
  95. 16
      src/lib/nip05-well-known.test.ts
  96. 23
      src/lib/nip05.ts
  97. 50
      src/lib/post-signup-backup-prompt.ts
  98. 13
      src/lib/publishing-feedback.tsx
  99. 27
      src/lib/random-publish-relay-pool.test.ts
  100. 39
      src/lib/random-publish-relay-pool.ts
  101. Some files were not shown because too many files have changed in this diff Show More

6
nip66-cron/index.mjs

@ -328,8 +328,9 @@ async function publishEvent (relayUrls, event) { @@ -328,8 +328,9 @@ async function publishEvent (relayUrls, event) {
let ok = 0
const conns = []
for (const url of relayUrls) {
let ws
try {
const ws = new WebSocket(url, { handshakeTimeout: 8000 })
ws = new WebSocket(url, { handshakeTimeout: 8000 })
await new Promise((resolve, reject) => {
let timeoutId
let resolved = false
@ -374,6 +375,9 @@ async function publishEvent (relayUrls, event) { @@ -374,6 +375,9 @@ async function publishEvent (relayUrls, event) {
})
} catch (err) {
log('Publish relay error', { url, err: err.message })
if (ws) {
try { ws.close() } catch (_) {}
}
}
}
for (const ws of conns) {

16
package-lock.json generated

@ -1,12 +1,12 @@ @@ -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",
@ -77,7 +77,6 @@ @@ -77,7 +77,6 @@
"lucide-react": "^0.469.0",
"marked": "^17.0.5",
"nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.3.0",
"prosemirror-state": "^1.4.3",
"qr-code-styling": "^1.9.2",
@ -12895,11 +12894,6 @@ @@ -12895,11 +12894,6 @@
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/nstart-modal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/nstart-modal/-/nstart-modal-2.1.0.tgz",
"integrity": "sha512-PolShYoWK07yJJbINtUn/IoOI5B0lmXRG9zOY9dirKKVjmMWFKjYLlafunNOl94EGcEndzAPWJAFDCt8flYRqg=="
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -15865,9 +15859,9 @@ @@ -15865,9 +15859,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": {

3
package.json

@ -1,6 +1,6 @@ @@ -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",
@ -107,7 +107,6 @@ @@ -107,7 +107,6 @@
"lucide-react": "^0.469.0",
"marked": "^17.0.5",
"nostr-tools": "^2.17.0",
"nstart-modal": "^2.0.0",
"path-to-regexp": "^8.3.0",
"prosemirror-state": "^1.4.3",
"qr-code-styling": "^1.9.2",

50
src/PageManager.tsx

@ -19,7 +19,7 @@ import { NavigationService } from '@/services/navigation.service' @@ -19,7 +19,7 @@ import { NavigationService } from '@/services/navigation.service'
import { ImwaldBrandBar } from '@/assets/Logo'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
import NoteDrawer from '@/components/NoteDrawer'
import { PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants'
import { APP_RESET_TO_LANDING_EVENT, PROFILE_SECONDARY_PANEL_DEFER_MS } from '@/constants'
import { extendProfileNetworkDeferral } from '@/lib/profile-batch-coordinator'
import client from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
@ -104,7 +104,7 @@ const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrima @@ -104,7 +104,7 @@ const CalendarPrimaryPageLazy = lazy(() => import('./pages/primary/CalendarPrima
const SidebarLazy = lazy(() => import('@/components/Sidebar'))
const BottomNavigationBarLazy = lazy(() => import('@/components/BottomNavigationBar'))
const TooManyRelaysAlertDialogLazy = lazy(() => import('@/components/TooManyRelaysAlertDialog'))
const CreateWalletGuideToastLazy = lazy(() => import('@/components/CreateWalletGuideToast'))
const PostSignupBackupRedirectLazy = lazy(() => import('@/components/PostSignupBackupRedirect'))
/** Mobile primary-note overlay: lazy so these pages are not in the main bundle (routes use the same modules → shared async chunks). */
const SecondaryProfilePageLazy = lazy(() => import('@/pages/secondary/ProfilePage'))
@ -2160,6 +2160,48 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2160,6 +2160,48 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
restorePrimaryTabAfterSecondaryClose()
}
/** Logout / session clear: drop note overlays and replace the current URL (e.g. `/feed/notes/…`) with `/`. */
const resetToLandingPage = () => {
ignorePopStateRef.current = true
pendingDrawerCloseUrlRef.current = '/'
setSavedPrimaryPage(null)
savedPrimaryPagePropsRef.current = undefined
setPrimaryNoteViewState(null)
setPrimaryViewType(null)
noteStatsService.setBackgroundStatsPaused(false)
if (drawerOpenRef.current) {
setDrawerOpen(false)
}
setSinglePaneSheetOpen(false)
secondaryStackRef.current = []
setSecondaryStack([])
setPrimaryPages((prev) => {
if (prev.some((p) => p.name === 'feed')) return prev
return [...prev, { name: 'feed', element: getPrimaryPageMap().feed }]
})
setCurrentPrimaryPage('feed')
window.history.replaceState(null, '', '/')
window.setTimeout(() => {
setDrawerNoteId(null)
setDrawerInitialEvent(null)
pendingDrawerCloseUrlRef.current = null
}, 400)
}
const resetToLandingPageStable = useEventCallback(resetToLandingPage)
useEffect(() => {
const onReset = () => resetToLandingPageStable()
window.addEventListener(APP_RESET_TO_LANDING_EVENT, onReset)
return () => window.removeEventListener(APP_RESET_TO_LANDING_EVENT, onReset)
}, [resetToLandingPageStable])
let lastPopSecondaryPageAt = 0
const POP_SECONDARY_PAGE_DEBOUNCE_MS = 400
@ -2414,7 +2456,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2414,7 +2456,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<TooManyRelaysAlertDialogLazy />
</Suspense>
<Suspense fallback={null}>
<CreateWalletGuideToastLazy />
<PostSignupBackupRedirectLazy />
</Suspense>
</NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider>
@ -2549,7 +2591,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2549,7 +2591,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
<TooManyRelaysAlertDialogLazy />
</Suspense>
<Suspense fallback={null}>
<CreateWalletGuideToastLazy />
<PostSignupBackupRedirectLazy />
</Suspense>
</NoteDrawerContext.Provider>
</PrimaryNoteViewContext.Provider>

85
src/components/AccountManager/GenerateNewAccount.tsx

@ -1,85 +0,0 @@ @@ -1,85 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { Check, Copy, RefreshCcw } from 'lucide-react'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function GenerateNewAccount({
back,
onLoginSuccess
}: {
back: () => void
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { nsecLogin } = useNostr()
const [nsec, setNsec] = useState(generateNsec())
const [copied, setCopied] = useState(false)
const [password, setPassword] = useState('')
const handleLogin = () => {
nsecLogin(nsec, password, true).then(() => onLoginSuccess())
}
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
handleLogin()
}}
>
<div className="text-orange-400">
{t(
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.'
)}
</div>
<div className="grid gap-2">
<Label>nsec</Label>
<div className="flex gap-2">
<Input value={nsec} />
<Button type="button" variant="secondary" onClick={() => setNsec(generateNsec())}>
<RefreshCcw />
</Button>
<Button
type="button"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="password-input">{t('password')}</Label>
<Input
id="password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button className="w-fit px-8" variant="secondary" type="button" onClick={back}>
{t('Back')}
</Button>
<Button className="flex-1" type="submit">
{t('Login')}
</Button>
</div>
</form>
)
}
function generateNsec() {
const sk = generateSecretKey()
return nsecEncode(sk)
}

83
src/components/AccountManager/index.tsx

@ -1,17 +1,19 @@ @@ -1,17 +1,19 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { useNostr } from '@/providers/NostrProvider'
import { useTheme } from '@/providers/ThemeProvider'
import { NstartModal } from 'nstart-modal'
import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import AccountList from '../AccountList'
import GenerateNewAccount from './GenerateNewAccount'
import NostrConnectLogin from './NostrConnectionLogin'
import NpubLogin from './NpubLogin'
import PrivateKeyLogin from './PrivateKeyLogin'
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
type TAccountManagerPage = 'nsec' | 'bunker' | 'npub' | null
export default function AccountManager({ close }: { close?: () => void }) {
const [page, setPage] = useState<TAccountManagerPage>(null)
@ -22,8 +24,6 @@ export default function AccountManager({ close }: { close?: () => void }) { @@ -22,8 +24,6 @@ export default function AccountManager({ close }: { close?: () => void }) {
<PrivateKeyLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'bunker' ? (
<NostrConnectLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'generate' ? (
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : page === 'npub' ? (
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
) : (
@ -40,9 +40,24 @@ function AccountManagerNav({ @@ -40,9 +40,24 @@ function AccountManagerNav({
setPage: (page: TAccountManagerPage) => void
close?: () => void
}) {
const { t, i18n } = useTranslation()
const { themeSetting } = useTheme()
const { nip07Login, bunkerLogin, nsecLogin, ncryptsecLogin, accounts } = useNostr()
const { t } = useTranslation()
const { nip07Login, nsecLogin, accounts } = useNostr()
const [password, setPassword] = useState('')
const [signingUp, setSigningUp] = useState(false)
const handleSignUp = async () => {
setSigningUp(true)
try {
const nsec = nsecEncode(generateSecretKey())
await nsecLogin(nsec, password.trim() || undefined, true)
setPassword('')
close?.()
} catch (error) {
toast.error(t('Login failed') + ': ' + ((error as Error).message ?? String(error)))
} finally {
setSigningUp(false)
}
}
return (
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-8">
@ -72,38 +87,24 @@ function AccountManagerNav({ @@ -72,38 +87,24 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold">
{t("Don't have an account yet?")}
</div>
<Button
onClick={() => {
const wizard = new NstartModal({
baseUrl: 'https://nstart.me',
an: 'Imwald',
am: themeSetting,
al: i18n.language.slice(0, 2),
onComplete: ({ nostrLogin }) => {
if (!nostrLogin) return
if (nostrLogin.startsWith('bunker://')) {
bunkerLogin(nostrLogin)
} else if (nostrLogin.startsWith('ncryptsec')) {
ncryptsecLogin(nostrLogin)
} else if (nostrLogin.startsWith('nsec')) {
nsecLogin(nostrLogin)
}
}
})
close?.()
wizard.open()
}}
className="w-full mt-4"
>
{t('Sign up')}
</Button>
<Button
variant="link"
onClick={() => setPage('generate')}
className="w-full text-muted-foreground py-0 h-fit mt-1"
>
{t('or simply generate a private key')}
<p className="text-center text-muted-foreground text-xs mt-2 px-2">
{t(
'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.'
)}
</p>
<div className="grid gap-2 mt-3">
<Label htmlFor="signup-password-input">{t('password')}</Label>
<Input
id="signup-password-input"
type="password"
placeholder={t('optional: encrypt nsec')}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={signingUp}
/>
</div>
<Button onClick={handleSignUp} disabled={signingUp} className="w-full mt-4">
{signingUp ? t('Signing up…') : t('Sign up')}
</Button>
</div>
{accounts.length > 0 && (

12
src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx

@ -176,7 +176,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -176,7 +176,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button>
</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>
{LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => (
<DropdownMenuItem key={type} onSelect={() => openCitationPicker(type)}>
@ -239,7 +239,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -239,7 +239,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button>
</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>
{(
[
@ -573,7 +573,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -573,7 +573,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button>
</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>
<DropdownMenuSeparator />
<DropdownMenuGroup>
@ -733,7 +733,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -733,7 +733,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button>
</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>
<DropdownMenuItem
onSelect={() =>
@ -1070,7 +1070,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -1070,7 +1070,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button>
</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>
<DropdownMenuItem
onSelect={() =>
@ -1212,7 +1212,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -1212,7 +1212,7 @@ export function AdvancedEventLabMarkupToolbar({
<ChevronDown className="hidden h-3 w-3 shrink-0 opacity-60 md:inline-block" />
</Button>
</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>
<DropdownMenuItem
onSelect={() => run((v) => labInsertSnippet(v, sliceRef, 'stem:[', 'x^2 + y^2', ']'))}

83
src/components/BookmarkButton/index.tsx

@ -1,83 +0,0 @@ @@ -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>
)
}

40
src/components/ConnectedRelays/ActiveRelaysDropdownSection.tsx

@ -1,34 +1,14 @@ @@ -1,34 +1,14 @@
import { useSmartRelayNavigation } from '@/PageManager'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import { ActiveRelaysIconGrid } from './ActiveRelaysIconGrid'
function rowMuted(connected: boolean) {
return !connected
}
function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}
function rowClass(connected: boolean) {
return cn(rowMuted(connected) && 'opacity-45 text-muted-foreground')
}
/** Relay list block for account (or similar) dropdown menus. */
/** Compact active-relay icons in the account (user badge) dropdown. */
export function ActiveRelaysDropdownSection() {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows, connectedCount } = useRelayConnectionRows()
if (rows.length === 0) return null
@ -42,17 +22,13 @@ export function ActiveRelaysDropdownSection() { @@ -42,17 +22,13 @@ export function ActiveRelaysDropdownSection() {
<span>{t('Active relays')}</span>
<span className="tabular-nums text-muted-foreground">{countSummary}</span>
</DropdownMenuLabel>
{rows.map(({ url, connected }) => (
<DropdownMenuItem
key={url}
title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
className={cn('min-w-52 gap-2', rowClass(connected))}
<div
className="px-2 pb-2"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<RelayIcon url={url} />
{simplifyUrl(url)}
</DropdownMenuItem>
))}
<ActiveRelaysIconGrid />
</div>
</>
)
}

106
src/components/ConnectedRelays/ActiveRelaysIconGrid.tsx

@ -0,0 +1,106 @@ @@ -0,0 +1,106 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import {
ACTIVE_RELAYS_MAX_ICONS,
activeRelayRowMuted,
activeRelayRowTitle
} from './active-relays-display'
/**
* Compact relay status: icon buttons only (no hostname labels).
*/
export function ActiveRelaysIconGrid({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows } = useRelayConnectionRows()
const shown = rows.slice(0, ACTIVE_RELAYS_MAX_ICONS)
const overflowRows = rows.slice(ACTIVE_RELAYS_MAX_ICONS)
const overflow = overflowRows.length
if (rows.length === 0) {
return (
<p className={cn('text-xs text-muted-foreground', className)} title={t('Active relays')}>
</p>
)
}
return (
<div className={cn('flex flex-wrap gap-1', className)} title={t('Active relays')}>
{shown.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
{overflow > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 min-h-7 min-w-7 shrink-0 rounded-full bg-muted px-1.5 py-0 text-[0.65rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground"
title={t('More relays', { count: overflow })}
aria-label={t('More relays', { count: overflow })}
>
+{overflow}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
className="w-auto max-w-[min(18rem,calc(100vw-1.5rem))] p-2"
>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground py-1">
{t('More relays', { count: overflow })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="flex flex-wrap gap-1 max-w-[16rem]">
{overflowRows.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80',
activeRelayRowMuted(connected) && 'opacity-40 grayscale'
)}
title={activeRelayRowTitle(url, connected, t)}
aria-label={activeRelayRowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)
}

113
src/components/ConnectedRelays/ConnectedRelaysSidebarStrip.tsx

@ -1,113 +0,0 @@ @@ -1,113 +0,0 @@
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
const MAX_ICONS = 14
function rowMuted(connected: boolean) {
return !connected
}
function rowMenuClass(connected: boolean) {
return cn(rowMuted(connected) && 'opacity-50 text-muted-foreground')
}
function rowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}
/**
* Desktop sidebar: relay avatars for relays with an open WebSocket in the pool.
*/
export function ConnectedRelaysSidebarStrip({ className }: { className?: string }) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const { rows } = useRelayConnectionRows()
const shown = rows.slice(0, MAX_ICONS)
const overflowRows = rows.slice(MAX_ICONS)
const overflow = overflowRows.length
if (rows.length === 0) {
return (
<div className={cn('px-1 py-1.5 xl:px-0', className)} title={t('Active relays')}>
<p className="text-center text-[0.6rem] font-medium text-muted-foreground xl:text-left">{t('Active relays')}</p>
<p className="mt-0.5 text-center text-[0.55rem] text-muted-foreground/80 xl:text-left"></p>
</div>
)
}
return (
<div className={cn('px-1 py-2 xl:px-0', className)} title={t('Active relays')}>
<p className="mb-1.5 text-center text-[0.65rem] font-medium leading-snug text-foreground xl:text-left">
{t('Active relays')}
</p>
<div className="flex flex-wrap justify-center gap-1 xl:justify-start">
{shown.map(({ url, connected }) => (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className={cn(
'h-5 w-5 min-h-5 min-w-5 shrink-0 rounded-full p-0 hover:bg-muted/80',
rowMuted(connected) && 'opacity-40 grayscale'
)}
title={rowTitle(url, connected, t)}
aria-label={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-5 w-5" iconSize={11} />
</Button>
))}
{overflow > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 min-h-5 min-w-5 shrink-0 rounded-full bg-muted px-1 py-0 text-[0.6rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground"
title={t('More relays', { count: overflow })}
aria-label={t('More relays', { count: overflow })}
>
+{overflow}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="max-h-[min(70vh,24rem)] w-72 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{t('More relays', { count: overflow })}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{overflowRows.map(({ url, connected }) => (
<DropdownMenuItem
key={url}
className={cn('min-w-0 gap-2', rowMenuClass(connected))}
title={rowTitle(url, connected, t)}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-5 w-5 shrink-0" iconSize={11} />
<span className="truncate">{simplifyUrl(url)}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
)
}

13
src/components/ConnectedRelays/active-relays-display.ts

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
import { simplifyUrl } from '@/lib/url'
export const ACTIVE_RELAYS_MAX_ICONS = 14
export function activeRelayRowMuted(connected: boolean) {
return !connected
}
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) {
const base = simplifyUrl(url)
if (!connected) return `${base}${t('Not connected')}`
return base
}

31
src/components/CreateWalletGuideToast/index.tsx

@ -1,31 +0,0 @@ @@ -1,31 +0,0 @@
import storage from '@/services/local-storage.service'
import { toWallet } from '@/lib/link'
import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export default function CreateWalletGuideToast() {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { profile } = useNostr()
useEffect(() => {
if (
profile &&
!profile.lightningAddress &&
!storage.hasShownCreateWalletGuideToast(profile.pubkey)
) {
toast(t('Set up your wallet to send and receive sats!'), {
action: {
label: t('Set up'),
onClick: () => push(toWallet())
}
})
storage.markCreateWalletGuideToastAsShown(profile.pubkey)
}
}, [profile])
return null
}

26
src/components/Embedded/EmbeddedNote.tsx

@ -5,7 +5,10 @@ import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' @@ -5,7 +5,10 @@ import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { isCalendarEventKind } from '@/lib/calendar-event'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import {
shouldDropEventOnIngest,
type ShouldDropEventOnIngestOptions
} from '@/lib/event-ingest-filter'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import client, { eventService } from '@/services/client.service'
@ -19,7 +22,9 @@ import { @@ -19,7 +22,9 @@ import {
syncViewerRelayStackNostrLandAggrEligible,
urlsForViewerNostrLandAggrEligibilitySync
} from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import {
sanitizeRelayUrlsForFetch
} from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
@ -73,6 +78,12 @@ function coordinateFromNoteId(noteId: string): string | null { @@ -73,6 +78,12 @@ function coordinateFromNoteId(noteId: string): string | null {
}
}
/** Match {@link EventService.fetchEvent} / wide-relay ingest: explicit id lookup relaxes kind-1 spam filters. */
function embedIngestOptsForNoteKey(noteKey: string): ShouldDropEventOnIngestOptions | undefined {
const hex = hexEventIdFromNoteId(noteKey)
return hex ? { explicitNoteLookupHexId: hex } : undefined
}
/** True if `fetchEventWithExternalRelays(noteId, …)` can build a REQ filter (hex, note, nevent, naddr). */
function canSearchOnExternalRelays(noteId: string): boolean {
if (hexEventIdFromNoteId(noteId)) return true
@ -221,6 +232,8 @@ function EmbeddedNoteFetched({ @@ -221,6 +232,8 @@ function EmbeddedNoteFetched({
const [isFetching, setIsFetching] = useState(true)
const eventRef = useRef<Event | undefined>(undefined)
const retryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const embedNoteKeyRef = useRef(noteId.trim())
embedNoteKeyRef.current = noteId.trim()
eventRef.current = event
const relayHintsFromParent = useMemo(
@ -259,12 +272,13 @@ function EmbeddedNoteFetched({ @@ -259,12 +272,13 @@ function EmbeddedNoteFetched({
const resolveAndSet = useCallback(
(ev: Event | undefined) => {
if (!ev || isEventDeleted(ev) || shouldDropEventOnIngest(ev)) return false
const ingestOpts = embedIngestOptsForNoteKey(embedNoteKeyRef.current)
if (!ev || isEventDeleted(ev) || shouldDropEventOnIngest(ev, ingestOpts)) return false
if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current)
retryIntervalRef.current = null
}
client.addEventToCache(ev)
client.addEventToCache(ev, ingestOpts)
setEvent(ev)
addReplies([ev])
return true
@ -291,10 +305,12 @@ function EmbeddedNoteFetched({ @@ -291,10 +305,12 @@ function EmbeddedNoteFetched({
useEffect(() => {
let cancelled = false
const noteKey = noteId.trim()
embedNoteKeyRef.current = noteKey
eventRef.current = undefined
setEvent(undefined)
setIsFetching(true)
const ingestOpts = embedIngestOptsForNoteKey(noteKey)
const resolve = (ev: Event | undefined) => resolveAndSetRef.current(ev)
const tryShortcuts = (): boolean => {
@ -315,7 +331,7 @@ function EmbeddedNoteFetched({ @@ -315,7 +331,7 @@ function EmbeddedNoteFetched({
const { fetchRelayOpts: opts, wideRelaysStatic: wideUrls } = embedFetchCtxRef.current
const hex = hexEventIdFromNoteId(noteKey)
const isUsable = (e: Event) =>
!isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e)
!isEventDeletedRef.current(e) && !shouldDropEventOnIngest(e, ingestOpts)
try {
const chosen = await firstResolvedUsableEmbedEvent(
[

45
src/components/FeedRelaysIconRow/index.tsx

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
import RelayIcon from '@/components/RelayIcon'
import { Button } from '@/components/ui/button'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useSmartRelayNavigation } from '@/PageManager'
import { useTranslation } from 'react-i18next'
export function FeedRelaysIconRow({
urls,
className
}: {
urls: readonly string[]
className?: string
}) {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
if (urls.length === 0) return null
return (
<div
className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}
role="group"
aria-label={t('Feed relays', { defaultValue: 'Relays in this feed' })}
>
{urls.map((url) => {
const label = simplifyUrl(url)
return (
<Button
key={url}
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80"
title={label}
aria-label={t('Open relay feed', { relay: label, defaultValue: `Open ${label} feed` })}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />
</Button>
)
})}
</div>
)
}

88
src/components/GifPicker/index.tsx

@ -10,14 +10,14 @@ import { Label } from '@/components/ui/label' @@ -10,14 +10,14 @@ import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserReadInboxUrls, useUserWriteOutboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useUserReadInboxUrls } from '@/hooks/useUserMailboxRelayUrls'
import { useNostr } from '@/providers/NostrProvider'
import { ExtendedKind, FAST_WRITE_RELAY_URLS, GIF_RELAY_URLS } from '@/constants'
import { ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url'
import {
fetchGifs,
getAllCachedGifsForSearch,
getGif1063RelayUrls,
gifMetadataMatchesSearch,
gifShouldOfferNip94Archive,
buildKind1063GifPublishDraft,
@ -28,12 +28,15 @@ import mediaUpload from '@/services/media-upload.service' @@ -28,12 +28,15 @@ import mediaUpload from '@/services/media-upload.service'
import { Download, ExternalLink, X } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useFollowListOptional } from '@/providers/follow-list-context'
/** In-session cache: survives Drawer/Dropdown open↔close without a relay re-fetch. */
let _sessionGifs: GifMetadata[] = []
import { useTranslation } from 'react-i18next'
const GIFBUDDY_URL = 'https://www.gifbuddy.lol/'
/** Stable empty follows list — avoids re-running picker fetch every render. */
const EMPTY_FOLLOWING_PUBKEYS: readonly string[] = []
/** Query param gifbuddy may use for pre-filled search (common convention). */
const GIFBUDDY_SEARCH_URL = (q: string) =>
q.trim() ? `${GIFBUDDY_URL}gifsearch?q=${encodeURIComponent(q.trim())}` : GIFBUDDY_URL
@ -51,6 +54,12 @@ export default function GifPicker({ @@ -51,6 +54,12 @@ export default function GifPicker({
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { publish, pubkey } = useNostr()
const followList = useFollowListOptional()
const followingPubkeys = useMemo(
() => followList?.followings ?? EMPTY_FOLLOWING_PUBKEYS,
[followList?.followings]
)
const loadGenerationRef = useRef(0)
const [open, setOpen] = useState(false)
const [searchInput, setSearchInput] = useState('')
// Initialise from the module-level session cache so re-opens are instant
@ -71,36 +80,9 @@ export default function GifPicker({ @@ -71,36 +80,9 @@ export default function GifPicker({
const gifbuddyPopupRef = useRef<Window | null>(null)
const userReadRelays = useUserReadInboxUrls()
const userWriteRelays = useUserWriteOutboxUrls()
/** Paste / upload: GIF discovery relays + user writes (unchanged). */
const gifPublishRelayUrls = useMemo(() => {
const writeUrls = [...GIF_RELAY_URLS, ...userWriteRelays]
const seen = new Set<string>()
return writeUrls.filter((u) => {
const n = (normalizeUrl(u) ?? u).toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Grid pick / archive: user write relays first, then fast write relays as fallback. */
const gifSelectPublishRelayUrls = useMemo(() => {
const primary =
userWriteRelays.length > 0 ? userWriteRelays : [...FAST_WRITE_RELAY_URLS]
const extra = userWriteRelays.length > 0 ? FAST_WRITE_RELAY_URLS : []
const seen = new Set<string>()
return [...primary, ...extra]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.filter((u) => {
const n = u.toLowerCase()
if (seen.has(n)) return false
seen.add(n)
return true
})
}, [userWriteRelays])
/** Kind 1063 publish targets — GIF relays only. */
const gif1063PublishRelayUrls = useMemo(() => getGif1063RelayUrls(), [])
/** Keep gifsRef, session cache, and React state in sync. */
const setGifs = useCallback((newGifs: GifMetadata[], isSearch = false) => {
@ -116,25 +98,27 @@ export default function GifPicker({ @@ -116,25 +98,27 @@ export default function GifPicker({
const pool = gifPoolRef.current
const filtered = trimmed
? pool.filter((g) => gifMetadataMatchesSearch(g, trimmed))
: pool.slice(0, 50)
: pool
setGifs(filtered, trimmed.length > 0)
},
[setGifs]
)
const refreshGifPoolFromIdb = useCallback(async () => {
const pool = await getAllCachedGifsForSearch(pubkey ?? null)
const pool = await getAllCachedGifsForSearch(pubkey ?? null, followingPubkeys)
gifPoolRef.current = pool
return pool
}, [pubkey])
}, [pubkey, followingPubkeys])
const loadGifs = useCallback(
async (forceRefresh = false) => {
const generation = ++loadGenerationRef.current
setError(null)
if (gifPoolRef.current.length === 0) {
try {
const cached = await refreshGifPoolFromIdb()
if (generation !== loadGenerationRef.current) return
if (cached.length > 0) {
applyLocalFilter(searchInputRef.current)
}
@ -145,10 +129,17 @@ export default function GifPicker({ @@ -145,10 +129,17 @@ export default function GifPicker({
}
try {
await fetchGifs(50, forceRefresh, userReadRelays, pubkey ?? null)
await fetchGifs({
forceRefresh: forceRefresh || Boolean(pubkey),
userPubkey: pubkey ?? null,
followingPubkeys,
noteFallbackRelays: userReadRelays
})
if (generation !== loadGenerationRef.current) return
await refreshGifPoolFromIdb()
if (generation !== loadGenerationRef.current) return
applyLocalFilter(searchInputRef.current)
if (gifPoolRef.current.length === 0 && !searchInput.trim()) {
if (gifPoolRef.current.length === 0 && !searchInputRef.current.trim()) {
setError(
t(
'No GIFs found. Try searching or add your own. GIFs come from Nostr kind 1063 (NIP-94) events on GIF relays.'
@ -156,13 +147,14 @@ export default function GifPicker({ @@ -156,13 +147,14 @@ export default function GifPicker({
)
}
} catch (e) {
if (generation !== loadGenerationRef.current) return
setError(e instanceof Error ? e.message : 'Failed to load GIFs')
if (gifPoolRef.current.length === 0) setGifsState([])
} finally {
setLoading(false)
if (generation === loadGenerationRef.current) setLoading(false)
}
},
[t, userReadRelays, pubkey, applyLocalFilter, refreshGifPoolFromIdb]
[t, userReadRelays, pubkey, followingPubkeys, applyLocalFilter, refreshGifPoolFromIdb]
)
useEffect(() => {
@ -185,11 +177,11 @@ export default function GifPicker({ @@ -185,11 +177,11 @@ export default function GifPicker({
if (!pubkey || !/^https?:\/\//i.test(url)) return
// Fire-and-forget: waiting on every relay can freeze the UI when relays are down.
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls
specifiedRelayUrls: gif1063PublishRelayUrls
}).catch(() => {})
if (desc) setPublishDescription('')
},
[pubkey, onSelect, publish, gifSelectPublishRelayUrls, publishDescription]
[pubkey, onSelect, publish, gif1063PublishRelayUrls, publishDescription]
)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@ -217,7 +209,7 @@ export default function GifPicker({ @@ -217,7 +209,7 @@ export default function GifPicker({
tags,
created_at: Math.floor(Date.now() / 1000)
}
await publish(draft, { specifiedRelayUrls: gifPublishRelayUrls })
await publish(draft, { specifiedRelayUrls: gif1063PublishRelayUrls })
setPublishDescription('')
setSearchInput('')
await loadGifs(true)
@ -273,7 +265,7 @@ export default function GifPicker({ @@ -273,7 +265,7 @@ export default function GifPicker({
setPublishingPaste(true)
try {
await publish(buildKind1063GifPublishDraft(url, descriptionForPublish), {
specifiedRelayUrls: gifPublishRelayUrls
specifiedRelayUrls: gif1063PublishRelayUrls
})
setPublishDescription('')
} catch {
@ -282,7 +274,7 @@ export default function GifPicker({ @@ -282,7 +274,7 @@ export default function GifPicker({
setPublishingPaste(false)
}
}
}, [pasteUrl, pubkey, onSelect, publish, gifPublishRelayUrls, descriptionForPublish])
}, [pasteUrl, pubkey, onSelect, publish, gif1063PublishRelayUrls, descriptionForPublish])
/** External GIF from a note: publish kind 1063, then insert URL and close (same relays as grid pick). */
const handleArchiveAndInsert = useCallback(
@ -298,7 +290,7 @@ export default function GifPicker({ @@ -298,7 +290,7 @@ export default function GifPicker({
setOpen(false)
void loadGifs(true)
void publish(buildKind1063GifPublishDraft(url, desc), {
specifiedRelayUrls: gifSelectPublishRelayUrls
specifiedRelayUrls: gif1063PublishRelayUrls
})
.catch(() => {})
.finally(() => {
@ -306,7 +298,7 @@ export default function GifPicker({ @@ -306,7 +298,7 @@ export default function GifPicker({
if (desc) setPublishDescription('')
})
},
[pubkey, publish, gifSelectPublishRelayUrls, onSelect, loadGifs, publishDescription]
[pubkey, publish, gif1063PublishRelayUrls, onSelect, loadGifs, publishDescription]
)
const gifSourceKindTitle = useCallback(
@ -372,8 +364,8 @@ export default function GifPicker({ @@ -372,8 +364,8 @@ export default function GifPicker({
<ScrollArea
className={
isDrawer
? 'flex-1 min-h-[200px] w-full rounded-md border'
: 'h-[280px] w-full rounded-md border'
? 'flex-1 min-h-[420px] w-full rounded-md border'
: 'h-[520px] w-full rounded-md border'
}
>
{loading ? (

26
src/components/HelpAndAccountMenu.tsx

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import LoginDialog from '@/components/LoginDialog'
import LogoutDialog from '@/components/LogoutDialog'
import SidebarItem from '@/components/Sidebar/SidebarItem'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@ -27,20 +27,18 @@ import { useCallback, useMemo, useState, type ReactNode } from 'react' @@ -27,20 +27,18 @@ 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'
function AccountDropdownItems({
onSwitchAccount,
onLogoutClick,
onBrowseCache,
showActiveRelays = false
onBrowseCache
}: {
onSwitchAccount: () => void
onLogoutClick: () => void
onBrowseCache: () => void
showActiveRelays?: boolean
}) {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
@ -59,7 +57,7 @@ function AccountDropdownItems({ @@ -59,7 +57,7 @@ function AccountDropdownItems({
<Database className="size-4" />
{t('Browse Cache')}
</DropdownMenuItem>
{showActiveRelays ? <ActiveRelaysDropdownSection /> : null}
<ActiveRelaysDropdownSection />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" />
@ -117,9 +115,9 @@ function SidebarAccountMenu({ @@ -117,9 +115,9 @@ function SidebarAccountMenu({
</div>
) : (
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} alt="" />
<AvatarImage src={avatar || defaultAvatar} className="object-cover object-center" />
<AvatarFallback delayMs={0}>
<AvatarIdenticon src={defaultAvatar} />
</AvatarFallback>
</Avatar>
)}
@ -175,9 +173,12 @@ function TitlebarAccountMenu({ @@ -175,9 +173,12 @@ function TitlebarAccountMenu({
</div>
) : (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}>
<AvatarImage src={resolvedProfile.avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt="" />
<AvatarImage
src={resolvedProfile.avatar || defaultAvatar}
className="object-cover object-center"
/>
<AvatarFallback delayMs={0}>
<AvatarIdenticon src={defaultAvatar} />
</AvatarFallback>
</Avatar>
)
@ -191,7 +192,6 @@ function TitlebarAccountMenu({ @@ -191,7 +192,6 @@ function TitlebarAccountMenu({
onSwitchAccount={onSwitchAccount}
onLogoutClick={onLogoutClick}
onBrowseCache={onBrowseCache}
showActiveRelays
/>
</DropdownMenuContent>
</DropdownMenu>

2
src/components/KindFilter/index.tsx

@ -379,7 +379,7 @@ export default function KindFilter({ @@ -379,7 +379,7 @@ export default function KindFilter({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<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 }}
side="bottom"
align="end"

13
src/components/MailboxSetting/RelayCountWarning.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { MAILBOX_RELAY_COUNT_WARNING_THRESHOLD } from '@/constants'
import { TMailboxRelay } from '@/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -11,8 +12,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] @@ -11,8 +12,8 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
const writeRelayCount = useMemo(() => {
return relays.filter((r) => r.scope !== 'read').length
}, [relays])
const showReadWarning = readRelayCount > 4
const showWriteWarning = writeRelayCount > 4
const showReadWarning = readRelayCount >= MAILBOX_RELAY_COUNT_WARNING_THRESHOLD
const showWriteWarning = writeRelayCount >= MAILBOX_RELAY_COUNT_WARNING_THRESHOLD
if (!showReadWarning && !showWriteWarning) {
return null
@ -24,12 +25,12 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[] @@ -24,12 +25,12 @@ export default function RelayCountWarning({ relays }: { relays: TMailboxRelay[]
content={
showReadWarning
? t(
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.',
{ count: readRelayCount }
'You have {{count}} read relays. Most clients only use up to {{limit}} relays, setting more is unnecessary.',
{ count: readRelayCount, limit: MAILBOX_RELAY_COUNT_WARNING_THRESHOLD }
)
: t(
'You have {{count}} write relays. Most clients only use 2-4 relays, setting more is unnecessary.',
{ count: writeRelayCount }
'You have {{count}} write relays. Most clients only use up to {{limit}} relays, setting more is unnecessary.',
{ count: writeRelayCount, limit: MAILBOX_RELAY_COUNT_WARNING_THRESHOLD }
)
}
/>

40
src/components/MetadataRelaysOnlySetting/index.tsx

@ -1,40 +0,0 @@ @@ -1,40 +0,0 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { setRestrictConnectionsToMetadataRelaysOnly } from '@/lib/read-only-relay-personal'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function MetadataRelaysOnlySetting() {
const { t } = useTranslation()
const [enabled, setEnabled] = useState(false)
useEffect(() => {
const on = storage.getRestrictRelaysToMetadataLists()
setEnabled(on)
setRestrictConnectionsToMetadataRelaysOnly(on)
}, [])
const onChange = (checked: boolean) => {
setEnabled(checked)
storage.setRestrictRelaysToMetadataLists(checked)
setRestrictConnectionsToMetadataRelaysOnly(checked)
client.interruptBackgroundQueries({ closePooledRelayConnections: true })
client.closeMetadataPolicyDisallowedRelayConnections()
}
return (
<div className="space-y-2 rounded-lg border border-border p-4">
<div className="flex items-center space-x-2">
<Label htmlFor="metadata-relays-only">{t('Only my relay lists')}</Label>
<Switch id="metadata-relays-only" checked={enabled} onCheckedChange={onChange} />
</div>
<div className="text-muted-foreground text-xs max-w-xl">
{t(
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.'
)}
</div>
</div>
)
}

54
src/components/Nip05AffiliationBadges/index.tsx

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
import { useFetchProfile } from '@/hooks'
import { useVerifiedNip05Affiliations } from '@/hooks/useVerifiedNip05Affiliations'
import { userIdToPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
export default function Nip05AffiliationBadges({
userId,
pubkey: pubkeyProp,
nip05: nip05Prop,
nip05List: nip05ListProp,
className
}: {
/** Hex or npub — loads kind 0 for NIP-05 when `nip05` / `nip05List` omitted. */
userId?: string
pubkey?: string
nip05?: string
nip05List?: string[]
className?: string
}) {
const { t } = useTranslation()
const pubkey = pubkeyProp ?? (userId ? userIdToPubkey(userId) : '')
const { profile } = useFetchProfile(
nip05Prop === undefined && nip05ListProp === undefined && pubkey ? pubkey : undefined
)
const nip05 = nip05Prop ?? profile?.nip05
const nip05List = nip05ListProp ?? profile?.nip05List
const affiliations = useVerifiedNip05Affiliations(pubkey, nip05, nip05List)
if (affiliations.length === 0) return null
return (
<span
className={cn('inline-flex shrink-0 items-center gap-0.5', className)}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{affiliations.map((aff) => {
const label = aff.label ?? aff.domain
return (
<span
key={aff.domain}
role="img"
aria-label={t('Verified NIP-05 affiliation', { domain: label })}
title={t('Verified NIP-05 affiliation', { domain: label })}
className="inline-flex size-[1.05em] items-center justify-center text-sm leading-none select-none grayscale contrast-125 opacity-90"
>
{aff.emoji}
</span>
)
})}
</span>
)
}

2
src/components/Nip05DomainPanel/ProfileListByNip05Domain.tsx

@ -66,7 +66,7 @@ export default function ProfileListByNip05Domain({ domain }: { domain: string }) @@ -66,7 +66,7 @@ export default function ProfileListByNip05Domain({ domain }: { domain: string })
<div className="px-4 pt-2">
{visible.map(({ name, pubkey }) => (
<div
key={pubkey}
key={`${pubkey}:${name}`}
className="flex min-w-0 items-center gap-2 border-b border-border/40 py-1 last:border-0"
>
{name && name !== '_' ? (

4
src/components/NormalFeed/index.tsx

@ -100,6 +100,8 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -100,6 +100,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
hostPrimaryPageName?: TPrimaryPageName
/** Single-relay kindless wave EOSEd with no events: parent re-subscribes with explicit kinds. */
onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSEd empty — parent widens to kindless `{ limit }` once. */
onSingleRelayBrowseEmpty?: () => void
/** Shown above the feed list (e.g. after kindless→kinds fallback on a single-relay chip). */
feedTopNotice?: ReactNode
/** Passed through to {@link NoteList} (d-tag browse one-shot). */
@ -151,6 +153,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -151,6 +153,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
showFeedClientFilter: showFeedClientFilterProp,
hostPrimaryPageName,
onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice,
oneShotFetch = false,
progressiveWarmupQuery,
@ -412,6 +415,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -412,6 +415,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
hostPrimaryPageName={hostPrimaryPageName}
feedClientFilterTabRowHost={mergeFilterWithTabsRow ? feedFilterTabRowHost : undefined}
onSingleRelayKindlessEmpty={onSingleRelayKindlessEmpty}
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
feedTopNotice={feedTopNotice}
oneShotFetch={oneShotFetch}
progressiveWarmupQuery={progressiveWarmupQuery}

50
src/components/Note/index.tsx

@ -46,11 +46,10 @@ import { CreateHighlightContext } from './CreateHighlightContext' @@ -46,11 +46,10 @@ import { CreateHighlightContext } from './CreateHighlightContext'
import SelectionHighlightTrigger from './SelectionHighlightTrigger'
import AudioPlayer from '../AudioPlayer'
import WebPreview from '../WebPreview'
import ClientTag from '../ClientTag'
import EventPowLabel from '../EventPowLabel'
import NoteAuthorMetaLine from '../NoteAuthorMetaLine'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import EventPowLabel from '../EventPowLabel'
import ParentNotePreview from '../ParentNotePreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@ -683,7 +682,6 @@ export default function Note({ @@ -683,7 +682,6 @@ export default function Note({
className={`max-w-[min(12rem,40vw)] shrink font-semibold truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{t(notificationReactionSummaryKey(reactionDisplay))}
</span>
@ -718,7 +716,6 @@ export default function Note({ @@ -718,7 +716,6 @@ export default function Note({
>
{t('Imwald synthetic event')}
</span>
<ClientTag event={event} />
</div>
</div>
</>
@ -730,45 +727,18 @@ export default function Note({ @@ -730,45 +727,18 @@ export default function Note({
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={deferAuthorAvatar}
/>
{showFull ? (
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username
<NoteAuthorMetaLine
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
<EventPowLabel event={event} />
</div>
</div>
) : (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden">
<Username
userId={event.pubkey}
className={`max-w-[min(12rem,40vw)] shrink font-semibold truncate ${size === 'small' ? 'text-sm' : ''}`}
powEvent={event}
usernameClassName={
size === 'small'
? 'max-w-[min(12rem,40vw)] text-sm'
: 'max-w-[min(16rem,50vw)]'
}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
timestampShort={isSmallScreen}
/>
<ClientTag event={event} />
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
<EventPowLabel event={event} />
</span>
</div>
)}
</>
)}
</div>

38
src/components/NoteAuthorMetaLine/index.tsx

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
import Nip05AffiliationBadges from '@/components/Nip05AffiliationBadges'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import EventPowLabel from '@/components/EventPowLabel'
import Username from '@/components/Username'
import { cn } from '@/lib/utils'
import type { Event } from 'nostr-tools'
/** Username, relative time, verified NIP-05 affiliation badges, optional PoW — one header row. */
export default function NoteAuthorMetaLine({
userId,
timestamp,
powEvent,
usernameClassName,
skeletonClassName,
timestampShort = false
}: {
userId: string
timestamp: number
powEvent?: Event
usernameClassName?: string
skeletonClassName?: string
timestampShort?: boolean
}) {
return (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden">
<Username
userId={userId}
className={cn('shrink font-semibold truncate', usernameClassName)}
skeletonClassName={skeletonClassName}
/>
<span className="inline-flex min-w-0 shrink-0 items-center gap-x-1.5 text-sm text-muted-foreground">
<FormattedTimestamp timestamp={timestamp} className="shrink-0" short={timestampShort} />
<Nip05AffiliationBadges userId={userId} />
{powEvent ? <EventPowLabel event={powEvent} /> : null}
</span>
</div>
)
}

72
src/components/NoteList/index.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import NewNotesButton from '@/components/NewNotesButton'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { FeedRelaysIconRow } from '@/components/FeedRelaysIconRow'
import {
ExtendedKind,
FAST_READ_RELAY_URLS,
@ -26,7 +27,9 @@ import { @@ -26,7 +27,9 @@ import {
isSpellSubRequestsSameFiltersDifferentRelays
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { eventSeenOnMatchesAllowlist } from '@/lib/relay-allowlist'
import { uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { collectLocalEventsForTextSearch } from '@/lib/local-nip50-search-merge'
@ -52,6 +55,7 @@ import dayjs from 'dayjs' @@ -52,6 +55,7 @@ import dayjs from 'dayjs'
import { type Event, type Filter, kinds } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19'
import RelayStatusDisplay from '@/components/RelayStatusDisplay'
import { detailedPublishToastsEnabled } from '@/lib/publishing-feedback'
import {
relayOpTerminalRowsToTimelineRelayUiStatuses,
type RelayOpTerminalRow
@ -69,7 +73,6 @@ import { @@ -69,7 +73,6 @@ import {
type ReactNode,
type SetStateAction
} from 'react'
import { CircleAlert } from 'lucide-react'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
@ -779,6 +782,7 @@ const NoteList = forwardRef( @@ -779,6 +782,7 @@ const NoteList = forwardRef(
*/
feedClientFilterTabRowHost,
onSingleRelayKindlessEmpty,
onSingleRelayBrowseEmpty,
feedTopNotice,
gridLayout = false,
/**
@ -853,6 +857,8 @@ const NoteList = forwardRef( @@ -853,6 +857,8 @@ const NoteList = forwardRef(
feedClientFilterTabRowHost?: HTMLElement | null
/** Single-relay kindless: if EOSE with no events, parent switches to explicit kinds in `subRequests`. */
onSingleRelayKindlessEmpty?: () => void
/** Relay explore: explicit kinds EOSE empty — parent retries kindless `{ limit }` once. */
onSingleRelayBrowseEmpty?: () => void
/** Optional banner above the feed (e.g. kindless→kinds fallback). */
feedTopNotice?: ReactNode
/** When true, render events as an Instagram-style 3-column square media grid. */
@ -944,8 +950,11 @@ const NoteList = forwardRef( @@ -944,8 +950,11 @@ const NoteList = forwardRef(
const feedRelayReturnedAnyEventRef = useRef(false)
/** One-shot per timeline init: avoid double-calling parent fallback (Strict Mode / duplicate EOSE). */
const singleRelayKindlessFallbackAttemptedRef = useRef(false)
const singleRelayBrowseFallbackAttemptedRef = useRef(false)
const onSingleRelayKindlessEmptyRef = useRef(onSingleRelayKindlessEmpty)
onSingleRelayKindlessEmptyRef.current = onSingleRelayKindlessEmpty
const onSingleRelayBrowseEmptyRef = useRef(onSingleRelayBrowseEmpty)
onSingleRelayBrowseEmptyRef.current = onSingleRelayBrowseEmpty
/** Timeout handle for kindless EOSE fallback; cleared when EOSE arrives or effect tears down. */
const kindlessEoseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** Dedupe {@link toast.error} when relays return nothing for a feed load. */
@ -1013,16 +1022,10 @@ const NoteList = forwardRef( @@ -1013,16 +1022,10 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const feedRelayUrls = useMemo(() => {
const urls = new Set<string>()
for (const req of subRequests) {
for (const url of req.urls ?? []) {
const trimmed = url.trim()
if (trimmed) urls.add(trimmed)
}
}
return [...urls]
}, [subRequestsKey])
const feedRelayUrls = useMemo(
() => uniqueRelayUrlsFromSubRequests(subRequests),
[subRequestsKey]
)
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)
@ -2254,6 +2257,7 @@ const NoteList = forwardRef( @@ -2254,6 +2257,7 @@ const NoteList = forwardRef(
feedPaintLiveRelayDoneRef.current = false
feedRelayReturnedAnyEventRef.current = false
singleRelayKindlessFallbackAttemptedRef.current = false
singleRelayBrowseFallbackAttemptedRef.current = false
}
// Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton.
@ -2297,6 +2301,14 @@ const NoteList = forwardRef( @@ -2297,6 +2301,14 @@ const NoteList = forwardRef(
if (useFilterAsIs && allowKindlessRelayExplore && urls.length === 1) {
return false
}
if (
useFilterAsIs &&
urls.length === 1 &&
relayAuthoritativeFeedOnlyRef.current &&
hostPrimaryPageNameRef.current === 'relay'
) {
return false
}
return true
})
if (invalidFilters.length > 0) {
@ -3405,6 +3417,28 @@ const NoteList = forwardRef( @@ -3405,6 +3417,28 @@ const NoteList = forwardRef(
}
}
// Relay explore: explicit kinds returned nothing — parent retries kindless once.
if (
eosed &&
effectActive &&
onSingleRelayBrowseEmptyRef.current &&
!singleRelayBrowseFallbackAttemptedRef.current &&
!feedRelayReturnedAnyEventRef.current &&
relayAuthoritativeFeedOnlyRef.current &&
hostPrimaryPageNameRef.current === 'relay'
) {
const reqs = subRequestsRef.current
const f0 = reqs[0]
if (reqs.length === 1 && f0 && f0.urls.length === 1) {
const f = f0.filter as Filter
const hasKinds = Array.isArray(f.kinds) && f.kinds.length > 0
if (hasKinds) {
singleRelayBrowseFallbackAttemptedRef.current = true
onSingleRelayBrowseEmptyRef.current()
}
}
}
if (
effectActive &&
eosed &&
@ -3926,15 +3960,12 @@ const NoteList = forwardRef( @@ -3926,15 +3960,12 @@ const NoteList = forwardRef(
const title = t(
'Relays returned no events for this feed. They may be offline, slow, or not indexing these notes.'
)
if (uiStatuses.length === 0) {
if (uiStatuses.length === 0 || !detailedPublishToastsEnabled()) {
toast.error(title, { duration: 8000 })
} else {
toast.error(
<div className="w-full min-w-0">
<div className="flex items-center gap-2 mb-3">
<CircleAlert className="w-5 h-5 text-red-500 shrink-0" />
<div className="font-semibold">{title}</div>
</div>
<div className="font-semibold mb-3">{title}</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Per-relay timeline results ({{count}} connections)', {
count: uiStatuses.length
@ -3947,7 +3978,7 @@ const NoteList = forwardRef( @@ -3947,7 +3978,7 @@ const NoteList = forwardRef(
aggregateSummary={false}
/>
</div>,
{ duration: 12_000, className: 'max-w-lg w-full' }
{ duration: 12_000, className: 'max-w-lg w-full', icon: null }
)
}
}, debounceMs)
@ -3967,6 +3998,7 @@ const NoteList = forwardRef( @@ -3967,6 +3998,7 @@ const NoteList = forwardRef(
useEffect(() => {
if (relayAuthoritativeFeedOnly) return
if (!timelinePublicReadFallback) return
if (isMetadataRelaysOnlyPolicyActive()) return
if (feedSubscriptionKey === 'home-all-favorites') return
if (oneShotFetch || areAlgoRelays) return
if (!navigator.onLine) return
@ -4522,6 +4554,12 @@ const NoteList = forwardRef( @@ -4522,6 +4554,12 @@ const NoteList = forwardRef(
const feedClientFilterPanel = feedClientFilterOpen ? (
<div id="feed-client-filter-panel" className={feedClientFilterPanelSurfaceClass}>
{feedRelayUrls.length > 0 ? (
<div className={feedClientFilterSectionClass}>
<p className="text-sm font-medium">{t('Feed relays', { defaultValue: 'Relays in this feed' })}</p>
<FeedRelaysIconRow urls={feedRelayUrls} />
</div>
) : null}
<div className={feedClientFilterSectionClass}>
<Label htmlFor="feed-client-search" className="text-sm font-medium">
{t('Search loaded posts')}

36
src/components/NoteOptions/DesktopMenu.tsx

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

6
src/components/NoteOptions/EditOrCloneEventDialog.tsx

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

27
src/components/NoteOptions/MobileMenu.tsx

@ -8,7 +8,7 @@ import { @@ -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 { @@ -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({ @@ -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({ @@ -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({ @@ -140,7 +154,12 @@ export function MobileMenu({
filteredSubMenu.map((subAction, index) => (
<Button
key={index}
onClick={subAction.onClick}
onClick={
subAction.subMenu?.length
? () =>
showSubMenuActions(subAction.subMenu!, String(subAction.label))
: subAction.onClick
}
className={cn(drawerMenuButtonClassName, subAction.className)}
variant="ghost"
>

33
src/components/NoteOptions/NoteOptionsMetaHeader.tsx

@ -4,8 +4,9 @@ import { useSeenOnRelays } from '@/hooks/useSeenOnRelays' @@ -4,8 +4,9 @@ import { useSeenOnRelays } from '@/hooks/useSeenOnRelays'
import { getKindDescription } from '@/lib/kind-description'
import { toRelay } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { useSecondaryPage } from '@/PageManager'
import { useSmartRelayNavigation } from '@/PageManager'
import type { Event } from 'nostr-tools'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
export default function NoteOptionsMetaHeader({
@ -20,10 +21,20 @@ export default function NoteOptionsMetaHeader({ @@ -20,10 +21,20 @@ export default function NoteOptionsMetaHeader({
inDropdown?: boolean
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays)
const { description } = getKindDescription(event.kind, event)
const openRelayFeed = useCallback(
(relay: string) => {
onNavigate?.()
setTimeout(() => {
navigateToRelay(toRelay(relay))
}, 0)
},
[navigateToRelay, onNavigate]
)
const relayRows = relays.map((relay) => {
const label = (
<>
@ -36,13 +47,16 @@ export default function NoteOptionsMetaHeader({ @@ -36,13 +47,16 @@ export default function NoteOptionsMetaHeader({
return (
<DropdownMenuItem
key={relay}
className="min-w-0 gap-2"
onSelect={() => {
onNavigate?.()
push(toRelay(relay))
}}
asChild
onSelect={(e) => e.preventDefault()}
>
<button
type="button"
className="flex min-w-0 w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus-visible:bg-accent"
onClick={() => openRelayFeed(relay)}
>
{label}
</button>
</DropdownMenuItem>
)
}
@ -52,10 +66,7 @@ export default function NoteOptionsMetaHeader({ @@ -52,10 +66,7 @@ export default function NoteOptionsMetaHeader({
<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))
}}
onClick={() => openRelayFeed(relay)}
>
{label}
</button>

1
src/components/NoteOptions/index.tsx

@ -160,6 +160,7 @@ export default function NoteOptions({ @@ -160,6 +160,7 @@ export default function NoteOptions({
subMenuSearchable={subMenuSearchable}
closeDrawer={closeDrawer}
goBackToMainMenu={goBackToMainMenu}
showSubMenuActions={showSubMenuActions}
/>
) : (
<DesktopMenu

550
src/components/NoteOptions/useMenuActions.tsx

@ -1,8 +1,21 @@ @@ -1,8 +1,21 @@
import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants'
import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event'
import ClientTag from '@/components/ClientTag'
import Nip05 from '@/components/Nip05'
import {
getNoteBech32Id,
getReplaceableCoordinateFromEvent,
getUsingClient,
isProtectedEvent,
isReplaceableEvent,
getRootEventHexId
} from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { buildHiveTalkJoinUrl } from '@/lib/hivetalk'
import { toAlexandria, encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr } from '@/lib/link'
import {
toAlexandria,
encodeArticleLikePublicationNaddr,
openAlexandriaPublicationFromNaddr
} from '@/lib/link'
import logger from '@/lib/logger'
import { pubkeyToNpub } from '@/lib/pubkey'
import {
@ -35,6 +48,8 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -35,6 +48,8 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useBookmarksOptional } from '@/providers/bookmarks-context'
import { useThreadNotificationMenuState } from '@/hooks/useThreadNotificationMenuState'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { eventService } from '@/services/client.service'
@ -42,21 +57,11 @@ import { nip66Service } from '@/services/nip66.service' @@ -42,21 +57,11 @@ import { nip66Service } from '@/services/nip66.service'
import {
Bell,
BellOff,
BookOpen,
Code,
Copy,
FileDown,
GitFork,
Globe,
Link,
MessageCircle,
PencilLine,
Bookmark,
Pin,
SatelliteDish,
Send,
Sparkles,
Settings,
Share2,
Trash2,
TriangleAlert,
Video,
Volume2,
Languages
@ -84,6 +89,7 @@ import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore @@ -84,6 +89,7 @@ import { useMemo, useState, useEffect, useRef, useContext, useSyncExternalStore
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import RelayIcon from '../RelayIcon'
import { useSecondaryPage } from '@/PageManager'
import { PrimaryPageContext } from '@/contexts/primary-page-context'
import { showPublishingFeedback, toastPublishPromise } from '@/lib/publishing-feedback'
import type { TEditOrCloneMode } from './EditOrCloneEventDialog'
@ -95,6 +101,8 @@ export interface SubMenuAction { @@ -95,6 +101,8 @@ export interface SubMenuAction {
separator?: boolean
/** Lowercase haystack for submenu filter when the parent sets {@link MenuAction.subMenuSearchable}. */
filterHaystack?: string
/** Nested submenu (desktop dropdown only). */
subMenu?: SubMenuAction[]
}
export interface MenuAction {
@ -143,10 +151,27 @@ export function useMenuActions({ @@ -143,10 +151,27 @@ export function useMenuActions({
onViewAttestation
}: UseMenuActionsProps) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
// 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 { threadFollowed, threadMuted, threadWatch } = useThreadNotificationMenuState(event)
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 +209,15 @@ export function useMenuActions({ @@ -184,6 +209,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 +966,133 @@ export function useMenuActions({ @@ -932,122 +966,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'),
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(translateTargetSubmenu, t('Translate note'), {
subMenuSearchable: true
showSubMenuActions(subMenu, title, {
subMenuSearchable: options?.subMenuSearchable
})
: undefined,
subMenu: isSmallScreen ? undefined : translateTargetSubmenu,
subMenuSearchable: true
} as MenuAction
]
: []),
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,160 +1100,205 @@ export function useMenuActions({ @@ -1055,160 +1100,205 @@ 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 advancedAuthorMetaRows: SubMenuAction[] = []
if (getUsingClient(event)) {
advancedAuthorMetaRows.push({
label: (
<div className="flex flex-col gap-0.5 py-0.5">
<span className="text-xs font-medium text-muted-foreground">{t('Posted via')}</span>
<ClientTag event={event} />
</div>
),
onClick: () => {},
className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent'
})
}
advancedAuthorMetaRows.push({
label: (
<div
className="flex flex-col gap-0.5 py-0.5"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<span className="text-xs font-medium text-muted-foreground">NIP-05</span>
<Nip05 pubkey={event.pubkey} />
</div>
),
onClick: () => {},
className: 'cursor-default focus:bg-transparent data-[highlighted]:bg-transparent',
filterHaystack: 'nip05'
})
const advancedSubMenu: SubMenuAction[] = [
...advancedAuthorMetaRows,
{
label: t('Copy event ID'),
separator: advancedAuthorMetaRows.length > 0,
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 && publicationBroadcastSubMenu.length > 0) {
advancedSubMenu.push({
label: t('Rebroadcast entire publication'),
separator: true,
onClick: isSmallScreen
? () =>
showSubMenuActions(
publicationBroadcastSubMenu,
t('Rebroadcast entire publication to ...')
)
: () => {},
subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu
} as SubMenuAction)
}
}
if (event.kind === ExtendedKind.PUBLICATION) {
const actions: MenuAction[] = []
if (READ_ALOUD_KINDS.includes(event.kind)) {
actions.push({
icon: SatelliteDish,
label: t('Rebroadcast entire publication'),
onClick: isSmallScreen
? () => showSubMenuActions(publicationBroadcastSubMenu, t('Rebroadcast entire publication to ...'))
: undefined,
subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu,
separator: true
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'))
}
})
}
})
}
const isProtected = isProtectedEvent(event)
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
if ((!isProtected || event.pubkey === pubkey) && !isDiscussion && !isReplyToDiscussion) {
if (noteSupportsTranslateMenu) {
actions.push({
icon: SatelliteDish,
label: t('Republish to ...'),
icon: Languages,
label: t('Translate note'),
onClick: isSmallScreen
? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...'))
? () =>
showSubMenuActions(translateTargetSubmenu, t('Translate note'), {
subMenuSearchable: true
})
: undefined,
subMenu: isSmallScreen ? undefined : broadcastSubMenu,
separator: true
subMenu: isSmallScreen ? undefined : translateTargetSubmenu,
subMenuSearchable: true
})
}
if (pubkey && event.pubkey !== pubkey) {
actions.push({
icon: TriangleAlert,
label: t('Report'),
className: 'text-destructive focus:text-destructive',
onClick: () => {
closeDrawer()
setIsReportDialogOpen(true)
},
separator: 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) {
@ -1232,7 +1322,7 @@ export function useMenuActions({ @@ -1232,7 +1322,7 @@ export function useMenuActions({
mutePubkeyPrivately(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
separator: true
separator: actions.length > 0
},
{
icon: BellOff,
@ -1247,15 +1337,106 @@ export function useMenuActions({ @@ -1247,15 +1337,106 @@ export function useMenuActions({
}
}
// Pin / unpin only against the signed-in user's list (not another profile's pinned section).
if (pubkey) {
const savesGroupStartIndex = actions.length
const savesGroupNeedsSeparator = savesGroupStartIndex > 0
if (threadWatch && pubkey) {
actions.push({
icon: Bell,
label: threadFollowed ? t('Unfollow thread notifications') : t('Follow this'),
separator: savesGroupNeedsSeparator,
onClick: () => {
closeDrawer()
void checkLogin(async () => {
try {
if (threadFollowed) {
const ok = await threadWatch.unfollowThreadForNotifications(event)
if (ok) {
toast.success(t('Unfollowed thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await threadWatch.followThreadForNotifications(event)
toast.success(t('Following thread for notifications'))
}
} catch (err) {
toast.error(
t('Thread notification list update failed') +
': ' +
(err instanceof Error ? err.message : String(err))
)
}
})
}
})
actions.push({
icon: BellOff,
label: threadMuted ? t('Unmute thread notifications') : t('Mute this'),
className: 'text-destructive focus:text-destructive',
onClick: () => {
closeDrawer()
void checkLogin(async () => {
try {
if (threadMuted) {
const ok = await threadWatch.unmuteThreadForNotifications(event)
if (ok) {
toast.success(t('Unmuted thread notifications'))
} else {
toast.error(t('Thread notification list update failed'))
}
} else {
await threadWatch.muteThreadForNotifications(event)
toast.success(t('Muted thread for notifications'))
}
} catch (err) {
toast.error(
t('Thread notification list update failed') +
': ' +
(err instanceof Error ? err.message : String(err))
)
}
})
}
})
}
if (pubkey && event.pubkey === pubkey) {
actions.push({
icon: Pin,
label: isPinnedInMyList ? t('Unpin note') : t('Pin note'),
onClick: () => {
handlePinNote()
},
separator: true
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
})
} 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: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator
})
}
@ -1290,6 +1471,13 @@ export function useMenuActions({ @@ -1290,6 +1471,13 @@ export function useMenuActions({
attemptDelete,
isPinnedInMyList,
handlePinNote,
bookmarkListEvent,
bookmarksContext,
isBookmarked,
bookmarkUpdating,
addBookmark,
removeBookmark,
checkLogin,
isArticleType,
articleMetadata,
dTag,
@ -1302,7 +1490,13 @@ export function useMenuActions({ @@ -1302,7 +1490,13 @@ export function useMenuActions({
profile,
noteTranslationFromMenu,
translateMenuOptions,
onViewAttestation
onViewAttestation,
push,
currentPrimaryPage,
isReplyToDiscussion,
threadWatch,
threadFollowed,
threadMuted
])
return menuActions

4
src/components/NoteStats/NoteStatsCountHover.tsx

@ -213,7 +213,7 @@ export function NoteStatsCountHover({ @@ -213,7 +213,7 @@ export function NoteStatsCountHover({
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<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}
</PopoverContent>
</Popover>
@ -223,7 +223,7 @@ export function NoteStatsCountHover({ @@ -223,7 +223,7 @@ export function NoteStatsCountHover({
return (
<HoverCard openDelay={220} closeDelay={80}>
<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}
</HoverCardContent>
</HoverCard>

13
src/components/NoteStats/SeenOnButton.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { useSecondaryPage } from '@/PageManager'
import { useSmartRelayNavigation } from '@/PageManager'
import { Button } from '@/components/ui/button'
import {
drawerMenuButtonClassName,
@ -34,7 +34,7 @@ export default function SeenOnButton({ @@ -34,7 +34,7 @@ export default function SeenOnButton({
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { push } = useSecondaryPage()
const { navigateToRelay } = useSmartRelayNavigation()
const relays = useSeenOnRelays(event.id, allowedRelays)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
@ -73,7 +73,7 @@ export default function SeenOnButton({ @@ -73,7 +73,7 @@ export default function SeenOnButton({
onClick={() => {
setIsDrawerOpen(false)
setTimeout(() => {
push(toRelay(relay))
navigateToRelay(toRelay(relay))
}, 50)
}}
>
@ -94,7 +94,12 @@ export default function SeenOnButton({ @@ -94,7 +94,12 @@ export default function SeenOnButton({
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel>
<DropdownMenuSeparator />
{relays.map((relay) => (
<DropdownMenuItem key={relay} onClick={() => push(toRelay(relay))} className="min-w-52">
<DropdownMenuItem
key={relay}
onSelect={(e) => e.preventDefault()}
onClick={() => navigateToRelay(toRelay(relay))}
className="min-w-52"
>
<RelayIcon url={relay} />
{simplifyUrl(relay)}
</DropdownMenuItem>

40
src/components/NoteStats/index.tsx

@ -10,16 +10,12 @@ import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot @@ -10,16 +10,12 @@ 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'
import { RepostButtonWithStats } from './RepostButton'
import { ZapButtonWithStats } from './ZapButton'
/** One column in the note action bar; default equal flex, or sized via `className` (discussions need wider vote slot). */
/** One slot in the note action bar; left-aligned with gap spacing (not equal-width columns). */
function NoteStatsBarItem({
children,
className
@ -30,7 +26,7 @@ function NoteStatsBarItem({ @@ -30,7 +26,7 @@ function NoteStatsBarItem({
return (
<div
className={cn(
'flex min-w-0 flex-1 basis-0 items-center justify-center overflow-hidden [&>*]:min-w-0 [&>*]:max-w-full',
'flex shrink-0 items-center overflow-hidden [&>*]:min-w-0 [&>*]:max-w-full',
className
)}
>
@ -142,17 +138,12 @@ export default function NoteStats({ @@ -142,17 +138,12 @@ export default function NoteStats({
statsFetchRelayScopeKey
])
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
const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] flex-[2] basis-28 sm:min-w-[7.25rem]' : undefined
const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] sm:min-w-[7.25rem]' : undefined
const barItems: ReactNode[] = [
<NoteStatsBarItem key="reply" className={compactBarItem}>
<NoteStatsBarItem key="reply">
<ReplyButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
]
@ -178,30 +169,12 @@ export default function NoteStats({ @@ -178,30 +169,12 @@ export default function NoteStats({
if (!isRssArticleRoot) {
barItems.push(
<NoteStatsBarItem key="tip" className={compactBarItem}>
<NoteStatsBarItem key="tip">
<ZapButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
)
}
if (!isRssArticleRoot && showThreadWatchButtons) {
barItems.push(
<NoteStatsBarItem key="thread-watch" className={compactBarItem}>
<div className="flex items-center justify-center gap-0.5">
<NotificationThreadWatchButtons event={event} />
</div>
</NoteStatsBarItem>
)
}
if (!isRssArticleRoot && showBookmarkButton) {
barItems.push(
<NoteStatsBarItem key="bookmark" className={compactBarItem}>
<BookmarkButton event={event} />
</NoteStatsBarItem>
)
}
return (
<div
ref={containerRef}
@ -211,7 +184,8 @@ export default function NoteStats({ @@ -211,7 +184,8 @@ export default function NoteStats({
>
<div
className={cn(
'flex w-full min-w-0 items-stretch [&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5',
'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-3 gap-y-1 sm:gap-x-4',
'[&_svg]:size-[15px] [&_button]:min-h-9 [&_button]:max-w-full [&_button]:px-1 sm:[&_button]:px-1.5',
loading ? 'animate-pulse' : '',
classNames?.buttonBar
)}

17
src/components/PostEditor/Mentions.tsx

@ -17,12 +17,15 @@ export default function Mentions({ @@ -17,12 +17,15 @@ export default function Mentions({
content,
mentions,
setMentions,
parentEvent
parentEvent,
/** When true, trigger shows only the count (section label supplies the title). */
compactTrigger = false
}: {
content: string
mentions: string[]
setMentions: (mentions: string[]) => void
parentEvent?: Event
compactTrigger?: boolean
}) {
const { t } = useTranslation()
const { pubkey } = useNostr()
@ -75,11 +78,21 @@ export default function Mentions({ @@ -75,11 +78,21 @@ export default function Mentions({
disabled={potentialMentions.length === 0}
onClick={(e) => e.stopPropagation()}
>
{compactTrigger ? (
potentialMentions.length > 0 ? (
`(${mentions.length}/${potentialMentions.length})`
) : (
'—'
)
) : (
<>
{t('Mentions')}{' '}
{potentialMentions.length > 0 && `(${mentions.length}/${potentialMentions.length})`}
</>
)}
</Button>
</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">
{potentialMentions.map((_, index) => {
const pubkey = potentialMentions[potentialMentions.length - 1 - index]

23
src/components/PostEditor/PollEditor.tsx

@ -7,18 +7,14 @@ import dayjs from 'dayjs' @@ -7,18 +7,14 @@ import dayjs from 'dayjs'
import { Eraser, X } from 'lucide-react'
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostRelaySelector from './PostRelaySelector'
export default function PollEditor({
pollCreateData,
setPollCreateData,
setIsPoll: _setIsPoll,
content = ''
setIsPoll: _setIsPoll
}: {
pollCreateData: TPollCreateData
setPollCreateData: Dispatch<SetStateAction<TPollCreateData>>
setIsPoll: Dispatch<SetStateAction<boolean>>
content?: string
}) {
const { t } = useTranslation()
const [isMultipleChoice, setIsMultipleChoice] = useState(pollCreateData.isMultipleChoice)
@ -26,15 +22,14 @@ export default function PollEditor({ @@ -26,15 +22,14 @@ export default function PollEditor({
const [endsAt, setEndsAt] = useState(
pollCreateData.endsAt ? dayjs(pollCreateData.endsAt * 1000).format('YYYY-MM-DDTHH:mm') : ''
)
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>(pollCreateData.relays)
useEffect(() => {
setPollCreateData({
setPollCreateData((prev) => ({
...prev,
isMultipleChoice,
options,
endsAt: endsAt ? dayjs(endsAt).startOf('minute').unix() : undefined,
relays: additionalRelayUrls
})
}, [isMultipleChoice, options, endsAt, additionalRelayUrls, setPollCreateData])
endsAt: endsAt ? dayjs(endsAt).startOf('minute').unix() : undefined
}))
}, [isMultipleChoice, options, endsAt, setPollCreateData])
const handleAddOption = () => {
setOptions([...options, ''])
@ -110,12 +105,6 @@ export default function PollEditor({ @@ -110,12 +105,6 @@ export default function PollEditor({
</div>
</div>
<div className="space-y-2">
<PostRelaySelector
setAdditionalRelayUrls={setAdditionalRelayUrls}
content={content}
/>
</div>
</div>
)
}

139
src/components/PostEditor/PostContent.tsx

@ -42,7 +42,6 @@ import { @@ -42,7 +42,6 @@ import {
ExtendedKind,
isNip71ShortVideoKind,
isNip71StyleVideoKind,
MAX_PUBLISH_RELAYS
} from '@/constants'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
@ -106,10 +105,9 @@ import { useTranslation } from 'react-i18next' @@ -106,10 +105,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Mentions, { extractMentions } from './Mentions'
import { extractMentions } from './Mentions'
import PollEditor from './PollEditor'
import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector'
import PostEditorAdvancedPanel from './PostEditorAdvancedPanel'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import {
newNostrSpecAffectedKindRow,
@ -479,6 +477,13 @@ export default function PostContent({ @@ -479,6 +477,13 @@ export default function PostContent({
if (isPoll) setRelayCapBlockInfo(null)
}, [isPoll])
useEffect(() => {
if (!isPoll) return
setPollCreateData((prev) =>
prev.relays === additionalRelayUrls ? prev : { ...prev, relays: additionalRelayUrls }
)
}, [isPoll, additionalRelayUrls])
// Clear highlight data when initialHighlightData changes or is removed
useEffect(() => {
if (initialHighlightData) {
@ -2409,7 +2414,12 @@ export default function PostContent({ @@ -2409,7 +2414,12 @@ export default function PostContent({
<ChevronDown className="h-4 w-4 opacity-70" />
</Button>
</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>
<div className="max-h-60 overflow-y-auto">
{allAvailableTopics.map((topic, index) => {
@ -3071,17 +3081,19 @@ export default function PostContent({ @@ -3071,17 +3081,19 @@ export default function PostContent({
isMediaNoteComposerMode ? t('Media Note') :
t('Short Note')
return (
<div className="flex flex-wrap items-center justify-end gap-1.5">
<div className="flex flex-nowrap items-center justify-end gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-sm font-normal shrink-0"
className="h-8 shrink-0 gap-1 px-2 text-xs font-normal sm:text-sm"
onClick={() => void handleOpenAdvancedLab()}
title={t('Advanced event lab')}
>
<Code2 className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[9rem] truncate">{t('Advanced event lab')}</span>
<span className="max-w-[5.25rem] truncate sm:max-w-[8.5rem]">
{t('Advanced event lab')}
</span>
</Button>
{!parentEvent ? (
<>
@ -3089,7 +3101,7 @@ export default function PostContent({ @@ -3089,7 +3101,7 @@ export default function PostContent({
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-sm font-normal shrink-0"
className="h-8 w-8 shrink-0 p-0"
disabled={!canUseMediaKindFromUrlButton}
title={
canUseMediaKindFromUrlButton
@ -3099,17 +3111,21 @@ export default function PostContent({ @@ -3099,17 +3111,21 @@ export default function PostContent({
onClick={handleUseMediaNoteKindFromUrl}
>
<Upload className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline max-w-[7.5rem] truncate">{t('Media kind')}</span>
<span className="sr-only">{t('Media kind')}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 h-8 text-sm font-normal">
<Button
variant="outline"
size="sm"
className="h-8 min-w-0 max-w-[6.75rem] shrink gap-1 px-2 text-xs font-normal sm:max-w-[9rem] sm:text-sm"
>
<ActiveIcon className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[120px] truncate">{activeLabel}</span>
<span className="min-w-0 truncate">{activeLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</Button>
</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">
{t('Note type')}
</DropdownMenuLabel>
@ -3276,7 +3292,6 @@ export default function PostContent({ @@ -3276,7 +3292,6 @@ export default function PostContent({
pollCreateData={pollCreateData}
setPollCreateData={setPollCreateData}
setIsPoll={setIsPoll}
content={text}
/>
)}
{isHighlight && (
@ -3289,24 +3304,19 @@ export default function PostContent({ @@ -3289,24 +3304,19 @@ export default function PostContent({
{isPublicMessage && (
<div className="rounded-lg border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">{t('Recipients')}</div>
<div className="space-y-2">
<Mentions
content={text}
parentEvent={undefined}
mentions={extractedMentions}
setMentions={setExtractedMentions}
/>
{extractedMentions.length > 0 ? (
<div className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t('Recipients detected from your message:')} {extractedMentions.length}
</div>
{!showMoreOptions ? (
<span className="block text-xs mt-1">{t('Open Advanced to adjust mention recipients')}</span>
) : null}
</p>
) : (
<div className="text-sm text-muted-foreground">
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')}
</div>
<p className="text-sm text-muted-foreground">
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced')}
</p>
)}
</div>
</div>
)}
{uploadProgresses.length > 0 &&
uploadProgresses.map(({ file, progress, cancel, phase }, index) => (
@ -3349,43 +3359,6 @@ export default function PostContent({ @@ -3349,43 +3359,6 @@ export default function PostContent({
</button>
</div>
))}
{!isPoll && (
<div
className={cn(
'shrink-0',
isDiscussionThread && threadErrors.relay && 'rounded-md ring-1 ring-destructive'
)}
>
<PostRelaySelector
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={handleRelayPublishCapChange}
parentEvent={parentEvent}
openFrom={openFrom}
content={text}
isPublicMessage={isPublicMessage}
mentions={extractedMentions}
/>
{relayCapBlockInfo && (
<p className="mt-2 text-sm text-amber-600 dark:text-amber-500" role="alert">
{relayCapBlockInfo.outboxSlotsInPublish > 0
? t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: relayCapBlockInfo.outboxSlotsInPublish,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})
: t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})}
</p>
)}
{isDiscussionThread && threadErrors.relay && (
<p className="mt-1 text-sm text-destructive">{threadErrors.relay}</p>
)}
</div>
)}
{/* Hidden uploader for the "Media Note" dropdown item */}
{!parentEvent && (
<Uploader
@ -3401,8 +3374,8 @@ export default function PostContent({ @@ -3401,8 +3374,8 @@ export default function PostContent({
<button ref={mediaUploaderBtnRef} type="button" aria-hidden="true" tabIndex={-1} />
</Uploader>
)}
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0">
<div className="flex gap-2 items-center min-w-0 shrink-0">
<div className="flex min-w-0 w-full items-center gap-1.5">
<div className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain">
<PostEditorFormatToolbar
insertText={(txt) => textareaRef.current?.insertText(txt)}
insertEmoji={(em) => textareaRef.current?.insertEmoji(em)}
@ -3417,13 +3390,7 @@ export default function PostContent({ @@ -3417,13 +3390,7 @@ export default function PostContent({
onToggleMoreOptions={() => setShowMoreOptions((pre) => !pre)}
/>
</div>
<div className="flex gap-2 items-center shrink-0">
<Mentions
content={text}
parentEvent={parentEvent}
mentions={mentions}
setMentions={setMentions}
/>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex gap-2 items-center max-sm:hidden">
<Button
type="button"
@ -3475,15 +3442,37 @@ export default function PostContent({ @@ -3475,15 +3442,37 @@ export default function PostContent({
</div>
</div>
</div>
<PostOptions
posting={posting}
<PostEditorAdvancedPanel
show={showMoreOptions}
posting={posting}
addClientTag={addClientTag}
setAddClientTag={setAddClientTag}
isNsfw={isNsfw}
setIsNsfw={setIsNsfw}
minPow={minPow}
setMinPow={setMinPow}
showMentionsPicker={!isHighlight}
mentionsContent={text}
mentionsParentEvent={isPublicMessage ? undefined : parentEvent}
mentions={isPublicMessage ? extractedMentions : mentions}
setMentions={isPublicMessage ? setExtractedMentions : setMentions}
showRelayPicker={
!isPublicationContent &&
!isCitationInternal &&
!isCitationExternal &&
!isCitationHardcopy &&
!isCitationPrompt
}
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={handleRelayPublishCapChange}
relayParentEvent={parentEvent}
relayOpenFrom={openFrom}
relayContent={text}
relayIsPublicMessage={isPublicMessage}
relayMentions={extractedMentions}
relayCapBlockInfo={relayCapBlockInfo}
discussionThreadRelayError={threadErrors.relay}
isDiscussionThread={isDiscussionThread}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button

189
src/components/PostEditor/PostEditorAdvancedPanel.tsx

@ -0,0 +1,189 @@ @@ -0,0 +1,189 @@
import { MAX_PUBLISH_RELAYS } from '@/constants'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap'
import { cn } from '@/lib/utils'
import storage from '@/services/local-storage.service'
import type { Event } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Mentions from './Mentions'
import PostRelaySelector from './PostRelaySelector'
export type PostEditorAdvancedPanelProps = {
show: boolean
posting: boolean
addClientTag: boolean
setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
/** Relay picker + cap hints (hidden for modes that do not pick relays). */
showRelayPicker?: boolean
setAdditionalRelayUrls?: Dispatch<SetStateAction<string[]>>
onRelayPublishCapChange?: (preview: TPrePublishRelayCapPreview) => void
relayParentEvent?: Event
relayOpenFrom?: string[]
relayContent?: string
relayIsPublicMessage?: boolean
relayMentions?: string[]
relayCapBlockInfo?: {
outboxSlotsInPublish: number
selectedTotal: number
selectedContacted: number
} | null
discussionThreadRelayError?: string | null
isDiscussionThread?: boolean
/** Reply / PM mention recipient picker. */
showMentionsPicker?: boolean
mentionsContent?: string
mentionsParentEvent?: Event
mentions?: string[]
setMentions?: Dispatch<SetStateAction<string[]>>
}
export default function PostEditorAdvancedPanel({
show,
posting,
addClientTag,
setAddClientTag,
isNsfw,
setIsNsfw,
minPow,
setMinPow,
showRelayPicker = false,
setAdditionalRelayUrls,
onRelayPublishCapChange,
relayParentEvent,
relayOpenFrom,
relayContent = '',
relayIsPublicMessage = false,
relayMentions = [],
relayCapBlockInfo = null,
discussionThreadRelayError = null,
isDiscussionThread = false,
showMentionsPicker = false,
mentionsContent = '',
mentionsParentEvent,
mentions = [],
setMentions
}: PostEditorAdvancedPanelProps) {
const { t } = useTranslation()
useEffect(() => {
setAddClientTag(storage.getAddClientTag())
}, [setAddClientTag])
if (!show) return null
const onAddClientTagChange = (checked: boolean) => {
storage.setAddClientTag(checked)
setAddClientTag(checked)
}
return (
<div className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div>
<p className="text-sm font-medium">{t('Advanced')}</p>
<p className="text-xs text-muted-foreground mt-0.5">{t('Post editor advanced hint')}</p>
</div>
{showMentionsPicker && setMentions ? (
<div className="space-y-2">
<Label className="text-sm font-normal">{t('Mentions')}</Label>
<Mentions
content={mentionsContent}
parentEvent={mentionsParentEvent}
mentions={mentions}
setMentions={setMentions}
compactTrigger
/>
</div>
) : null}
{showRelayPicker && setAdditionalRelayUrls ? (
<div
className={cn(
'space-y-2',
isDiscussionThread && discussionThreadRelayError && 'rounded-md ring-1 ring-destructive p-2'
)}
>
<Label className="text-sm font-normal">{t('Post to')}</Label>
<PostRelaySelector
setAdditionalRelayUrls={setAdditionalRelayUrls}
onRelayPublishCapChange={onRelayPublishCapChange}
parentEvent={relayParentEvent}
openFrom={relayOpenFrom}
content={relayContent}
isPublicMessage={relayIsPublicMessage}
mentions={relayMentions}
/>
{relayCapBlockInfo ? (
<p className="text-sm text-amber-600 dark:text-amber-500" role="alert">
{relayCapBlockInfo.outboxSlotsInPublish > 0
? t('Publish relay cap hint with outbox first', {
max: MAX_PUBLISH_RELAYS,
reservedSlots: relayCapBlockInfo.outboxSlotsInPublish,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})
: t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: relayCapBlockInfo.selectedTotal,
selectedContacted: relayCapBlockInfo.selectedContacted
})}
</p>
) : null}
{isDiscussionThread && discussionThreadRelayError ? (
<p className="text-sm text-destructive">{discussionThreadRelayError}</p>
) : null}
</div>
) : null}
<div className="space-y-4 pt-1 border-t border-border">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag" className="text-sm font-normal">
{t('Add client tag')}
</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
disabled={posting}
/>
</div>
<p className="text-muted-foreground text-xs">{t('Show others this was sent via Imwald')}</p>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="add-nsfw-tag" className="text-sm font-normal">
{t('NSFW')}
</Label>
<Switch
id="add-nsfw-tag"
checked={isNsfw}
onCheckedChange={setIsNsfw}
disabled={posting}
/>
</div>
<div className="grid gap-2">
<Label className="text-sm font-normal">
{t('Proof of Work (difficulty {{minPow}})', { minPow })}
</Label>
<Slider
defaultValue={[0]}
value={[minPow]}
onValueChange={([pow]) => setMinPow(pow)}
max={28}
step={1}
disabled={posting}
/>
</div>
</div>
</div>
)
}

30
src/components/PostEditor/PostEditorFormatToolbar.tsx

@ -3,7 +3,7 @@ import GifPicker from '@/components/GifPicker' @@ -3,7 +3,7 @@ import GifPicker from '@/components/GifPicker'
import MemePicker from '@/components/MemePicker'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { isTouchDevice } from '@/lib/utils'
import { cn, isTouchDevice } from '@/lib/utils'
import type { TEmoji } from '@/types'
import { Film, ImageUp, Laugh, Mic, Settings, Smile } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@ -46,8 +46,10 @@ export function PostEditorFormatToolbar({ @@ -46,8 +46,10 @@ export function PostEditorFormatToolbar({
}: PostEditorFormatToolbarProps) {
const { t } = useTranslation()
const iconBtnClass = 'h-8 w-8 shrink-0 p-0'
return (
<div className="flex flex-wrap items-center gap-2 min-w-0 shrink-0">
<div className="flex flex-nowrap items-center gap-0.5 min-w-0">
{showAudioUpload && (
<Uploader
onUploadSuccess={upload.onUploadSuccess}
@ -63,7 +65,7 @@ export function PostEditorFormatToolbar({ @@ -63,7 +65,7 @@ export function PostEditorFormatToolbar({
variant="ghost"
size="icon"
title={audioUploadTitle}
className={audioButtonHighlighted ? 'bg-accent' : ''}
className={cn(iconBtnClass, audioButtonHighlighted && 'bg-accent')}
>
<Mic className="h-4 w-4" />
</Button>
@ -78,11 +80,11 @@ export function PostEditorFormatToolbar({ @@ -78,11 +80,11 @@ export function PostEditorFormatToolbar({
onUploadCompressProgress={upload.onUploadCompressProgress}
accept="image/*"
>
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}>
<Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Upload Image')}>
<ImageUp />
</Button>
</Uploader>
<Separator orientation="vertical" className="h-6 shrink-0" />
<Separator orientation="vertical" className="mx-0.5 h-5 shrink-0 max-sm:hidden" />
{!isTouchDevice() && (
<EmojiPickerDialog
onEmojiClick={(emoji) => {
@ -90,29 +92,33 @@ export function PostEditorFormatToolbar({ @@ -90,29 +92,33 @@ export function PostEditorFormatToolbar({
insertEmoji(emoji)
}}
>
<Button type="button" variant="ghost" size="icon" title={t('Insert emoji')}>
<Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert emoji')}>
<Smile />
</Button>
</EmojiPickerDialog>
)}
<GifPicker onSelect={(gifUrl) => insertText(gifUrl)}>
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}>
<Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert GIF')}>
<Film className="h-4 w-4" />
</Button>
</GifPicker>
<MemePicker onSelect={(memeUrl) => insertText(memeUrl)}>
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}>
<Button type="button" variant="ghost" size="icon" className={iconBtnClass} title={t('Insert meme')}>
<Laugh className="h-4 w-4" />
</Button>
</MemePicker>
<Separator orientation="vertical" className="h-6 shrink-0" />
<MentionAndEventToolbarButtons insertAtCursor={insertText} variant="ghost" />
<Separator orientation="vertical" className="mx-0.5 h-5 shrink-0 max-sm:hidden" />
<MentionAndEventToolbarButtons
insertAtCursor={insertText}
variant="ghost"
buttonClassName={iconBtnClass}
/>
<Button
type="button"
variant="ghost"
size="icon"
title={t('More options')}
className={showMoreOptions ? 'bg-accent' : ''}
title={t('Advanced')}
className={cn(iconBtnClass, showMoreOptions && 'bg-accent')}
onClick={onToggleMoreOptions}
>
<Settings />

84
src/components/PostEditor/PostOptions.tsx

@ -1,84 +0,0 @@ @@ -1,84 +0,0 @@
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import storage from '@/services/local-storage.service'
import { Dispatch, SetStateAction, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
export default function PostOptions({
posting,
show,
addClientTag,
setAddClientTag,
isNsfw,
setIsNsfw,
minPow,
setMinPow
}: {
posting: boolean
show: boolean
addClientTag: boolean
setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
}) {
const { t } = useTranslation()
useEffect(() => {
setAddClientTag(storage.getAddClientTag())
}, [])
if (!show) return null
const onAddClientTagChange = (checked: boolean) => {
storage.setAddClientTag(checked)
setAddClientTag(checked)
}
const onNsfwChange = (checked: boolean) => {
setIsNsfw(checked)
}
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
disabled={posting}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Imwald')}
</div>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="add-nsfw-tag">{t('NSFW')}</Label>
<Switch
id="add-nsfw-tag"
checked={isNsfw}
onCheckedChange={onNsfwChange}
disabled={posting}
/>
</div>
<div className="grid gap-4 pb-4">
<Label>{t('Proof of Work (difficulty {{minPow}})', { minPow })}</Label>
<Slider
defaultValue={[0]}
value={[minPow]}
onValueChange={([pow]) => setMinPow(pow)}
max={28}
step={1}
disabled={posting}
/>
</div>
</div>
)
}

29
src/components/PostEditor/PostRelaySelector.tsx

@ -12,6 +12,8 @@ import { NostrEvent } from 'nostr-tools' @@ -12,6 +12,8 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon'
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
import nip66Service from '@/services/nip66.service'
import relaySelectionService, { type RelaySourceType } from '@/services/relay-selection.service'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -22,7 +24,7 @@ import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } fro @@ -22,7 +24,7 @@ import { computePrePublishRelayCapPreview, type TPrePublishRelayCapPreview } fro
/** Stable default when `mentions` is omitted — inline `= []` is a new array every render and retriggers effects. */
const NO_MENTIONS: string[] = []
/** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving {@link selectableRelaysOrder} (top of list first). */
/** Keep auto-selection within {@link MAX_PUBLISH_RELAYS}, preserving picker order (outboxes before randoms). */
function capAutoSelectedRelays(selectableRelaysOrder: string[], selectedWithCache: string[]): string[] {
const norm = (u: string) => normalizeRelayUrlByScheme(u) || u
const selectedNormSet = new Set(selectedWithCache.map(norm))
@ -68,7 +70,9 @@ export default function PostRelaySelector({ @@ -68,7 +70,9 @@ export default function PostRelaySelector({
const { isSmallScreen } = useScreenSize()
useCurrentRelays() // Keep this hook call for any side effects
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { addRandomRelaysToPublish } = useUserPreferences()
const { pubkey, relayList, cacheRelayListEvent } = useNostr()
const [publicLivelyRevision, setPublicLivelyRevision] = useState(0)
const userReadRelaysForSelection = useMemo(
() => userReadInboxUrls(relayList, cacheRelayListEvent),
[relayList, cacheRelayListEvent]
@ -86,6 +90,18 @@ export default function PostRelaySelector({ @@ -86,6 +90,18 @@ export default function PostRelaySelector({
// it's still the latest invocation before committing state, preventing stale races.
const selectionGenRef = useRef(0)
useEffect(() => {
return nip66Service.subscribePublicLivelyUpdated(() => {
setPublicLivelyRevision((v) => v + 1)
})
}, [])
useEffect(() => {
void nip66Service.getPublicLivelyRelayUrls().then(() => {
setPublicLivelyRevision((v) => v + 1)
})
}, [])
// For discussion replies, content doesn't affect relay selection
// Check if this is a reply to a discussion by looking for "K" tag with "11"
const isDiscussionReply = useMemo(() => {
@ -235,6 +251,8 @@ export default function PostRelaySelector({ @@ -235,6 +251,8 @@ export default function PostRelaySelector({
contentRelaySignature,
mentions,
describeRelaySelection,
addRandomRelaysToPublish,
publicLivelyRevision,
t
])
@ -423,7 +441,12 @@ export default function PostRelaySelector({ @@ -423,7 +441,12 @@ export default function PostRelaySelector({
<ChevronDown className="w-3 h-3 shrink-0" />
</Button>
</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="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{t('Select relays')}</span>
@ -431,7 +454,7 @@ export default function PostRelaySelector({ @@ -431,7 +454,7 @@ export default function PostRelaySelector({
</div>
{capHintEl}
</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}
</div>
</PopoverContent>

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

@ -83,7 +83,7 @@ export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, re @@ -83,7 +83,7 @@ export const EmojiList = forwardRef<EmojiListHandler, EmojiListProps>((props, re
return (
<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()}
onTouchMove={(e) => e.stopPropagation()}
>

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

@ -64,7 +64,12 @@ export function MentionAndEventToolbarButtons({ @@ -64,7 +64,12 @@ export function MentionAndEventToolbarButtons({
<AtSign className="h-4 w-4" />
</Button>
</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
placeholder={t('Search for user…')}
value={mentionQuery}

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

@ -102,7 +102,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) @@ -102,7 +102,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
return (
<div
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]'
)}
onWheel={(e: React.WheelEvent) => e.stopPropagation()}

10
src/components/PostEditor/PostTextarea/index.tsx

@ -272,8 +272,8 @@ const PostTextarea = forwardRef< @@ -272,8 +272,8 @@ const PostTextarea = forwardRef<
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<TabsList className="w-auto justify-start">
<div className="flex min-w-0 flex-col gap-2">
<TabsList className="w-auto shrink-0 justify-start">
<TabsTrigger value="edit" title={t('Edit')}>
{t('Edit')}
</TabsTrigger>
@ -281,11 +281,11 @@ const PostTextarea = forwardRef< @@ -281,11 +281,11 @@ const PostTextarea = forwardRef<
{t('Preview')}
</TabsTrigger>
</TabsList>
{headerActions && (
<div className="flex gap-1 items-center flex-wrap">
{headerActions ? (
<div className="flex min-w-0 flex-nowrap items-center justify-end gap-1 overflow-x-auto overscroll-x-contain">
{headerActions}
</div>
)}
) : null}
</div>
<TabsContent
value="edit"

49
src/components/PostSignupBackupRedirect/index.tsx

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
import { toCacheSettings } from '@/lib/link'
import {
consumePostSignupBackupPrompt,
showNewUserBackupBanner
} from '@/lib/post-signup-backup-prompt'
import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useRef } from 'react'
/** After one-click sign up, open Cache settings so the user can back up their private key. */
export default function PostSignupBackupRedirect() {
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (!pubkey) return
const tryRedirect = () => {
if (!consumePostSignupBackupPrompt(pubkey)) return false
showNewUserBackupBanner()
push(toCacheSettings())
return true
}
if (tryRedirect()) return
// Prompt is scheduled at login; brief poll covers pubkey/login race.
let attempts = 0
pollRef.current = setInterval(() => {
attempts += 1
if (tryRedirect() || attempts >= 15) {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}, 200)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}, [pubkey, push])
return null
}

226
src/components/PrivateKeyRecoverySetting/index.tsx

@ -0,0 +1,226 @@ @@ -0,0 +1,226 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
dismissNewUserBackupBanner,
isNewUserBackupBannerVisible
} from '@/lib/post-signup-backup-prompt'
import { requestNewUserTemplateBroadcast } from '@/lib/new-user-template-broadcast'
import NcryptsecPasswordPrompt from '@/components/NcryptsecPasswordPrompt'
import { pubkeyToNpub } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
import storage from '@/services/local-storage.service'
import { Check, Copy, Eye, EyeOff, KeyRound, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as nip19 from 'nostr-tools/nip19'
import * as nip49 from 'nostr-tools/nip49'
import { toast } from 'sonner'
export default function PrivateKeyRecoverySetting() {
const { t } = useTranslation()
const { pubkey, account, nsec, ncryptsec, discardLocalPrivateKey } = useNostr()
const [showKey, setShowKey] = useState(false)
const [revealedNsec, setRevealedNsec] = useState<string | null>(null)
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false)
const [copiedNpub, setCopiedNpub] = useState(false)
const [copiedKey, setCopiedKey] = useState(false)
const [showBackupBanner, setShowBackupBanner] = useState(false)
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false)
useEffect(() => {
setShowBackupBanner(isNewUserBackupBannerVisible())
}, [])
const npub = useMemo(() => (pubkey ? pubkeyToNpub(pubkey) : null), [pubkey])
const storedNsec = pubkey ? storage.getAccountNsec(pubkey) : undefined
const storedNcryptsec = pubkey ? storage.getAccountNcryptsec(pubkey) : undefined
const plainNsec = nsec ?? storedNsec
const encryptedBlob = ncryptsec ?? storedNcryptsec
const usesEncryption = !!encryptedBlob && !plainNsec
const recoverableKey = plainNsec ?? encryptedBlob
const displayedKey = revealedNsec ?? (showKey && !usesEncryption ? recoverableKey : null)
const copyKeyValue = revealedNsec ?? recoverableKey
const keyLabel = revealedNsec || plainNsec ? 'nsec' : 'ncryptsec'
const hasLocalKey =
account?.signerType === 'nsec' ||
account?.signerType === 'ncryptsec' ||
!!storedNsec ||
!!storedNcryptsec
if (!pubkey || !hasLocalKey || !recoverableKey) {
return null
}
const copyToClipboard = async (text: string, which: 'npub' | 'key') => {
await navigator.clipboard.writeText(text)
if (which === 'npub') {
setCopiedNpub(true)
setTimeout(() => setCopiedNpub(false), 2000)
} else {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}
}
const dismissBanner = () => {
dismissNewUserBackupBanner()
setShowBackupBanner(false)
requestNewUserTemplateBroadcast(pubkey)
}
const handleToggleShowKey = () => {
if (showKey) {
setShowKey(false)
setRevealedNsec(null)
return
}
if (usesEncryption) {
setPasswordPromptOpen(true)
return
}
setShowKey(true)
}
const handleDecryptPassword = (password: string | null) => {
setPasswordPromptOpen(false)
if (!password || !encryptedBlob) return
try {
const privkey = nip49.decrypt(encryptedBlob, password)
setRevealedNsec(nip19.nsecEncode(privkey))
setShowKey(true)
} catch {
toast.error(t('Could not decrypt — check your password and try again.'))
}
}
const handleRemoveLocalKey = () => {
try {
discardLocalPrivateKey()
dismissBanner()
setRemoveConfirmOpen(false)
toast.success(
t(
'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.'
)
)
} catch (error) {
toast.error((error as Error).message)
}
}
return (
<section className="space-y-4">
{showBackupBanner && (
<div className="rounded-lg border border-orange-500/50 bg-orange-500/10 p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-orange-600 dark:text-orange-400">
{t('Back up your private key now')}
</p>
<Button type="button" variant="ghost" size="icon" className="shrink-0 h-7 w-7" onClick={dismissBanner}>
<X className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t(
'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.'
)}
</p>
</div>
)}
<div className="flex items-center gap-2">
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
<h2 className="text-sm font-semibold">{t('Private key recovery')}</h2>
</div>
<p className="text-sm text-muted-foreground">
{t(
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.'
)}
</p>
{usesEncryption && (
<p className="text-sm text-muted-foreground">
{t(
'This account uses an encrypted key (ncryptsec). Use Show key and your encryption password to reveal the original nsec for backup.'
)}
</p>
)}
<div className="grid gap-2">
<Label>{t('npub')}</Label>
<div className="flex gap-2">
<Input readOnly value={npub ?? ''} className="font-mono text-xs" />
<Button
type="button"
variant="secondary"
size="icon"
aria-label={t('Copy npub')}
onClick={() => npub && copyToClipboard(npub, 'npub')}
>
{copiedNpub ? <Check /> : <Copy />}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label>
{t('Copy private key')} ({keyLabel})
</Label>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => copyKeyValue && copyToClipboard(copyKeyValue, 'key')}>
{copiedKey ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />}
{t('Copy private key')}
</Button>
<Button type="button" variant="outline" onClick={handleToggleShowKey}>
{showKey ? <EyeOff className="mr-2 size-4" /> : <Eye className="mr-2 size-4" />}
{showKey ? t('Hide key') : t('Show key')}
</Button>
</div>
{showKey && displayedKey && (
<div className="rounded-md border bg-muted/40 p-3">
<p className="text-xs text-orange-500 mb-2">
{t('Do not share this with anyone. Anyone with this key can control your account.')}
</p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{displayedKey}</pre>
</div>
)}
</div>
<NcryptsecPasswordPrompt open={passwordPromptOpen} onResult={handleDecryptPassword} />
<div className="pt-2 border-t space-y-2">
<p className="text-sm text-muted-foreground">
{t(
'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.'
)}
</p>
<Button type="button" variant="destructive" onClick={() => setRemoveConfirmOpen(true)}>
<Trash2 className="mr-2 size-4" />
{t('Remove local private key')}
</Button>
</div>
<AlertDialog open={removeConfirmOpen} onOpenChange={setRemoveConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Remove local private key?')}</AlertDialogTitle>
<AlertDialogDescription>
{t(
'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveLocalKey}>{t('Remove local private key')}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
)
}

10
src/components/Profile/index.tsx

@ -8,7 +8,7 @@ import { ProfileBotBadge } from '@/components/ProfileBotBadge' @@ -8,7 +8,7 @@ import { ProfileBotBadge } from '@/components/ProfileBotBadge'
import ProfileOptions from '@/components/ProfileOptions'
import ProfileZapButton from '@/components/ProfileZapButton'
import PubkeyCopy from '@/components/PubkeyCopy'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks'
@ -396,13 +396,13 @@ export default function Profile({ @@ -396,13 +396,13 @@ export default function Profile({
<div className="relative h-full w-full">
<Avatar className="h-full w-full border-4 border-background">
<AvatarImage
src={avatar}
src={avatar || defaultImage}
className="object-cover object-center"
fetchPriority="high"
loading="eager"
/>
<AvatarFallback>
<img src={defaultImage} alt="" />
<AvatarFallback delayMs={0}>
<AvatarIdenticon src={defaultImage} />
</AvatarFallback>
</Avatar>
{isBot ? (
@ -434,7 +434,7 @@ export default function Profile({ @@ -434,7 +434,7 @@ export default function Profile({
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" showScrollButtons>
{profileEvent && (
<>
<DropdownMenuItem onClick={() => setOpenSelfReply(true)}>

87
src/components/ProfileList/index.tsx

@ -1,9 +1,17 @@ @@ -1,9 +1,17 @@
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import client from '@/services/client.service'
import type { TProfile } from '@/types'
import { useEffect, useMemo, useRef, useState } from 'react'
import UserItem from '../UserItem'
const PROFILE_CHUNK = 80
export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
const [visiblePubkeys, setVisiblePubkeys] = useState<string[]>([])
const [profilesByPubkey, setProfilesByPubkey] = useState<Map<string, TProfile>>(() => new Map())
const bottomRef = useRef<HTMLDivElement>(null)
const loadedRef = useRef<Set<string>>(new Set())
const batchGenRef = useRef(0)
const pubkeysKey = useMemo(() => pubkeys.join('\u0001'), [pubkeys])
useEffect(() => {
@ -35,11 +43,84 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) { @@ -35,11 +43,84 @@ export default function ProfileList({ pubkeys }: { pubkeys: string[] }) {
}
}, [visiblePubkeys, pubkeysKey, pubkeys])
const visibleHexPubkeysKey = useMemo(
() =>
visiblePubkeys
.filter((pk) => pk.length === 64 && /^[0-9a-f]{64}$/i.test(pk))
.map((pk) => pk.toLowerCase())
.join('\u0001'),
[visiblePubkeys]
)
useEffect(() => {
const need = visibleHexPubkeysKey
.split('\u0001')
.filter(Boolean)
.filter((pk) => !loadedRef.current.has(pk))
if (need.length === 0) return
const gen = ++batchGenRef.current
need.forEach((pk) => loadedRef.current.add(pk))
void (async () => {
const chunks: string[][] = []
for (let i = 0; i < need.length; i += PROFILE_CHUNK) {
chunks.push(need.slice(i, i + PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
)
if (gen !== batchGenRef.current) return
setProfilesByPubkey((prev) => {
const next = new Map(prev)
settled.forEach((res, idx) => {
const chunk = chunks[idx]!
if (res.status === 'rejected') {
chunk.forEach((pk) => loadedRef.current.delete(pk))
return
}
for (const p of res.value) {
const pkNorm = p.pubkey.toLowerCase()
next.set(pkNorm, { ...p, pubkey: pkNorm })
}
for (const pk of chunk) {
const pkNorm = pk.toLowerCase()
if (!next.has(pkNorm)) {
next.set(pkNorm, {
pubkey: pkNorm,
npub: pubkeyToNpub(pkNorm) ?? '',
username: formatPubkey(pkNorm),
batchPlaceholder: true
})
}
}
})
return next
})
})()
}, [visibleHexPubkeysKey])
useEffect(() => {
batchGenRef.current += 1
loadedRef.current.clear()
setProfilesByPubkey(new Map())
}, [pubkeysKey])
return (
<div className="px-4 pt-2">
{visiblePubkeys.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{visiblePubkeys.map((pubkey, index) => {
const pkNorm = pubkey.length === 64 ? pubkey.toLowerCase() : pubkey
const prefetchedProfile = profilesByPubkey.get(pkNorm)
return (
<UserItem
key={`${index}-${pubkey}`}
pubkey={pubkey}
prefetchedProfile={prefetchedProfile}
deferRemoteAvatar={false}
/>
)
})}
{pubkeys.length > visiblePubkeys.length && <div ref={bottomRef} />}
</div>
)

2
src/components/ProfileOptions/index.tsx

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

34
src/components/Relay/index.tsx

@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed' @@ -2,7 +2,7 @@ import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import RelayInfo from '@/components/RelayInfo'
import SearchInput from '@/components/SearchInput'
import { useBypassMetadataRelaysOnlyPolicy, useFetchRelayInfo } from '@/hooks'
import { useFetchRelayInfo, useRelayPageFeedPolicy } from '@/hooks'
import type { TPrimaryPageName } from '@/PageManager'
import { SINGLE_RELAY_KINDLESS_REQ_LIMIT } from '@/constants'
import { canonicalRelaySessionKey, isLocalNetworkUrl, normalizeRelayUrlForPage } from '@/lib/url'
@ -15,6 +15,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r @@ -15,6 +15,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r
import { useTranslation } from 'react-i18next'
import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta'
import { buildAlexandriaEventsSearchUrlFromNotesQuery } from '@/lib/alexandria-events-search-url'
import { kindsForSingleRelayBrowse } from '@/lib/single-relay-browse-kinds'
import { stableFeedKindKey } from '@/features/feed/descriptor'
import NotFound from '../NotFound'
@ -32,13 +33,15 @@ const Relay = forwardRef< @@ -32,13 +33,15 @@ const Relay = forwardRef<
ref
) {
const { t } = useTranslation()
useBypassMetadataRelaysOnlyPolicy()
useRelayPageFeedPolicy()
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
const { showKinds } = useKindFilterOrDefaults()
const normalizedUrl = useMemo(() => (url ? normalizeRelayUrlForPage(url) : undefined), [url])
const { relayInfo } = useFetchRelayInfo(normalizedUrl)
const [searchInput, setSearchInput] = useState('')
const [debouncedInput, setDebouncedInput] = useState(searchInput)
/** After explicit-kinds REQ EOSEs empty, retry kindless `{ limit }` once (document/specialty relays). */
const [kindlessBrowseFallback, setKindlessBrowseFallback] = useState(false)
const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref ?? internalNoteListRef
@ -81,13 +84,21 @@ const Relay = forwardRef< @@ -81,13 +84,21 @@ const Relay = forwardRef<
}
}, [normalizedUrl, noteListRef])
useEffect(() => {
setKindlessBrowseFallback(false)
}, [normalizedUrl])
/** Default browse: explicit kinds (many strfry / small relays never return a useful kindless global REQ). */
const relayBrowseKindsKey = useMemo(() => stableFeedKindKey(showKinds), [showKinds])
const relayBrowseKinds = useMemo(
() => (showKinds.length > 0 ? showKinds : [kinds.ShortTextNote]),
[relayBrowseKindsKey, showKinds]
() => (normalizedUrl ? kindsForSingleRelayBrowse(normalizedUrl, showKinds) : [kinds.ShortTextNote]),
[relayBrowseKindsKey, showKinds, normalizedUrl]
)
const onSingleRelayBrowseEmpty = useCallback(() => {
setKindlessBrowseFallback(true)
}, [])
const relayFeedSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!normalizedUrl) return []
const q = debouncedInput.trim()
@ -99,13 +110,21 @@ const Relay = forwardRef< @@ -99,13 +110,21 @@ const Relay = forwardRef<
}
]
}
if (kindlessBrowseFallback) {
return [
{
urls: [normalizedUrl],
filter: { limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
}
]
}
return [
{
urls: [normalizedUrl],
filter: { kinds: [...relayBrowseKinds], limit: SINGLE_RELAY_KINDLESS_REQ_LIMIT }
}
]
}, [normalizedUrl, debouncedInput, relayBrowseKindsKey])
}, [normalizedUrl, debouncedInput, relayBrowseKindsKey, kindlessBrowseFallback])
const allowKindlessRelayExplore = debouncedInput.trim().length > 0
@ -116,7 +135,7 @@ const Relay = forwardRef< @@ -116,7 +135,7 @@ const Relay = forwardRef<
)
const shouldHideEventNotFromThisRelay = useCallback(
(ev: Event) => {
if (hostPrimaryPageName === 'relay' || allowKindlessRelayExplore) {
if (allowKindlessRelayExplore) {
return false
}
if (!relaySeenMatchKey) return false
@ -127,7 +146,7 @@ const Relay = forwardRef< @@ -127,7 +146,7 @@ const Relay = forwardRef<
if (seen.length === 0) return false
return !seen.some((u) => canonicalRelaySessionKey(u) === relaySeenMatchKey)
},
[relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore]
[relaySeenMatchKey, normalizedUrl, allowKindlessRelayExplore]
)
const alexandriaFeedEmptyUrl = useMemo(() => {
@ -168,6 +187,7 @@ const Relay = forwardRef< @@ -168,6 +187,7 @@ const Relay = forwardRef<
extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly
onSingleRelayBrowseEmpty={onSingleRelayBrowseEmpty}
alexandriaEmptyUrl={alexandriaFeedEmptyUrl}
/>
</div>

27
src/components/ReplyNote/index.tsx

@ -25,19 +25,15 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -25,19 +25,15 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
import EventPowLabel from '../EventPowLabel'
import Collapsible from '../Collapsible'
import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteAuthorMetaLine from '../NoteAuthorMetaLine'
import NoteOptions from '../NoteOptions'
import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview'
import WebPreview from '../WebPreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import Superchat from '../Note/Superchat'
import Zap from '../Note/Zap'
import MoneroTip from '../Note/MoneroTip'
@ -126,25 +122,14 @@ export default function ReplyNote({ @@ -126,25 +122,14 @@ export default function ReplyNote({
maxFileSizeKb={2048}
deferRemoteAvatar={false}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<Username
<NoteAuthorMetaLine
userId={headerUserId}
className="truncate text-sm font-semibold text-muted-foreground hover:text-foreground"
skeletonClassName="h-3"
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={headerUserId} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
powEvent={event}
usernameClassName="max-w-[min(12rem,40vw)] text-sm text-muted-foreground hover:text-foreground"
skeletonClassName="h-3"
timestampShort={isSmallScreen}
/>
<EventPowLabel event={event} />
</div>
</div>
</div>
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>

41
src/components/Settings/SettingsMenuBody.tsx

@ -12,12 +12,9 @@ import { cn } from '@/lib/utils' @@ -12,12 +12,9 @@ import { cn } from '@/lib/utils'
import { useSmartSettingsNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import {
Check,
ChevronRight,
Copy,
Database,
Info,
KeyRound,
PencilLine,
Rss,
Server,
@ -25,7 +22,7 @@ import { @@ -25,7 +22,7 @@ import {
Users,
Wallet
} from 'lucide-react'
import { forwardRef, HTMLProps, useState } from 'react'
import { forwardRef, HTMLProps } from 'react'
import { useTranslation } from 'react-i18next'
/**
@ -34,10 +31,8 @@ import { useTranslation } from 'react-i18next' @@ -34,10 +31,8 @@ import { useTranslation } from 'react-i18next'
*/
export default function SettingsMenuBody({ className }: { className?: string }) {
const { t } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr()
const { pubkey } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation()
const [copiedNsec, setCopiedNsec] = useState(false)
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
return (
<div className={cn('min-w-0', className)}>
@ -98,38 +93,6 @@ export default function SettingsMenuBody({ className }: { className?: string }) @@ -98,38 +93,6 @@ export default function SettingsMenuBody({ className }: { className?: string })
<ChevronRight />
</SettingItem>
)}
{!!nsec && (
<SettingItem
className="clickable"
onClick={() => {
navigator.clipboard.writeText(nsec)
setCopiedNsec(true)
setTimeout(() => setCopiedNsec(false), 2000)
}}
>
<div className="flex items-center gap-4">
<KeyRound />
<div>{t('Copy private key')} (nsec)</div>
</div>
{copiedNsec ? <Check /> : <Copy />}
</SettingItem>
)}
{!!ncryptsec && (
<SettingItem
className="clickable"
onClick={() => {
navigator.clipboard.writeText(ncryptsec)
setCopiedNcryptsec(true)
setTimeout(() => setCopiedNcryptsec(false), 2000)
}}
>
<div className="flex items-center gap-4">
<KeyRound />
<div>{t('Copy private key')} (ncryptsec)</div>
</div>
{copiedNcryptsec ? <Check /> : <Copy />}
</SettingItem>
)}
<AboutInfoDialog>
<SettingItem className="clickable">
<div className="flex items-center gap-4">

2
src/components/Sidebar/index.tsx

@ -11,7 +11,6 @@ import SearchButton from './SearchButton' @@ -11,7 +11,6 @@ import SearchButton from './SearchButton'
import FavoritesButton from './FavoritesButton'
import DiscussionsButton from './DiscussionsButton'
import SpellsButton from './SpellsButton'
import { ConnectedRelaysSidebarStrip } from '@/components/ConnectedRelays/ConnectedRelaysSidebarStrip'
import PaneModeToggle from './PaneModeToggle'
import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton'
import LiveActivitiesStrip from '@/components/LiveActivitiesStrip'
@ -47,7 +46,6 @@ export default function PrimaryPageSidebar() { @@ -47,7 +46,6 @@ export default function PrimaryPageSidebar() {
<DiscussionsButton />
<SpellsButton />
<RssButton />
<ConnectedRelaysSidebarStrip />
<PostButton />
<div className="max-xl:hidden w-full min-w-0 space-y-2 px-1">
<LiveActivitiesStrip placement="sidebar" />

8
src/components/TooManyRelaysAlertDialog/index.tsx

@ -16,7 +16,7 @@ import { @@ -16,7 +16,7 @@ import {
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { toRelaySettings } from '@/lib/link'
import { MAILBOX_RELAY_COUNT_WARNING_THRESHOLD } from '@/constants'
import { useSecondaryPage } from '@/contexts/secondary-page-context'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -34,7 +34,11 @@ export default function TooManyRelaysAlertDialog() { @@ -34,7 +34,11 @@ export default function TooManyRelaysAlertDialog() {
const dismissed = storage.getDismissedTooManyRelaysAlert()
if (dismissed) return
if (relayList && (relayList.read.length > 4 || relayList.write.length > 4)) {
if (
relayList &&
(relayList.read.length >= MAILBOX_RELAY_COUNT_WARNING_THRESHOLD ||
relayList.write.length >= MAILBOX_RELAY_COUNT_WARNING_THRESHOLD)
) {
setOpen(true)
} else {
setOpen(false)

10
src/components/Username/index.tsx

@ -33,7 +33,15 @@ export default function Username({ @@ -33,7 +33,15 @@ export default function Username({
const { profile: fetchedProfile, isFetching } = useFetchProfile(userId)
const profile = useMemo(() => {
const idPk = userId ? userIdToPubkey(userId) : ''
if (prefetchedProfile && idPk && prefetchedProfile.pubkey === idPk) {
if (
prefetchedProfile &&
idPk &&
prefetchedProfile.pubkey.toLowerCase() === idPk.toLowerCase()
) {
const fetchedOk = fetchedProfile && !fetchedProfile.batchPlaceholder
const prefetchedOk = !prefetchedProfile.batchPlaceholder
if (fetchedOk) return fetchedProfile
if (prefetchedOk) return prefetchedProfile
return fetchedProfile ?? prefetchedProfile
}
return fetchedProfile

16
src/components/ui/avatar.tsx

@ -38,7 +38,8 @@ const AvatarFallback = React.forwardRef< @@ -38,7 +38,8 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
'flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-muted',
'[&_img]:block [&_img]:h-full [&_img]:w-full [&_img]:object-cover [&_img]:object-center',
className
)}
{...props}
@ -46,4 +47,15 @@ const AvatarFallback = React.forwardRef< @@ -46,4 +47,15 @@ const AvatarFallback = React.forwardRef<
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
/** Pubkey identicon (or other fallback) sized to fill a circular avatar. */
function AvatarIdenticon({ src, className }: { src: string; className?: string }) {
return (
<img
src={src}
alt=""
className={cn('block h-full w-full object-cover object-center', className)}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback, AvatarIdenticon }

39
src/components/ui/dropdown-menu.tsx

@ -3,8 +3,17 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' @@ -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<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>, 'side' | 'align'>
@ -37,7 +46,7 @@ const DropdownMenuSubTrigger = React.forwardRef< @@ -37,7 +46,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
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',
className
)}
@ -117,14 +126,10 @@ const DropdownMenuSubContent = React.forwardRef< @@ -117,14 +126,10 @@ const DropdownMenuSubContent = React.forwardRef<
align={align}
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',
submenuBelow && 'max-w-[min(100vw-1.5rem,24rem)]',
floatingPanelMaxWidthClass,
inDialog ? 'z-[290]' : 'z-[100]'
)}
onAnimationEnd={() => {
if (showScrollButtons) {
checkScrollability()
}
}}
onAnimationEnd={checkScrollability}
collisionPadding={16}
{...props}
>
@ -144,7 +149,10 @@ const DropdownMenuSubContent = React.forwardRef< @@ -144,7 +149,10 @@ const DropdownMenuSubContent = React.forwardRef<
<div
ref={scrollAreaRef}
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
)}
onScroll={checkScrollability}
@ -223,13 +231,10 @@ const DropdownMenuContent = React.forwardRef< @@ -223,13 +231,10 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
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',
floatingPanelMaxWidthClass,
inDialog ? 'z-[290]' : 'z-[100]'
)}
onAnimationEnd={() => {
if (showScrollButtons) {
checkScrollability()
}
}}
onAnimationEnd={checkScrollability}
collisionPadding={16}
{...props}
>
@ -249,7 +254,10 @@ const DropdownMenuContent = React.forwardRef< @@ -249,7 +254,10 @@ const DropdownMenuContent = React.forwardRef<
<div
ref={scrollAreaRef}
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
)}
onScroll={checkScrollability}
@ -284,7 +292,8 @@ const DropdownMenuItem = React.forwardRef< @@ -284,7 +292,8 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
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',
className
)}

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

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import {
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
hoverCardMaxHeightClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils'
const HoverCard = HoverCardPrimitive.Root
@ -15,9 +20,12 @@ const HoverCardContent = React.forwardRef< @@ -15,9 +20,12 @@ const HoverCardContent = React.forwardRef<
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={10}
collisionPadding={16}
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
)}
{...props}

12
src/components/ui/popover.tsx

@ -2,6 +2,11 @@ import * as React from 'react' @@ -2,6 +2,11 @@ import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { DialogContext } from '@/components/ui/dialog'
import {
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
popoverMaxHeightClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
@ -21,9 +26,12 @@ const PopoverContent = React.forwardRef< @@ -21,9 +26,12 @@ const PopoverContent = React.forwardRef<
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={10}
collisionPadding={16}
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]',
className
)}

19
src/components/ui/select.tsx

@ -3,6 +3,12 @@ import * as SelectPrimitive from '@radix-ui/react-select' @@ -3,6 +3,12 @@ import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { DialogContext } from '@/components/ui/dialog'
import {
floatingPanelMaxWidthClass,
floatingPanelScrollClass,
menuItemLargeTextClass,
selectViewportMaxHeightClass
} from '@/lib/menu-popover-layout'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
@ -68,8 +74,10 @@ const SelectContent = React.forwardRef< @@ -68,8 +74,10 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
collisionPadding={16}
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]',
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',
@ -80,9 +88,13 @@ const SelectContent = React.forwardRef< @@ -80,9 +88,13 @@ const SelectContent = React.forwardRef<
>
<SelectPrimitive.Viewport
className={cn(
'p-1 popover-scroll-y',
'p-1',
floatingPanelScrollClass,
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}
@ -113,6 +125,7 @@ const SelectItem = React.forwardRef< @@ -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}

62
src/constants.ts

@ -165,7 +165,7 @@ export const RELAY_SLOW_PARK_SIGNALS_THRESHOLD = 2 @@ -165,7 +165,7 @@ export const RELAY_SLOW_PARK_SIGNALS_THRESHOLD = 2
export const RELAY_SLOW_PARK_COOLDOWN_MS = 5 * 60 * 1000
/** Close pooled WebSocket when no SUBs and no pool activity for this long (see {@link initRelayPoolIdle}). */
export const RELAY_POOL_SOCKET_IDLE_MS = 90_000
export const RELAY_POOL_SOCKET_IDLE_MS = 15_000
/** How often to scan for idle relay sockets. */
export const RELAY_POOL_IDLE_SWEEP_INTERVAL_MS = 45_000
@ -276,6 +276,31 @@ export const PROFILE_BATCH_POST_COOLDOWN_MS = 90_000 @@ -276,6 +276,31 @@ export const PROFILE_BATCH_POST_COOLDOWN_MS = 90_000
*/
export const PROFILE_SECONDARY_PANEL_DEFER_MS = 120_000
/**
* Trusted NIP-05 domains shown as compact affiliation badges beside usernames (verified only).
* Add entries here to recognize more community registries.
*/
export type TNip05AffiliationDomain = {
/** Host part after `@` in the NIP-05 identifier (lowercase). */
domain: string
/** Badge glyph shown to the right of the display name. */
emoji: string
/** Tooltip / screen-reader label (defaults to `domain`). */
label?: string
}
export const NIP05_AFFILIATION_DOMAINS: readonly TNip05AffiliationDomain[] = [
{ domain: 'nostr.land', emoji: '🌐', label: 'Land' },
{ domain: 'theforest.nostr1.com', emoji: '🌲', label: 'TheForest' },
{ domain: 'gitcitadel.com', emoji: '🛡', label: 'GitCitadel' },
{ domain: 'blog.imwald.eu', emoji: '✍🏼', label: 'Imwald' }
] as const
/** @internal — built from {@link NIP05_AFFILIATION_DOMAINS} for O(1) domain lookup. */
export const NIP05_AFFILIATION_BY_DOMAIN: ReadonlyMap<string, TNip05AffiliationDomain> = new Map(
NIP05_AFFILIATION_DOMAINS.map((entry) => [entry.domain.toLowerCase(), entry])
)
/**
* Hex-id / replaceable-coordinate note lookup ({@link EventService.tryHarderToFetchEvent}, big-relays dataloader).
*/
@ -320,6 +345,9 @@ export const BLOSSOM_PRESET_SELECT_PREFIX = 'blossom-preset:' @@ -320,6 +345,9 @@ export const BLOSSOM_PRESET_SELECT_PREFIX = 'blossom-preset:'
/** [Lotus](https://github.com/0ceanSlim/lotus) — self-hosted Blossom (BUD) server (see GitHub for cdn_url / api_addr). */
export const LOTUS_BLOSSOM_REPO_URL = 'https://github.com/0ceanSlim/lotus'
/** Window event: session cleared — PageManager returns to `/` and closes note overlays. */
export const APP_RESET_TO_LANDING_EVENT = 'app-reset-to-landing'
export const StorageKey = {
VERSION: 'version',
THEME_SETTING: 'themeSetting',
@ -363,10 +391,12 @@ export const StorageKey = { @@ -363,10 +391,12 @@ export const StorageKey = {
SHOW_RSS_FEED: 'showRssFeed',
PANE_MODE: 'paneMode',
ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish',
/** When `'true'`, only connect to relays on the viewer's NIP-65 / favorites / cache / HTTP lists. */
/** @deprecated Removed — personal-relay read policy is always on when logged in. */
RESTRICT_RELAYS_TO_METADATA_LISTS: 'restrictRelaysToMetadataLists',
/** When not `'false'`, show green Sonner toasts after successful publishes (default on). */
/** When `'true'`, show Sonner toasts after successful publishes (default off). */
SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts',
/** When not `'false'`, publish/feed toasts include per-relay breakdown when success toasts are on (default on). */
SHOW_DETAILED_PUBLISH_TOASTS: 'showDetailedPublishToasts',
/** When not `'false'`, show NIP-53 live activity banner (default on). */
SHOW_LIVE_ACTIVITIES_BANNER: 'showLiveActivitiesBanner',
/** Max approximate archive size (MB). `0` in UI means “use platform default”. */
@ -400,6 +430,9 @@ export const FONT_SIZE = { @@ -400,6 +430,9 @@ export const FONT_SIZE = {
*/
export const RANDOM_PUBLISH_RELAY_COUNT = 5
/** Read or write mailbox count above this triggers “too many relays” warnings and the optimize dialog. */
export const MAILBOX_RELAY_COUNT_WARNING_THRESHOLD = 10
/** Relays to query for NIP-66 relay monitoring events (30166), in addition to FAST_READ_RELAY_URLS. */
export const NIP66_DISCOVERY_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
@ -418,7 +451,8 @@ export const BOOKSTR_RELAY_URLS = [ @@ -418,7 +451,8 @@ export const BOOKSTR_RELAY_URLS = [
*/
export const DOCUMENT_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
'wss://relay.wikifreedia.xyz'
'wss://relay.wikifreedia.xyz',
'wss://essayist.decentnewsroom.com'
] as const
/**
@ -435,7 +469,9 @@ export const READ_ONLY_RELAY_URLS = [ @@ -435,7 +469,9 @@ export const READ_ONLY_RELAY_URLS = [
'wss://search.nos.today',
'wss://relay.nip46.com',
'wss://filter.nostr.wine',
'wss://primus.nostr1.com'
'wss://primus.nostr1.com',
'wss://feeds.nostrarchives.com',
'wss://feeds.nostrarchives.com/notes/trending/reactions/today'
]
/**
@ -459,7 +495,6 @@ export const NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS = ['wss://nostr.wine'] as cons @@ -459,7 +495,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',
@ -468,12 +503,13 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [ @@ -468,12 +503,13 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [
'wss://hist.nostr.land',
]
// Optimized relay list for read operations (includes aggregator)
// Optimized relay list for read operations
export const FAST_READ_RELAY_URLS = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://nostr.wine',
'wss://nostr21.com'
'wss://nostr21.com',
'wss://primus.nostr1.com'
]
// Optimized relay list for write operations (no aggregator since it's read-only)
@ -499,11 +535,8 @@ export const MONERO_NOSTR_RELAY_URLS = [ @@ -499,11 +535,8 @@ export const MONERO_NOSTR_RELAY_URLS = [
/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish.
* Publish to all of these so GIFs are discoverable across clients; some may be temporarily down. */
export const GIF_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://thecitadel.nostr1.com',
'wss://nos.lol',
'wss://nostr.mom'
'wss://relay.gifbuddy.lol'
]
export const SEARCHABLE_RELAY_URLS = [
@ -521,10 +554,9 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550 @@ -521,10 +554,9 @@ 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://indexer.coracle.social/'
]
export const FOLLOWS_HISTORY_RELAY_URLS = [

2
src/hooks/index.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
export * from './useBypassMetadataRelaysOnlyPolicy'
export * from './useRelayPageFeedPolicy'
export * from './useNearViewport'
export * from './useFetchCalendarRsvps'
export * from './useFetchEvent'

13
src/hooks/useBypassMetadataRelaysOnlyPolicy.ts

@ -1,13 +0,0 @@ @@ -1,13 +0,0 @@
import {
enterMetadataRelaysOnlyBypass,
leaveMetadataRelaysOnlyBypass
} from '@/lib/read-only-relay-personal'
import { useEffect } from 'react'
/** Disable “only my relay lists” while mounted (relay explore, search, relay directory). */
export function useBypassMetadataRelaysOnlyPolicy(): void {
useEffect(() => {
enterMetadataRelaysOnlyBypass()
return () => leaveMetadataRelaysOnlyBypass()
}, [])
}

25
src/hooks/useFetchProfile.tsx

@ -485,7 +485,30 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -485,7 +485,30 @@ export function useFetchProfile(id?: string, skipCache = false) {
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
return
const awaitingCancelled = { current: false }
void tryHydrateProfileFromLocalCaches(pkL, false).then((quick) => {
if (awaitingCancelled.current || !quick) return
setProfile(quick)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
})
const awaitingEscapeTimer = window.setTimeout(() => {
if (awaitingCancelled.current) return
void checkProfile(extractedPubkey, awaitingCancelled)
}, FEED_PROFILE_PENDING_BATCH_ESCAPE_MS)
return () => {
awaitingCancelled.current = true
window.clearTimeout(awaitingEscapeTimer)
if (processingPubkeyRef.current === extractedPubkey) {
processingPubkeyRef.current = null
}
if (extractedPubkey) {
effectRunCountRef.current.delete(extractedPubkey)
}
}
}
// Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag).

10
src/hooks/useRelayPageFeedPolicy.ts

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
import { enterSingleRelayExplicitBrowse, leaveSingleRelayExplicitBrowse } from '@/lib/read-only-relay-personal'
import { useEffect } from 'react'
/** Relay detail feed: connect to the page relay even if it is not on the viewer's personal lists. */
export function useRelayPageFeedPolicy(): void {
useEffect(() => {
enterSingleRelayExplicitBrowse()
return () => leaveSingleRelayExplicitBrowse()
}, [])
}

57
src/hooks/useThreadNotificationMenuState.ts

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
import { ExtendedKind } from '@/constants'
import {
eventHasExactNotificationThreadWatchRef,
parseThreadWatchListRefs
} from '@/lib/notification-thread-watch'
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider'
import { useNostr } from '@/providers/NostrProvider'
import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
/** Local kind 19130 / 19132 lists — thread follow/mute menu state (not the open note’s kind). */
export function useThreadNotificationMenuState(event: Event) {
const { pubkey } = useNostr()
const threadWatch = useNotificationThreadWatchOptional()
const [idbFollowed, setIdbFollowed] = useState(false)
const [idbMuted, setIdbMuted] = useState(false)
const refreshFromIdb = useCallback(async () => {
if (!pubkey) {
setIdbFollowed(false)
setIdbMuted(false)
return
}
const pk = pubkey.trim().toLowerCase()
try {
const [followEv, muteEv] = await Promise.all([
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST),
indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST)
])
const followRefs = parseThreadWatchListRefs(followEv ?? undefined)
const muteRefs = parseThreadWatchListRefs(muteEv ?? undefined)
setIdbFollowed(eventHasExactNotificationThreadWatchRef(event, followRefs))
setIdbMuted(eventHasExactNotificationThreadWatchRef(event, muteRefs))
} catch {
setIdbFollowed(false)
setIdbMuted(false)
}
}, [pubkey, event.id, event.kind, event.created_at])
useEffect(() => {
void refreshFromIdb()
}, [
refreshFromIdb,
threadWatch?.eventsIFollowListEvent?.id,
threadWatch?.eventsIMutedListEvent?.id
])
const threadFollowed = threadWatch
? threadWatch.isFollowedForNotifications(event)
: idbFollowed
const threadMuted = threadWatch
? threadWatch.isMutedForNotifications(event)
: idbMuted
return { threadFollowed, threadMuted, threadWatch }
}

51
src/hooks/useVerifiedNip05Affiliations.ts

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { NIP05_AFFILIATION_DOMAINS, type TNip05AffiliationDomain } from '@/constants'
import { affiliationNip05CandidatesFromProfile } from '@/lib/nip05-affiliation'
import { verifyNip05 } from '@/lib/nip05'
import { useEffect, useMemo, useState } from 'react'
export function useVerifiedNip05Affiliations(
pubkey: string | undefined,
nip05?: string,
nip05List?: string[]
): readonly TNip05AffiliationDomain[] {
const candidates = useMemo(
() => affiliationNip05CandidatesFromProfile(nip05, nip05List),
[nip05, nip05List]
)
const candidatesKey = useMemo(
() => candidates.map((c) => c.nip05).join('\u0001'),
[candidates]
)
const [verified, setVerified] = useState<readonly TNip05AffiliationDomain[]>([])
useEffect(() => {
if (!pubkey || candidates.length === 0) {
setVerified([])
return
}
let cancelled = false
void (async () => {
const confirmed = new Set<string>()
await Promise.all(
candidates.map(async ({ nip05: nip05Id, affiliation }) => {
const result = await verifyNip05(nip05Id, pubkey)
if (
result.isVerified &&
result.nip05Domain.toLowerCase() === affiliation.domain
) {
confirmed.add(affiliation.domain)
}
})
)
if (cancelled) return
setVerified(
NIP05_AFFILIATION_DOMAINS.filter((entry) => confirmed.has(entry.domain))
)
})()
return () => {
cancelled = true
}
}, [pubkey, candidatesKey])
return verified
}

8
src/i18n/locales/cs.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set',
Add: 'Add',
@ -906,8 +904,8 @@ export default { @@ -906,8 +904,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

30
src/i18n/locales/de.ts

@ -69,7 +69,7 @@ export default { @@ -69,7 +69,7 @@ export default {
'load more older replies': 'ältere Antworten laden',
'Write something...': 'Schreibe etwas...',
Cancel: 'Abbrechen',
Mentions: '@',
Mentions: 'Erwähnungen',
'Search for event or address…': 'Nach Event oder Adresse suchen…',
'Search notes…': 'Notizen suchen…',
'No notes found': 'Keine Notizen gefunden',
@ -88,6 +88,7 @@ export default { @@ -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',
@ -115,9 +116,6 @@ export default { @@ -115,9 +116,6 @@ export default {
'Follows you': 'Folgt dir',
'Relay Settings': 'Relay-Einstellungen',
'Relays and Storage Settings': 'Relays und Speicher',
'Only my relay lists': 'Nur meine Relay-Listen',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'Wenn aktiv, bleiben Lese-Verbindungen auf deinen Listen plus den eingebauten Profilindex-Relays (profiles.nostr1.com, relay.damus.io, …). Andere Relays für Feeds, Threads oder Suche werden nur bei Listeneintrag genutzt. Veröffentlichen bleibt unverändert. Relay-Entdecken und Suche sind ausgenommen.',
'Relay set name': 'Relay-Set Name',
'Add a new relay set': 'Neues Relay-Set hinzufügen',
Add: 'Hinzufügen',
@ -475,6 +473,8 @@ export default { @@ -475,6 +473,8 @@ export default {
'Add an Account': 'Konto hinzufügen',
'More options': 'Mehr Optionen',
'Add client tag': 'Client-Tag hinzufügen',
'Posted via': 'Veröffentlicht mit',
'Verified NIP-05 affiliation': 'Verifiziert auf {{domain}}',
'Show others this was sent via Imwald': 'Anderen zeigen, dass dies über Imwald gesendet wurde',
'Are you sure you want to logout?': 'Bist du sicher, dass du dich abmelden möchtest?',
'relay sets': 'Relay-Sets',
@ -754,9 +754,21 @@ export default { @@ -754,9 +754,21 @@ export default {
'Favorited by': 'Favorisiert von',
'Post settings': 'Beitragseinstellungen',
'Publishing feedback': 'Rückmeldungen beim Veröffentlichen',
'Publish success toasts': 'Erfolgs-Benachrichtigungen beim Veröffentlichen',
'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.':
'Grüne Hinweise anzeigen, wenn Beiträge, Antworten, Reaktionen und andere Veröffentlichungen gelingen. Wenn aus, erscheint kurz ein kleines Häkchen unten rechts. Fehler weiterhin als Hinweis.',
'Publish success toasts': 'Erfolg beim Veröffentlichen anzeigen',
'Publish success toasts hint':
'Wenn an, bestätigt ein Hinweis gelungene Beiträge, Antworten, Reaktionen und ähnliche Aktionen. Wenn aus, erscheint kurz ein kleines Häkchen unten rechts.',
'Publish toast per-relay details': 'Pro-Relay-Aufschlüsselung in Hinweisen',
'Publish toast per-relay details hint':
'Wenn an, listet der Hinweis jedes Relay (angenommen, fehlgeschlagen, Fehlertext). Wenn aus, nur eine kurze Zusammenfassung.',
'Publishing feedback errors note':
'Fehlgeschlagene Veröffentlichungen und andere Fehler zeigen immer einen Hinweis — mit Kurzfassung oder Pro-Relay-Aufschlüsselung wie oben.',
Advanced: 'Erweitert',
'Post editor advanced hint':
'Relay-Ziele, Erwähnungen, Client-Tag, NSFW und Proof-of-Work.',
'Open Advanced to adjust mention recipients':
'Öffne Erweitert, um Empfänger anzupassen.',
'Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced':
'Erwähne nostr:npub… oder nostr:nevent… im Text oder öffne Erweitert für die Empfängerauswahl.',
'Publish successful': 'Veröffentlichung erfolgreich',
'Media upload service': 'Medien-Upload-Service',
BlossomUploadYourListOption: 'Blossom (eigene Liste)',
@ -930,8 +942,8 @@ export default { @@ -930,8 +942,8 @@ export default {
Quotes: 'Zitate',
'Lightning Invoice': 'Lightning-Rechnung',
'Bookmark failed': 'Bookmark fehlgeschlagen',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Diesem Thread folgen',
'Mute this': 'Diesen Thread stummschalten',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

68
src/i18n/locales/en.ts

@ -85,6 +85,7 @@ export default { @@ -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',
@ -112,9 +113,6 @@ export default { @@ -112,9 +113,6 @@ export default {
'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set',
Add: 'Add',
@ -289,6 +287,13 @@ export default { @@ -289,6 +287,13 @@ 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',
'Post editor advanced hint': 'Relay targets, mention recipients, client tag, NSFW, and proof of work.',
'Open Advanced to adjust mention recipients': 'Open Advanced to adjust who receives this message.',
'Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced':
'Add nostr:npub… or nostr:nevent… mentions in the text, or open Advanced to pick recipients.',
'Share with Imwald': 'Share with Imwald',
'Share with Alexandria': 'Share with Alexandria',
'Start video call': 'Start video call',
@ -469,6 +474,8 @@ export default { @@ -469,6 +474,8 @@ export default {
'Add an Account': 'Add an Account',
'More options': 'More options',
'Add client tag': 'Add client tag',
'Posted via': 'Posted via',
'Verified NIP-05 affiliation': 'Verified on {{domain}}',
'Show others this was sent via Imwald': 'Show others this was sent via Imwald',
'Are you sure you want to logout?': 'Are you sure you want to logout?',
'relay sets': 'relay sets',
@ -532,9 +539,35 @@ export default { @@ -532,9 +539,35 @@ export default {
'read & write relays notice':
'The number of read and write servers should ideally be kept between 2 and 4.',
"Don't have an account yet?": "Don't have an account yet?",
'or simply generate a private key': 'or simply generate a private key',
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.':
'This is a private key. Do not share it with anyone. Keep it safe and secure. You will not be able to recover it if you lose it.',
'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.':
'Sign up creates a private key stored in this browser. Back it up anytime under Settings → Cache & offline storage.',
'Signing up…': 'Signing up…',
'Account created — customize profile and relays in Settings.':
'Account created — customize profile and relays in Settings.',
'Private key recovery': 'Private key recovery',
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.':
'Your private key is stored in this browser. Clearing cache does not remove your account, but losing this browser profile does. Back up your key somewhere safe.',
'This account uses an encrypted key (ncryptsec). Use Show key and your encryption password to reveal the original nsec for backup.':
'This account uses an encrypted key (ncryptsec). Use Show key and your encryption password to reveal the original nsec for backup.',
'Could not decrypt — check your password and try again.':
'Could not decrypt — check your password and try again.',
'Show key': 'Show key',
'Hide key': 'Hide key',
'Do not share this with anyone. Anyone with this key can control your account.':
'Do not share this with anyone. Anyone with this key can control your account.',
'Back up your private key now': 'Back up your private key now',
'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.':
'Your account was just created. Copy your nsec (or ncryptsec) below and store it somewhere safe — password manager, encrypted file, or paper offline. Anyone with this key controls your account.',
'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.':
'After backing up, you can remove the key from this browser and sign in with a browser extension or bunker instead.',
'Remove local private key': 'Remove local private key',
'Remove local private key?': 'Remove local private key?',
'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.':
'The private key will be deleted from this browser only. Make sure you have copied it first. This account will become read-only here until you log in again with an extension, bunker, or private key.',
'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.':
'Local private key removed. This account is read-only here until you log in with an extension, bunker, or private key again.',
'Copy npub': 'Copy npub',
npub: 'npub',
Edit: 'Edit',
Save: 'Save',
'Display Name': 'Display Name',
@ -750,9 +783,14 @@ export default { @@ -750,9 +783,14 @@ export default {
'Favorited by': 'Favorited by',
'Post settings': 'Post settings',
'Publishing feedback': 'Publishing feedback',
'Publish success toasts': 'Publish success toasts',
'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.':
'Show green notifications when posts, replies, reactions, and other publishes succeed. When off, a small checkmark appears briefly at the bottom-right instead. Errors and failures still use a toast.',
'Publish success toasts': 'Success notifications when publishing',
'Publish success toasts hint':
'When on, a toast confirms successful posts, replies, reactions, and similar actions. When off, a small checkmark appears briefly at the bottom-right instead.',
'Publish toast per-relay details': 'Per-relay breakdown in toasts',
'Publish toast per-relay details hint':
'When on, those toasts list each relay (accepted, failed, errors). When off, only a short summary line.',
'Publishing feedback errors note':
'Failed publishes and other errors always show a toast, using the same summary or per-relay style as above.',
'Publish successful': 'Publish successful',
'Media upload service': 'Media upload service',
BlossomUploadYourListOption: 'Blossom (your list)',
@ -921,8 +959,8 @@ export default { @@ -921,8 +959,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',
@ -1197,10 +1235,10 @@ export default { @@ -1197,10 +1235,10 @@ export default {
'After changing emojis, you may need to refresh the page',
'Too many read relays': 'Too many read relays',
'Too many write relays': 'Too many write relays',
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.':
'You have {{count}} read relays. Most clients only use 2-4 relays, setting more is unnecessary.',
'You have {{count}} write relays. Most clients only use 2-4 relays, setting more is unnecessary.':
'You have {{count}} write relays. Most clients only use 2-4 relays, setting more is unnecessary.',
'You have {{count}} read relays. Most clients only use up to {{limit}} relays, setting more is unnecessary.':
'You have {{count}} read relays. Most clients only use up to {{limit}} relays, setting more is unnecessary.',
'You have {{count}} write relays. Most clients only use up to {{limit}} relays, setting more is unnecessary.':
'You have {{count}} write relays. Most clients only use up to {{limit}} relays, setting more is unnecessary.',
'Optimize Relay Settings': 'Optimize Relay Settings',
'Your current relay configuration may not be optimal. This could make it difficult for others to find your posts and may result in incomplete notifications.':
'Your current relay configuration may not be optimal. This could make it difficult for others to find your posts and may result in incomplete notifications.',

8
src/i18n/locales/es.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Te sigue',
'Relay Settings': 'Configuración de relés',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Nombre del conjunto de relés',
'Add a new relay set': 'Agregar un nuevo conjunto de relés',
Add: 'Agregar',
@ -910,8 +908,8 @@ export default { @@ -910,8 +908,8 @@ export default {
Quotes: 'Citas',
'Lightning Invoice': 'Factura Lightning',
'Bookmark failed': 'Error al marcar',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/i18n/locales/fr.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Vous suit',
'Relay Settings': 'Paramètres des relais',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Nom du groupe de relais',
'Add a new relay set': 'Ajouter un nouveau groupe de relais',
Add: 'Ajouter',
@ -910,8 +908,8 @@ export default { @@ -910,8 +908,8 @@ export default {
Quotes: 'Citations',
'Lightning Invoice': 'Facture Lightning',
'Bookmark failed': 'Échec de la mise en favori',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/i18n/locales/nl.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set',
Add: 'Add',
@ -906,8 +904,8 @@ export default { @@ -906,8 +904,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/i18n/locales/pl.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Obserwujący',
'Relay Settings': 'Ustawienia transmiterów',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Wpisz nazwę grupy',
'Add a new relay set': 'Utwórz grupę transmiterów',
Add: 'Dodaj',
@ -907,8 +905,8 @@ export default { @@ -907,8 +905,8 @@ export default {
Quotes: 'Cytaty',
'Lightning Invoice': 'Faktura Lightning',
'Bookmark failed': 'Nie udało się dodać zakładki',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/i18n/locales/ru.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Подписан на вас',
'Relay Settings': 'Настройки ретрансляторов',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Имя набора ретрансляторов',
'Add a new relay set': 'Добавить новый набор ретрансляторов',
Add: 'Добавить',
@ -909,8 +907,8 @@ export default { @@ -909,8 +907,8 @@ export default {
Quotes: 'Цитаты',
'Lightning Invoice': 'Lightning-счет',
'Bookmark failed': 'Не удалось добавить закладку',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/i18n/locales/tr.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': 'Follows you',
'Relay Settings': 'Relays and Storage Settings',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': 'Relay set name',
'Add a new relay set': 'Add a new relay set',
Add: 'Add',
@ -906,8 +904,8 @@ export default { @@ -906,8 +904,8 @@ export default {
Quotes: 'Quotes',
'Lightning Invoice': 'Lightning Invoice',
'Bookmark failed': 'Bookmark failed',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/i18n/locales/zh.ts

@ -87,6 +87,7 @@ export default { @@ -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',
@ -114,9 +115,6 @@ export default { @@ -114,9 +115,6 @@ export default {
'Follows you': '关注了你',
'Relay Settings': '服务器设置',
'Relays and Storage Settings': 'Relays and Storage Settings',
'Only my relay lists': 'Only my relay lists',
'When on, the app only opens read connections to relays on your Read & Write, Favorite, Cache, and HTTP relay lists. Publishing is unchanged. Relay explore and Search pages are exempt.':
'When on, read connections stay on your relay lists plus the built-in profile index relays (profiles.nostr1.com, relay.damus.io, etc.). Other relays used for feeds, threads, or search are not contacted unless listed. Publishing is unchanged. Relay explore and Search pages are exempt.',
'Relay set name': '服务器组名',
'Add a new relay set': '添加新的服务器组',
Add: '添加',
@ -904,8 +902,8 @@ export default { @@ -904,8 +902,8 @@ export default {
Quotes: '引用',
'Lightning Invoice': '闪电发票',
'Bookmark failed': '收藏失败',
'Follow this': 'Follow this',
'Mute this': 'Mute this',
'Follow this': 'Follow this thread',
'Mute this': 'Mute this thread',
'Following thread for notifications': 'Following thread for notifications',
'Muted thread for notifications': 'Muted thread for notifications',
'Unfollow thread notifications': 'Unfollow thread notifications',

8
src/lib/account-list-relay-urls.ts

@ -4,6 +4,10 @@ import { normalizeRelayUrlByScheme } from '@/lib/url' @@ -4,6 +4,10 @@ import { normalizeRelayUrlByScheme } from '@/lib/url'
import { collectViewerReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectViewerWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import {
viewerIncludeGlobalFastReadRelayLayer,
viewerIncludeGlobalFastWriteRelayLayer
} from '@/lib/read-only-relay-personal'
import client from '@/services/client.service'
/**
@ -32,7 +36,7 @@ export async function buildAccountListRelayUrlsForMerge(options: { @@ -32,7 +36,7 @@ export async function buildAccountListRelayUrlsForMerge(options: {
blockedRelays,
maxRelays: 100,
applySocialKindBlockedFilter: false,
includeGlobalFastRead: useGlobal
includeGlobalFastRead: useGlobal && viewerIncludeGlobalFastReadRelayLayer()
})
const write = buildPrioritizedWriteRelayUrls({
userWriteRelays: writeOutboxes,
@ -40,7 +44,7 @@ export async function buildAccountListRelayUrlsForMerge(options: { @@ -40,7 +44,7 @@ export async function buildAccountListRelayUrlsForMerge(options: {
blockedRelays,
maxRelays: 100,
applySocialKindBlockedFilter: false,
includeGlobalFastWriteReadTails: useGlobal
includeGlobalFastWriteReadTails: useGlobal && viewerIncludeGlobalFastWriteRelayLayer()
})
const merged = [...read, ...write]
return [...new Set(merged.map((u) => normalizeRelayUrlByScheme(u) || u).filter(Boolean))]

74
src/lib/event-ingest-filter.test.ts

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import type { Event } from 'nostr-tools'
const DRIFT_GITS_SPAM: Event = {
kind: 1,
content:
'sp_4c43bd1d.949ac75f.06.OHCFKDGO2J6TV4KYHAB2JBLIMXHR6RQWVYAGRVBPBUKCH6CPR7JJU3PMG4SBCQA.drift.gits.net',
created_at: 1780215168,
id: '00f077ecb154545e5a5ae98b1fe28db5e30661e2cad5c714c6b2b8d9a81c774a',
pubkey: '53ce12f561b8ecf9e20ae19acb0201bdc661d9e36801b47a642d9f8fdb01a245',
sig: '72bb0acfe6174a51ab176b3bf178ebcaf648e4427fb5b5500341af1603be420509d833e56e4e4315b7f6a30f6223ea85475f0f2946dbad23dc6cf95959ce9646',
tags: [
['t', 'sp_4c43bd1d'],
['nonce', '3559b6bd', '8']
]
}
const BASE64_BLOB_SPAM: Event = {
kind: 1,
content:
'yBH9z+dFkrjXwdWnT43WlzguqTlaMEaeVr2+2A5cJKpbgnSxuU/rstTbQzkb1ormLJOt6ary5iWeBVul1xHFgMzVFlnDeIrUyOGeMIBu18gwTlOyJ4NY4RsmegRYivAoej1Hik+ifi5DmXYQN3dsIiz2xYqMiks+uegscL71yY2QZOA=',
created_at: 1780215178,
id: '6b5451748d2aa66c699b99d343275d161708a0692b3edd95dcc162409bd8e0c6',
pubkey: '3ccf8522563127b37aaf0cafd0545851d9d1f6a62033ce373636b2fb72a2ffdf',
sig: '960d9c5fe890de907e2ebe922d4a3101670add71306d3f55f066b11c301f457b3635e305a5487a3c8bdad3c5405bab3bca26623ccbd56753c13c5e68b400c20e',
tags: []
}
describe('shouldDropEventOnIngest', () => {
it('drops drift.gits.net kind-1 spam', () => {
expect(shouldDropEventOnIngest(DRIFT_GITS_SPAM)).toBe(true)
})
it('allows drift.gits.net spam on explicit note lookup', () => {
expect(
shouldDropEventOnIngest(DRIFT_GITS_SPAM, {
explicitNoteLookupHexId: DRIFT_GITS_SPAM.id
})
).toBe(false)
})
it('drops long base64-like kind-1 blobs ending with =', () => {
expect(shouldDropEventOnIngest(BASE64_BLOB_SPAM)).toBe(true)
})
it('allows long base64 blob on explicit note lookup', () => {
expect(
shouldDropEventOnIngest(BASE64_BLOB_SPAM, {
explicitNoteLookupHexId: BASE64_BLOB_SPAM.id
})
).toBe(false)
})
it('does not drop short kind-1 text ending with =', () => {
expect(
shouldDropEventOnIngest({
...BASE64_BLOB_SPAM,
content: 'x=3',
tags: []
})
).toBe(false)
})
it('does not drop normal kind-1 text', () => {
expect(
shouldDropEventOnIngest({
...DRIFT_GITS_SPAM,
content: 'Hello nostr',
tags: []
})
).toBe(false)
})
})

54
src/lib/event-ingest-filter.ts

@ -41,9 +41,47 @@ function isKactiBroadcastSpamKind1(event: Pick<NEvent, 'kind' | 'content'>): boo @@ -41,9 +41,47 @@ function isKactiBroadcastSpamKind1(event: Pick<NEvent, 'kind' | 'content'>): boo
return c.startsWith('[broadcast:[#')
}
/**
* drift.gits.net kind-1 payloads (`sp_<id>.….drift.gits.net` + `t` tag) relay index noise, not discussion text.
*/
/** Min length for kind-1 opaque blobs (base64-like relay noise). */
const OPAQUE_BLOB_KIND1_MIN_LEN = 80
/** Min length when there is no `=` padding but the whole note is still one opaque token. */
const OPAQUE_BLOB_KIND1_MIN_LEN_NO_PAD = 120
/**
* Long single-token payloads (usually base64) with no readable text relay index spam.
*/
function isLongOpaqueRandomStringKind1(event: Pick<NEvent, 'kind' | 'content'>): boolean {
if (event.kind !== kinds.ShortTextNote) return false
const raw = typeof event.content === 'string' ? event.content : ''
const compact = raw.trim().replace(/\s+/g, '')
if (compact.length < OPAQUE_BLOB_KIND1_MIN_LEN) return false
if (!/^[A-Za-z0-9+/]+=*$/.test(compact)) return false
const bodyLen = compact.replace(/=+$/, '').length
if (compact.endsWith('=')) {
return bodyLen >= OPAQUE_BLOB_KIND1_MIN_LEN - 4
}
return compact.length >= OPAQUE_BLOB_KIND1_MIN_LEN_NO_PAD
}
function isDriftGitsNetSpamKind1(
event: Pick<NEvent, 'kind' | 'content' | 'tags'>
): boolean {
if (event.kind !== kinds.ShortTextNote) return false
const c = typeof event.content === 'string' ? event.content.trim() : ''
if (/\.drift\.gits\.net$/i.test(c) || /^sp_[0-9a-f]+\./i.test(c)) return true
for (const tag of event.tags) {
if (tag[0] === 't' && typeof tag[1] === 'string' && /^sp_[0-9a-f]+$/i.test(tag[1].trim())) {
return true
}
}
return false
}
export type ShouldDropEventOnIngestOptions = {
/**
* When set to the same 64-char hex as {@link NEvent.id} (lowercase), {@link isKactiBroadcastSpamKind1} does not apply
* When set to the same 64-char hex as {@link NEvent.id} (lowercase), kind-1 ingest spam filters do not apply
* so `fetchEvent` / direct note views can still show the payload.
*/
explicitNoteLookupHexId?: string
@ -61,7 +99,9 @@ const DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND = 34236 @@ -61,7 +99,9 @@ const DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND = 34236
/**
* Single gate for subscribe/cache/IDB read paths: drop kind-1 JSON-object spam, Kacti broadcast spam,
* and malformed relay reviews. Optional {@link ShouldDropEventOnIngestOptions} relaxes Kacti drops for explicit id fetch.
* drift.gits.net spam, long opaque random strings, and malformed relay reviews. Optional
* {@link ShouldDropEventOnIngestOptions} relaxes
* kind-1 spam drops for explicit id fetch.
*/
export function shouldDropEventOnIngest(
event: NEvent,
@ -70,9 +110,15 @@ export function shouldDropEventOnIngest( @@ -70,9 +110,15 @@ export function shouldDropEventOnIngest(
if (event.kind === DEPRECATED_NIP71_SHORT_VIDEO_ADDRESSABLE_KIND) return true
if (isIncompleteRelayReviewIngest(event)) return true
if (isStringifiedJsonObjectContentNostrEvent(event)) return true
const relaxKind1Spam = explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)
if (isKactiBroadcastSpamKind1(event)) {
if (explicitLookupMatchesEvent(event.id, options?.explicitNoteLookupHexId)) return false
return true
if (!relaxKind1Spam) return true
}
if (isDriftGitsNetSpamKind1(event)) {
if (!relaxKind1Spam) return true
}
if (isLongOpaqueRandomStringKind1(event)) {
if (!relaxKind1Spam) return true
}
return false
}

12
src/lib/favorites-feed-relays.ts

@ -20,6 +20,7 @@ import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay- @@ -20,6 +20,7 @@ import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { relaySessionStrikes } from '@/lib/relay-strikes'
import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults'
import { viewerIncludeGlobalFastReadRelayLayer } from '@/lib/read-only-relay-personal'
import { getCacheRelayUrlsFromEvent } from '@/lib/private-relays'
import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
@ -141,12 +142,13 @@ export function buildProfileAugmentedReadRelayUrls( @@ -141,12 +142,13 @@ export function buildProfileAugmentedReadRelayUrls(
maxRelays: number = PROFILE_AUGMENTED_READ_MAX_RELAYS,
useGlobalRelayBootstrap = true
): string[] {
const allowFastReadBootstrap = useGlobalRelayBootstrap && viewerIncludeGlobalFastReadRelayLayer()
const fastReadLayer =
useGlobalRelayBootstrap
allowFastReadBootstrap
? (FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
: []
const merged = mergeRelayUrlLayers(
useGlobalRelayBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer],
allowFastReadBootstrap ? [fastReadLayer, authorRelayUrls] : [authorRelayUrls, fastReadLayer],
blockedRelays
)
return merged.slice(0, maxRelays)
@ -196,7 +198,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox( @@ -196,7 +198,8 @@ export function getRelayUrlsWithFavoritesFastReadAndInbox(
options?: ReadRelayPriorityOptions
): string[] {
const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false
const includeFast =
options?.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer()
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
return buildPrioritizedReadRelayUrls({
userReadRelays: userInboxReadRelays,
@ -315,7 +318,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox( @@ -315,7 +318,8 @@ export function augmentSubRequestsWithFavoritesFastReadAndInbox(
: relayFilterIncludesSocialKindBlockedKind(r.filter)
const useFavDefaults = options?.useGlobalFavoriteDefaults !== false
const includeFast = options?.includeGlobalFastRead !== false
const includeFast =
options?.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer()
const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useFavDefaults)
const authorOnly = dedupeNormalizeRelayUrlsOrdered(options?.authorWriteRelays ?? [])

21
src/lib/feed-relay-urls.test.ts

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { pinHttpIndexRelaysInRelayCap, uniqueRelayUrlsFromSubRequests } from '@/lib/feed-relay-urls'
describe('feed-relay-urls', () => {
it('collects deduped relay URLs from subrequests', () => {
expect(
uniqueRelayUrlsFromSubRequests([
{ urls: ['wss://a.example/', 'wss://b.example/'], filter: { limit: 1 } },
{ urls: ['wss://a.example/', 'wss://c.example/'], filter: { limit: 1 } }
])
).toEqual(['wss://a.example/', 'wss://b.example/', 'wss://c.example/'])
})
it('pins kind-10243 HTTP read relays into a capped faux spell stack', () => {
const ws = Array.from({ length: 10 }, (_, i) => `wss://relay-${i}.example/`)
const http = 'https://index.example.com/'
const capped = pinHttpIndexRelaysInRelayCap(ws, [...ws, http], 10)
expect(capped.some((u) => u.includes('index.example.com'))).toBe(true)
expect(capped.length).toBe(10)
})
})

67
src/lib/feed-relay-urls.ts

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
import { normalizeHttpRelayUrl, normalizeRelayUrlByScheme, isHttpOrHttpsScheme } from '@/lib/url'
import type { TFeedSubRequest } from '@/types'
function relayDedupeKey(url: string): string {
return (normalizeRelayUrlByScheme(url) || url.trim()).toLowerCase()
}
/** Deduped relay URLs from all timeline subrequests (REQ order preserved). */
export function uniqueRelayUrlsFromSubRequests(requests: readonly TFeedSubRequest[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const req of requests) {
for (const raw of req.urls) {
const n = normalizeRelayUrlByScheme(raw) || raw.trim()
if (!n) continue
const key = relayDedupeKey(n)
if (seen.has(key)) continue
seen.add(key)
out.push(n)
}
}
return out
}
/**
* Keep viewer kind-10243 HTTP index relays in a capped feed stack (they are easy to drop when
* favorites + NIP-65 WS fill {@link FAUX_SPELL_MAX_RELAYS}).
*/
export function pinHttpIndexRelaysInRelayCap(
capped: readonly string[],
sourceUrls: readonly string[],
maxRelays: number
): string[] {
const httpSources = sourceUrls
.map((u) => normalizeHttpRelayUrl(u) || (isHttpOrHttpsScheme(u.trim()) ? u.trim() : ''))
.filter(Boolean)
if (httpSources.length === 0) return [...capped]
const httpKeySet = new Set(httpSources.map((u) => u.toLowerCase()))
const out = [...capped]
const outKeys = new Set(out.map(relayDedupeKey))
for (const http of httpSources) {
const key = http.toLowerCase()
if (outKeys.has(key)) continue
while (out.length >= maxRelays) {
let dropped = false
for (let i = out.length - 1; i >= 0; i--) {
const candidate = out[i]!
const ck = relayDedupeKey(candidate)
if (httpKeySet.has(ck) || isHttpOrHttpsScheme(candidate.trim())) continue
out.splice(i, 1)
outKeys.delete(ck)
dropped = true
break
}
if (!dropped) break
}
if (out.length >= maxRelays) continue
out.push(http)
outKeys.add(key)
}
return out.slice(0, maxRelays)
}

7
src/lib/home-feed-relays.ts

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
export { stripNostrLandAggrFromRelayUrls }
@ -28,6 +30,9 @@ export function buildAllFavoritesFeedRelayUrls( @@ -28,6 +30,9 @@ export function buildAllFavoritesFeedRelayUrls(
extraFeedRelayUrls: string[],
useGlobalFavoriteDefaults = true
): string[] {
const extras = isMetadataRelaysOnlyPolicyActive()
? extraFeedRelayUrls.filter((u) => !isWispTrendingNotesRelayUrl(u))
: extraFeedRelayUrls
return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
@ -35,7 +40,7 @@ export function buildAllFavoritesFeedRelayUrls( @@ -35,7 +40,7 @@ export function buildAllFavoritesFeedRelayUrls(
source: 'favorites',
urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults)
},
{ source: 'fallback', urls: extraFeedRelayUrls }
{ source: 'fallback', urls: extras }
],
{
operation: 'favorites-feed',

4
src/lib/live-activities.ts

@ -7,6 +7,7 @@ import { @@ -7,6 +7,7 @@ import {
MAX_REQ_RELAY_URLS,
relayUrlsLocalsFirst
} from '@/lib/relay-url-priority'
import { viewerIncludeGlobalFastReadRelayLayer } from '@/lib/read-only-relay-personal'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { nip19, type Event, type Filter } from 'nostr-tools'
@ -669,7 +670,8 @@ export function buildLiveActivitiesRelayUrls(options: { @@ -669,7 +670,8 @@ export function buildLiveActivitiesRelayUrls(options: {
includeGlobalFastRead?: boolean
}): string[] {
const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options
const includeFast = options.includeGlobalFastRead !== false
const includeFast =
options.includeGlobalFastRead !== false && viewerIncludeGlobalFastReadRelayLayer()
const useGlobalFavoriteDefaults = includeFast
if (loggedIn) {
const fav = relayUrlsLocalsFirst(

29
src/lib/menu-popover-layout.ts

@ -0,0 +1,29 @@ @@ -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'

21
src/lib/metadata-policy-curated-relays.test.ts

@ -1,10 +1,27 @@ @@ -1,10 +1,27 @@
import { PROFILE_RELAY_URLS } from '@/constants'
import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest'
import { isMetadataPolicyCuratedRelay } from './metadata-policy-curated-relays'
import {
isMetadataPolicyActiveReadGrantRelay,
isMetadataPolicyCuratedRelay,
isMetadataPolicyOperationScopedRelay
} from './metadata-policy-curated-relays'
describe('metadata-policy-curated-relays', () => {
it('recognizes profile relay constants', () => {
expect(isMetadataPolicyCuratedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyCuratedRelay('wss://nostr.wirednet.jp/')).toBe(false)
})
it('operation scope excludes FAST_READ widening', () => {
expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false)
})
it('active read grant includes search and discovery stacks', () => {
expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true)
expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false)
})
})

65
src/lib/metadata-policy-curated-relays.ts

@ -24,12 +24,49 @@ const METADATA_POLICY_CURATED_RELAY_LISTS: readonly (readonly string[])[] = [ @@ -24,12 +24,49 @@ const METADATA_POLICY_CURATED_RELAY_LISTS: readonly (readonly string[])[] = [
NIP42_POOL_AUTOMATIC_AUTH_RELAY_URLS
]
/**
* Curated stacks allowed to connect briefly under metadata-only policy when merged into an active
* query/subscribe (documents, GIFs, profiles, ). Excludes FAST_READ, search indexers, and read-only mirrors.
*/
const METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS: readonly (readonly string[])[] = [
PROFILE_RELAY_URLS,
DOCUMENT_RELAY_URLS,
GIF_RELAY_URLS,
BOOKSTR_RELAY_URLS,
FOLLOWS_HISTORY_RELAY_URLS
]
let curatedRelayKeySet: ReadonlySet<string> | null = null
let operationScopedRelayKeySet: ReadonlySet<string> | null = null
function relayKeyForCuratedSet(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
/** Relays grantable for the duration of an active read query/subscribe (not general feed widening). */
const METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS: readonly (readonly string[])[] = [
...METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS,
SEARCHABLE_RELAY_URLS,
READ_ONLY_RELAY_URLS,
NIP66_DISCOVERY_RELAY_URLS
]
let activeReadGrantRelayKeySet: ReadonlySet<string> | null = null
function getActiveReadGrantRelayKeySet(): ReadonlySet<string> {
if (!activeReadGrantRelayKeySet) {
const out = new Set<string>()
for (const list of METADATA_POLICY_ACTIVE_READ_GRANT_RELAY_LISTS) {
for (const u of list) {
const key = relayKeyForCuratedSet(u)
if (key) out.add(key)
}
}
activeReadGrantRelayKeySet = out
}
return activeReadGrantRelayKeySet
}
function getCuratedRelayKeySet(): ReadonlySet<string> {
if (!curatedRelayKeySet) {
const out = new Set<string>()
@ -44,12 +81,38 @@ function getCuratedRelayKeySet(): ReadonlySet<string> { @@ -44,12 +81,38 @@ function getCuratedRelayKeySet(): ReadonlySet<string> {
return curatedRelayKeySet
}
function getOperationScopedRelayKeySet(): ReadonlySet<string> {
if (!operationScopedRelayKeySet) {
const out = new Set<string>()
for (const list of METADATA_POLICY_OPERATION_SCOPED_RELAY_LISTS) {
for (const u of list) {
const key = relayKeyForCuratedSet(u)
if (key) out.add(key)
}
}
operationScopedRelayKeySet = out
}
return operationScopedRelayKeySet
}
/** True for relays from specialized constants (profile fetch, read-only indexers, NIP-50, …). */
export function isMetadataPolicyCuratedRelay(url: string): boolean {
const key = relayKeyForCuratedSet(url)
return key.length > 0 && getCuratedRelayKeySet().has(key)
}
/** Purpose-specific constants that may connect during an in-flight read (not general feed widening). */
export function isMetadataPolicyOperationScopedRelay(url: string): boolean {
const key = relayKeyForCuratedSet(url)
return key.length > 0 && getOperationScopedRelayKeySet().has(key)
}
/** Search / index / discovery stacks allowed only while an active read operation lists them. */
export function isMetadataPolicyActiveReadGrantRelay(url: string): boolean {
const key = relayKeyForCuratedSet(url)
return key.length > 0 && getActiveReadGrantRelayKeySet().has(key)
}
let profileRelayKeySet: ReadonlySet<string> | null = null
function getProfileRelayKeySet(): ReadonlySet<string> {
@ -73,5 +136,7 @@ export function isMetadataPolicyProfileRelay(url: string): boolean { @@ -73,5 +136,7 @@ export function isMetadataPolicyProfileRelay(url: string): boolean {
/** For tests: reset lazy-built key set after constant changes. */
export function resetMetadataPolicyCuratedRelayKeysForTests(): void {
curatedRelayKeySet = null
operationScopedRelayKeySet = null
activeReadGrantRelayKeySet = null
profileRelayKeySet = null
}

186
src/lib/new-user-template-broadcast.ts

@ -0,0 +1,186 @@ @@ -0,0 +1,186 @@
import { ExtendedKind, PROFILE_RELAY_URLS } from '@/constants'
import { getRelayListFromEvent, getHttpRelayListFromEvent } from '@/lib/event-metadata'
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { collectWriteOutboxUrlsFromRelayList } from '@/lib/viewer-write-outboxes'
import logger from '@/lib/logger'
import { NEW_USER_HTTP_RELAY_URL } from '@/lib/new-user-template'
import { normalizeAnyRelayUrl } from '@/lib/url'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { TRelayList } from '@/types'
import { Event, kinds } from 'nostr-tools'
const BROADCAST_PENDING_KEY = 'imwaldNewUserTemplateBroadcastPending'
/** Space between replaceable template events — keeps relay publish rate limits from tripping. */
export const NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS = 20_000
/** Replaceable kinds created during one-click signup, in publish order. */
export const NEW_USER_TEMPLATE_BROADCAST_KINDS = [
kinds.RelayList,
ExtendedKind.HTTP_RELAY_LIST,
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS,
kinds.Metadata,
10015,
kinds.Contacts,
kinds.Mutelist
] as const
/** Relays that reject bursts or return HTTP 429 on connect during signup publish. */
const NEW_USER_TEMPLATE_PUBLISH_EXCLUDED = [
'wss://relay.layer.systems',
'wss://profiles.nostrver.se/'
] as const
/** Profile mirrors that only mirror kind 10002 (not kind 0 or other lists). */
const RELAY_LIST_ONLY_PROFILE_MIRRORS = ['wss://indexer.coracle.social/'] as const
const broadcastScheduledOrRunning = new Set<string>()
function templateRelayKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url).toLowerCase()
}
function isExcludedFromTemplateBroadcast(url: string): boolean {
const key = templateRelayKey(url)
return NEW_USER_TEMPLATE_PUBLISH_EXCLUDED.some((u) => templateRelayKey(u) === key)
}
function relayAllowsTemplateKind(url: string, kind: number): boolean {
if (kind === kinds.RelayList) return true
const key = templateRelayKey(url)
return !RELAY_LIST_ONLY_PROFILE_MIRRORS.some((u) => templateRelayKey(u) === key)
}
function maxTemplatePublishRelays(kind: number): number {
return kind === kinds.Metadata || kind === kinds.RelayList ? 4 : 3
}
/** Prefer mercury + stable write relays; profile index when kind allows. */
function prioritizeNewUserTemplateRelays(urls: string[]): string[] {
const preferredOrder = [
NEW_USER_HTTP_RELAY_URL,
'wss://profiles.nostr1.com',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://thecitadel.nostr1.com'
]
const byKey = new Map(urls.map((u) => [templateRelayKey(u), u]))
const ordered: string[] = []
for (const pref of preferredOrder) {
const u = byKey.get(templateRelayKey(pref))
if (u) {
ordered.push(u)
byKey.delete(templateRelayKey(u))
}
}
for (const u of urls) {
const k = templateRelayKey(u)
if (byKey.has(k)) {
ordered.push(u)
byKey.delete(k)
}
}
return ordered
}
export function markNewUserTemplateBroadcastPending(pubkey: string): void {
if (typeof sessionStorage === 'undefined') return
sessionStorage.setItem(BROADCAST_PENDING_KEY, pubkey)
}
function consumeBroadcastPending(pubkey: string): boolean {
if (typeof sessionStorage === 'undefined') return false
if (sessionStorage.getItem(BROADCAST_PENDING_KEY) !== pubkey) return false
sessionStorage.removeItem(BROADCAST_PENDING_KEY)
return true
}
/** Write outboxes from the stored template plus profile index relays where the kind allows it. */
export function newUserTemplatePublishRelays(kind: number, relayList: TRelayList): string[] {
const write = collectWriteOutboxUrlsFromRelayList(relayList)
const merged =
kind === kinds.Metadata || kind === kinds.RelayList
? dedupeNormalizeRelayUrlsOrdered([...write, ...PROFILE_RELAY_URLS])
: write
const filtered = filterRelaysForEventPublish(merged, kind)
.filter((u) => !isExcludedFromTemplateBroadcast(u))
.filter((u) => relayAllowsTemplateKind(u, kind))
return prioritizeNewUserTemplateRelays(filtered).slice(0, maxTemplatePublishRelays(kind))
}
async function loadRelayListForPublish(pubkey: string): Promise<TRelayList> {
const peeked = await client.peekRelayListFromStorage(pubkey)
if (peeked.write.length > 0 || peeked.httpWrite.length > 0) {
return peeked
}
const [relayListEvent, httpRelayListEvent] = await Promise.all([
indexedDb.getReplaceableEvent(pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST)
])
const emptyHttp = {
httpRead: [] as string[],
httpWrite: [] as string[],
httpOriginalRelays: [] as TRelayList['httpOriginalRelays']
}
let base: TRelayList = relayListEvent
? getRelayListFromEvent(relayListEvent, [])
: { write: [], read: [], originalRelays: [], ...emptyHttp }
if (httpRelayListEvent) {
const http = getHttpRelayListFromEvent(httpRelayListEvent, [])
base = {
...base,
httpRead: http.httpRead,
httpWrite: http.httpWrite,
httpOriginalRelays: http.httpOriginalRelays
}
}
return base
}
async function broadcastNewUserTemplateFromStorage(pubkey: string): Promise<void> {
const relayList = await loadRelayListForPublish(pubkey)
for (let i = 0; i < NEW_USER_TEMPLATE_BROADCAST_KINDS.length; i++) {
const kind = NEW_USER_TEMPLATE_BROADCAST_KINDS[i]
const event = (await indexedDb.getReplaceableEvent(pubkey, kind)) as Event | undefined
if (!event) continue
const relays = newUserTemplatePublishRelays(kind, relayList)
if (relays.length === 0) continue
try {
await client.publishEvent(relays, event, {
skipOutboxRetry: true,
publishBatchLabel: 'new user template broadcast'
})
} catch (error) {
logger.warn('[newUserTemplateBroadcast] publish failed', { kind, error })
}
if (i < NEW_USER_TEMPLATE_BROADCAST_KINDS.length - 1) {
await new Promise((resolve) => setTimeout(resolve, NEW_USER_TEMPLATE_BROADCAST_INTERVAL_MS))
}
}
}
/**
* After the user dismisses the backup banner or leaves cache settings, broadcast locally stored
* template events to their write outboxes and profile relays (spaced to avoid relay rate limits).
*/
export function requestNewUserTemplateBroadcast(pubkey: string): void {
if (!pubkey || broadcastScheduledOrRunning.has(pubkey)) return
if (typeof sessionStorage === 'undefined') return
if (sessionStorage.getItem(BROADCAST_PENDING_KEY) !== pubkey) return
broadcastScheduledOrRunning.add(pubkey)
void (async () => {
try {
if (!consumeBroadcastPending(pubkey)) return
await broadcastNewUserTemplateFromStorage(pubkey)
} catch (error) {
logger.error('[newUserTemplateBroadcast] failed', { error })
} finally {
broadcastScheduledOrRunning.delete(pubkey)
}
})()
}

124
src/lib/new-user-template.test.ts

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools'
import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { NEW_USER_BLOCKED_RELAY_URLS, NEW_USER_HTTP_RELAY_URL, buildNewUserTemplateDrafts, newUserProfileDisplayName, newUserProfileName, newUserProfileSuffix } from '@/lib/new-user-template'
import { newUserTemplatePublishRelays } from '@/lib/new-user-template-broadcast'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { TRelayList } from '@/types'
const TEST_PUBKEY = 'a'.repeat(63) + 'b'
function relayKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url).toLowerCase()
}
function expectRelayKeys(actual: string[], expected: string[]) {
const actualKeys = new Set(actual.map(relayKey))
for (const url of expected) {
expect(actualKeys.has(relayKey(url))).toBe(true)
}
}
const templateRelayList = (): TRelayList => ({
write: [...FAST_WRITE_RELAY_URLS],
read: [...FAST_READ_RELAY_URLS],
originalRelays: [],
httpRead: [],
httpWrite: [NEW_USER_HTTP_RELAY_URL],
httpOriginalRelays: []
})
describe('newUserProfileSuffix', () => {
it('returns a number between 1000 and 9999', () => {
const suffix = newUserProfileSuffix(TEST_PUBKEY)
expect(suffix).toBeGreaterThanOrEqual(1000)
expect(suffix).toBeLessThanOrEqual(9999)
})
it('formats profile names with the suffix', () => {
const suffix = newUserProfileSuffix(TEST_PUBKEY)
expect(newUserProfileName(TEST_PUBKEY)).toBe(`ImwaldUser${suffix}`)
expect(newUserProfileDisplayName(TEST_PUBKEY)).toBe(`Imwald User ${suffix}`)
})
})
describe('buildNewUserTemplateDrafts', () => {
const drafts = buildNewUserTemplateDrafts(TEST_PUBKEY)
it('builds profile kind 0 with unique names', () => {
expect(drafts.profile.kind).toBe(kinds.Metadata)
const profile = JSON.parse(drafts.profile.content)
expect(profile.name).toBe(newUserProfileName(TEST_PUBKEY))
expect(profile.display_name).toBe(newUserProfileDisplayName(TEST_PUBKEY))
expect(profile.about).toContain('Imwald')
})
it('builds favorite relays kind 10012', () => {
expect(drafts.favoriteRelays.kind).toBe(ExtendedKind.FAVORITE_RELAYS)
expect(drafts.favoriteRelays.tags.filter((t) => t[0] === 'relay')).toHaveLength(2)
})
it('builds blocked relays kind 10006 with dead relays', () => {
expect(drafts.blockedRelays.kind).toBe(ExtendedKind.BLOCKED_RELAYS)
const blocked = drafts.blockedRelays.tags.filter((t) => t[0] === 'relay').map((t) => t[1])
expect(blocked).toEqual([...NEW_USER_BLOCKED_RELAY_URLS])
})
it('splits mailbox read and write relays', () => {
expect(drafts.relayList.kind).toBe(kinds.RelayList)
const readTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'read')
const writeTags = drafts.relayList.tags.filter((t) => t[0] === 'r' && t[2] === 'write')
expect(readTags).toHaveLength(FAST_READ_RELAY_URLS.length)
expect(writeTags).toHaveLength(FAST_WRITE_RELAY_URLS.length)
})
it('builds HTTP relay list kind 10243 with mercury', () => {
expect(drafts.httpRelayList.kind).toBe(ExtendedKind.HTTP_RELAY_LIST)
expect(drafts.httpRelayList.tags.some((t) => t[1]?.includes('mercury-relay.imwald.eu'))).toBe(true)
})
it('builds interest list with expected topics', () => {
expect(drafts.interestList.kind).toBe(10015)
const topics = drafts.interestList.tags.filter((t) => t[0] === 't').map((t) => t[1])
expect(topics).toEqual([
'art',
'music',
'news',
'foodstr',
'coffeechain',
'travel',
'grownostr',
'plebchain'
])
})
it('builds empty follow and mute lists', () => {
expect(drafts.followList.kind).toBe(kinds.Contacts)
expect(drafts.followList.tags).toHaveLength(0)
expect(drafts.muteList.kind).toBe(10000)
expect(drafts.muteList.tags).toHaveLength(0)
})
})
describe('newUserTemplatePublishRelays', () => {
const relayList = templateRelayList()
it('caps list kinds to three stable write relays and skips flaky mirrors', () => {
const targets = newUserTemplatePublishRelays(10015, relayList)
expect(targets.length).toBeLessThanOrEqual(3)
expect(targets.map(relayKey)).not.toContain(relayKey('wss://relay.layer.systems'))
expect(targets.map(relayKey)).not.toContain(relayKey('wss://profiles.nostrver.se/'))
expect(targets.map(relayKey)).not.toContain(relayKey('wss://indexer.coracle.social/'))
expectRelayKeys(targets, [NEW_USER_HTTP_RELAY_URL])
})
it('adds profile relays for kind 0 and 10002 up to four targets', () => {
const profileTargets = newUserTemplatePublishRelays(kinds.Metadata, relayList)
expect(profileTargets.length).toBeLessThanOrEqual(4)
expectRelayKeys(profileTargets, [NEW_USER_HTTP_RELAY_URL, 'wss://profiles.nostr1.com'])
expect(profileTargets.map(relayKey)).not.toContain(relayKey('wss://indexer.coracle.social/'))
const relayListTargets = newUserTemplatePublishRelays(kinds.RelayList, relayList)
expect(relayListTargets.length).toBeLessThanOrEqual(4)
expectRelayKeys(relayListTargets, [NEW_USER_HTTP_RELAY_URL, 'wss://profiles.nostr1.com'])
})
})

122
src/lib/new-user-template.ts

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
import {
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS
} from '@/constants'
import {
createBlockedRelaysDraftEvent,
createFavoriteRelaysDraftEvent,
createFollowListDraftEvent,
createHttpRelayListDraftEvent,
createInterestListDraftEvent,
createMuteListDraftEvent,
createProfileDraftEvent,
createRelayListDraftEvent
} from '@/lib/draft-event'
import { TDraftEvent, TMailboxRelay } from '@/types'
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/'
/** Dead relays seeded into kind 10006 for new accounts. */
export const NEW_USER_BLOCKED_RELAY_URLS = [
'wss://orly-relay.imwald.eu',
'wss://relay.nostr.band'
] as const
export const NEW_USER_INTEREST_TOPICS = [
'art',
'music',
'news',
'foodstr',
'coffeechain',
'travel',
'grownostr',
'plebchain'
] as const
export const NEW_USER_PROFILE_ABOUT = 'New on Nostr via Imwald. Edit your profile in Settings.'
/** Stable 4-digit suffix (1000–9999) from pubkey hex. */
export function newUserProfileSuffix(pubkey: string): number {
const hex = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(hex)) {
return 1000
}
return (parseInt(hex.slice(-4), 16) % 9000) + 1000
}
export function newUserProfileName(pubkey: string): string {
return `ImwaldUser${newUserProfileSuffix(pubkey)}`
}
export function newUserProfileDisplayName(pubkey: string): string {
return `Imwald User ${newUserProfileSuffix(pubkey)}`
}
export function buildNewUserMailboxRelays(): TMailboxRelay[] {
return [
...FAST_READ_RELAY_URLS.map((url) => ({ url, scope: 'read' as const })),
...FAST_WRITE_RELAY_URLS.map((url) => ({ url, scope: 'write' as const }))
]
}
export function buildNewUserProfileDraft(pubkey: string): TDraftEvent {
const content = JSON.stringify({
name: newUserProfileName(pubkey),
display_name: newUserProfileDisplayName(pubkey),
about: NEW_USER_PROFILE_ABOUT
})
return createProfileDraftEvent(content)
}
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent {
return createFavoriteRelaysDraftEvent([...DEFAULT_FAVORITE_RELAYS], [])
}
export function buildNewUserBlockedRelaysDraft(): TDraftEvent {
return createBlockedRelaysDraftEvent([...NEW_USER_BLOCKED_RELAY_URLS])
}
export function buildNewUserRelayListDraft(): TDraftEvent {
return createRelayListDraftEvent(buildNewUserMailboxRelays())
}
export function buildNewUserHttpRelayListDraft(): TDraftEvent {
return createHttpRelayListDraftEvent([{ url: NEW_USER_HTTP_RELAY_URL, scope: 'both' }])
}
export function buildNewUserInterestListDraft(): TDraftEvent {
return createInterestListDraftEvent([...NEW_USER_INTEREST_TOPICS])
}
export function buildNewUserFollowListDraft(): TDraftEvent {
return createFollowListDraftEvent([])
}
export function buildNewUserMuteListDraft(): TDraftEvent {
return createMuteListDraftEvent([])
}
export type TNewUserTemplateDrafts = {
profile: TDraftEvent
favoriteRelays: TDraftEvent
blockedRelays: TDraftEvent
relayList: TDraftEvent
httpRelayList: TDraftEvent
interestList: TDraftEvent
followList: TDraftEvent
muteList: TDraftEvent
}
export function buildNewUserTemplateDrafts(pubkey: string): TNewUserTemplateDrafts {
return {
profile: buildNewUserProfileDraft(pubkey),
favoriteRelays: buildNewUserFavoriteRelaysDraft(),
blockedRelays: buildNewUserBlockedRelaysDraft(),
relayList: buildNewUserRelayListDraft(),
httpRelayList: buildNewUserHttpRelayListDraft(),
interestList: buildNewUserInterestListDraft(),
followList: buildNewUserFollowListDraft(),
muteList: buildNewUserMuteListDraft()
}
}

51
src/lib/nip05-affiliation.ts

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
import { NIP05_AFFILIATION_BY_DOMAIN, type TNip05AffiliationDomain } from '@/constants'
import { splitNip05Identifier } from '@/lib/nip05'
export function normalizeNip05AffiliationDomain(domain: string): string {
return domain.trim().toLowerCase().replace(/\.$/, '')
}
export function affiliationForNip05Domain(domain: string): TNip05AffiliationDomain | undefined {
return NIP05_AFFILIATION_BY_DOMAIN.get(normalizeNip05AffiliationDomain(domain))
}
/** Unique NIP-05 identifiers from kind-0 primary + list fields. */
export function collectProfileNip05Identifiers(
nip05?: string,
nip05List?: string[]
): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw?: string) => {
const id = raw?.trim()
if (!id || seen.has(id)) return
seen.add(id)
out.push(id)
}
add(nip05)
for (const entry of nip05List ?? []) {
add(entry)
}
return out
}
/**
* NIP-05 rows on the profile whose domain is in {@link NIP05_AFFILIATION_DOMAINS}.
* One row per identifier (verification runs separately).
*/
export function affiliationNip05CandidatesFromProfile(
nip05?: string,
nip05List?: string[]
): { nip05: string; affiliation: TNip05AffiliationDomain }[] {
const out: { nip05: string; affiliation: TNip05AffiliationDomain }[] = []
const domainsSeen = new Set<string>()
for (const id of collectProfileNip05Identifiers(nip05, nip05List)) {
const parts = splitNip05Identifier(id)
if (!parts) continue
const affiliation = affiliationForNip05Domain(parts.domain)
if (!affiliation || domainsSeen.has(affiliation.domain)) continue
domainsSeen.add(affiliation.domain)
out.push({ nip05: id, affiliation })
}
return out
}

16
src/lib/nip05-well-known.test.ts

@ -31,7 +31,8 @@ const THEFOREST_WELL_KNOWN = { @@ -31,7 +31,8 @@ const THEFOREST_WELL_KNOWN = {
describe('parseNip05NamePubkeysFromWellKnownJson', () => {
it('parses theforest.nostr1.com well-known names', () => {
const rows = parseNip05NamePubkeysFromWellKnownJson(THEFOREST_WELL_KNOWN)
expect(rows).toHaveLength(15)
expect(rows).toHaveLength(14)
expect(new Set(rows.map((r) => r.pubkey)).size).toBe(14)
expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(
'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
)
@ -71,6 +72,19 @@ describe('parseNip05NamePubkeysFromWellKnownJson', () => { @@ -71,6 +72,19 @@ describe('parseNip05NamePubkeysFromWellKnownJson', () => {
expect(rows.find((r) => r.name === 'laeserin')?.pubkey).toBe(laeserinHex)
})
it('dedupes multiple names for the same pubkey', () => {
const hex = '6da819f91d69cbe591c08b31f555c6d0ab9905197eb515856e339049c018c1af'
const rows = parseNip05NamePubkeysFromWellKnownJson({
names: {
'137': hex,
'430': hex,
laeserin: 'dd664d5e4016433a8cd69f005ae1480804351789b59de5af06276de65633d319'
}
})
expect(rows).toHaveLength(2)
expect(rows.filter((r) => r.pubkey === hex)).toHaveLength(1)
})
it('partial name-filtered documents omit other users', () => {
const partial = {
names: {

23
src/lib/nip05.ts

@ -447,22 +447,33 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]> @@ -447,22 +447,33 @@ export async function fetchPubkeysFromDomain(domain: string): Promise<string[]>
return entries.map((e) => e.pubkey)
}
/** Prefer human NIP-05 local parts over `_`, hex keys, or npub labels when one pubkey appears twice. */
function nip05DomainListNameScore(name: string): number {
if (name === '_') return 0
if (/^[0-9a-f]{64}$/i.test(name) || name.startsWith('npub1')) return 1
return 2
}
export function parseNip05NamePubkeysFromWellKnownJson(
json: Record<string, unknown>
): Array<{ name: string; pubkey: string }> {
const normalized = normalizeWellKnownDocument(json)
if (!normalized) return []
const names = normalized.names as Record<string, unknown>
const out: Array<{ name: string; pubkey: string }> = []
const seen = new Set<string>()
const byPubkey = new Map<string, { name: string; pubkey: string }>()
for (const [key, v] of Object.entries(names)) {
const entry = parseNip05NamePubkeyEntry(key, v)
if (!entry || !isValidPubkey(entry.pubkey)) continue
const dedupe = `${entry.name}:${entry.pubkey}`
if (seen.has(dedupe)) continue
seen.add(dedupe)
out.push(entry)
const pk = entry.pubkey.toLowerCase()
const prev = byPubkey.get(pk)
if (
!prev ||
nip05DomainListNameScore(entry.name) > nip05DomainListNameScore(prev.name)
) {
byPubkey.set(pk, { name: entry.name, pubkey: pk })
}
}
const out = [...byPubkey.values()]
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}

50
src/lib/post-signup-backup-prompt.ts

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
const POST_SIGNUP_NAV_KEY = 'imwaldPostSignupBackupNav'
const NEW_USER_BACKUP_BANNER_KEY = 'imwaldNewUserBackupBanner'
const SKIP_NETWORK_HYDRATE_KEY = 'imwaldNewUserSkipNetworkHydrate'
export function schedulePostSignupBackupPrompt(pubkey: string): void {
if (typeof sessionStorage === 'undefined') return
sessionStorage.setItem(POST_SIGNUP_NAV_KEY, pubkey)
}
/** Returns true when this pubkey had a pending post-signup nav (and clears it). */
export function consumePostSignupBackupPrompt(pubkey: string): boolean {
if (typeof sessionStorage === 'undefined') return false
const pending = sessionStorage.getItem(POST_SIGNUP_NAV_KEY)
if (pending !== pubkey) return false
sessionStorage.removeItem(POST_SIGNUP_NAV_KEY)
return true
}
export function showNewUserBackupBanner(): void {
if (typeof sessionStorage === 'undefined') return
sessionStorage.setItem(NEW_USER_BACKUP_BANNER_KEY, '1')
}
export function isNewUserBackupBannerVisible(): boolean {
if (typeof sessionStorage === 'undefined') return false
return sessionStorage.getItem(NEW_USER_BACKUP_BANNER_KEY) === '1'
}
export function dismissNewUserBackupBanner(): void {
if (typeof sessionStorage === 'undefined') return
sessionStorage.removeItem(NEW_USER_BACKUP_BANNER_KEY)
}
/** Skip heavy network hydrate while local template is written and relays publish in background. */
export function markFreshSignupSkipNetworkHydrate(pubkey: string): void {
if (typeof sessionStorage === 'undefined') return
sessionStorage.setItem(SKIP_NETWORK_HYDRATE_KEY, pubkey)
}
export function shouldSkipNetworkHydrateForFreshSignup(pubkey: string): boolean {
if (typeof sessionStorage === 'undefined') return false
return sessionStorage.getItem(SKIP_NETWORK_HYDRATE_KEY) === pubkey
}
export function clearFreshSignupSkipNetworkHydrate(pubkey: string): void {
if (typeof sessionStorage === 'undefined') return
if (sessionStorage.getItem(SKIP_NETWORK_HYDRATE_KEY) === pubkey) {
sessionStorage.removeItem(SKIP_NETWORK_HYDRATE_KEY)
}
}

13
src/lib/publishing-feedback.tsx

@ -23,6 +23,11 @@ function publishSuccessToastsEnabled(): boolean { @@ -23,6 +23,11 @@ function publishSuccessToastsEnabled(): boolean {
return storage.getShowPublishSuccessToasts()
}
/** Per-relay toast panels only when success toasts are on and the nested setting is enabled. */
export function detailedPublishToastsEnabled(): boolean {
return publishSuccessToastsEnabled() && storage.getShowDetailedPublishToasts()
}
function resolvePromiseSuccessLabel(success: string | (() => ReactNode)): string | undefined {
if (typeof success === 'string') return success
try {
@ -124,9 +129,15 @@ export function showPublishingFeedback( @@ -124,9 +129,15 @@ export function showPublishingFeedback(
const toastFunction = isSuccess ? toast.success : toast.error
if (!detailedPublishToastsEnabled()) {
toastFunction(message, { duration: isSuccess ? 2000 : duration })
return
}
toastFunction(<PublishToastRelayPanel message={message} result={result} />, {
duration,
className: 'max-w-lg w-full'
className: 'max-w-lg w-full',
icon: null
})
}

27
src/lib/random-publish-relay-pool.test.ts

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { buildRandomPublishRelayCandidateList } from './random-publish-relay-pool'
describe('buildRandomPublishRelayCandidateList', () => {
it('fills from fallback write relays when NIP-66 list is empty', () => {
const candidates = buildRandomPublishRelayCandidateList({
excludeSessionKeys: new Set(['wss://relay.user.example']),
sessionBoost: [],
nip66Lively: [],
fallbackWriteRelays: ['wss://alpha.example', 'wss://beta.example', 'wss://gamma.example']
})
expect(candidates.length).toBeGreaterThanOrEqual(3)
expect(candidates.some((u) => u.includes('alpha.example'))).toBe(true)
expect(candidates.some((u) => u.includes('relay.user.example'))).toBe(false)
})
it('dedupes session boost and NIP-66 entries', () => {
const candidates = buildRandomPublishRelayCandidateList({
excludeSessionKeys: new Set(),
sessionBoost: ['wss://same.example/'],
nip66Lively: ['wss://same.example'],
fallbackWriteRelays: []
})
expect(candidates).toHaveLength(1)
expect(candidates[0]).toMatch(/same\.example/)
})
})

39
src/lib/random-publish-relay-pool.ts

@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
import { RANDOM_PUBLISH_RELAY_COUNT } from '@/constants'
import { canonicalRelaySessionKey, normalizeAnyRelayUrl, normalizeRelayUrlByScheme } from '@/lib/url'
export function normalizePublishRelayCandidate(url: string): string {
return normalizeRelayUrlByScheme(normalizeAnyRelayUrl(url) || url) || url.trim()
}
/**
* Ordered candidate pool for optional random publish relays (NIP-66 lively, then write fallbacks).
* Excludes relays already in the user's publish picker list ({@link excludeSessionKeys}).
*/
export function buildRandomPublishRelayCandidateList(args: {
excludeSessionKeys: ReadonlySet<string>
sessionBoost: readonly string[]
nip66Lively: readonly string[]
fallbackWriteRelays: readonly string[]
/** Upper bound on candidates before {@link pickRandomPublishRelays} narrows to {@link RANDOM_PUBLISH_RELAY_COUNT}. */
maxCandidates?: number
}): string[] {
const seen = new Set<string>()
const out: string[] = []
const max = args.maxCandidates ?? RANDOM_PUBLISH_RELAY_COUNT * 8
const push = (raw: string) => {
if (out.length >= max) return
const normalized = normalizePublishRelayCandidate(raw)
const key = canonicalRelaySessionKey(normalized)
if (!key || args.excludeSessionKeys.has(key) || seen.has(key)) return
seen.add(key)
out.push(normalized)
}
for (const u of args.sessionBoost) push(u)
for (const u of args.nip66Lively) push(u)
if (out.length < RANDOM_PUBLISH_RELAY_COUNT) {
for (const u of args.fallbackWriteRelays) push(u)
}
return out
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save