Browse Source

more refactoring

imwald
Silberengel 1 month ago
parent
commit
16e435cdfe
  1. 2
      README.md
  2. 104
      src/PageManager.tsx
  3. 38
      src/components/ContentPreview/ZapPreview.tsx
  4. 2
      src/components/Explore/ExploreFavoriteRelays.tsx
  5. 324
      src/components/KeyboardShortcutsHelp/index.tsx
  6. 10
      src/components/KindFilter/index.tsx
  7. 69
      src/components/Note/Zap.tsx
  8. 61
      src/components/NoteBoostBadges/index.tsx
  9. 2
      src/components/NoteCard/RepostDescription.tsx
  10. 14
      src/components/NoteInteractions/Tabs.tsx
  11. 7
      src/components/NoteInteractions/index.tsx
  12. 14
      src/components/NoteStats/RepostButton.tsx
  13. 2
      src/components/NoteStats/index.tsx
  14. 2
      src/components/NotificationList/NotificationItem/RepostNotification.tsx
  15. 2
      src/components/Profile/ProfileFeed.tsx
  16. 2
      src/components/RepostList/index.tsx
  17. 6
      src/components/Sidebar/AccountButton.tsx
  18. 2
      src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx
  19. 2
      src/components/Sidebar/index.tsx
  20. 760
      src/components/TrendingNotes/index.tsx
  21. 2
      src/constants.ts
  22. 13
      src/i18n/locales/ar.ts
  23. 26
      src/i18n/locales/de.ts
  24. 26
      src/i18n/locales/en.ts
  25. 13
      src/i18n/locales/es.ts
  26. 13
      src/i18n/locales/fa.ts
  27. 13
      src/i18n/locales/fr.ts
  28. 13
      src/i18n/locales/hi.ts
  29. 13
      src/i18n/locales/it.ts
  30. 13
      src/i18n/locales/ja.ts
  31. 13
      src/i18n/locales/ko.ts
  32. 13
      src/i18n/locales/pl.ts
  33. 13
      src/i18n/locales/pt-BR.ts
  34. 13
      src/i18n/locales/pt-PT.ts
  35. 13
      src/i18n/locales/ru.ts
  36. 13
      src/i18n/locales/th.ts
  37. 13
      src/i18n/locales/zh.ts
  38. 147
      src/pages/primary/ExplorePage/index.tsx
  39. 1
      src/pages/primary/MePage/index.tsx
  40. 2
      src/pages/primary/SettingsPrimaryPage/index.tsx
  41. 5
      src/pages/secondary/NotePage/index.tsx
  42. 25
      src/services/local-storage.service.ts
  43. 5
      src/vite-env.d.ts

2
README.md

@ -42,7 +42,7 @@ High-level changes versus a “stock” Jumble-style layout:
### Explore quality-of-life ### Explore quality-of-life
- **Relay URL search** in the Explore title bar: paste `wss://…` or a host, submit, and open the relay page with the same navigation as the relay cards. While typing, **suggestions** come from the **NIP-66 monitoring (public lively) list** on partial or full URL/host matches; you can still submit any URL the app does not know. - **Search for Relays** on Explore (below Favorite Relays): paste `wss://…` or a host, submit, and open the relay page with the same navigation as the relay cards. While typing, **suggestions** come from the **NIP-66 monitoring (public lively) list** on partial or full URL/host matches; you can still submit any URL the app does not know.
### Other ### Other

104
src/PageManager.tsx

@ -622,10 +622,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null) const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode()) const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
const navigationCounterRef = useRef(0) const navigationCounterRef = useRef(0)
const savedFeedStateRef = useRef<Map<TPrimaryPageName, { const savedFeedStateRef = useRef<Map<TPrimaryPageName, { tab?: string }>>(new Map())
tab?: string,
trendingTab?: 'relays' | 'hashtags' | 'calendar'
}>>(new Map())
const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page
const currentPageProps = useMemo((): object | undefined => { const currentPageProps = useMemo((): object | undefined => {
@ -640,20 +637,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Get current tab state from ref (updated by components via events) // Get current tab state from ref (updated by components via events)
const currentTab = currentTabStateRef.current.get(currentPrimaryPage) const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
// Get trending tab if on search page if (currentTab) {
const trendingTab = currentTabStateRef.current.get('search') as 'relays' | 'hashtags' | 'calendar' | undefined logger.info('PageManager: Saving page state', {
page: currentPrimaryPage,
// Save state (tab, trending) if any exists tab: currentTab
if (currentTab || trendingTab) {
logger.info('PageManager: Saving page state', {
page: currentPrimaryPage,
tab: currentTab,
trendingTab
}) })
savedFeedStateRef.current.set(currentPrimaryPage, { savedFeedStateRef.current.set(currentPrimaryPage, {
tab: currentTab, tab: currentTab
trendingTab
}) })
} }
} }
@ -684,19 +675,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})) }))
currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(savedPrimaryPage, savedFeedState.tab)
} }
// Restore trending tab for search page (map legacy 'nostr' to 'relays')
if (savedFeedState?.trendingTab && savedPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
logger.info('PageManager: Restoring trending tab', {
page: savedPrimaryPage,
trendingTab: tab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab }
}))
currentTabStateRef.current.set('search', tab)
}
} }
} }
@ -1149,19 +1127,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Update ref immediately // Update ref immediately
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
logger.info('PageManager: Browser back - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: tab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab }
}))
currentTabStateRef.current.set('search', tab)
}
} }
}, [secondaryStack.length, currentPrimaryPage]) }, [secondaryStack.length, currentPrimaryPage])
@ -1215,15 +1180,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Save tab state before navigating // Save tab state before navigating
const currentTab = currentTabStateRef.current.get(currentPrimaryPage) const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
const trendingTab = currentTabStateRef.current.get('search') as 'relays' | 'hashtags' | 'calendar' | undefined
if (currentPrimaryPage && currentTab) {
if (currentPrimaryPage && (currentTab || trendingTab)) { logger.info('PageManager: Desktop - Saving page state', {
logger.info('PageManager: Desktop - Saving page state', { page: currentPrimaryPage,
page: currentPrimaryPage, tab: currentTab
tab: currentTab,
trendingTab
}) })
savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab, trendingTab }) savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab })
} }
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {
@ -1289,19 +1252,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})) }))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
logger.info('PageManager: Desktop - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: tab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab }
}))
currentTabStateRef.current.set('search', tab)
}
} else if (secondaryStack.length > 1) { } else if (secondaryStack.length > 1) {
// Pop from stack directly instead of using history.go(-1) // Pop from stack directly instead of using history.go(-1)
// This ensures the stack is updated immediately // This ensures the stack is updated immediately
@ -1347,19 +1297,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})) }))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
logger.info('PageManager: Mobile/Single-pane - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: tab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab }
}))
currentTabStateRef.current.set('search', tab)
}
return return
} }
@ -1378,19 +1315,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
})) }))
currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab) currentTabStateRef.current.set(currentPrimaryPage, savedFeedState.tab)
} }
// Restore trending tab for search page
if (savedFeedState?.trendingTab && currentPrimaryPage === 'search') {
const tab = (savedFeedState.trendingTab as string) === 'nostr' ? 'relays' : savedFeedState.trendingTab
logger.info('PageManager: Desktop - Restoring trending tab', {
page: currentPrimaryPage,
trendingTab: tab
})
window.dispatchEvent(new CustomEvent('restorePageTab', {
detail: { page: 'search', tab }
}))
currentTabStateRef.current.set('search', tab)
}
} else if (secondaryStack.length > 1) { } else if (secondaryStack.length > 1) {
// Pop to previous page (e.g. from /settings/general back to /settings) so Back/Close return to the list instead of closing the panel // Pop to previous page (e.g. from /settings/general back to /settings) so Back/Close return to the list instead of closing the panel
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {

38
src/components/ContentPreview/ZapPreview.tsx

@ -15,7 +15,7 @@ export default function ZapPreview({ event, className }: { event: Event; classNa
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) {
return ( return (
<div className={cn('text-sm text-muted-foreground', className)}> <div className={cn('rounded-lg border border-border bg-muted/20 p-3 text-sm text-muted-foreground', className)}>
[{t('Invalid zap receipt')}] [{t('Invalid zap receipt')}]
</div> </div>
) )
@ -24,26 +24,32 @@ export default function ZapPreview({ event, className }: { event: Event; classNa
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
return ( return (
<div className={cn('flex items-start gap-3 p-3 rounded-lg border bg-card', className)}> <div
<Zap size={24} className="text-yellow-400 shrink-0 mt-0.5" fill="currentColor" /> className={cn(
<div className="flex-1 min-w-0"> 'flex items-start gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-sm',
<div className="flex items-center gap-2 flex-wrap"> className
<Username userId={senderPubkey} className="font-semibold" /> )}
<span className="text-muted-foreground text-sm">{t('zapped')}</span> >
<Zap size={24} className="mt-0.5 shrink-0 text-primary" strokeWidth={2} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-sm text-muted-foreground">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && ( {recipientPubkey && recipientPubkey !== senderPubkey && (
<Username userId={recipientPubkey} className="font-semibold" /> <Username userId={recipientPubkey} className="font-semibold text-foreground" />
)} )}
</div> </div>
<div className="font-bold text-yellow-400 mt-1"> {comment ? (
{formatAmount(amount)} {t('sats')} <p className="mt-2 rounded-r-md border-l-[3px] border-primary bg-muted/40 py-2 pl-3 pr-1 text-base font-semibold leading-snug text-foreground dark:bg-muted/25 whitespace-pre-wrap break-words">
</div>
{comment && (
<div className="text-sm text-muted-foreground mt-2 break-words">
{comment} {comment}
</div> </p>
)} ) : null}
<div className="mt-2 flex flex-wrap items-baseline gap-x-1.5">
<span className="text-lg font-bold tabular-nums text-foreground">{formatAmount(amount)}</span>
<span className="text-sm font-medium text-muted-foreground">{t('sats')}</span>
</div>
{targetEvent && ( {targetEvent && (
<div className="text-xs text-muted-foreground mt-2"> <div className="mt-2 text-xs text-muted-foreground">
{t('on note')} {targetEvent.id.substring(0, 8)}... {t('on note')} {targetEvent.id.substring(0, 8)}...
</div> </div>
)} )}

2
src/components/Explore/ExploreFavoriteRelays.tsx

@ -105,7 +105,7 @@ export default function ExploreFavoriteRelays() {
<span className="text-xs text-muted-foreground">{t('Using app default relays')}</span> <span className="text-xs text-muted-foreground">{t('Using app default relays')}</span>
) : null} ) : null}
</div> </div>
<div className="flex gap-3 overflow-x-auto overflow-y-hidden pb-1 pt-0.5 [scrollbar-gutter:stable] snap-x snap-mandatory"> <div className="flex gap-3 overflow-x-auto overflow-y-hidden pb-4 pt-0.5 snap-x snap-mandatory [scrollbar-gutter:stable]">
{urls.map((url) => ( {urls.map((url) => (
<div key={url} className="snap-start"> <div key={url} className="snap-start">
<FavoriteRelayCard url={url} /> <FavoriteRelayCard url={url} />

324
src/components/KeyboardShortcutsHelp/index.tsx

@ -1,3 +1,4 @@
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@ -6,11 +7,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { createFakeEvent } from '@/lib/event'
import { import {
isRadixDialogOpen, isRadixDialogOpen,
OPEN_NEW_POST_SHORTCUT_KEY, OPEN_NEW_POST_SHORTCUT_KEY,
shouldIgnoreKeyboardShortcutEvent shouldIgnoreKeyboardShortcutEvent
} from '@/lib/keyboard-shortcuts' } from '@/lib/keyboard-shortcuts'
import { cn } from '@/lib/utils'
import postEditorService from '@/services/post-editor.service' import postEditorService from '@/services/post-editor.service'
import { CircleHelp } from 'lucide-react' import { CircleHelp } from 'lucide-react'
import { import {
@ -23,6 +27,7 @@ import {
type ReactNode type ReactNode
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import readmeMarkdown from '../../../README.md?raw'
type KeyboardShortcutsHelpContextValue = { type KeyboardShortcutsHelpContextValue = {
openHelp: () => void openHelp: () => void
@ -55,6 +60,162 @@ function KbdRow({ keys, label }: { keys: ReactNode; label: string }) {
) )
} }
function ShortcutsPanel() {
const { t } = useTranslation()
return (
<div className="space-y-4 pt-1 text-sm">
<p className="text-sm text-muted-foreground">{t('shortcuts.intro')}</p>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('shortcuts.sectionApp')}
</h3>
<div className="space-y-3">
<KbdRow
label={t('shortcuts.openHelp')}
keys={
<>
<Kbd>?</Kbd>
<span className="px-0.5 text-muted-foreground">{t('shortcuts.or')}</span>
<Kbd>F1</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.focusPrimary')}
keys={
<>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>F</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.focusSecondary')}
keys={
<>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>S</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.newNote')}
keys={
<>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>N</Kbd>
</>
}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('shortcuts.sectionSearch')}
</h3>
<div className="space-y-3">
<KbdRow
label={t('shortcuts.searchSuggest')}
keys={
<>
<Kbd></Kbd>
<Kbd></Kbd>
<span className="px-1 text-muted-foreground">{t('shortcuts.then')}</span>
<Kbd>Enter</Kbd>
</>
}
/>
<KbdRow label={t('shortcuts.searchDismiss')} keys={<Kbd>Esc</Kbd>} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('shortcuts.sectionStandard')}
</h3>
<div className="space-y-3">
<KbdRow
label={t('shortcuts.tabNavigate')}
keys={
<>
<Kbd>Tab</Kbd>
<span className="px-1 text-muted-foreground">{t('shortcuts.or')}</span>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Tab</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.activate')}
keys={
<>
<Kbd>Enter</Kbd>
<span className="px-1 text-muted-foreground">{t('shortcuts.or')}</span>
<Kbd>Space</Kbd>
</>
}
/>
<KbdRow label={t('shortcuts.closeOverlays')} keys={<Kbd>Esc</Kbd>} />
<KbdRow
label={t('shortcuts.scrollWhenFocused')}
keys={
<>
<Kbd></Kbd>
<Kbd></Kbd>
<Kbd>PgUp</Kbd>
<Kbd>PgDn</Kbd>
<Kbd>Home</Kbd>
<Kbd>End</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.browserBack')}
keys={
<>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd></Kbd>
</>
}
/>
</div>
</section>
</div>
)
}
function ReadmeOverviewPanel({ className }: { className?: string }) {
const readmeEvent = useMemo(
() =>
createFakeEvent({
id: '0'.repeat(64),
pubkey: '0'.repeat(64),
content: readmeMarkdown,
created_at: 0,
kind: 1,
tags: [],
sig: '0'.repeat(128)
}),
[]
)
return (
<div className={cn('min-w-0 pt-1', className)}>
<MarkdownArticle event={readmeEvent} hideMetadata className="text-sm" />
</div>
)
}
export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNode }) { export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const openHelp = useCallback(() => setOpen(true), []) const openHelp = useCallback(() => setOpen(true), [])
@ -103,144 +264,29 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod
<KeyboardShortcutsHelpContext.Provider value={value}> <KeyboardShortcutsHelpContext.Provider value={value}>
{children} {children}
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[min(85vh,32rem)] overflow-y-auto"> <DialogContent className="flex max-h-[min(88vh,40rem)] max-w-2xl flex-col gap-0 overflow-hidden p-6 sm:max-w-2xl">
<DialogHeader> <DialogHeader className="shrink-0 space-y-1 pb-2 pr-8 text-left">
<DialogTitle>{t('shortcuts.title')}</DialogTitle> <DialogTitle>{t('help.title')}</DialogTitle>
<DialogDescription>{t('shortcuts.intro')}</DialogDescription> <DialogDescription className="sr-only">{t('shortcuts.intro')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-2 text-sm"> <Tabs defaultValue="shortcuts" className="flex min-h-0 flex-1 flex-col gap-2">
<section className="space-y-3"> <TabsList className="grid w-full shrink-0 grid-cols-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <TabsTrigger value="shortcuts">{t('help.tabShortcuts')}</TabsTrigger>
{t('shortcuts.sectionApp')} <TabsTrigger value="overview">{t('help.tabOverview')}</TabsTrigger>
</h3> </TabsList>
<div className="space-y-3"> <TabsContent
<KbdRow value="shortcuts"
label={t('shortcuts.openHelp')} className="mt-0 max-h-[min(62vh,32rem)] min-h-0 flex-1 overflow-y-auto overscroll-contain pr-4 [scrollbar-gutter:stable] data-[state=inactive]:hidden"
keys={ >
<> <ShortcutsPanel />
<Kbd>?</Kbd> </TabsContent>
<span className="text-muted-foreground px-0.5">{t('shortcuts.or')}</span> <TabsContent
<Kbd>F1</Kbd> value="overview"
</> className="mt-0 max-h-[min(62vh,32rem)] min-h-0 flex-1 overflow-y-auto overscroll-contain pr-4 [scrollbar-gutter:stable] data-[state=inactive]:hidden"
} >
/> <ReadmeOverviewPanel />
<KbdRow </TabsContent>
label={t('shortcuts.focusPrimary')} </Tabs>
keys={
<>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>F</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.focusSecondary')}
keys={
<>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>S</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.newNote')}
keys={
<>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>N</Kbd>
</>
}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('shortcuts.sectionSearch')}
</h3>
<div className="space-y-3">
<KbdRow
label={t('shortcuts.searchSuggest')}
keys={
<>
<Kbd></Kbd>
<Kbd></Kbd>
<span className="text-muted-foreground px-1">{t('shortcuts.then')}</span>
<Kbd>Enter</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.searchDismiss')}
keys={<Kbd>Esc</Kbd>}
/>
</div>
</section>
<section className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('shortcuts.sectionStandard')}
</h3>
<div className="space-y-3">
<KbdRow
label={t('shortcuts.tabNavigate')}
keys={
<>
<Kbd>Tab</Kbd>
<span className="text-muted-foreground px-1">{t('shortcuts.or')}</span>
<Kbd>Shift</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd>Tab</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.activate')}
keys={
<>
<Kbd>Enter</Kbd>
<span className="text-muted-foreground px-1">{t('shortcuts.or')}</span>
<Kbd>Space</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.closeOverlays')}
keys={<Kbd>Esc</Kbd>}
/>
<KbdRow
label={t('shortcuts.scrollWhenFocused')}
keys={
<>
<Kbd></Kbd>
<Kbd></Kbd>
<Kbd>PgUp</Kbd>
<Kbd>PgDn</Kbd>
<Kbd>Home</Kbd>
<Kbd>End</Kbd>
</>
}
/>
<KbdRow
label={t('shortcuts.browserBack')}
keys={
<>
<Kbd>Alt</Kbd>
<span className="text-muted-foreground">+</span>
<Kbd></Kbd>
</>
}
/>
</div>
</section>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</KeyboardShortcutsHelpContext.Provider> </KeyboardShortcutsHelpContext.Provider>
@ -257,8 +303,8 @@ export function KeyboardShortcutsHelpButton() {
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
onClick={() => openHelp()} onClick={() => openHelp()}
title={t('shortcuts.title')} title={t('help.title')}
aria-label={t('shortcuts.title')} aria-label={t('help.title')}
> >
<CircleHelp /> <CircleHelp />
</Button> </Button>

10
src/components/KindFilter/index.tsx

@ -16,9 +16,7 @@ const KIND_1 = kinds.ShortTextNote
const KIND_1111 = ExtendedKind.COMMENT const KIND_1111 = ExtendedKind.COMMENT
const KIND_FILTER_OPTIONS = [ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.Repost], label: 'Reposts' },
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [ExtendedKind.PUBLICATION], label: 'Publications' },
{ kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' }, { kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.POLL], label: 'Polls' },
@ -212,7 +210,13 @@ export default function KindFilter({
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setTemporaryShowKinds( setTemporaryShowKinds(
SUPPORTED_KINDS.filter((k) => k !== kinds.Repost && k !== KIND_1 && k !== KIND_1111) SUPPORTED_KINDS.filter(
(k) =>
k !== kinds.Repost &&
k !== ExtendedKind.PUBLICATION &&
k !== KIND_1 &&
k !== KIND_1111
)
) )
setTemporaryShowKind1OPs(true) setTemporaryShowKind1OPs(true)
setTemporaryShowKind1Replies(true) setTemporaryShowKind1Replies(true)

69
src/components/Note/Zap.tsx

@ -31,7 +31,12 @@ export default function Zap({ event, className }: { event: Event; className?: st
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) { if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) {
return ( return (
<div className={cn('text-sm text-muted-foreground p-4 border rounded-lg', className)}> <div
className={cn(
'text-sm text-muted-foreground rounded-lg border border-border bg-muted/20 p-4',
className
)}
>
[{t('Invalid zap receipt')}] [{t('Invalid zap receipt')}]
</div> </div>
) )
@ -40,7 +45,7 @@ export default function Zap({ event, className }: { event: Event; className?: st
// Determine if this is an event zap or profile zap // Determine if this is an event zap or profile zap
const isEventZap = targetEvent || zapInfo?.eventId const isEventZap = targetEvent || zapInfo?.eventId
const isProfileZap = !isEventZap && zapInfo?.recipientPubkey const isProfileZap = !isEventZap && zapInfo?.recipientPubkey
// For event zaps, we need to determine the recipient from the zapped event // For event zaps, we need to determine the recipient from the zapped event
const actualRecipientPubkey = useMemo(() => { const actualRecipientPubkey = useMemo(() => {
if (isEventZap && targetEvent) { if (isEventZap && targetEvent) {
@ -53,20 +58,18 @@ export default function Zap({ event, className }: { event: Event; className?: st
return undefined return undefined
}, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey]) }, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey])
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) {
return (
<div className={cn('text-sm text-muted-foreground p-4 border rounded-lg', className)}>
[{t('Invalid zap receipt')}]
</div>
)
}
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
return ( return (
<div className={cn('relative border rounded-lg p-4 bg-gradient-to-br from-yellow-50/50 to-amber-50/50 dark:from-yellow-950/20 dark:to-amber-950/20', className)}> <div
className={cn(
'relative rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm',
className
)}
>
{/* Zapped note/profile link in bottom-right corner */} {/* Zapped note/profile link in bottom-right corner */}
<button <button
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (isEventZap) { if (isEventZap) {
@ -81,10 +84,12 @@ export default function Zap({ event, className }: { event: Event; className?: st
push(toProfile(actualRecipientPubkey)) push(toProfile(actualRecipientPubkey))
} }
}} }}
className="absolute bottom-2 right-2 px-3 py-2 bg-white/90 dark:bg-black/50 border border-gray-200 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-white dark:hover:bg-black hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-900 dark:hover:text-gray-100 transition-all duration-200 shadow-md hover:shadow-lg flex items-center gap-2" className="absolute bottom-3 right-3 flex items-center gap-2 rounded-md border border-border bg-secondary/80 px-2.5 py-1.5 text-xs font-medium text-secondary-foreground shadow-sm transition-colors hover:bg-secondary"
> >
{isEventZap ? ( {isEventZap ? (
<span className="font-mono text-xs">{(targetEvent?.id || zapInfo.eventId)?.substring(0, 12)}...</span> <span className="font-mono text-muted-foreground">
{(targetEvent?.id || zapInfo.eventId)?.substring(0, 12)}
</span>
) : isProfileZap && actualRecipientPubkey ? ( ) : isProfileZap && actualRecipientPubkey ? (
<> <>
<UserAvatar userId={actualRecipientPubkey} size="xSmall" /> <UserAvatar userId={actualRecipientPubkey} size="xSmall" />
@ -94,34 +99,36 @@ export default function Zap({ event, className }: { event: Event; className?: st
t('Zap') t('Zap')
)} )}
</button> </button>
<div className="flex items-start gap-3 pb-8"> <div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<ZapIcon size={28} className="text-yellow-500 shrink-0 mt-1" fill="currentColor" /> <ZapIcon size={28} className="mt-0.5 shrink-0 text-primary" strokeWidth={2} />
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-2"> <div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" /> <UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold" /> <Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-muted-foreground text-sm">{t('zapped')}</span> <span className="text-sm text-muted-foreground">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && ( {recipientPubkey && recipientPubkey !== senderPubkey && (
<> <>
<UserAvatar userId={recipientPubkey} size="small" /> <UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold" /> <Username userId={recipientPubkey} className="font-semibold text-foreground" />
</> </>
)} )}
</div> </div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-yellow-600 dark:text-yellow-400"> {comment ? (
<div className="mb-3 rounded-r-md border-l-[3px] border-primary bg-muted/40 py-2.5 pl-3 pr-2 dark:bg-muted/25">
<p className="text-lg font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
</div>
) : null}
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-2xl font-bold tabular-nums tracking-tight text-foreground sm:text-3xl">
{formatAmount(amount)} {formatAmount(amount)}
</span> </span>
<span className="text-lg font-semibold text-yellow-600/70 dark:text-yellow-400/70"> <span className="text-base font-medium text-muted-foreground">{t('sats')}</span>
{t('sats')}
</span>
</div> </div>
{comment && (
<div className="mt-3 text-sm bg-white/50 dark:bg-black/20 rounded-lg p-3 break-words">
{comment}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

61
src/components/NoteBoostBadges/index.tsx

@ -0,0 +1,61 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { cn } from '@/lib/utils'
import { ExtendedKind } from '@/constants'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
const MAX_VISIBLE = 28
/**
* Small avatar strip of users who boosted (kind 6) the note shown under the OP on the note page.
*/
export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const boosters = useMemo(() => {
if (event.kind === ExtendedKind.DISCUSSION) return []
return (noteStats?.reposts ?? [])
.filter((r) => !hideUntrustedInteractions || isUserTrusted(r.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.kind, hideUntrustedInteractions, isUserTrusted])
if (shouldHideInteractions(event) || boosters.length === 0) {
return null
}
const visible = boosters.slice(0, MAX_VISIBLE)
const overflow = boosters.length - visible.length
return (
<div
className={cn('flex flex-wrap items-center gap-x-0 gap-y-1', className)}
role="list"
aria-label={t('Boosts')}
>
{visible.map((r, i) => (
<div
key={r.id}
role="listitem"
className={cn(i > 0 && '-ml-2')}
style={{ zIndex: visible.length - i }}
>
<UserAvatar userId={r.pubkey} size="small" className="ring-2 ring-background" />
</div>
))}
{overflow > 0 ? (
<span
className="-ml-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground ring-2 ring-background"
title={t('No more boosts')}
>
+{overflow}
</span>
) : null}
</div>
)
}

2
src/components/NoteCard/RepostDescription.tsx

@ -17,7 +17,7 @@ export default function RepostDescription({
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}> <div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" /> <Repeat2 size={16} className="shrink-0" />
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" /> <Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
<div className="shrink-0">{t('reposted')}</div> <div className="shrink-0">{t('boosted')}</div>
</div> </div>
) )
} }

14
src/components/NoteInteractions/Tabs.tsx

@ -2,11 +2,10 @@ import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react' import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'quotes' | 'reactions' | 'reposts' | 'zaps' export type TTabValue = 'replies' | 'quotes' | 'reactions' | 'zaps'
const TABS = [ const TABS = [
{ value: 'replies', label: 'Replies' }, { value: 'replies', label: 'Replies' },
{ value: 'zaps', label: 'Zaps' }, { value: 'zaps', label: 'Zaps' },
{ value: 'reposts', label: 'Reposts' },
{ value: 'reactions', label: 'Reactions' }, { value: 'reactions', label: 'Reactions' },
{ value: 'quotes', label: 'Quotes' } { value: 'quotes', label: 'Quotes' }
] as { value: TTabValue; label: string }[] ] as { value: TTabValue; label: string }[]
@ -14,20 +13,21 @@ const TABS = [
export function Tabs({ export function Tabs({
selectedTab, selectedTab,
onTabChange, onTabChange,
hideRepostsAndQuotes = false hideQuotesForDiscussion = false
}: { }: {
selectedTab: TTabValue selectedTab: TTabValue
onTabChange: (tab: TTabValue) => void onTabChange: (tab: TTabValue) => void
hideRepostsAndQuotes?: boolean /** Hide the quotes tab on discussion threads */
hideQuotesForDiscussion?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const tabRefs = useRef<(HTMLDivElement | null)[]>([]) const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 }) const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 })
// Filter tabs based on hideRepostsAndQuotes // Filter tabs based on hideBoostsAndQuotes
const visibleTabs = hideRepostsAndQuotes const visibleTabs = hideBoostsAndQuotes
? TABS.filter(tab => tab.value !== 'reposts' && tab.value !== 'quotes') ? TABS.filter((tab) => tab.value !== 'boosts' && tab.value !== 'quotes')
: TABS : TABS
useEffect(() => { useEffect(() => {

7
src/components/NoteInteractions/index.tsx

@ -7,7 +7,6 @@ import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList' import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList' import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList' import ReplyNoteList from '../ReplyNoteList'
import RepostList from '../RepostList'
import ZapList from '../ZapList' import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs' import { Tabs, TTabValue } from './Tabs'
import ReplySort, { ReplySortOption } from './ReplySort' import ReplySort, { ReplySortOption } from './ReplySort'
@ -40,8 +39,8 @@ export default function NoteInteractions({
case 'reactions': case 'reactions':
list = <ReactionList event={event} /> list = <ReactionList event={event} />
break break
case 'reposts': case 'boosts':
if (isDiscussion) return null // Hide reposts for discussions if (isDiscussion) return null // Hide boosts for discussions
list = <RepostList event={event} /> list = <RepostList event={event} />
break break
case 'zaps': case 'zaps':
@ -55,7 +54,7 @@ export default function NoteInteractions({
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1 w-0"> <div className="flex-1 w-0">
<Tabs selectedTab={type} onTabChange={setType} hideRepostsAndQuotes={isDiscussion} /> <Tabs selectedTab={type} onTabChange={setType} hideBoostsAndQuotes={isDiscussion} />
</div> </div>
<Separator orientation="vertical" className="h-6" /> <Separator orientation="vertical" className="h-6" />
{type === 'replies' && isDiscussion && ( {type === 'replies' && isDiscussion && (

14
src/components/NoteStats/RepostButton.tsx

@ -69,16 +69,16 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length, successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length totalCount: (evt as any).relayStatuses.length
}, { }, {
message: t('Repost published'), message: t('Boost published'),
duration: 4000 duration: 4000
}) })
} else { } else {
showSimplePublishSuccess(t('Repost published')) showSimplePublishSuccess(t('Boost published'))
} }
noteStatsService.updateNoteStatsByEvents([evt]) noteStatsService.updateNoteStatsByEvents([evt])
} catch (error) { } catch (error) {
logger.error('Repost failed', { error, eventId: event.id }) logger.error('Boost failed', { error, eventId: event.id })
} finally { } finally {
setReposting(false) setReposting(false)
clearTimeout(timer) clearTimeout(timer)
@ -92,7 +92,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
'flex gap-1 items-center enabled:hover:text-lime-500 px-3 h-full', 'flex gap-1 items-center enabled:hover:text-lime-500 px-3 h-full',
hasReposted ? 'text-lime-500' : 'text-muted-foreground' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
title={t('Repost')} title={t('Boost')}
onClick={() => { onClick={() => {
if (isSmallScreen) { if (isSmallScreen) {
setIsDrawerOpen(true) setIsDrawerOpen(true)
@ -120,7 +120,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} /> <DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay> <DrawerContent hideOverlay>
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Repost</DrawerTitle> <DrawerTitle>{t('Boost')}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="py-2"> <div className="py-2">
<Button <Button
@ -133,7 +133,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5" className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
variant="ghost" variant="ghost"
> >
<Repeat /> {t('Repost')} <Repeat /> {t('Boost')}
</Button> </Button>
<Button <Button
onClick={(e) => { onClick={(e) => {
@ -168,7 +168,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
}} }}
disabled={!canRepost} disabled={!canRepost}
> >
<Repeat /> {t('Repost')} <Repeat /> {t('Boost')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {

2
src/components/NoteStats/index.tsx

@ -38,7 +38,7 @@ export default function NoteStats({
const { favoriteRelays } = useFavoriteRelays() const { favoriteRelays } = useFavoriteRelays()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// Hide repost button for discussion events and replies to discussions // Hide boost button for discussion events and replies to discussions
const isDiscussion = event.kind === ExtendedKind.DISCUSSION const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false) const [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false)

2
src/components/NotificationList/NotificationItem/RepostNotification.tsx

@ -26,7 +26,7 @@ export function RepostNotification({ notification }: { notification: Event }) {
sender={notification.pubkey} sender={notification.pubkey}
sentAt={notification.created_at} sentAt={notification.created_at}
targetEvent={event} targetEvent={event}
description={t('reposted your note')} description={t('boosted your note')}
/> />
) )
} }

2
src/components/Profile/ProfileFeed.tsx

@ -49,7 +49,7 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[]
if (!kindValue || kindValue === 'all') return 'posts' if (!kindValue || kindValue === 'all') return 'posts'
const kindNum = parseInt(kindValue, 10) const kindNum = parseInt(kindValue, 10)
if (kindNum === kinds.ShortTextNote) return 'notes' if (kindNum === kinds.ShortTextNote) return 'notes'
if (kindNum === kinds.Repost) return 'reposts' if (kindNum === kinds.Repost) return 'boosts'
if (kindNum === ExtendedKind.COMMENT) return 'comments' if (kindNum === ExtendedKind.COMMENT) return 'comments'
if (kindNum === ExtendedKind.DISCUSSION) return 'discussions' if (kindNum === ExtendedKind.DISCUSSION) return 'discussions'
if (kindNum === ExtendedKind.POLL) return 'polls' if (kindNum === ExtendedKind.POLL) return 'polls'

2
src/components/RepostList/index.tsx

@ -74,7 +74,7 @@ export default function RepostList({ event }: { event: Event }) {
<div ref={bottomRef} /> <div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground"> <div className="text-sm mt-2 text-center text-muted-foreground">
{filteredReposts.length > 0 ? t('No more reposts') : t('No reposts yet')} {filteredReposts.length > 0 ? t('No more boosts') : t('No boosts yet')}
</div> </div>
</div> </div>
) )

6
src/components/Sidebar/AccountButton.tsx

@ -11,7 +11,7 @@ import { toWallet } from '@/lib/link'
import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey' import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager' import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, LogIn, LogOut, MoreVertical, Wallet } from 'lucide-react' import { ArrowDownUp, LogIn, LogOut, MoreVertical, Settings, Wallet } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog' import LoginDialog from '../LoginDialog'
@ -78,6 +78,10 @@ function ProfileButton() {
<Wallet /> <Wallet />
{t('Wallet')} {t('Wallet')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('settings')}>
<Settings />
{t('Settings')}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}> <DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp /> <ArrowDownUp />

2
src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx

@ -6,7 +6,7 @@ export default function KeyboardShortcutsHelpSidebarButton() {
const { openHelp } = useKeyboardShortcutsHelp() const { openHelp } = useKeyboardShortcutsHelp()
return ( return (
<SidebarItem title="shortcuts.title" onClick={openHelp}> <SidebarItem title="help.title" onClick={openHelp}>
<CircleHelp strokeWidth={2.5} /> <CircleHelp strokeWidth={2.5} />
</SidebarItem> </SidebarItem>
) )

2
src/components/Sidebar/index.tsx

@ -18,7 +18,7 @@ export default function PrimaryPageSidebar() {
if (isSmallScreen) return null if (isSmallScreen) return null
return ( return (
<div className="w-16 xl:w-52 flex flex-col pb-2 pt-4 px-2 xl:pl-4 xl:pr-6 justify-between h-full shrink-0"> <div className="w-[4.8rem] xl:w-[15.6rem] flex flex-col pb-2 pt-4 px-2 xl:pl-4 xl:pr-6 justify-between h-full shrink-0">
<div className="space-y-2"> <div className="space-y-2">
<div className="px-3 xl:px-4 mb-6 w-full"> <div className="px-3 xl:px-4 mb-6 w-full">
<Icon className="xl:hidden" /> <Icon className="xl:hidden" />

760
src/components/TrendingNotes/index.tsx

@ -1,5 +1,4 @@
import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
@ -14,59 +13,18 @@ import noteStatsService from '@/services/note-stats.service'
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { getCalendarEventMeta } from '@/lib/calendar-event'
const SHOW_COUNT = 25 const SHOW_COUNT = 25
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes
// Unified cache for all custom trending feeds
let cachedCustomEvents: { let cachedCustomEvents: {
events: Array<{ event: NostrEvent; score: number }> events: Array<{ event: NostrEvent; score: number }>
timestamp: number timestamp: number
hashtags: string[]
} | null = null } | null = null
// Flag to prevent concurrent initialization
let isInitializing = false let isInitializing = false
type TrendingTab = 'relays' | 'hashtags' | 'calendar'
type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular' type SortOrder = 'newest' | 'oldest' | 'most-popular' | 'least-popular'
type HashtagFilter = 'popular'
/** Sort key for calendar events: time-based use start (unix), date-based use startDate as timestamp. */
function calendarEventSortKey(evt: NostrEvent): number {
const meta = getCalendarEventMeta(evt as any)
if (meta.start != null && !isNaN(meta.start)) return meta.start
if (meta.startDate) return new Date(meta.startDate + 'T00:00:00').getTime() / 1000
return evt.created_at
}
const CALENDAR_MONTHS_AHEAD = 6
/** YYYY-MM for grouping; derived from calendar event start. */
function calendarEventMonthKey(evt: NostrEvent): string {
const ts = calendarEventSortKey(evt)
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
/** Filter calendar events: from start of today (or 1 month ago if in past) through the next CALENDAR_MONTHS_AHEAD months. */
function filterCalendarEventsToNextMonths(events: NostrEvent[], monthsAhead: number): NostrEvent[] {
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
const oneMonthAgo = new Date(startOfToday)
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1)
const minSec = Math.floor(oneMonthAgo.getTime() / 1000)
const end = new Date()
end.setMonth(end.getMonth() + monthsAhead)
const endSec = Math.floor(end.getTime() / 1000)
return events.filter((evt) => {
const k = calendarEventSortKey(evt)
return k >= minSec && k <= endSec
})
}
export default function TrendingNotes() { export default function TrendingNotes() {
const { t } = useTranslation() const { t } = useTranslation()
@ -76,165 +34,68 @@ export default function TrendingNotes() {
const { favoriteRelays } = useFavoriteRelays() const { favoriteRelays } = useFavoriteRelays()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const [activeTab, setActiveTab] = useState<TrendingTab>('relays')
const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular') const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular')
const [hashtagFilter] = useState<HashtagFilter>('popular')
const [selectedHashtag, setSelectedHashtag] = useState<string | null>(null)
const [popularHashtags, setPopularHashtags] = useState<string[]>([])
const [cacheEvents, setCacheEvents] = useState<NostrEvent[]>([]) const [cacheEvents, setCacheEvents] = useState<NostrEvent[]>([])
const [cacheLoading, setCacheLoading] = useState(false) const [cacheLoading, setCacheLoading] = useState(false)
const [calendarEvents, setCalendarEvents] = useState<NostrEvent[]>([])
const [calendarLoading, setCalendarLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
// Listen for tab restoration from PageManager const trendingRelaySource = useMemo<'favorites' | 'default'>(() => {
useEffect(() => { if (!pubkey) return 'default'
const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { const hasFavorites = favoriteRelays.length > 0
if (e.detail.page === 'search' && e.detail.tab && ['relays', 'hashtags', 'calendar'].includes(e.detail.tab)) { const hasRead = (relayList?.read?.length ?? 0) > 0
setActiveTab(e.detail.tab as TrendingTab) if (hasFavorites || hasRead) return 'favorites'
} return 'default'
} }, [pubkey, favoriteRelays, relayList])
window.addEventListener('restorePageTab', handleRestore as EventListener)
return () => window.removeEventListener('restorePageTab', handleRestore as EventListener)
}, [])
// Debug: Track cacheEvents changes
useEffect(() => {
logger.debug('[TrendingNotes] cacheEvents state changed:', cacheEvents.length, 'events')
}, [cacheEvents])
// Debug: Track cacheLoading changes
useEffect(() => {
logger.debug('[TrendingNotes] cacheLoading state changed:', cacheLoading)
}, [cacheLoading])
// Calculate popular hashtags from cache events (all events from relays)
const calculatePopularHashtags = useMemo(() => {
logger.debug('[TrendingNotes] calculatePopularHashtags - cacheEvents.length:', cacheEvents.length)
const eventsToAnalyze = cacheEvents
if (eventsToAnalyze.length === 0) {
return []
}
const hashtagCounts = new Map<string, number>()
let eventsWithHashtags = 0
eventsToAnalyze.forEach((event) => {
let hasAnyHashtag = false
// Count hashtags from 't' tags
event.tags.forEach(tag => {
if (tag[0] === 't' && tag[1]) {
const hashtag = tag[1].toLowerCase()
hashtagCounts.set(hashtag, (hashtagCounts.get(hashtag) || 0) + 1)
hasAnyHashtag = true
}
})
// Count hashtags from content (simple regex for #hashtag)
const contentHashtags = event.content.match(/#[a-zA-Z0-9_]+/g)
if (contentHashtags) {
contentHashtags.forEach(hashtag => {
const cleanHashtag = hashtag.slice(1).toLowerCase() // Remove #
hashtagCounts.set(cleanHashtag, (hashtagCounts.get(cleanHashtag) || 0) + 1)
hasAnyHashtag = true
})
}
if (hasAnyHashtag) eventsWithHashtags++
})
// Sort by count and return top 10
const result = Array.from(hashtagCounts.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([hashtag]) => hashtag)
logger.debug('[TrendingNotes] calculatePopularHashtags - found hashtags:', result)
logger.debug('[TrendingNotes] calculatePopularHashtags - eventsWithHashtags:', eventsWithHashtags)
return result
}, [cacheEvents, activeTab, hashtagFilter, pubkey])
// Get relays based on user login status
const getRelays = useMemo(() => { const getRelays = useMemo(() => {
const relays: string[] = [] const relays: string[] = []
if (pubkey) { if (pubkey) {
// User is logged in: favorite relays + inboxes (read relays)
relays.push(...favoriteRelays) relays.push(...favoriteRelays)
if (relayList?.read) { if (relayList?.read) {
relays.push(...relayList.read) relays.push(...relayList.read)
} }
// If user has no favorites and no read relays, fallback to FAST_READ_RELAY_URLS
if (relays.length === 0) { if (relays.length === 0) {
relays.push(...FAST_READ_RELAY_URLS) relays.push(...FAST_READ_RELAY_URLS)
} }
} else { } else {
// User is not logged in: use FAST_READ_RELAY_URLS (includes all FAST_READ_RELAY_URLS)
relays.push(...FAST_READ_RELAY_URLS) relays.push(...FAST_READ_RELAY_URLS)
} }
// Normalize and deduplicate const normalized = relays.map((url) => normalizeUrl(url)).filter((url): url is string => !!url)
const normalized = relays
.map(url => normalizeUrl(url))
.filter((url): url is string => !!url)
return Array.from(new Set(normalized)) return Array.from(new Set(normalized))
}, [pubkey, favoriteRelays, relayList]) }, [pubkey, favoriteRelays, relayList])
// Update popular hashtags when trending notes change
useEffect(() => {
logger.debug('[TrendingNotes] calculatePopularHashtags result:', calculatePopularHashtags)
setPopularHashtags(calculatePopularHashtags)
}, [calculatePopularHashtags])
// Initialize cache only once on mount
useEffect(() => { useEffect(() => {
const initializeCache = async () => { const initializeCache = async () => {
// Prevent concurrent initialization if (isInitializing) return
if (isInitializing) {
return
}
// Prevent re-initialization if cache is already populated
if (cacheEvents.length > 0) { if (cacheEvents.length > 0) {
logger.debug('[TrendingNotes] Cache already populated, skipping initialization') logger.debug('[TrendingNotes] Cache already populated, skipping initialization')
return return
} }
const now = Date.now() const now = Date.now()
// Check if cache is still valid if (cachedCustomEvents && now - cachedCustomEvents.timestamp < CACHE_DURATION) {
if (cachedCustomEvents && (now - cachedCustomEvents.timestamp) < CACHE_DURATION) { const allEvents = cachedCustomEvents.events.map((item) => item.event)
// If cache is valid, set cacheEvents to ALL events from cache
const allEvents = cachedCustomEvents.events.map(item => item.event)
logger.debug('[TrendingNotes] Using existing cache - loading', allEvents.length, 'events') logger.debug('[TrendingNotes] Using existing cache - loading', allEvents.length, 'events')
setCacheEvents(allEvents) setCacheEvents(allEvents)
setCacheLoading(false) // Ensure loading state is cleared setCacheLoading(false)
return return
} }
isInitializing = true isInitializing = true
setCacheLoading(true) setCacheLoading(true)
const relays = getRelays // Get current relays value const relays = getRelays
// Set a timeout to prevent infinite loading
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
logger.debug('[TrendingNotes] Cache initialization timeout - forcing completion') logger.debug('[TrendingNotes] Cache initialization timeout - forcing completion')
isInitializing = false isInitializing = false
setCacheLoading(false) setCacheLoading(false)
}, 180000) // 3 minute timeout }, 180000)
// Prevent running if we have no relays
if (relays.length === 0) { if (relays.length === 0) {
logger.debug('[TrendingNotes] No relays available, skipping cache initialization')
clearTimeout(timeoutId) clearTimeout(timeoutId)
isInitializing = false isInitializing = false
setCacheLoading(false) setCacheLoading(false)
@ -244,20 +105,11 @@ export default function TrendingNotes() {
try { try {
const allEvents: NostrEvent[] = [] const allEvents: NostrEvent[] = []
const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60 const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60
const batchSize = 3
logger.debug('[TrendingNotes] Starting cache initialization with', relays.length, 'relays:', relays)
// 1. Fetch top-level posts from last 24 hours from ALL relays for comprehensive statistics
// Relay list: If user logged in = favoriteRelays + user's read relays (fallback to FAST_READ_RELAY_URLS), else = FAST_READ_RELAY_URLS
const batchSize = 3 // Process 3 relays at a time
const recentEvents: NostrEvent[] = [] const recentEvents: NostrEvent[] = []
logger.debug('[TrendingNotes] Using full relay set for comprehensive statistics:', relays.length, 'relays')
logger.debug('[TrendingNotes] Relay source:', pubkey ? 'user favorites + read relays (or FAST_READ_RELAY_URLS fallback)' : 'FAST_READ_RELAY_URLS')
for (let i = 0; i < relays.length; i += batchSize) { for (let i = 0; i < relays.length; i += batchSize) {
const batch = relays.slice(i, i + batchSize) const batch = relays.slice(i, i + batchSize)
logger.debug('[TrendingNotes] Processing batch', Math.floor(i/batchSize) + 1, 'of', Math.ceil(relays.length/batchSize), 'relays:', batch)
const batchPromises = batch.map(async (relay) => { const batchPromises = batch.map(async (relay) => {
try { try {
const events = await queryService.fetchEvents([relay], { const events = await queryService.fetchEvents([relay], {
@ -265,132 +117,89 @@ export default function TrendingNotes() {
since: twentyFourHoursAgo, since: twentyFourHoursAgo,
limit: 200 limit: 200
}) })
logger.debug('[TrendingNotes] Fetched', events.length, 'events from relay', relay)
return events return events
} catch (error) { } catch (error) {
logger.warn(`[TrendingNotes] Error fetching from relay ${relay}:`, error) logger.warn(`[TrendingNotes] Error fetching from relay ${relay}:`, error)
return [] return []
} }
}) })
const batchResults = await Promise.all(batchPromises) const batchResults = await Promise.all(batchPromises)
const batchEvents = batchResults.flat() recentEvents.push(...batchResults.flat())
recentEvents.push(...batchEvents)
logger.debug('[TrendingNotes] Batch completed, total events so far:', recentEvents.length)
// Add a small delay between batches to be respectful to relays
if (i + batchSize < relays.length) { if (i + batchSize < relays.length) {
await new Promise(resolve => setTimeout(resolve, 200)) await new Promise((resolve) => setTimeout(resolve, 200))
} }
} }
allEvents.push(...recentEvents)
allEvents.push(...recentEvents)
// Filter for top-level posts only (no replies or quotes) const topLevelEvents = allEvents.filter((event) => {
const topLevelEvents = allEvents.filter(event => { const eTags = event.tags.filter((tag) => tag[0] === 'e')
const eTags = event.tags.filter(t => t[0] === 'e')
return eTags.length === 0 return eTags.length === 0
}) })
// Filter out NSFW content and content warnings const filteredEvents = topLevelEvents.filter((event) => {
const filteredEvents = topLevelEvents.filter(event => { const hasNsfwTag = event.tags.some(
// Check for NSFW in 't' tags (tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw'
const hasNsfwTag = event.tags.some(tag =>
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw'
) )
const hasSensitiveTag = event.tags.some(
// Check for sensitive content tag (tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'sensitive'
const hasSensitiveTag = event.tags.some(tag =>
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'sensitive'
) )
// Check for #NSFW hashtag in content
const hasNsfwHashtag = event.content.toLowerCase().includes('#nsfw') const hasNsfwHashtag = event.content.toLowerCase().includes('#nsfw')
const hasContentWarning = event.tags.some((tag) => tag[0] === 'content-warning')
// Check for content-warning tag (NIP-36) const hasContentWarningL = event.tags.some(
const hasContentWarning = event.tags.some(tag => (tag) => tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning'
tag[0] === 'content-warning'
) )
const hasContentWarningl = event.tags.some(
// Check for L tag with content-warning namespace (tag) => tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning'
const hasContentWarningL = event.tags.some(tag =>
tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning'
) )
return (
// Check for l tag with content-warning namespace !hasNsfwTag &&
const hasContentWarningl = event.tags.some(tag => !hasSensitiveTag &&
tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning' !hasNsfwHashtag &&
!hasContentWarning &&
!hasContentWarningL &&
!hasContentWarningl
) )
// Filter out if any NSFW or content warning indicators are found
return !hasNsfwTag && !hasSensitiveTag && !hasNsfwHashtag &&
!hasContentWarning && !hasContentWarningL && !hasContentWarningl
}) })
// Fetch stats for events in batches with longer delays const eventsNeedingStats = filteredEvents.filter((event) => !noteStatsService.getNoteStats(event.id))
const eventsNeedingStats = filteredEvents.filter(event => !noteStatsService.getNoteStats(event.id))
logger.debug('[TrendingNotes] Need to fetch stats for', eventsNeedingStats.length, 'events')
if (eventsNeedingStats.length > 0) { if (eventsNeedingStats.length > 0) {
const batchSize = 10 // Increased batch size to speed up const statsBatchSize = 10
const totalBatches = Math.ceil(eventsNeedingStats.length / batchSize) for (let i = 0; i < eventsNeedingStats.length; i += statsBatchSize) {
logger.debug('[TrendingNotes] Fetching stats in', totalBatches, 'batches') const batch = eventsNeedingStats.slice(i, i + statsBatchSize)
await Promise.all(
for (let i = 0; i < eventsNeedingStats.length; i += batchSize) { batch.map((event) => noteStatsService.fetchNoteStats(event, undefined, favoriteRelays).catch(() => {}))
const batch = eventsNeedingStats.slice(i, i + batchSize) )
const batchNum = Math.floor(i / batchSize) + 1 if (i + statsBatchSize < eventsNeedingStats.length) {
logger.debug('[TrendingNotes] Fetching stats batch', batchNum, 'of', totalBatches) await new Promise((resolve) => setTimeout(resolve, 200))
await Promise.all(batch.map(event =>
noteStatsService.fetchNoteStats(event, undefined, favoriteRelays).catch(() => {})
))
if (i + batchSize < eventsNeedingStats.length) {
await new Promise(resolve => setTimeout(resolve, 200)) // Reduced delay
} }
} }
logger.debug('[TrendingNotes] Stats fetching completed')
} }
// Score events
logger.debug('[TrendingNotes] Scoring', filteredEvents.length, 'events')
const scoredEvents = filteredEvents.map((event) => { const scoredEvents = filteredEvents.map((event) => {
const stats = noteStatsService.getNoteStats(event.id) const stats = noteStatsService.getNoteStats(event.id)
let score = 0 let score = 0
if (stats?.likes) score += stats.likes.length if (stats?.likes) score += stats.likes.length
if (stats?.zaps) { if (stats?.zaps) {
// Superzaps (above threshold) count as quotes (8 points) stats.zaps.forEach((zap) => {
// Regular zaps count as reactions (1 point) score += zap.amount >= zapReplyThreshold ? 8 : 1
stats.zaps.forEach(zap => {
if (zap.amount >= zapReplyThreshold) {
score += 8 // Superzap
} else {
score += 1 // Regular zap
}
}) })
} }
if (stats?.replies) score += stats.replies.length * 3 if (stats?.replies) score += stats.replies.length * 3
if (stats?.reposts) score += stats.reposts.length * 5 if (stats?.reposts) score += stats.reposts.length * 5
if (stats?.quotes) score += stats.quotes.length * 8 if (stats?.quotes) score += stats.quotes.length * 8
if (stats?.highlights) score += stats.highlights.length * 10 if (stats?.highlights) score += stats.highlights.length * 10
return { event, score } return { event, score }
}) })
// Update cache
logger.debug('[TrendingNotes] Updating cache with', scoredEvents.length, 'scored events')
cachedCustomEvents = { cachedCustomEvents = {
events: scoredEvents, events: scoredEvents,
timestamp: now, timestamp: now
hashtags: []
} }
// Store ALL events from the cache for hashtag analysis
// This includes all events from relays, not just the trending ones
logger.debug('[TrendingNotes] Cache initialization complete - storing', filteredEvents.length, 'events')
setCacheEvents(filteredEvents) setCacheEvents(filteredEvents)
} catch (error) { } catch (error) {
logger.error('[TrendingNotes] Error initializing cache:', error) logger.error('[TrendingNotes] Error initializing cache:', error)
@ -402,223 +211,70 @@ export default function TrendingNotes() {
} }
initializeCache() initializeCache()
}, [])
}, []) // Only run once on mount to prevent infinite loop
// Fetch calendar events when calendar tab is active. Use same filters as profile/notifications: by author and by invitee (#p).
useEffect(() => {
if (activeTab !== 'calendar') return
const userRelays = getRelays ?? []
const relaySet = new Set<string>([
...userRelays.map((url) => normalizeUrl(url) || url).filter(Boolean),
...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url).filter(Boolean)
])
if (relayList?.write?.length) {
relayList.write.forEach((url) => {
const u = normalizeUrl(url)
if (u) relaySet.add(u)
})
}
const relays = Array.from(relaySet)
if (relays.length === 0) {
setCalendarLoading(false)
return
}
let cancelled = false
setCalendarLoading(true)
const run = async () => {
try {
const calendarKinds = [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
// Same query pattern as profile timeline: events you created + events you're invited to. Relays respond to these; global kind-only often returns nothing.
const filters = pubkey
? [
{ kinds: calendarKinds, authors: [pubkey], limit: 100 },
{ kinds: calendarKinds, '#p': [pubkey], limit: 100 }
]
: [{ kinds: calendarKinds, limit: 200 }]
const events = await queryService.fetchEvents(relays, filters, {
eoseTimeout: 8000,
globalTimeout: 20000
})
if (cancelled) return
const seen = new Set<string>()
const deduped: NostrEvent[] = []
events.forEach((evt) => {
const id = isReplaceableEvent((evt as any).kind) ? getReplaceableCoordinateFromEvent(evt as any) : (evt as any).id
if (!seen.has(id)) {
seen.add(id)
deduped.push(evt)
}
})
const inRange = filterCalendarEventsToNextMonths(deduped, CALENDAR_MONTHS_AHEAD)
inRange.sort((a, b) => calendarEventSortKey(a) - calendarEventSortKey(b))
setCalendarEvents(inRange)
} catch (e) {
if (!cancelled) setCalendarEvents([])
} finally {
if (!cancelled) setCalendarLoading(false)
}
}
run()
return () => {
cancelled = true
}
}, [activeTab, getRelays, relayList?.write, pubkey])
// Compute filtered events without slicing (for pagination length check)
const relaysFilteredEventsAll = useMemo(() => { const relaysFilteredEventsAll = useMemo(() => {
const idSet = new Set<string>() const idSet = new Set<string>()
const sourceEvents = cacheEvents
const filtered = sourceEvents.filter((evt) => { const filtered = cacheEvents.filter((evt) => {
if (isEventDeleted(evt)) return false if (isEventDeleted(evt)) return false
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false
// Filter based on active tab
if (activeTab === 'hashtags') {
if (hashtagFilter === 'popular') {
// Check if event has any hashtags (either in 't' tags or content)
const eventHashtags = evt.tags
.filter(tag => tag[0] === 't' && tag[1])
.map(tag => tag[1].toLowerCase())
const contentHashtags = evt.content.match(/#[a-zA-Z0-9_]+/g)?.map(h => h.slice(1).toLowerCase()) || []
const allHashtags = [...eventHashtags, ...contentHashtags]
// Only show events that have at least one hashtag
if (allHashtags.length === 0) return false
if (selectedHashtag) {
// Filter by selected popular hashtag - only show events that contain this specific hashtag
if (!allHashtags.includes(selectedHashtag.toLowerCase())) return false
}
}
}
// Deduplicate events
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (idSet.has(id)) { if (idSet.has(id)) return false
return false
}
idSet.add(id) idSet.add(id)
return true return true
}) })
// Apply sorting
filtered.sort((a, b) => { filtered.sort((a, b) => {
if (sortOrder === 'newest') { if (sortOrder === 'newest') return b.created_at - a.created_at
return b.created_at - a.created_at if (sortOrder === 'oldest') return a.created_at - b.created_at
} else if (sortOrder === 'oldest') { if (sortOrder === 'most-popular' || sortOrder === 'least-popular') {
return a.created_at - b.created_at
} else if (sortOrder === 'most-popular' || sortOrder === 'least-popular') {
const statsA = noteStatsService.getNoteStats(a.id) const statsA = noteStatsService.getNoteStats(a.id)
const statsB = noteStatsService.getNoteStats(b.id) const statsB = noteStatsService.getNoteStats(b.id)
let scoreA = 0 let scoreA = 0
let scoreB = 0 let scoreB = 0
if (statsA) { if (statsA) {
scoreA += (statsA.likes?.length || 0) scoreA += statsA.likes?.length || 0
scoreA += (statsA.replies?.length || 0) * 3 scoreA += (statsA.replies?.length || 0) * 3
scoreA += (statsA.reposts?.length || 0) * 5 scoreA += (statsA.reposts?.length || 0) * 5
scoreA += (statsA.quotes?.length || 0) * 8 scoreA += (statsA.quotes?.length || 0) * 8
scoreA += (statsA.highlights?.length || 0) * 10 scoreA += (statsA.highlights?.length || 0) * 10
if (statsA.zaps) { if (statsA.zaps) {
statsA.zaps.forEach(zap => { statsA.zaps.forEach((zap) => {
scoreA += zap.amount >= zapReplyThreshold ? 8 : 1 scoreA += zap.amount >= zapReplyThreshold ? 8 : 1
}) })
} }
} }
if (statsB) { if (statsB) {
scoreB += (statsB.likes?.length || 0) scoreB += statsB.likes?.length || 0
scoreB += (statsB.replies?.length || 0) * 3 scoreB += (statsB.replies?.length || 0) * 3
scoreB += (statsB.reposts?.length || 0) * 5 scoreB += (statsB.reposts?.length || 0) * 5
scoreB += (statsB.quotes?.length || 0) * 8 scoreB += (statsB.quotes?.length || 0) * 8
scoreB += (statsB.highlights?.length || 0) * 10 scoreB += (statsB.highlights?.length || 0) * 10
if (statsB.zaps) { if (statsB.zaps) {
statsB.zaps.forEach(zap => { statsB.zaps.forEach((zap) => {
scoreB += zap.amount >= zapReplyThreshold ? 8 : 1 scoreB += zap.amount >= zapReplyThreshold ? 8 : 1
}) })
} }
} }
return sortOrder === 'most-popular' ? scoreB - scoreA : scoreA - scoreB return sortOrder === 'most-popular' ? scoreB - scoreA : scoreA - scoreB
} }
return 0 return 0
}) })
return filtered return filtered
}, [ }, [cacheEvents, hideUntrustedNotes, isEventDeleted, isUserTrusted, sortOrder, zapReplyThreshold])
cacheEvents,
hideUntrustedNotes,
isEventDeleted,
isUserTrusted,
activeTab,
hashtagFilter,
selectedHashtag,
sortOrder,
zapReplyThreshold
])
// Slice to showCount for display
const relaysFilteredEvents = useMemo(() => {
return relaysFilteredEventsAll.slice(0, showCount)
}, [relaysFilteredEventsAll, showCount])
// For calendar tab: group events by month (YYYY-MM), months in order; for others use relays
const calendarEventsByMonth = useMemo(() => {
const byMonth = new Map<string, NostrEvent[]>()
calendarEvents.forEach((evt) => {
const key = calendarEventMonthKey(evt)
if (!byMonth.has(key)) byMonth.set(key, [])
byMonth.get(key)!.push(evt)
})
byMonth.forEach((list) => list.sort((a, b) => calendarEventSortKey(a) - calendarEventSortKey(b)))
const monthKeys = Array.from(byMonth.keys()).sort()
return { monthKeys, byMonth }
}, [calendarEvents])
const filteredEvents = useMemo(() => {
if (activeTab === 'calendar') {
return calendarEvents.slice(0, showCount)
}
return relaysFilteredEvents
}, [activeTab, calendarEvents, relaysFilteredEvents, showCount])
// Reset showCount when tab changes
useEffect(() => {
setShowCount(SHOW_COUNT)
}, [activeTab])
// Reset filters when switching tabs
useEffect(() => {
if (activeTab === 'relays') {
setSortOrder('most-popular')
// If cache is empty and not loading, log the issue for debugging
if (cacheEvents.length === 0 && !cacheLoading && !isInitializing) {
logger.debug('[TrendingNotes] Relays tab selected but cache is empty - this should not happen if cache initialization completed')
}
} else if (activeTab === 'hashtags') {
setSortOrder('most-popular')
setSelectedHashtag(null)
}
}, [activeTab, pubkey, cacheEvents.length, cacheLoading])
const relaysFilteredEvents = useMemo(
() => relaysFilteredEventsAll.slice(0, showCount),
[relaysFilteredEventsAll, showCount]
)
useEffect(() => { useEffect(() => {
const totalLength = relaysFilteredEventsAll.length const totalLength = relaysFilteredEventsAll.length
if (showCount >= totalLength) return if (showCount >= totalLength) return
const options = { const options = { root: null, rootMargin: '10px', threshold: 0.1 }
root: null,
rootMargin: '10px',
threshold: 0.1
}
const observerInstance = new IntersectionObserver((entries) => { const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) { if (entries[0].isIntersecting) {
setShowCount((prev) => prev + SHOW_COUNT) setShowCount((prev) => prev + SHOW_COUNT)
@ -626,232 +282,100 @@ export default function TrendingNotes() {
}, options) }, options)
const currentBottomRef = bottomRef.current const currentBottomRef = bottomRef.current
if (currentBottomRef) observerInstance.observe(currentBottomRef)
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => { return () => {
if (observerInstance && currentBottomRef) { if (currentBottomRef) observerInstance.unobserve(currentBottomRef)
observerInstance.unobserve(currentBottomRef)
}
} }
}, [relaysFilteredEventsAll.length, showCount, cacheLoading]) }, [relaysFilteredEventsAll.length, showCount, cacheLoading])
const headerTitle =
trendingRelaySource === 'favorites'
? t('Trending on Your Favorite Relays')
: t('Trending on the Default Relays')
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<div className="sticky top-12 bg-background z-30 border-b"> <div className="sticky top-12 z-30 border-b bg-background">
<div className="h-12 px-4 flex flex-col justify-center text-lg font-bold"> <div className="px-4 pb-3 pt-3">
{t('Trending Notes')} <h2 className="text-lg font-bold leading-tight">{headerTitle}</h2>
</div> </div>
<div className="flex items-center gap-2 px-4 pb-2"> <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<span className="text-sm font-medium text-muted-foreground">Trending:</span> <span className="text-xs text-muted-foreground">{t('Sort')}:</span>
<div className="flex gap-1"> <div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => setSortOrder('newest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'newest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('newest')}
</button>
<button <button
onClick={() => { type="button"
setActiveTab('relays') onClick={() => setSortOrder('oldest')}
window.dispatchEvent(new CustomEvent('pageTabChanged', { className={`rounded px-2 py-1 text-xs transition-colors ${
detail: { page: 'search', tab: 'relays' } sortOrder === 'oldest'
})) ? 'bg-secondary text-secondary-foreground'
}} : 'bg-muted/50 text-muted-foreground hover:bg-muted'
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'relays'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
}`} }`}
> >
on your relays {t('oldest')}
</button> </button>
<button <button
onClick={() => { type="button"
setActiveTab('hashtags') onClick={() => setSortOrder('most-popular')}
window.dispatchEvent(new CustomEvent('pageTabChanged', { className={`rounded px-2 py-1 text-xs transition-colors ${
detail: { page: 'search', tab: 'hashtags' } sortOrder === 'most-popular'
})) ? 'bg-secondary text-secondary-foreground'
}} : 'bg-muted/50 text-muted-foreground hover:bg-muted'
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'hashtags'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
}`} }`}
> >
hashtags {t('most popular')}
</button> </button>
<button <button
onClick={() => { type="button"
setActiveTab('calendar') onClick={() => setSortOrder('least-popular')}
window.dispatchEvent(new CustomEvent('pageTabChanged', { className={`rounded px-2 py-1 text-xs transition-colors ${
detail: { page: 'search', tab: 'calendar' } sortOrder === 'least-popular'
})) ? 'bg-secondary text-secondary-foreground'
}} : 'bg-muted/50 text-muted-foreground hover:bg-muted'
className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'calendar'
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
}`} }`}
> >
{t('calendar entries')} {t('least popular')}
</button> </button>
</div> </div>
</div> </div>
{/* Second row controls for relays / hashtags (calendar has no sort – ordered by datetime) */}
{(activeTab === 'relays' || activeTab === 'hashtags') && (
<div className="flex items-center gap-4 px-4 pb-2">
{/* Sorting controls - not shown for hashtags tab */}
{activeTab !== 'hashtags' && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Sort:</span>
<div className="flex gap-1">
<button
onClick={() => setSortOrder('newest')}
className={`px-2 py-1 text-xs rounded transition-colors ${
sortOrder === 'newest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 hover:bg-muted text-muted-foreground'
}`}
>
newest
</button>
<button
onClick={() => setSortOrder('oldest')}
className={`px-2 py-1 text-xs rounded transition-colors ${
sortOrder === 'oldest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 hover:bg-muted text-muted-foreground'
}`}
>
oldest
</button>
<button
onClick={() => setSortOrder('most-popular')}
className={`px-2 py-1 text-xs rounded transition-colors ${
sortOrder === 'most-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 hover:bg-muted text-muted-foreground'
}`}
>
most popular
</button>
<button
onClick={() => setSortOrder('least-popular')}
className={`px-2 py-1 text-xs rounded transition-colors ${
sortOrder === 'least-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 hover:bg-muted text-muted-foreground'
}`}
>
least popular
</button>
</div>
</div>
)}
</div>
)}
{/* Popular hashtag buttons for hashtags tab */}
{activeTab === 'hashtags' && hashtagFilter === 'popular' && popularHashtags.length > 0 && (
<div className="flex items-center gap-2 px-4 pb-2">
<span className="text-xs text-muted-foreground">Popular hashtags:</span>
<div className="flex gap-1 flex-wrap">
{popularHashtags.map((hashtag) => (
<button
key={hashtag}
onClick={() => setSelectedHashtag(selectedHashtag === hashtag ? null : hashtag)}
className={`px-2 py-1 text-xs rounded transition-colors ${
selectedHashtag === hashtag
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 hover:bg-muted text-muted-foreground'
}`}
>
#{hashtag}
</button>
))}
</div>
</div>
)}
</div> </div>
{/* Show loading message for relays tab when cache is loading */} {cacheLoading && cacheEvents.length === 0 ? (
{activeTab === 'relays' && cacheLoading && cacheEvents.length === 0 && ( <div className="mt-8 text-center text-sm text-muted-foreground">
<div className="text-center text-sm text-muted-foreground mt-8"> {t('Loading trending notes from your relays...')}
Loading trending notes from your relays...
</div>
)}
{/* Show loading message for calendar tab */}
{activeTab === 'calendar' && calendarLoading && calendarEvents.length === 0 && (
<div className="text-center text-sm text-muted-foreground mt-8">
{t('Loading calendar events...')}
</div> </div>
)} ) : null}
{activeTab === 'calendar' && !calendarLoading && calendarEvents.length === 0 && (
<div className="text-center text-sm text-muted-foreground mt-8"> {relaysFilteredEvents.map((event) => (
{t('No calendar events found')} <NoteCard
key={
isReplaceableEvent((event as NostrEvent).kind)
? getReplaceableCoordinateFromEvent(event as NostrEvent)
: (event as NostrEvent).id
}
className="w-full"
event={event}
/>
))}
{cacheLoading || showCount < relaysFilteredEventsAll.length ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div> </div>
) : (
<div className="mt-2 text-center text-sm text-muted-foreground">{t('no more notes')}</div>
)} )}
{activeTab === 'calendar'
? calendarEventsByMonth.monthKeys.map((monthKey) => {
const eventsInMonth = calendarEventsByMonth.byMonth.get(monthKey) ?? []
const [y, m] = monthKey.split('-')
const monthLabel = new Date(parseInt(y, 10), parseInt(m, 10) - 1, 1).toLocaleDateString(undefined, {
month: 'long',
year: 'numeric'
})
return (
<div key={monthKey} className="mt-6 first:mt-0">
<h3 className="text-sm font-semibold text-muted-foreground px-4 py-2 border-b bg-muted/30">
{monthLabel}
</h3>
<div className="space-y-0">
{eventsInMonth.map((event) => (
<NoteCard
key={isReplaceableEvent((event as any).kind) ? getReplaceableCoordinateFromEvent(event as any) : (event as any).id}
className="w-full"
event={event}
/>
))}
</div>
</div>
)
})
: filteredEvents.map((event) => (
<NoteCard
key={isReplaceableEvent((event as any).kind) ? getReplaceableCoordinateFromEvent(event as any) : (event as any).id}
className="w-full"
event={event}
/>
))}
{(() => {
const actualAvailableLength = activeTab === 'calendar' ? calendarEvents.length : relaysFilteredEventsAll.length
const isLoading = activeTab === 'relays' ? cacheLoading : activeTab === 'calendar' ? calendarLoading : false
const calendarShowingAll = activeTab === 'calendar' && !calendarLoading
const shouldShowLoading =
isLoading ||
(activeTab !== 'calendar' && showCount < actualAvailableLength)
if (shouldShowLoading) {
return (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
)
}
if (calendarShowingAll && calendarEvents.length > 0) {
return (
<div className="text-center text-sm text-muted-foreground mt-4 pb-4">
{t('Calendar events in the next {{count}} months', { count: CALENDAR_MONTHS_AHEAD })}
</div>
)
}
return <div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
})()}
</div> </div>
) )
} }

2
src/constants.ts

@ -267,7 +267,7 @@ export const SUPPORTED_KINDS = [
ExtendedKind.APPLICATION_HANDLER_INFO ExtendedKind.APPLICATION_HANDLER_INFO
] ]
/** Kinds for profile feed and favorites-style feeds: supported kinds except reposts, publications, publication content, NIP-89 handlers. */ /** Kinds for profile feed and favorites-style feeds: supported kinds except boosts (kind 6), publications, publication content, NIP-89 handlers. */
export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter(
(k) => (k) =>
k !== kinds.Repost && k !== kinds.Repost &&

13
src/i18n/locales/ar.ts

@ -14,7 +14,7 @@ export default {
Logout: 'تسجيل الخروج', Logout: 'تسجيل الخروج',
Following: 'المتابعون', Following: 'المتابعون',
followings: 'المتابعين', followings: 'المتابعين',
reposted: ُعيد نشره', boosted: 'قوّى',
'just now': 'الآن', 'just now': 'الآن',
'n minutes ago': 'منذ {{n}} دقيقة', 'n minutes ago': 'منذ {{n}} دقيقة',
'n m': '{{n}} دقيقة', 'n m': '{{n}} دقيقة',
@ -41,7 +41,8 @@ export default {
'Failed to post': 'فشل النشر', 'Failed to post': 'فشل النشر',
'Post successful': 'تم النشر بنجاح', 'Post successful': 'تم النشر بنجاح',
'Your post has been published': 'تم نشر مشاركتك', 'Your post has been published': 'تم نشر مشاركتك',
Repost: 'إعادة النشر', Boost: 'Boost',
'Boost published': ُشر الـ Boost',
Quote: 'اقتباس', Quote: 'اقتباس',
'Copy event ID': 'نسخ معرف الحدث', 'Copy event ID': 'نسخ معرف الحدث',
'Copy user ID': 'نسخ معرف المستخدم', 'Copy user ID': 'نسخ معرف المستخدم',
@ -333,9 +334,9 @@ export default {
'No reactions yet': 'لا توجد تفاعلات بعد', 'No reactions yet': 'لا توجد تفاعلات بعد',
'No more zaps': 'لا توجد مزيد من الزابس', 'No more zaps': 'لا توجد مزيد من الزابس',
'No zaps yet': 'لا توجد زابس بعد', 'No zaps yet': 'لا توجد زابس بعد',
'No more reposts': 'لا توجد مزيد من إعادة النشر', 'No more boosts': 'لا مزيد من الـ Boosts',
'No reposts yet': 'لا توجد إعادة نشر بعد', 'No boosts yet': 'لا توجد Boosts بعد',
Reposts: 'إعادة النشر', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'لم يتم العثور على قائمة المتابعة. هل تريد إنشاء واحدة جديدة؟ إذا كنت قد تابعت مستخدمين من قبل، يرجى عدم التأكيد لأن هذه العملية ستؤدي إلى فقدان قائمة المتابعة السابقة.', 'لم يتم العثور على قائمة المتابعة. هل تريد إنشاء واحدة جديدة؟ إذا كنت قد تابعت مستخدمين من قبل، يرجى عدم التأكيد لأن هذه العملية ستؤدي إلى فقدان قائمة المتابعة السابقة.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -380,7 +381,7 @@ export default {
'quoted your note': 'اقتبس ملاحظتك', 'quoted your note': 'اقتبس ملاحظتك',
'voted in your poll': 'صوت في استطلاعك', 'voted in your poll': 'صوت في استطلاعك',
'reacted to your note': 'تفاعل مع ملاحظتك', 'reacted to your note': 'تفاعل مع ملاحظتك',
'reposted your note': 'أعاد نشر ملاحظتك', 'boosted your note': 'قوّى ملاحظتك',
'zapped your note': 'زاب ملاحظتك', 'zapped your note': 'زاب ملاحظتك',
'zapped you': 'زابك', 'zapped you': 'زابك',
'Mark as read': 'تعليم كمقروء', 'Mark as read': 'تعليم كمقروء',

26
src/i18n/locales/de.ts

@ -8,13 +8,14 @@ export default {
Home: 'Startseite', Home: 'Startseite',
'Relay settings': 'Relay-Einstellungen', 'Relay settings': 'Relay-Einstellungen',
Settings: 'Einstellungen', Settings: 'Einstellungen',
'Account menu': 'Kontomenü',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',
Refresh: 'Aktualisieren', Refresh: 'Aktualisieren',
Profile: 'Profil', Profile: 'Profil',
Logout: 'Abmelden', Logout: 'Abmelden',
Following: 'Folgende', Following: 'Folgende',
followings: 'Folgekonten', followings: 'Folgekonten',
reposted: 'erneut gepostet', boosted: 'geboostet',
'just now': 'gerade eben', 'just now': 'gerade eben',
'n minutes ago': 'vor {{n}} Minuten', 'n minutes ago': 'vor {{n}} Minuten',
'n m': 'vor {{n}}m', 'n m': 'vor {{n}}m',
@ -48,7 +49,8 @@ export default {
'Failed to post': 'Posten fehlgeschlagen', 'Failed to post': 'Posten fehlgeschlagen',
'Post successful': 'Beitrag erfolgreich', 'Post successful': 'Beitrag erfolgreich',
'Your post has been published': 'Dein Beitrag wurde veröffentlicht', 'Your post has been published': 'Dein Beitrag wurde veröffentlicht',
Repost: 'Erneut posten', Boost: 'Boost',
'Boost published': 'Boost veröffentlicht',
Quote: 'Zitat', Quote: 'Zitat',
'Copy event ID': 'Ereignis-ID kopieren', 'Copy event ID': 'Ereignis-ID kopieren',
'Copy user ID': 'Benutzer-ID kopieren', 'Copy user ID': 'Benutzer-ID kopieren',
@ -314,6 +316,7 @@ export default {
'no more replies': 'keine weiteren Antworten', 'no more replies': 'keine weiteren Antworten',
'Relay sets': 'Relay-Sets', 'Relay sets': 'Relay-Sets',
'Favorite Relays': 'Lieblings-Relays', 'Favorite Relays': 'Lieblings-Relays',
'Search for Relays': 'Relays suchen',
'Using app default relays': 'Standard-Relays der App', 'Using app default relays': 'Standard-Relays der App',
"Following's Favorites": 'Favoriten der Folgenden', "Following's Favorites": 'Favoriten der Folgenden',
'no more relays': 'keine weiteren Relays', 'no more relays': 'keine weiteren Relays',
@ -493,9 +496,9 @@ export default {
'No reactions yet': 'Noch keine Reaktionen', 'No reactions yet': 'Noch keine Reaktionen',
'No more zaps': 'Keine weiteren Zaps', 'No more zaps': 'Keine weiteren Zaps',
'No zaps yet': 'Noch keine Zaps', 'No zaps yet': 'Noch keine Zaps',
'No more reposts': 'Keine weiteren Reposts', 'No more boosts': 'Keine weiteren Boosts',
'No reposts yet': 'Noch keine Reposts', 'No boosts yet': 'Noch keine Boosts',
Reposts: 'Reposts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Folgeliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer gefolgt haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Folgeliste verlieren.', 'Folgeliste nicht gefunden. Möchten Sie eine neue erstellen? Wenn Sie zuvor Benutzer gefolgt haben, bestätigen Sie bitte NICHT, da diese Operation dazu führt, dass Sie Ihre vorherige Folgeliste verlieren.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -548,7 +551,7 @@ export default {
'quoted your note': 'hat Ihre Notiz zitiert', 'quoted your note': 'hat Ihre Notiz zitiert',
'voted in your poll': 'hat in Ihrer Umfrage abgestimmt', 'voted in your poll': 'hat in Ihrer Umfrage abgestimmt',
'reacted to your note': 'hat auf Ihre Notiz reagiert', 'reacted to your note': 'hat auf Ihre Notiz reagiert',
'reposted your note': 'hat Ihre Notiz geteilt', 'boosted your note': 'hat Ihre Notiz geboostet',
'zapped your note': 'hat Ihre Notiz gezappt', 'zapped your note': 'hat Ihre Notiz gezappt',
'zapped you': 'hat Sie gezappt', 'zapped you': 'hat Sie gezappt',
'Mark as read': 'Als gelesen markieren', 'Mark as read': 'Als gelesen markieren',
@ -594,6 +597,14 @@ export default {
'{{count}} relays': '{{count}} Relays', '{{count}} relays': '{{count}} Relays',
'Republishing...': 'Wird erneut veröffentlicht...', 'Republishing...': 'Wird erneut veröffentlicht...',
'Trending Notes': 'Trendende Notizen', 'Trending Notes': 'Trendende Notizen',
'Trending on Your Favorite Relays': 'Trending auf deinen Lieblings-Relays',
'Trending on the Default Relays': 'Trending auf den Standard-Relays',
'Loading trending notes from your relays...': 'Trendende Notizen werden geladen …',
Sort: 'Sortierung',
newest: 'neueste',
oldest: 'älteste',
'most popular': 'beliebteste',
'least popular': 'am wenigsten beliebt',
'Connected to': 'Verbunden mit', 'Connected to': 'Verbunden mit',
'Disconnect Wallet': 'Wallet trennen', 'Disconnect Wallet': 'Wallet trennen',
'Are you absolutely sure?': 'Bist du dir absolut sicher?', 'Are you absolutely sure?': 'Bist du dir absolut sicher?',
@ -620,6 +631,9 @@ export default {
'Richte deine Wallet ein, um Sats zu senden und zu empfangen!', 'Richte deine Wallet ein, um Sats zu senden und zu empfangen!',
'Set up': 'Einrichten', 'Set up': 'Einrichten',
'help.title': 'Hilfe',
'help.tabShortcuts': 'Tastenkürzel',
'help.tabOverview': 'App-Übersicht',
'shortcuts.title': 'Tastenkürzel', 'shortcuts.title': 'Tastenkürzel',
'shortcuts.intro': 'shortcuts.intro':
'Kürzel für diese App und übliche Bedienung. Kombinationen: Umschalt+Alt+Taste (unter macOS: Umschalt+Wahltaste); die Reihenfolge der Modifier beim Drücken ist egal.', 'Kürzel für diese App und übliche Bedienung. Kombinationen: Umschalt+Alt+Taste (unter macOS: Umschalt+Wahltaste); die Reihenfolge der Modifier beim Drücken ist egal.',

26
src/i18n/locales/en.ts

@ -10,13 +10,14 @@ export default {
'Pinned note': 'Pinned note', 'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings', 'Relay settings': 'Relays and Storage Settings',
Settings: 'Settings', Settings: 'Settings',
'Account menu': 'Account menu',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',
Refresh: 'Refresh', Refresh: 'Refresh',
Profile: 'Profile', Profile: 'Profile',
Logout: 'Logout', Logout: 'Logout',
Following: 'Following', Following: 'Following',
followings: 'followings', followings: 'followings',
reposted: 'reposted', boosted: 'boosted',
'just now': 'just now', 'just now': 'just now',
'n minutes ago': '{{n}} minutes ago', 'n minutes ago': '{{n}} minutes ago',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -51,7 +52,8 @@ export default {
'Failed to post': 'Failed to post', 'Failed to post': 'Failed to post',
'Post successful': 'Post successful', 'Post successful': 'Post successful',
'Your post has been published': 'Your post has been published', 'Your post has been published': 'Your post has been published',
Repost: 'Repost', Boost: 'Boost',
'Boost published': 'Boost published',
Quote: 'Quote', Quote: 'Quote',
'Copy event ID': 'Copy event ID', 'Copy event ID': 'Copy event ID',
'Copy user ID': 'Copy user ID', 'Copy user ID': 'Copy user ID',
@ -381,6 +383,7 @@ export default {
'no more replies': 'no more replies', 'no more replies': 'no more replies',
'Relay sets': 'Relay sets', 'Relay sets': 'Relay sets',
'Favorite Relays': 'Favorite Relays', 'Favorite Relays': 'Favorite Relays',
'Search for Relays': 'Search for Relays',
'Using app default relays': 'Using app default relays', 'Using app default relays': 'Using app default relays',
"Following's Favorites": "Following's Favorites", "Following's Favorites": "Following's Favorites",
'no more relays': 'no more relays', 'no more relays': 'no more relays',
@ -563,9 +566,9 @@ export default {
'No reactions yet': 'No reactions yet', 'No reactions yet': 'No reactions yet',
'No more zaps': 'No more zaps', 'No more zaps': 'No more zaps',
'No zaps yet': 'No zaps yet', 'No zaps yet': 'No zaps yet',
'No more reposts': 'No more reposts', 'No more boosts': 'No more boosts',
'No reposts yet': 'No reposts yet', 'No boosts yet': 'No boosts yet',
Reposts: 'Reposts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Follow list not found. Do you want to create a new one? If you have followed users before, please DO NOT confirm as this operation will cause you to lose your previous follow list.', 'Follow list not found. Do you want to create a new one? If you have followed users before, please DO NOT confirm as this operation will cause you to lose your previous follow list.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -615,7 +618,7 @@ export default {
'quoted your note': 'quoted your note', 'quoted your note': 'quoted your note',
'voted in your poll': 'voted in your poll', 'voted in your poll': 'voted in your poll',
'reacted to your note': 'reacted to your note', 'reacted to your note': 'reacted to your note',
'reposted your note': 'reposted your note', 'boosted your note': 'boosted your note',
'zapped your note': 'zapped your note', 'zapped your note': 'zapped your note',
'zapped you': 'zapped you', 'zapped you': 'zapped you',
zapped: 'zapped', zapped: 'zapped',
@ -663,6 +666,14 @@ export default {
'{{count}} relays': '{{count}} relays', '{{count}} relays': '{{count}} relays',
'Republishing...': 'Republishing...', 'Republishing...': 'Republishing...',
'Trending Notes': 'Trending Notes', 'Trending Notes': 'Trending Notes',
'Trending on Your Favorite Relays': 'Trending on Your Favorite Relays',
'Trending on the Default Relays': 'Trending on the Default Relays',
'Loading trending notes from your relays...': 'Loading trending notes from your relays...',
Sort: 'Sort',
newest: 'newest',
oldest: 'oldest',
'most popular': 'most popular',
'least popular': 'least popular',
'Connected to': 'Connected to', 'Connected to': 'Connected to',
'Disconnect Wallet': 'Disconnect Wallet', 'Disconnect Wallet': 'Disconnect Wallet',
'Are you absolutely sure?': 'Are you absolutely sure?', 'Are you absolutely sure?': 'Are you absolutely sure?',
@ -706,6 +717,9 @@ export default {
'Compact': 'Compact', 'Compact': 'Compact',
'Expand': 'Expand', 'Expand': 'Expand',
'help.title': 'Help',
'help.tabShortcuts': 'Keyboard shortcuts',
'help.tabOverview': 'App overview',
'shortcuts.title': 'Keyboard shortcuts', 'shortcuts.title': 'Keyboard shortcuts',
'shortcuts.intro': 'shortcuts.intro':
'Shortcuts for this app and common browsing. Modifier combos are Shift+Alt+key (Option+Shift+key on macOS); either modifier order works when typing.', 'Shortcuts for this app and common browsing. Modifier combos are Shift+Alt+key (Option+Shift+key on macOS); either modifier order works when typing.',

13
src/i18n/locales/es.ts

@ -14,7 +14,7 @@ export default {
Logout: 'Cerrar sesión', Logout: 'Cerrar sesión',
Following: 'Siguiendo', Following: 'Siguiendo',
followings: 'siguiendo', followings: 'siguiendo',
reposted: 'retransmitido', boosted: 'boosteado',
'just now': 'justo ahora', 'just now': 'justo ahora',
'n minutes ago': 'hace {{n}} minutos', 'n minutes ago': 'hace {{n}} minutos',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -41,7 +41,8 @@ export default {
'Failed to post': 'Error al publicar', 'Failed to post': 'Error al publicar',
'Post successful': 'Publicación exitosa', 'Post successful': 'Publicación exitosa',
'Your post has been published': 'Tu publicación ha sido publicada', 'Your post has been published': 'Tu publicación ha sido publicada',
Repost: 'Reenviar', Boost: 'Boost',
'Boost published': 'Boost publicado',
Quote: 'Citar', Quote: 'Citar',
'Copy event ID': 'Copiar ID del evento', 'Copy event ID': 'Copiar ID del evento',
'Copy user ID': 'Copiar ID del usuario', 'Copy user ID': 'Copiar ID del usuario',
@ -337,9 +338,9 @@ export default {
'No reactions yet': 'Sin reacciones aún', 'No reactions yet': 'Sin reacciones aún',
'No more zaps': 'No hay más zaps', 'No more zaps': 'No hay más zaps',
'No zaps yet': 'Sin zaps aún', 'No zaps yet': 'Sin zaps aún',
'No more reposts': 'No hay más reposts', 'No more boosts': 'No hay más boosts',
'No reposts yet': 'Sin reposts aún', 'No boosts yet': 'Sin boosts aún',
Reposts: 'Reposts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Lista de seguidos no encontrada. ¿Quieres crear una nueva? Si has seguido usuarios antes, por favor NO confirmes ya que esta operación te hará perder tu lista de seguidos anterior.', 'Lista de seguidos no encontrada. ¿Quieres crear una nueva? Si has seguido usuarios antes, por favor NO confirmes ya que esta operación te hará perder tu lista de seguidos anterior.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -385,7 +386,7 @@ export default {
'quoted your note': 'citó tu nota', 'quoted your note': 'citó tu nota',
'voted in your poll': 'votó en tu encuesta', 'voted in your poll': 'votó en tu encuesta',
'reacted to your note': 'reaccionó a tu nota', 'reacted to your note': 'reaccionó a tu nota',
'reposted your note': 'reposteó tu nota', 'boosted your note': 'boosteó tu nota',
'zapped your note': 'zappeó tu nota', 'zapped your note': 'zappeó tu nota',
'zapped you': 'te zappeó', 'zapped you': 'te zappeó',
'Mark as read': 'Marcar como leído', 'Mark as read': 'Marcar como leído',

13
src/i18n/locales/fa.ts

@ -13,7 +13,7 @@ export default {
Logout: 'خروج', Logout: 'خروج',
Following: 'دنبال میکنم', Following: 'دنبال میکنم',
followings: 'دنبال شوندهها', followings: 'دنبال شوندهها',
reposted: 'بازنشر شده', boosted: 'بوست شده',
'just now': 'همین الان', 'just now': 'همین الان',
'n minutes ago': '{{n}} دقیقه پیش', 'n minutes ago': '{{n}} دقیقه پیش',
'n m': '{{n}}د', 'n m': '{{n}}د',
@ -40,7 +40,8 @@ export default {
'Failed to post': 'ارسال ناموفق', 'Failed to post': 'ارسال ناموفق',
'Post successful': 'ارسال موفق', 'Post successful': 'ارسال موفق',
'Your post has been published': 'پست شما منتشر شد', 'Your post has been published': 'پست شما منتشر شد',
Repost: 'بازنشر', Boost: 'بوست',
'Boost published': 'بوست منتشر شد',
Quote: 'نقل قول', Quote: 'نقل قول',
'Copy event ID': 'کپی شناسه رویداد', 'Copy event ID': 'کپی شناسه رویداد',
'Copy user ID': 'کپی شناسه کاربر', 'Copy user ID': 'کپی شناسه کاربر',
@ -334,9 +335,9 @@ export default {
'No reactions yet': 'هنوز هیچ واکنشی وجود ندارد', 'No reactions yet': 'هنوز هیچ واکنشی وجود ندارد',
'No more zaps': 'هیچ زپی بیشتر وجود ندارد', 'No more zaps': 'هیچ زپی بیشتر وجود ندارد',
'No zaps yet': 'هنوز هیچ زپی وجود ندارد', 'No zaps yet': 'هنوز هیچ زپی وجود ندارد',
'No more reposts': 'هیچ بازنشر بیشتری وجود ندارد', 'No more boosts': 'بوست دیگری نیست',
'No reposts yet': 'هنوز هیچ بازنشر وجود ندارد', 'No boosts yet': 'هنوز بوستی نیست',
Reposts: 'بازنشرها', Boosts: 'بوستها',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'فهرست دنبالکنندگان پیدا نشد. آیا میخواهید یکی جدید ایجاد کنید؟ اگر قبلاً کاربرانی را دنبال کردهاید، لطفاً تأیید نکنید زیرا این عملیات باعث از دست رفتن فهرست دنبالکنندگان قبلی شما خواهد شد.', 'فهرست دنبالکنندگان پیدا نشد. آیا میخواهید یکی جدید ایجاد کنید؟ اگر قبلاً کاربرانی را دنبال کردهاید، لطفاً تأیید نکنید زیرا این عملیات باعث از دست رفتن فهرست دنبالکنندگان قبلی شما خواهد شد.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -381,7 +382,7 @@ export default {
'quoted your note': 'یادداشت شما را نقل قول کرد', 'quoted your note': 'یادداشت شما را نقل قول کرد',
'voted in your poll': 'در نظرسنجی شما رأی داد', 'voted in your poll': 'در نظرسنجی شما رأی داد',
'reacted to your note': 'به یادداشت شما واکنش نشان داد', 'reacted to your note': 'به یادداشت شما واکنش نشان داد',
'reposted your note': 'یادداشت شما را بازنشر کرد', 'boosted your note': 'یادداشت شما را بوست کرد',
'zapped your note': 'یادداشت شما را زپ کرد', 'zapped your note': 'یادداشت شما را زپ کرد',
'zapped you': 'شما را زپ کرد', 'zapped you': 'شما را زپ کرد',
'Mark as read': 'علامتگذاری به عنوان خوانده شده', 'Mark as read': 'علامتگذاری به عنوان خوانده شده',

13
src/i18n/locales/fr.ts

@ -14,7 +14,7 @@ export default {
Logout: 'Déconnexion', Logout: 'Déconnexion',
Following: 'Abonnements', Following: 'Abonnements',
followings: 'abonnements', followings: 'abonnements',
reposted: 'republié', boosted: 'a boosté',
'just now': "à l'instant", 'just now': "à l'instant",
'n minutes ago': 'il y a {{n}} minutes', 'n minutes ago': 'il y a {{n}} minutes',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -41,7 +41,8 @@ export default {
'Failed to post': 'Publication échouée', 'Failed to post': 'Publication échouée',
'Post successful': 'Publication réussie', 'Post successful': 'Publication réussie',
'Your post has been published': 'Votre publication a été publiée', 'Your post has been published': 'Votre publication a été publiée',
Repost: 'Reposter', Boost: 'Boost',
'Boost published': 'Boost publié',
Quote: 'Citer', Quote: 'Citer',
'Copy event ID': "Copier l'ID de l'événement", 'Copy event ID': "Copier l'ID de l'événement",
'Copy user ID': "Copier l'ID de l'utilisateur", 'Copy user ID': "Copier l'ID de l'utilisateur",
@ -339,9 +340,9 @@ export default {
'No reactions yet': 'Pas encore de réactions', 'No reactions yet': 'Pas encore de réactions',
'No more zaps': 'Plus de zaps', 'No more zaps': 'Plus de zaps',
'No zaps yet': 'Pas encore de zaps', 'No zaps yet': 'Pas encore de zaps',
'No more reposts': 'Plus de reposts', 'No more boosts': 'Plus de boosts',
'No reposts yet': 'Pas encore de reposts', 'No boosts yet': 'Pas encore de boosts',
Reposts: 'Reposts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Liste de suivi non trouvée. Voulez-vous en créer une nouvelle ? Si vous avez suivi des utilisateurs auparavant, veuillez NE PAS confirmer car cette opération vous fera perdre votre liste de suivi précédente.', 'Liste de suivi non trouvée. Voulez-vous en créer une nouvelle ? Si vous avez suivi des utilisateurs auparavant, veuillez NE PAS confirmer car cette opération vous fera perdre votre liste de suivi précédente.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -389,7 +390,7 @@ export default {
'quoted your note': 'a cité votre note', 'quoted your note': 'a cité votre note',
'voted in your poll': 'a voté dans votre sondage', 'voted in your poll': 'a voté dans votre sondage',
'reacted to your note': 'a réagi à votre note', 'reacted to your note': 'a réagi à votre note',
'reposted your note': 'a repartagé votre note', 'boosted your note': 'a boosté votre note',
'zapped your note': 'a zappé votre note', 'zapped your note': 'a zappé votre note',
'zapped you': 'vous a zappé', 'zapped you': 'vous a zappé',
'Mark as read': 'Marquer comme lu', 'Mark as read': 'Marquer comme lu',

13
src/i18n/locales/hi.ts

@ -13,7 +13,7 @@ export default {
Logout: 'लगआउट', Logout: 'लगआउट',
Following: 'फ कर रह', Following: 'फ कर रह',
followings: 'फग', followings: 'फग',
reposted: 'रट कि', boosted: 'बट कि',
'just now': 'अभ', 'just now': 'अभ',
'n minutes ago': '{{n}} मिनट पहल', 'n minutes ago': '{{n}} मिनट पहल',
'n m': '{{n}}मि', 'n m': '{{n}}मि',
@ -40,7 +40,8 @@ export default {
'Failed to post': 'पट असफल', 'Failed to post': 'पट असफल',
'Post successful': 'पट सफल', 'Post successful': 'पट सफल',
'Your post has been published': 'आपकट परकित ह गई ह', 'Your post has been published': 'आपकट परकित ह गई ह',
Repost: 'रट', Boost: 'बट',
'Boost published': 'बट परकित',
Quote: 'उदधरण', Quote: 'उदधरण',
'Copy event ID': 'इवट आईड कर', 'Copy event ID': 'इवट आईड कर',
'Copy user ID': 'यजर आईड कर', 'Copy user ID': 'यजर आईड कर',
@ -335,9 +336,9 @@ export default {
'No reactions yet': 'अभ तक कई परतिि नह', 'No reactions yet': 'अभ तक कई परतिि नह',
'No more zaps': 'कई और जस नह', 'No more zaps': 'कई और जस नह',
'No zaps yet': 'अभ तक कई जस नह', 'No zaps yet': 'अभ तक कई जस नह',
'No more reposts': 'कई और रट नह', 'No more boosts': 'और कई बट नह',
'No reposts yet': 'अभ तक कई रट नह', 'No boosts yet': 'अभ तक कई बट नह',
Reposts: 'रट', Boosts: 'बट',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'फ नहि। क आप एक नई बनहत? यदि आपन पहल उपयगकरि, तपयि न करि इस ऑपरशन स आपकिछल नषट हएग।', 'फ नहि। क आप एक नई बनहत? यदि आपन पहल उपयगकरि, तपयि न करि इस ऑपरशन स आपकिछल नषट हएग।',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -384,7 +385,7 @@ export default {
'quoted your note': 'न आपकट क उदत कि', 'quoted your note': 'न आपकट क उदत कि',
'voted in your poll': 'न आपकल मट कि', 'voted in your poll': 'न आपकल मट कि',
'reacted to your note': 'न आपकट पर परतिि', 'reacted to your note': 'न आपकट पर परतिि',
'reposted your note': 'न आपकट क ट कि', 'boosted your note': 'न आपकट क ट कि',
'zapped your note': 'न आपकट कप कि', 'zapped your note': 'न आपकट कप कि',
'zapped you': 'न आपकप कि', 'zapped you': 'न आपकप कि',
'Mark as read': 'पढआ मक कर', 'Mark as read': 'पढआ मक कर',

13
src/i18n/locales/it.ts

@ -13,7 +13,7 @@ export default {
Logout: 'Disconnetti', Logout: 'Disconnetti',
Following: 'Seguendo', Following: 'Seguendo',
followings: 'seguiti', followings: 'seguiti',
reposted: 'ripubblica', boosted: 'ha boostato',
'just now': 'adesso', 'just now': 'adesso',
'n minutes ago': '{{n}} minuti fa', 'n minutes ago': '{{n}} minuti fa',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -40,7 +40,8 @@ export default {
'Failed to post': 'Impossibile pubblicare', 'Failed to post': 'Impossibile pubblicare',
'Post successful': 'Pubblicazione riuscita', 'Post successful': 'Pubblicazione riuscita',
'Your post has been published': 'Il tuo post è stato pubblicato', 'Your post has been published': 'Il tuo post è stato pubblicato',
Repost: 'Ripubblica', Boost: 'Boost',
'Boost published': 'Boost pubblicato',
Quote: 'Quota', Quote: 'Quota',
'Copy event ID': 'Copia ID evento', 'Copy event ID': 'Copia ID evento',
'Copy user ID': 'Copia ID utente', 'Copy user ID': 'Copia ID utente',
@ -337,9 +338,9 @@ export default {
'No reactions yet': 'Ancora nessuna reazione', 'No reactions yet': 'Ancora nessuna reazione',
'No more zaps': 'Non ci sono più zaps', 'No more zaps': 'Non ci sono più zaps',
'No zaps yet': 'Ancora nessuno zap', 'No zaps yet': 'Ancora nessuno zap',
'No more reposts': 'Non ci sono più repost', 'No more boosts': 'Non ci sono più boost',
'No reposts yet': 'Ancora nessun repost', 'No boosts yet': 'Ancora nessun boost',
Reposts: 'Repost', Boosts: 'Boost',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Elenco seguiti non trovato. Vuoi crearne uno nuovo? Se hai già seguito degli utenti in precedenza, per favore NON confermare poiché questa operazione causerà la perdita del tuo elenco seguiti precedente.', 'Elenco seguiti non trovato. Vuoi crearne uno nuovo? Se hai già seguito degli utenti in precedenza, per favore NON confermare poiché questa operazione causerà la perdita del tuo elenco seguiti precedente.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -385,7 +386,7 @@ export default {
'quoted your note': 'ha citato la tua nota', 'quoted your note': 'ha citato la tua nota',
'voted in your poll': 'ha votato nel tuo sondaggio', 'voted in your poll': 'ha votato nel tuo sondaggio',
'reacted to your note': 'ha reagito alla tua nota', 'reacted to your note': 'ha reagito alla tua nota',
'reposted your note': 'ha ricondiviso la tua nota', 'boosted your note': 'ha boostato la tua nota',
'zapped your note': 'ha zappato la tua nota', 'zapped your note': 'ha zappato la tua nota',
'zapped you': 'ti ha zappato', 'zapped you': 'ti ha zappato',
'Mark as read': 'Segna come letto', 'Mark as read': 'Segna come letto',

13
src/i18n/locales/ja.ts

@ -14,7 +14,7 @@ export default {
Logout: 'ログアウト', Logout: 'ログアウト',
Following: 'フォロー中', Following: 'フォロー中',
followings: 'フォロー', followings: 'フォロー',
reposted: 'リポスト済み', boosted: 'ブースト済み',
'just now': 'たった今', 'just now': 'たった今',
'n minutes ago': '{{n}}分前', 'n minutes ago': '{{n}}分前',
'n m': '{{n}}分', 'n m': '{{n}}分',
@ -41,7 +41,8 @@ export default {
'Failed to post': '投稿に失敗しました', 'Failed to post': '投稿に失敗しました',
'Post successful': '投稿に成功しました', 'Post successful': '投稿に成功しました',
'Your post has been published': '投稿が公開されました', 'Your post has been published': '投稿が公開されました',
Repost: 'リポスト', Boost: 'ブースト',
'Boost published': 'ブーストを公開しました',
Quote: '引用', Quote: '引用',
'Copy event ID': 'イベントIDをコピー', 'Copy event ID': 'イベントIDをコピー',
'Copy user ID': 'ユーザーIDをコピー', 'Copy user ID': 'ユーザーIDをコピー',
@ -334,9 +335,9 @@ export default {
'No reactions yet': 'まだ反応はありません', 'No reactions yet': 'まだ反応はありません',
'No more zaps': 'これ以上のZapはありません', 'No more zaps': 'これ以上のZapはありません',
'No zaps yet': 'まだZapはありません', 'No zaps yet': 'まだZapはありません',
'No more reposts': 'これ以上のリポストはありません', 'No more boosts': 'これ以上のブーストはありません',
'No reposts yet': 'まだリポストはありません', 'No boosts yet': 'まだブーストはありません',
Reposts: 'リポスト', Boosts: 'ブースト',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'フォローリストが見つかりません。新しいものを作成しますか?以前にユーザーをフォローしたことがある場合は、この操作により前のフォローリストが失われるため、確認しないでください。', 'フォローリストが見つかりません。新しいものを作成しますか?以前にユーザーをフォローしたことがある場合は、この操作により前のフォローリストが失われるため、確認しないでください。',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -382,7 +383,7 @@ export default {
'quoted your note': 'あなたのノートを引用しました', 'quoted your note': 'あなたのノートを引用しました',
'voted in your poll': 'あなたの投票に投票しました', 'voted in your poll': 'あなたの投票に投票しました',
'reacted to your note': 'あなたのノートにリアクションしました', 'reacted to your note': 'あなたのノートにリアクションしました',
'reposted your note': 'あなたのノートをリポストしました', 'boosted your note': 'あなたのノートをブーストしました',
'zapped your note': 'あなたのノートにザップしました', 'zapped your note': 'あなたのノートにザップしました',
'zapped you': 'あなたにザップしました', 'zapped you': 'あなたにザップしました',
'Mark as read': '既読にする', 'Mark as read': '既読にする',

13
src/i18n/locales/ko.ts

@ -13,7 +13,7 @@ export default {
Logout: '로그아웃', Logout: '로그아웃',
Following: '팔로잉', Following: '팔로잉',
followings: '팔로잉', followings: '팔로잉',
reposted: '리포스트', boosted: '부스트함',
'just now': '방금 전', 'just now': '방금 전',
'n minutes ago': '{{n}}분 전', 'n minutes ago': '{{n}}분 전',
'n m': '{{n}}분', 'n m': '{{n}}분',
@ -40,7 +40,8 @@ export default {
'Failed to post': '게시 실패', 'Failed to post': '게시 실패',
'Post successful': '게시 성공', 'Post successful': '게시 성공',
'Your post has been published': '게시물이 게시되었습니다', 'Your post has been published': '게시물이 게시되었습니다',
Repost: '리포스트', Boost: '부스트',
'Boost published': '부스트가 게시되었습니다',
Quote: '인용', Quote: '인용',
'Copy event ID': '이벤트 ID 복사', 'Copy event ID': '이벤트 ID 복사',
'Copy user ID': '사용자 ID 복사', 'Copy user ID': '사용자 ID 복사',
@ -334,9 +335,9 @@ export default {
'No reactions yet': '아직 반응이 없습니다', 'No reactions yet': '아직 반응이 없습니다',
'No more zaps': '더 이상 잽이 없습니다', 'No more zaps': '더 이상 잽이 없습니다',
'No zaps yet': '아직 잽이 없습니다', 'No zaps yet': '아직 잽이 없습니다',
'No more reposts': '더 이상 리포스트가 없습니다', 'No more boosts': '더 이상 부스트가 없습니다',
'No reposts yet': '아직 리포스트가 없습니다', 'No boosts yet': '아직 부스트가 없습니다',
Reposts: '리포스트', Boosts: '부스트',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'팔로우 목록을 찾을 수 없습니다. 새로 만드시겠습니까? 이전에 사용자를 팔로우한 적이 있다면 이 작업으로 인해 이전 팔로우 목록을 잃게 되므로 확인하지 마시기 바랍니다.', '팔로우 목록을 찾을 수 없습니다. 새로 만드시겠습니까? 이전에 사용자를 팔로우한 적이 있다면 이 작업으로 인해 이전 팔로우 목록을 잃게 되므로 확인하지 마시기 바랍니다.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -382,7 +383,7 @@ export default {
'quoted your note': '당신의 노트를 인용했습니다', 'quoted your note': '당신의 노트를 인용했습니다',
'voted in your poll': '당신의 투표에 참여했습니다', 'voted in your poll': '당신의 투표에 참여했습니다',
'reacted to your note': '당신의 노트에 반응했습니다', 'reacted to your note': '당신의 노트에 반응했습니다',
'reposted your note': '당신의 노트를 리포스트했습니다', 'boosted your note': '당신의 노트를 부스트했습니다',
'zapped your note': '당신의 노트를 잽했습니다', 'zapped your note': '당신의 노트를 잽했습니다',
'zapped you': '당신을 잽했습니다', 'zapped you': '당신을 잽했습니다',
'Mark as read': '읽음으로 표시', 'Mark as read': '읽음으로 표시',

13
src/i18n/locales/pl.ts

@ -13,7 +13,7 @@ export default {
Logout: 'Wyloguj', Logout: 'Wyloguj',
Following: 'Obserwowani', Following: 'Obserwowani',
followings: 'niżej wymienionych', followings: 'niżej wymienionych',
reposted: 'Udostępnił', boosted: 'zboostował',
'just now': 'teraz', 'just now': 'teraz',
'n minutes ago': '{{n}} m', 'n minutes ago': '{{n}} m',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -40,7 +40,8 @@ export default {
'Failed to post': 'Nie udało się opublikować', 'Failed to post': 'Nie udało się opublikować',
'Post successful': 'Twój wpis został wysłany.', 'Post successful': 'Twój wpis został wysłany.',
'Your post has been published': 'Publikowani są jedynie użytkownicy z białej listy', 'Your post has been published': 'Publikowani są jedynie użytkownicy z białej listy',
Repost: 'Udostępnij', Boost: 'Boost',
'Boost published': 'Opublikowano boost',
Quote: 'Zacytuj', Quote: 'Zacytuj',
'Copy event ID': 'Skopiuj ID wydarzenia', 'Copy event ID': 'Skopiuj ID wydarzenia',
'Copy user ID': 'Skopiuj ID użytkownika', 'Copy user ID': 'Skopiuj ID użytkownika',
@ -338,9 +339,9 @@ export default {
'No reactions yet': 'Brak reakcji', 'No reactions yet': 'Brak reakcji',
'No more zaps': 'Brak kolejnych zapów', 'No more zaps': 'Brak kolejnych zapów',
'No zaps yet': 'Brak zapów', 'No zaps yet': 'Brak zapów',
'No more reposts': 'Brak kolejnych repostów', 'No more boosts': 'Brak kolejnych boostów',
'No reposts yet': 'Brak repostów', 'No boosts yet': 'Brak boostów',
Reposts: 'Reposty', Boosts: 'Boosty',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Lista obserwowanych nie została znaleziona. Czy chcesz utworzyć nową? Jeśli wcześniej obserwowałeś użytkowników, proszę NIE potwierdzaj, ponieważ ta operacja spowoduje utratę poprzedniej listy obserwowanych.', 'Lista obserwowanych nie została znaleziona. Czy chcesz utworzyć nową? Jeśli wcześniej obserwowałeś użytkowników, proszę NIE potwierdzaj, ponieważ ta operacja spowoduje utratę poprzedniej listy obserwowanych.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -386,7 +387,7 @@ export default {
'quoted your note': 'zacytował twoją notatkę', 'quoted your note': 'zacytował twoją notatkę',
'voted in your poll': 'zagłosował w twojej ankiecie', 'voted in your poll': 'zagłosował w twojej ankiecie',
'reacted to your note': 'zareagował na twoją notatkę', 'reacted to your note': 'zareagował na twoją notatkę',
'reposted your note': 'przepostował twoją notatkę', 'boosted your note': 'zboostował twoją notatkę',
'zapped your note': 'zappował twoją notatkę', 'zapped your note': 'zappował twoją notatkę',
'zapped you': 'zappował cię', 'zapped you': 'zappował cię',
'Mark as read': 'Oznacz jako przeczytane', 'Mark as read': 'Oznacz jako przeczytane',

13
src/i18n/locales/pt-BR.ts

@ -13,7 +13,7 @@ export default {
Logout: 'Sair', Logout: 'Sair',
Following: 'Seguindo', Following: 'Seguindo',
followings: 'Seguidos', followings: 'Seguidos',
reposted: 'Repostado', boosted: 'deu boost',
'just now': 'agora mesmo', 'just now': 'agora mesmo',
'n minutes ago': '{{n}} minutos atrás', 'n minutes ago': '{{n}} minutos atrás',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -40,7 +40,8 @@ export default {
'Failed to post': 'Falha ao postar', 'Failed to post': 'Falha ao postar',
'Post successful': 'Nota publicada com sucesso', 'Post successful': 'Nota publicada com sucesso',
'Your post has been published': 'Sua nota foi publicada', 'Your post has been published': 'Sua nota foi publicada',
Repost: 'Repostar', Boost: 'Boost',
'Boost published': 'Boost publicado',
Quote: 'Citar', Quote: 'Citar',
'Copy event ID': 'Copiar ID do evento', 'Copy event ID': 'Copiar ID do evento',
'Copy user ID': 'Copiar ID do usuário', 'Copy user ID': 'Copiar ID do usuário',
@ -335,9 +336,9 @@ export default {
'No reactions yet': 'Ainda sem reações', 'No reactions yet': 'Ainda sem reações',
'No more zaps': 'Sem mais zaps', 'No more zaps': 'Sem mais zaps',
'No zaps yet': 'Ainda sem zaps', 'No zaps yet': 'Ainda sem zaps',
'No more reposts': 'Sem mais reposts', 'No more boosts': 'Sem mais boosts',
'No reposts yet': 'Ainda sem reposts', 'No boosts yet': 'Ainda sem boosts',
Reposts: 'Reposts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Lista de seguindo não encontrada. Deseja criar uma nova? Se você seguiu usuários antes, por favor NÃO confirme, pois esta operação fará você perder sua lista de seguindo anterior.', 'Lista de seguindo não encontrada. Deseja criar uma nova? Se você seguiu usuários antes, por favor NÃO confirme, pois esta operação fará você perder sua lista de seguindo anterior.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -382,7 +383,7 @@ export default {
'quoted your note': 'citou sua nota', 'quoted your note': 'citou sua nota',
'voted in your poll': 'votou na sua enquete', 'voted in your poll': 'votou na sua enquete',
'reacted to your note': 'reagiu à sua nota', 'reacted to your note': 'reagiu à sua nota',
'reposted your note': 'republicou sua nota', 'boosted your note': 'deu boost na sua nota',
'zapped your note': 'zappeou sua nota', 'zapped your note': 'zappeou sua nota',
'zapped you': 'zappeou você', 'zapped you': 'zappeou você',
'Mark as read': 'Marcar como lida', 'Mark as read': 'Marcar como lida',

13
src/i18n/locales/pt-PT.ts

@ -14,7 +14,7 @@ export default {
Logout: 'Sair', Logout: 'Sair',
Following: 'Seguindo', Following: 'Seguindo',
followings: 'seguidos', followings: 'seguidos',
reposted: 'repostado', boosted: 'deu boost',
'just now': 'agora mesmo', 'just now': 'agora mesmo',
'n minutes ago': '{{n}} minutos atrás', 'n minutes ago': '{{n}} minutos atrás',
'n m': '{{n}}m', 'n m': '{{n}}m',
@ -41,7 +41,8 @@ export default {
'Failed to post': 'Falha ao postar', 'Failed to post': 'Falha ao postar',
'Post successful': 'Postagem bem-sucedida', 'Post successful': 'Postagem bem-sucedida',
'Your post has been published': 'Sua postagem foi publicada', 'Your post has been published': 'Sua postagem foi publicada',
Repost: 'Repostar', Boost: 'Boost',
'Boost published': 'Boost publicado',
Quote: 'Citar', Quote: 'Citar',
'Copy event ID': 'Copiar ID do evento', 'Copy event ID': 'Copiar ID do evento',
'Copy user ID': 'Copiar ID do usuário', 'Copy user ID': 'Copiar ID do usuário',
@ -337,9 +338,9 @@ export default {
'No reactions yet': 'Ainda sem reações', 'No reactions yet': 'Ainda sem reações',
'No more zaps': 'Sem mais zaps', 'No more zaps': 'Sem mais zaps',
'No zaps yet': 'Ainda sem zaps', 'No zaps yet': 'Ainda sem zaps',
'No more reposts': 'Sem mais reposts', 'No more boosts': 'Sem mais boosts',
'No reposts yet': 'Ainda sem reposts', 'No boosts yet': 'Ainda sem boosts',
Reposts: 'Reposts', Boosts: 'Boosts',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Lista de seguir não encontrada. Deseja criar uma nova? Se seguiu utilizadores anteriormente, por favor NÃO confirme, pois esta operação fará com que perca a sua lista de seguir anterior.', 'Lista de seguir não encontrada. Deseja criar uma nova? Se seguiu utilizadores anteriormente, por favor NÃO confirme, pois esta operação fará com que perca a sua lista de seguir anterior.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -385,7 +386,7 @@ export default {
'quoted your note': 'citou a sua nota', 'quoted your note': 'citou a sua nota',
'voted in your poll': 'votou na sua sondagem', 'voted in your poll': 'votou na sua sondagem',
'reacted to your note': 'reagiu à sua nota', 'reacted to your note': 'reagiu à sua nota',
'reposted your note': 'republicou a sua nota', 'boosted your note': 'deu boost na sua nota',
'zapped your note': 'zappeou a sua nota', 'zapped your note': 'zappeou a sua nota',
'zapped you': 'zappeou-o', 'zapped you': 'zappeou-o',
'Mark as read': 'Marcar como lida', 'Mark as read': 'Marcar como lida',

13
src/i18n/locales/ru.ts

@ -14,7 +14,7 @@ export default {
Logout: 'Выйти', Logout: 'Выйти',
Following: 'Подписки', Following: 'Подписки',
followings: 'подписки', followings: 'подписки',
reposted: 'репостнул', boosted: 'сделал буст',
'just now': 'только что', 'just now': 'только что',
'n minutes ago': '{{n}} минут назад', 'n minutes ago': '{{n}} минут назад',
'n m': '{{n}}м', 'n m': '{{n}}м',
@ -41,7 +41,8 @@ export default {
'Failed to post': 'Ошибка публикации', 'Failed to post': 'Ошибка публикации',
'Post successful': 'Успешно опубликовано', 'Post successful': 'Успешно опубликовано',
'Your post has been published': 'Ваш пост опубликован', 'Your post has been published': 'Ваш пост опубликован',
Repost: 'Репост', Boost: 'Буст',
'Boost published': 'Буст опубликован',
Quote: 'Цитировать', Quote: 'Цитировать',
'Copy event ID': 'Копировать ID события', 'Copy event ID': 'Копировать ID события',
'Copy user ID': 'Копировать ID пользователя', 'Copy user ID': 'Копировать ID пользователя',
@ -338,9 +339,9 @@ export default {
'No reactions yet': 'Пока нет реакций', 'No reactions yet': 'Пока нет реакций',
'No more zaps': 'Больше нет запов', 'No more zaps': 'Больше нет запов',
'No zaps yet': 'Пока нет запов', 'No zaps yet': 'Пока нет запов',
'No more reposts': 'Больше нет репостов', 'No more boosts': 'Больше нет бустов',
'No reposts yet': 'Пока нет репостов', 'No boosts yet': 'Пока нет бустов',
Reposts: 'Репосты', Boosts: 'Бусты',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'Список подписок не найден. Хотите создать новый? Если вы уже подписывались на пользователей ранее, пожалуйста, НЕ подтверждайте, так как эта операция приведет к потере вашего предыдущего списка подписок.', 'Список подписок не найден. Хотите создать новый? Если вы уже подписывались на пользователей ранее, пожалуйста, НЕ подтверждайте, так как эта операция приведет к потере вашего предыдущего списка подписок.',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -386,7 +387,7 @@ export default {
'quoted your note': 'процитировал вашу заметку', 'quoted your note': 'процитировал вашу заметку',
'voted in your poll': 'проголосовал в вашем опросе', 'voted in your poll': 'проголосовал в вашем опросе',
'reacted to your note': 'отреагировал на вашу заметку', 'reacted to your note': 'отреагировал на вашу заметку',
'reposted your note': 'репостнул вашу заметку', 'boosted your note': 'сделал буст вашей заметки',
'zapped your note': 'заппил вашу заметку', 'zapped your note': 'заппил вашу заметку',
'zapped you': 'заппил вас', 'zapped you': 'заппил вас',
'Mark as read': 'Отметить как прочитанное', 'Mark as read': 'Отметить как прочитанное',

13
src/i18n/locales/th.ts

@ -13,7 +13,7 @@ export default {
Logout: 'ออกจากระบบ', Logout: 'ออกจากระบบ',
Following: 'กำลงตดตาม', Following: 'กำลงตดตาม',
followings: 'กำลงตดตาม', followings: 'กำลงตดตาม',
reposted: 'รโพสต', boosted: 'บสตแล',
'just now': 'เมอสกคร', 'just now': 'เมอสกคร',
'n minutes ago': '{{n}} นาทแลว', 'n minutes ago': '{{n}} นาทแลว',
'n m': '{{n}}น', 'n m': '{{n}}น',
@ -40,7 +40,8 @@ export default {
'Failed to post': 'โพสตไมสำเรจ', 'Failed to post': 'โพสตไมสำเรจ',
'Post successful': 'โพสตสำเรจ', 'Post successful': 'โพสตสำเรจ',
'Your post has been published': 'โพสตของคณถกเผยแพรแลว', 'Your post has been published': 'โพสตของคณถกเผยแพรแลว',
Repost: 'รโพสต', Boost: 'บสต',
'Boost published': 'เผยแพรสตแลว',
Quote: 'อางอง', Quote: 'อางอง',
'Copy event ID': 'คดลอก ID เหตการณ', 'Copy event ID': 'คดลอก ID เหตการณ',
'Copy user ID': 'คดลอก ID ผใช', 'Copy user ID': 'คดลอก ID ผใช',
@ -331,9 +332,9 @@ export default {
'No reactions yet': 'ยงไมปฏยา', 'No reactions yet': 'ยงไมปฏยา',
'No more zaps': 'ไมซาตสเพมเตม', 'No more zaps': 'ไมซาตสเพมเตม',
'No zaps yet': 'ยงไมซาตส', 'No zaps yet': 'ยงไมซาตส',
'No more reposts': 'ไมการรโพสตเพมเตม', 'No more boosts': 'ไมสตเพมเตม',
'No reposts yet': 'ยงไมการรโพสต', 'No boosts yet': 'ยงไมสต',
Reposts: 'การรโพสต', Boosts: 'บสต',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'ไมพบรายการตดตาม คณตองการสรางรายการใหมหรอไม? หากคณเคยตดตามผใชมากอน กรณาอยายนยน เพราะการดำเนนการนจะทำใหณสญเสยรายการตดตามกอนหนาน', 'ไมพบรายการตดตาม คณตองการสรางรายการใหมหรอไม? หากคณเคยตดตามผใชมากอน กรณาอยายนยน เพราะการดำเนนการนจะทำใหณสญเสยรายการตดตามกอนหนาน',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -378,7 +379,7 @@ export default {
'quoted your note': 'ไดยกคำพดจากโนตของคณ', 'quoted your note': 'ไดยกคำพดจากโนตของคณ',
'voted in your poll': 'ไดโหวตในการสำรวจของคณ', 'voted in your poll': 'ไดโหวตในการสำรวจของคณ',
'reacted to your note': 'ไดแสดงปฏยาตอโนตของคณ', 'reacted to your note': 'ไดแสดงปฏยาตอโนตของคณ',
'reposted your note': 'ไดโพสตโนตของคณ', 'boosted your note': 'ไดสตโนตของคณ',
'zapped your note': 'ไดแซปโนตของคณ', 'zapped your note': 'ไดแซปโนตของคณ',
'zapped you': 'ไดแซปคณ', 'zapped you': 'ไดแซปคณ',
'Mark as read': 'ทำเครองหมายวาอานแลว', 'Mark as read': 'ทำเครองหมายวาอานแลว',

13
src/i18n/locales/zh.ts

@ -13,7 +13,7 @@ export default {
Logout: '退出登录', Logout: '退出登录',
Following: '关注', Following: '关注',
followings: '关注', followings: '关注',
reposted: '转发', boosted: '已助推',
'just now': '刚刚', 'just now': '刚刚',
'n minutes ago': '{{n}} 分钟前', 'n minutes ago': '{{n}} 分钟前',
'n m': '{{n}}分', 'n m': '{{n}}分',
@ -40,7 +40,8 @@ export default {
'Failed to post': '发布失败', 'Failed to post': '发布失败',
'Post successful': '发布成功', 'Post successful': '发布成功',
'Your post has been published': '您的笔记已发布', 'Your post has been published': '您的笔记已发布',
Repost: '转发', Boost: '助推',
'Boost published': '助推已发布',
Quote: '引用', Quote: '引用',
'Copy event ID': '复制事件 ID', 'Copy event ID': '复制事件 ID',
'Copy user ID': '复制用户 ID', 'Copy user ID': '复制用户 ID',
@ -330,9 +331,9 @@ export default {
'No reactions yet': '暂无互动', 'No reactions yet': '暂无互动',
'No more zaps': '没有更多打闪了', 'No more zaps': '没有更多打闪了',
'No zaps yet': '暂无打闪', 'No zaps yet': '暂无打闪',
'No more reposts': '没有更多转发了', 'No more boosts': '没有更多助推了',
'No reposts yet': '暂无转发', 'No boosts yet': '暂无助推',
Reposts: '转发', Boosts: '助推',
FollowListNotFoundConfirmation: FollowListNotFoundConfirmation:
'未找到关注列表。你想创建一个新的吗?如果你之前已经关注了用户,请不要确认,因为此操作会导致你丢失之前的关注列表。', '未找到关注列表。你想创建一个新的吗?如果你之前已经关注了用户,请不要确认,因为此操作会导致你丢失之前的关注列表。',
MuteListNotFoundConfirmation: MuteListNotFoundConfirmation:
@ -376,7 +377,7 @@ export default {
'quoted your note': '引用了您的笔记', 'quoted your note': '引用了您的笔记',
'voted in your poll': '在您的投票中投票', 'voted in your poll': '在您的投票中投票',
'reacted to your note': '对您的笔记做出了反应', 'reacted to your note': '对您的笔记做出了反应',
'reposted your note': '转发了您的笔记', 'boosted your note': '助推了您的笔记',
'zapped your note': '打闪了您的笔记', 'zapped your note': '打闪了您的笔记',
'zapped you': '给您打闪', 'zapped you': '给您打闪',
'Mark as read': '标记为已读', 'Mark as read': '标记为已读',

147
src/pages/primary/ExplorePage/index.tsx

@ -109,6 +109,7 @@ const ExplorePage = forwardRef((_, ref) => {
{tab === 'explore' && ( {tab === 'explore' && (
<> <>
<ExploreFavoriteRelays /> <ExploreFavoriteRelays />
<ExploreRelaySearchSection />
<Explore /> <Explore />
</> </>
)} )}
@ -122,6 +123,33 @@ export default ExplorePage
function ExplorePageTitlebar() { function ExplorePageTitlebar() {
const { t } = useTranslation() const { t } = useTranslation()
return (
<div className="flex h-full min-w-0 w-full items-center justify-between gap-2 px-2 py-1 sm:pl-3 sm:pr-2">
<div className="flex shrink-0 items-center gap-2">
<Compass className="size-5 shrink-0" />
<div className="text-lg font-semibold">{t('Explore')}</div>
</div>
<Button
variant="ghost"
size="titlebar-icon"
className="relative w-fit shrink-0 px-3"
onClick={() => {
window.open(
'https://github.com/CodyTseng/awesome-nostr-relays/issues/new?template=add-relay.md',
'_blank'
)
}}
>
<Plus size={16} />
{t('Submit Relay')}
</Button>
</div>
)
}
function ExploreRelaySearchSection() {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation() const { navigateToRelay } = useSmartRelayNavigation()
const [relayQuery, setRelayQuery] = useState('') const [relayQuery, setRelayQuery] = useState('')
const [monitoringRelays, setMonitoringRelays] = useState<string[]>([]) const [monitoringRelays, setMonitoringRelays] = useState<string[]>([])
@ -175,35 +203,58 @@ function ExplorePageTitlebar() {
} }
return ( return (
<div className="flex h-full min-w-0 w-full flex-wrap items-center justify-between gap-2 gap-y-2 px-2 py-1 sm:pl-3 sm:pr-2"> <section className="min-w-0 px-2 pb-4 pt-0" aria-label={t('Search for Relays')}>
<div className="flex shrink-0 items-center gap-2"> <h2 className="mb-2 px-2 text-base font-semibold tracking-tight">{t('Search for Relays')}</h2>
<Compass className="size-5 shrink-0" /> <div className="max-w-xl px-2">
<div className="text-lg font-semibold">{t('Explore')}</div>
</div>
<div className="relative min-w-0 max-w-xl flex-1 basis-full sm:basis-64">
<form className="flex items-center gap-1.5" onSubmit={onSubmitRelay}> <form className="flex items-center gap-1.5" onSubmit={onSubmitRelay}>
<Input <div className="relative min-w-0 flex-1">
type="text" <Input
inputMode="url" type="text"
autoComplete="off" inputMode="url"
placeholder={t('Relay URL…')} autoComplete="off"
className="h-9 min-w-0 flex-1 font-mono text-sm" placeholder={t('Relay URL…')}
value={relayQuery} className="h-9 w-full font-mono text-sm"
onChange={(e) => setRelayQuery(e.target.value)} value={relayQuery}
aria-label={t('Relay URL…')} onChange={(e) => setRelayQuery(e.target.value)}
aria-autocomplete="list" aria-label={t('Relay URL…')}
aria-expanded={suggestOpen && relaySuggestions.length > 0} aria-autocomplete="list"
aria-controls="explore-relay-suggestions" aria-expanded={suggestOpen && relaySuggestions.length > 0}
role="combobox" aria-controls="explore-relay-suggestions"
onFocus={() => { role="combobox"
clearBlurTimer() onFocus={() => {
setSuggestOpen(true) clearBlurTimer()
}} setSuggestOpen(true)
onBlur={() => { }}
clearBlurTimer() onBlur={() => {
blurCloseTimer.current = setTimeout(() => setSuggestOpen(false), 200) clearBlurTimer()
}} blurCloseTimer.current = setTimeout(() => setSuggestOpen(false), 200)
/> }}
/>
{suggestOpen && relaySuggestions.length > 0 ? (
<ul
id="explore-relay-suggestions"
role="listbox"
className={cn(
'absolute inset-x-0 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-md'
)}
onMouseDown={(e) => e.preventDefault()}
>
{relaySuggestions.map((url) => (
<li key={url} role="presentation">
<button
type="button"
role="option"
className="flex w-full flex-col items-stretch gap-0.5 px-3 py-2 text-left text-sm hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => openRelayAndReset(url)}
>
<span className="truncate font-mono">{simplifyUrl(url)}</span>
<span className="truncate text-xs text-muted-foreground">{url}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
<Button <Button
type="submit" type="submit"
variant="secondary" variant="secondary"
@ -214,45 +265,7 @@ function ExplorePageTitlebar() {
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</Button> </Button>
</form> </form>
{suggestOpen && relaySuggestions.length > 0 ? (
<ul
id="explore-relay-suggestions"
role="listbox"
className={cn(
'absolute left-0 right-12 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-md'
)}
onMouseDown={(e) => e.preventDefault()}
>
{relaySuggestions.map((url) => (
<li key={url} role="presentation">
<button
type="button"
role="option"
className="flex w-full flex-col items-stretch gap-0.5 px-3 py-2 text-left text-sm hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => openRelayAndReset(url)}
>
<span className="truncate font-mono">{simplifyUrl(url)}</span>
<span className="truncate text-xs text-muted-foreground">{url}</span>
</button>
</li>
))}
</ul>
) : null}
</div> </div>
<Button </section>
variant="ghost"
size="titlebar-icon"
className="relative w-fit shrink-0 px-3"
onClick={() => {
window.open(
'https://github.com/CodyTseng/awesome-nostr-relays/issues/new?template=add-relay.md',
'_blank'
)
}}
>
<Plus size={16} />
{t('Submit Relay')}
</Button>
</div>
) )
} }

1
src/pages/primary/MePage/index.tsx

@ -16,7 +16,6 @@ import {
ChevronRight, ChevronRight,
LogOut, LogOut,
Server, Server,
Settings,
UserRound, UserRound,
Wallet Wallet
} from 'lucide-react' } from 'lucide-react'

2
src/pages/primary/SettingsPrimaryPage/index.tsx

@ -4,7 +4,7 @@ import { Settings } from 'lucide-react'
import { forwardRef } from 'react' import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const SettingsPrimaryPage = forwardRef<HTMLDivElement>((_, ref) => { const SettingsPrimaryPage = forwardRef((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (

5
src/pages/secondary/NotePage/index.tsx

@ -3,6 +3,7 @@ import { ExtendedKind } from '@/constants'
import ContentPreview from '@/components/ContentPreview' import ContentPreview from '@/components/ContentPreview'
import client from '@/services/client.service' import client from '@/services/client.service'
import Note from '@/components/Note' import Note from '@/components/Note'
import NoteBoostBadges from '@/components/NoteBoostBadges'
import NoteInteractions from '@/components/NoteInteractions' import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
@ -160,8 +161,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Calendar Event' return 'Note: Calendar Event'
case 9735: // ExtendedKind.ZAP_RECEIPT case 9735: // ExtendedKind.ZAP_RECEIPT
return 'Note: Zap Receipt' return 'Note: Zap Receipt'
case 6: // kinds.Repost case 6: // kinds.Repost (Nostr boost)
return 'Note: Repost' return 'Note: Boost'
case 7: // kinds.Reaction case 7: // kinds.Reaction
return 'Note: Reaction' return 'Note: Reaction'
case 1111: // ExtendedKind.COMMENT case 1111: // ExtendedKind.COMMENT

25
src/services/local-storage.service.ts

@ -3,7 +3,7 @@ import {
ExtendedKind, ExtendedKind,
MEDIA_AUTO_LOAD_POLICY, MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE, NOTIFICATION_LIST_STYLE,
SUPPORTED_KINDS, PROFILE_FEED_KINDS,
StorageKey StorageKey
} from '@/constants' } from '@/constants'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
@ -223,15 +223,7 @@ class LocalStorageService {
const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS) const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS)
if (!showKindsStr) { if (!showKindsStr) {
// Default: show all supported kinds except reposts, publications, publication content, and NIP-89 handler kinds this.showKinds = [...PROFILE_FEED_KINDS]
this.showKinds = SUPPORTED_KINDS.filter(
kind =>
kind !== kinds.Repost &&
kind !== ExtendedKind.PUBLICATION &&
kind !== ExtendedKind.PUBLICATION_CONTENT &&
kind !== ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION &&
kind !== ExtendedKind.APPLICATION_HANDLER_INFO
)
} else { } else {
const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION) const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION)
const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0 const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0
@ -243,7 +235,7 @@ class LocalStorageService {
showKinds.push(ExtendedKind.ZAP_RECEIPT) showKinds.push(ExtendedKind.ZAP_RECEIPT)
} }
if (showKindsVersion < 3) { if (showKindsVersion < 3) {
// Remove reposts from existing users' filters // Remove boosts (kind 6) from existing users' filters
const repostIndex = showKinds.indexOf(kinds.Repost) const repostIndex = showKinds.indexOf(kinds.Repost)
if (repostIndex !== -1) { if (repostIndex !== -1) {
showKinds.splice(repostIndex, 1) showKinds.splice(repostIndex, 1)
@ -290,10 +282,19 @@ class LocalStorageService {
showKinds.splice(nip89InfoIndex, 1) showKinds.splice(nip89InfoIndex, 1)
} }
} }
if (showKindsVersion < 8) {
// Boosts (kind 6) and publications removed from feed filter UI — strip from saved preferences
for (let i = showKinds.length - 1; i >= 0; i--) {
const k = showKinds[i]
if (k === kinds.Repost || k === ExtendedKind.PUBLICATION) {
showKinds.splice(i, 1)
}
}
}
this.showKinds = showKinds this.showKinds = showKinds
} }
this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '7') this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '8')
// Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set)
const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs) const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs)

5
src/vite-env.d.ts vendored

@ -1,6 +1,11 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
import { TNip07 } from '@/types' import { TNip07 } from '@/types'
declare module '*.md?raw' {
const content: string
export default content
}
declare global { declare global {
interface Window { interface Window {
nostr?: TNip07 nostr?: TNip07

Loading…
Cancel
Save