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: @@ -42,7 +42,7 @@ High-level changes versus a “stock” Jumble-style layout:
### 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

104
src/PageManager.tsx

@ -622,10 +622,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -622,10 +622,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const [drawerNoteId, setDrawerNoteId] = useState<string | null>(null)
const [panelMode, setPanelMode] = useState<'single' | 'double'>(() => storage.getPanelMode())
const navigationCounterRef = useRef(0)
const savedFeedStateRef = useRef<Map<TPrimaryPageName, {
tab?: string,
trendingTab?: 'relays' | 'hashtags' | 'calendar'
}>>(new Map())
const savedFeedStateRef = useRef<Map<TPrimaryPageName, { tab?: string }>>(new Map())
const currentTabStateRef = useRef<Map<TPrimaryPageName, string>>(new Map()) // Track current tab state for each page
const currentPageProps = useMemo((): object | undefined => {
@ -640,20 +637,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -640,20 +637,14 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Get current tab state from ref (updated by components via events)
const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
// Get trending tab if on search page
const trendingTab = currentTabStateRef.current.get('search') as 'relays' | 'hashtags' | 'calendar' | undefined
// Save state (tab, trending) if any exists
if (currentTab || trendingTab) {
logger.info('PageManager: Saving page state', {
page: currentPrimaryPage,
tab: currentTab,
trendingTab
if (currentTab) {
logger.info('PageManager: Saving page state', {
page: currentPrimaryPage,
tab: currentTab
})
savedFeedStateRef.current.set(currentPrimaryPage, {
tab: currentTab,
trendingTab
savedFeedStateRef.current.set(currentPrimaryPage, {
tab: currentTab
})
}
}
@ -684,19 +675,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -684,19 +675,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}))
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 }) { @@ -1149,19 +1127,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Update ref immediately
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])
@ -1215,15 +1180,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1215,15 +1180,13 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Save tab state before navigating
const currentTab = currentTabStateRef.current.get(currentPrimaryPage)
const trendingTab = currentTabStateRef.current.get('search') as 'relays' | 'hashtags' | 'calendar' | undefined
if (currentPrimaryPage && (currentTab || trendingTab)) {
logger.info('PageManager: Desktop - Saving page state', {
page: currentPrimaryPage,
tab: currentTab,
trendingTab
if (currentPrimaryPage && currentTab) {
logger.info('PageManager: Desktop - Saving page state', {
page: currentPrimaryPage,
tab: currentTab
})
savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab, trendingTab })
savedFeedStateRef.current.set(currentPrimaryPage, { tab: currentTab })
}
setSecondaryStack((prevStack) => {
@ -1289,19 +1252,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1289,19 +1252,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}))
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) {
// Pop from stack directly instead of using history.go(-1)
// This ensures the stack is updated immediately
@ -1347,19 +1297,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1347,19 +1297,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}))
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
}
@ -1378,19 +1315,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1378,19 +1315,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}))
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) {
// 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) => {

38
src/components/ContentPreview/ZapPreview.tsx

@ -15,7 +15,7 @@ export default function ZapPreview({ event, className }: { event: Event; classNa @@ -15,7 +15,7 @@ export default function ZapPreview({ event, className }: { event: Event; classNa
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) {
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')}]
</div>
)
@ -24,26 +24,32 @@ export default function ZapPreview({ event, className }: { event: Event; classNa @@ -24,26 +24,32 @@ export default function ZapPreview({ event, className }: { event: Event; classNa
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
return (
<div className={cn('flex items-start gap-3 p-3 rounded-lg border bg-card', className)}>
<Zap size={24} className="text-yellow-400 shrink-0 mt-0.5" fill="currentColor" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Username userId={senderPubkey} className="font-semibold" />
<span className="text-muted-foreground text-sm">{t('zapped')}</span>
<div
className={cn(
'flex items-start gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-sm',
className
)}
>
<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 && (
<Username userId={recipientPubkey} className="font-semibold" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
)}
</div>
<div className="font-bold text-yellow-400 mt-1">
{formatAmount(amount)} {t('sats')}
</div>
{comment && (
<div className="text-sm text-muted-foreground mt-2 break-words">
{comment ? (
<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">
{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 && (
<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)}...
</div>
)}

2
src/components/Explore/ExploreFavoriteRelays.tsx

@ -105,7 +105,7 @@ export default function ExploreFavoriteRelays() { @@ -105,7 +105,7 @@ export default function ExploreFavoriteRelays() {
<span className="text-xs text-muted-foreground">{t('Using app default relays')}</span>
) : null}
</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) => (
<div key={url} className="snap-start">
<FavoriteRelayCard url={url} />

324
src/components/KeyboardShortcutsHelp/index.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import { Button } from '@/components/ui/button'
import {
Dialog,
@ -6,11 +7,14 @@ import { @@ -6,11 +7,14 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { createFakeEvent } from '@/lib/event'
import {
isRadixDialogOpen,
OPEN_NEW_POST_SHORTCUT_KEY,
shouldIgnoreKeyboardShortcutEvent
} from '@/lib/keyboard-shortcuts'
import { cn } from '@/lib/utils'
import postEditorService from '@/services/post-editor.service'
import { CircleHelp } from 'lucide-react'
import {
@ -23,6 +27,7 @@ import { @@ -23,6 +27,7 @@ import {
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import readmeMarkdown from '../../../README.md?raw'
type KeyboardShortcutsHelpContextValue = {
openHelp: () => void
@ -55,6 +60,162 @@ function KbdRow({ keys, label }: { keys: ReactNode; label: string }) { @@ -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 }) {
const [open, setOpen] = useState(false)
const openHelp = useCallback(() => setOpen(true), [])
@ -103,144 +264,29 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod @@ -103,144 +264,29 @@ export function KeyboardShortcutsHelpProvider({ children }: { children: ReactNod
<KeyboardShortcutsHelpContext.Provider value={value}>
{children}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg max-h-[min(85vh,32rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('shortcuts.title')}</DialogTitle>
<DialogDescription>{t('shortcuts.intro')}</DialogDescription>
<DialogContent className="flex max-h-[min(88vh,40rem)] max-w-2xl flex-col gap-0 overflow-hidden p-6 sm:max-w-2xl">
<DialogHeader className="shrink-0 space-y-1 pb-2 pr-8 text-left">
<DialogTitle>{t('help.title')}</DialogTitle>
<DialogDescription className="sr-only">{t('shortcuts.intro')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2 text-sm">
<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="text-muted-foreground px-0.5">{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="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>
<Tabs defaultValue="shortcuts" className="flex min-h-0 flex-1 flex-col gap-2">
<TabsList className="grid w-full shrink-0 grid-cols-2">
<TabsTrigger value="shortcuts">{t('help.tabShortcuts')}</TabsTrigger>
<TabsTrigger value="overview">{t('help.tabOverview')}</TabsTrigger>
</TabsList>
<TabsContent
value="shortcuts"
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"
>
<ShortcutsPanel />
</TabsContent>
<TabsContent
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 />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</KeyboardShortcutsHelpContext.Provider>
@ -257,8 +303,8 @@ export function KeyboardShortcutsHelpButton() { @@ -257,8 +303,8 @@ export function KeyboardShortcutsHelpButton() {
variant="ghost"
size="titlebar-icon"
onClick={() => openHelp()}
title={t('shortcuts.title')}
aria-label={t('shortcuts.title')}
title={t('help.title')}
aria-label={t('help.title')}
>
<CircleHelp />
</Button>

10
src/components/KindFilter/index.tsx

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

69
src/components/Note/Zap.tsx

@ -31,7 +31,12 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -31,7 +31,12 @@ export default function Zap({ event, className }: { event: Event; className?: st
if (!zapInfo || !zapInfo.senderPubkey || !zapInfo.amount) {
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')}]
</div>
)
@ -40,7 +45,7 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -40,7 +45,7 @@ export default function Zap({ event, className }: { event: Event; className?: st
// Determine if this is an event zap or profile zap
const isEventZap = targetEvent || zapInfo?.eventId
const isProfileZap = !isEventZap && zapInfo?.recipientPubkey
// For event zaps, we need to determine the recipient from the zapped event
const actualRecipientPubkey = useMemo(() => {
if (isEventZap && targetEvent) {
@ -53,20 +58,18 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -53,20 +58,18 @@ export default function Zap({ event, className }: { event: Event; className?: st
return undefined
}, [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
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 */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (isEventZap) {
@ -81,10 +84,12 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -81,10 +84,12 @@ export default function Zap({ event, className }: { event: Event; className?: st
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 ? (
<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 ? (
<>
<UserAvatar userId={actualRecipientPubkey} size="xSmall" />
@ -94,34 +99,36 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -94,34 +99,36 @@ export default function Zap({ event, className }: { event: Event; className?: st
t('Zap')
)}
</button>
<div className="flex items-start gap-3 pb-8">
<ZapIcon size={28} className="text-yellow-500 shrink-0 mt-1" fill="currentColor" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-2">
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<ZapIcon size={28} className="mt-0.5 shrink-0 text-primary" strokeWidth={2} />
<div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold" />
<span className="text-muted-foreground text-sm">{t('zapped')}</span>
<Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-sm text-muted-foreground">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</>
)}
</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)}
</span>
<span className="text-lg font-semibold text-yellow-600/70 dark:text-yellow-400/70">
{t('sats')}
</span>
<span className="text-base font-medium text-muted-foreground">{t('sats')}</span>
</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>

61
src/components/NoteBoostBadges/index.tsx

@ -0,0 +1,61 @@ @@ -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({ @@ -17,7 +17,7 @@ export default function RepostDescription({
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" />
<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>
)
}

14
src/components/NoteInteractions/Tabs.tsx

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

7
src/components/NoteInteractions/index.tsx

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

14
src/components/NoteStats/RepostButton.tsx

@ -69,16 +69,16 @@ export default function RepostButton({ event, hideCount = false }: { event: Even @@ -69,16 +69,16 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
successCount: (evt as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (evt as any).relayStatuses.length
}, {
message: t('Repost published'),
message: t('Boost published'),
duration: 4000
})
} else {
showSimplePublishSuccess(t('Repost published'))
showSimplePublishSuccess(t('Boost published'))
}
noteStatsService.updateNoteStatsByEvents([evt])
} catch (error) {
logger.error('Repost failed', { error, eventId: event.id })
logger.error('Boost failed', { error, eventId: event.id })
} finally {
setReposting(false)
clearTimeout(timer)
@ -92,7 +92,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even @@ -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',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
title={t('Repost')}
title={t('Boost')}
onClick={() => {
if (isSmallScreen) {
setIsDrawerOpen(true)
@ -120,7 +120,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even @@ -120,7 +120,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
<DrawerHeader className="sr-only">
<DrawerTitle>Repost</DrawerTitle>
<DrawerTitle>{t('Boost')}</DrawerTitle>
</DrawerHeader>
<div className="py-2">
<Button
@ -133,7 +133,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even @@ -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"
variant="ghost"
>
<Repeat /> {t('Repost')}
<Repeat /> {t('Boost')}
</Button>
<Button
onClick={(e) => {
@ -168,7 +168,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even @@ -168,7 +168,7 @@ export default function RepostButton({ event, hideCount = false }: { event: Even
}}
disabled={!canRepost}
>
<Repeat /> {t('Repost')}
<Repeat /> {t('Boost')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {

2
src/components/NoteStats/index.tsx

@ -38,7 +38,7 @@ export default function NoteStats({ @@ -38,7 +38,7 @@ export default function NoteStats({
const { favoriteRelays } = useFavoriteRelays()
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 [isReplyToDiscussion, setIsReplyToDiscussion] = useState(false)

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

@ -26,7 +26,7 @@ export function RepostNotification({ notification }: { notification: Event }) { @@ -26,7 +26,7 @@ export function RepostNotification({ notification }: { notification: Event }) {
sender={notification.pubkey}
sentAt={notification.created_at}
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[] @@ -49,7 +49,7 @@ const ProfileFeed = forwardRef<{ refresh: () => void; getEvents?: () => Event[]
if (!kindValue || kindValue === 'all') return 'posts'
const kindNum = parseInt(kindValue, 10)
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.DISCUSSION) return 'discussions'
if (kindNum === ExtendedKind.POLL) return 'polls'

2
src/components/RepostList/index.tsx

@ -74,7 +74,7 @@ export default function RepostList({ event }: { event: Event }) { @@ -74,7 +74,7 @@ export default function RepostList({ event }: { event: Event }) {
<div ref={bottomRef} />
<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>
)

6
src/components/Sidebar/AccountButton.tsx

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

2
src/components/Sidebar/KeyboardShortcutsHelpSidebarButton.tsx

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

2
src/components/Sidebar/index.tsx

@ -18,7 +18,7 @@ export default function PrimaryPageSidebar() { @@ -18,7 +18,7 @@ export default function PrimaryPageSidebar() {
if (isSmallScreen) return null
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="px-3 xl:px-4 mb-6 w-full">
<Icon className="xl:hidden" />

760
src/components/TrendingNotes/index.tsx

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
@ -14,59 +13,18 @@ import noteStatsService from '@/services/note-stats.service' @@ -14,59 +13,18 @@ import noteStatsService from '@/services/note-stats.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import { getCalendarEventMeta } from '@/lib/calendar-event'
const SHOW_COUNT = 25
const CACHE_DURATION = 30 * 60 * 1000 // 30 minutes
// Unified cache for all custom trending feeds
let cachedCustomEvents: {
events: Array<{ event: NostrEvent; score: number }>
timestamp: number
hashtags: string[]
} | null = null
// Flag to prevent concurrent initialization
let isInitializing = false
type TrendingTab = 'relays' | 'hashtags' | 'calendar'
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() {
const { t } = useTranslation()
@ -76,165 +34,68 @@ export default function TrendingNotes() { @@ -76,165 +34,68 @@ export default function TrendingNotes() {
const { favoriteRelays } = useFavoriteRelays()
const { zapReplyThreshold } = useZap()
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [activeTab, setActiveTab] = useState<TrendingTab>('relays')
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 [cacheLoading, setCacheLoading] = useState(false)
const [calendarEvents, setCalendarEvents] = useState<NostrEvent[]>([])
const [calendarLoading, setCalendarLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
// Listen for tab restoration from PageManager
useEffect(() => {
const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => {
if (e.detail.page === 'search' && e.detail.tab && ['relays', 'hashtags', 'calendar'].includes(e.detail.tab)) {
setActiveTab(e.detail.tab as TrendingTab)
}
}
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])
const trendingRelaySource = useMemo<'favorites' | 'default'>(() => {
if (!pubkey) return 'default'
const hasFavorites = favoriteRelays.length > 0
const hasRead = (relayList?.read?.length ?? 0) > 0
if (hasFavorites || hasRead) return 'favorites'
return 'default'
}, [pubkey, favoriteRelays, relayList])
// 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 relays: string[] = []
if (pubkey) {
// User is logged in: favorite relays + inboxes (read relays)
relays.push(...favoriteRelays)
if (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) {
relays.push(...FAST_READ_RELAY_URLS)
}
} else {
// User is not logged in: use FAST_READ_RELAY_URLS (includes all 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))
}, [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(() => {
const initializeCache = async () => {
// Prevent concurrent initialization
if (isInitializing) {
return
}
// Prevent re-initialization if cache is already populated
if (isInitializing) return
if (cacheEvents.length > 0) {
logger.debug('[TrendingNotes] Cache already populated, skipping initialization')
return
}
const now = Date.now()
// Check if cache is still valid
if (cachedCustomEvents && (now - cachedCustomEvents.timestamp) < CACHE_DURATION) {
// If cache is valid, set cacheEvents to ALL events from cache
const allEvents = cachedCustomEvents.events.map(item => item.event)
if (cachedCustomEvents && now - cachedCustomEvents.timestamp < CACHE_DURATION) {
const allEvents = cachedCustomEvents.events.map((item) => item.event)
logger.debug('[TrendingNotes] Using existing cache - loading', allEvents.length, 'events')
setCacheEvents(allEvents)
setCacheLoading(false) // Ensure loading state is cleared
setCacheLoading(false)
return
}
isInitializing = true
setCacheLoading(true)
const relays = getRelays // Get current relays value
// Set a timeout to prevent infinite loading
const relays = getRelays
const timeoutId = setTimeout(() => {
logger.debug('[TrendingNotes] Cache initialization timeout - forcing completion')
isInitializing = false
setCacheLoading(false)
}, 180000) // 3 minute timeout
// Prevent running if we have no relays
}, 180000)
if (relays.length === 0) {
logger.debug('[TrendingNotes] No relays available, skipping cache initialization')
clearTimeout(timeoutId)
isInitializing = false
setCacheLoading(false)
@ -244,20 +105,11 @@ export default function TrendingNotes() { @@ -244,20 +105,11 @@ export default function TrendingNotes() {
try {
const allEvents: NostrEvent[] = []
const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - 24 * 60 * 60
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 batchSize = 3
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) {
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) => {
try {
const events = await queryService.fetchEvents([relay], {
@ -265,132 +117,89 @@ export default function TrendingNotes() { @@ -265,132 +117,89 @@ export default function TrendingNotes() {
since: twentyFourHoursAgo,
limit: 200
})
logger.debug('[TrendingNotes] Fetched', events.length, 'events from relay', relay)
return events
} catch (error) {
logger.warn(`[TrendingNotes] Error fetching from relay ${relay}:`, error)
return []
}
})
const batchResults = await Promise.all(batchPromises)
const batchEvents = 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
recentEvents.push(...batchResults.flat())
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 eTags = event.tags.filter(t => t[0] === 'e')
const topLevelEvents = allEvents.filter((event) => {
const eTags = event.tags.filter((tag) => tag[0] === 'e')
return eTags.length === 0
})
// Filter out NSFW content and content warnings
const filteredEvents = topLevelEvents.filter(event => {
// Check for NSFW in 't' tags
const hasNsfwTag = event.tags.some(tag =>
tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw'
const filteredEvents = topLevelEvents.filter((event) => {
const hasNsfwTag = event.tags.some(
(tag) => tag[0] === 't' && tag[1] && tag[1].toLowerCase() === 'nsfw'
)
// Check for sensitive content tag
const hasSensitiveTag = event.tags.some(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')
// Check for content-warning tag (NIP-36)
const hasContentWarning = event.tags.some(tag =>
tag[0] === 'content-warning'
const hasContentWarning = event.tags.some((tag) => tag[0] === 'content-warning')
const hasContentWarningL = event.tags.some(
(tag) => tag[0] === 'L' && tag[1] && tag[1].toLowerCase() === 'content-warning'
)
// Check for L tag with content-warning namespace
const hasContentWarningL = event.tags.some(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'
)
// Check for l tag with content-warning namespace
const hasContentWarningl = event.tags.some(tag =>
tag[0] === 'l' && tag[1] && tag[1].toLowerCase() === 'content-warning'
return (
!hasNsfwTag &&
!hasSensitiveTag &&
!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))
logger.debug('[TrendingNotes] Need to fetch stats for', eventsNeedingStats.length, 'events')
const eventsNeedingStats = filteredEvents.filter((event) => !noteStatsService.getNoteStats(event.id))
if (eventsNeedingStats.length > 0) {
const batchSize = 10 // Increased batch size to speed up
const totalBatches = Math.ceil(eventsNeedingStats.length / batchSize)
logger.debug('[TrendingNotes] Fetching stats in', totalBatches, 'batches')
for (let i = 0; i < eventsNeedingStats.length; i += batchSize) {
const batch = eventsNeedingStats.slice(i, i + batchSize)
const batchNum = Math.floor(i / batchSize) + 1
logger.debug('[TrendingNotes] Fetching stats batch', batchNum, 'of', totalBatches)
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
const statsBatchSize = 10
for (let i = 0; i < eventsNeedingStats.length; i += statsBatchSize) {
const batch = eventsNeedingStats.slice(i, i + statsBatchSize)
await Promise.all(
batch.map((event) => noteStatsService.fetchNoteStats(event, undefined, favoriteRelays).catch(() => {}))
)
if (i + statsBatchSize < eventsNeedingStats.length) {
await new Promise((resolve) => setTimeout(resolve, 200))
}
}
logger.debug('[TrendingNotes] Stats fetching completed')
}
// Score events
logger.debug('[TrendingNotes] Scoring', filteredEvents.length, 'events')
const scoredEvents = filteredEvents.map((event) => {
const stats = noteStatsService.getNoteStats(event.id)
let score = 0
if (stats?.likes) score += stats.likes.length
if (stats?.zaps) {
// Superzaps (above threshold) count as quotes (8 points)
// Regular zaps count as reactions (1 point)
stats.zaps.forEach(zap => {
if (zap.amount >= zapReplyThreshold) {
score += 8 // Superzap
} else {
score += 1 // Regular zap
}
stats.zaps.forEach((zap) => {
score += zap.amount >= zapReplyThreshold ? 8 : 1
})
}
if (stats?.replies) score += stats.replies.length * 3
if (stats?.reposts) score += stats.reposts.length * 5
if (stats?.quotes) score += stats.quotes.length * 8
if (stats?.highlights) score += stats.highlights.length * 10
return { event, score }
})
// Update cache
logger.debug('[TrendingNotes] Updating cache with', scoredEvents.length, 'scored events')
cachedCustomEvents = {
events: scoredEvents,
timestamp: now,
hashtags: []
timestamp: now
}
// 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)
} catch (error) {
logger.error('[TrendingNotes] Error initializing cache:', error)
@ -402,223 +211,70 @@ export default function TrendingNotes() { @@ -402,223 +211,70 @@ export default function TrendingNotes() {
}
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 idSet = new Set<string>()
const sourceEvents = cacheEvents
const filtered = sourceEvents.filter((evt) => {
const filtered = cacheEvents.filter((evt) => {
if (isEventDeleted(evt)) 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
if (idSet.has(id)) {
return false
}
if (idSet.has(id)) return false
idSet.add(id)
return true
})
// Apply sorting
filtered.sort((a, b) => {
if (sortOrder === 'newest') {
return b.created_at - a.created_at
} else if (sortOrder === 'oldest') {
return a.created_at - b.created_at
} else if (sortOrder === 'most-popular' || sortOrder === 'least-popular') {
if (sortOrder === 'newest') return b.created_at - a.created_at
if (sortOrder === 'oldest') return a.created_at - b.created_at
if (sortOrder === 'most-popular' || sortOrder === 'least-popular') {
const statsA = noteStatsService.getNoteStats(a.id)
const statsB = noteStatsService.getNoteStats(b.id)
let scoreA = 0
let scoreB = 0
if (statsA) {
scoreA += (statsA.likes?.length || 0)
scoreA += statsA.likes?.length || 0
scoreA += (statsA.replies?.length || 0) * 3
scoreA += (statsA.reposts?.length || 0) * 5
scoreA += (statsA.quotes?.length || 0) * 8
scoreA += (statsA.highlights?.length || 0) * 10
if (statsA.zaps) {
statsA.zaps.forEach(zap => {
statsA.zaps.forEach((zap) => {
scoreA += zap.amount >= zapReplyThreshold ? 8 : 1
})
}
}
if (statsB) {
scoreB += (statsB.likes?.length || 0)
scoreB += statsB.likes?.length || 0
scoreB += (statsB.replies?.length || 0) * 3
scoreB += (statsB.reposts?.length || 0) * 5
scoreB += (statsB.quotes?.length || 0) * 8
scoreB += (statsB.highlights?.length || 0) * 10
if (statsB.zaps) {
statsB.zaps.forEach(zap => {
statsB.zaps.forEach((zap) => {
scoreB += zap.amount >= zapReplyThreshold ? 8 : 1
})
}
}
return sortOrder === 'most-popular' ? scoreB - scoreA : scoreA - scoreB
}
return 0
})
return filtered
}, [
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])
}, [cacheEvents, hideUntrustedNotes, isEventDeleted, isUserTrusted, sortOrder, zapReplyThreshold])
const relaysFilteredEvents = useMemo(
() => relaysFilteredEventsAll.slice(0, showCount),
[relaysFilteredEventsAll, showCount]
)
useEffect(() => {
const totalLength = relaysFilteredEventsAll.length
if (showCount >= totalLength) return
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const options = { root: null, rootMargin: '10px', threshold: 0.1 }
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setShowCount((prev) => prev + SHOW_COUNT)
@ -626,232 +282,100 @@ export default function TrendingNotes() { @@ -626,232 +282,100 @@ export default function TrendingNotes() {
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
if (currentBottomRef) observerInstance.observe(currentBottomRef)
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
if (currentBottomRef) observerInstance.unobserve(currentBottomRef)
}
}, [relaysFilteredEventsAll.length, showCount, cacheLoading])
const headerTitle =
trendingRelaySource === 'favorites'
? t('Trending on Your Favorite Relays')
: t('Trending on the Default Relays')
return (
<div className="min-h-screen">
<div className="sticky top-12 bg-background z-30 border-b">
<div className="h-12 px-4 flex flex-col justify-center text-lg font-bold">
{t('Trending Notes')}
<div className="sticky top-12 z-30 border-b bg-background">
<div className="px-4 pb-3 pt-3">
<h2 className="text-lg font-bold leading-tight">{headerTitle}</h2>
</div>
<div className="flex items-center gap-2 px-4 pb-2">
<span className="text-sm font-medium text-muted-foreground">Trending:</span>
<div className="flex gap-1">
<div className="flex flex-wrap items-center gap-2 px-4 pb-3">
<span className="text-xs text-muted-foreground">{t('Sort')}:</span>
<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
onClick={() => {
setActiveTab('relays')
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'search', tab: 'relays' }
}))
}}
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'
type="button"
onClick={() => setSortOrder('oldest')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'oldest'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
on your relays
{t('oldest')}
</button>
<button
onClick={() => {
setActiveTab('hashtags')
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'search', tab: 'hashtags' }
}))
}}
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'
type="button"
onClick={() => setSortOrder('most-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'most-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
hashtags
{t('most popular')}
</button>
<button
onClick={() => {
setActiveTab('calendar')
window.dispatchEvent(new CustomEvent('pageTabChanged', {
detail: { page: 'search', tab: 'calendar' }
}))
}}
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'
type="button"
onClick={() => setSortOrder('least-popular')}
className={`rounded px-2 py-1 text-xs transition-colors ${
sortOrder === 'least-popular'
? 'bg-secondary text-secondary-foreground'
: 'bg-muted/50 text-muted-foreground hover:bg-muted'
}`}
>
{t('calendar entries')}
{t('least popular')}
</button>
</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>
{/* Show loading message for relays tab when cache is loading */}
{activeTab === 'relays' && cacheLoading && cacheEvents.length === 0 && (
<div className="text-center text-sm text-muted-foreground mt-8">
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...')}
{cacheLoading && cacheEvents.length === 0 ? (
<div className="mt-8 text-center text-sm text-muted-foreground">
{t('Loading trending notes from your relays...')}
</div>
)}
{activeTab === 'calendar' && !calendarLoading && calendarEvents.length === 0 && (
<div className="text-center text-sm text-muted-foreground mt-8">
{t('No calendar events found')}
) : null}
{relaysFilteredEvents.map((event) => (
<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 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>
)
}

2
src/constants.ts

@ -267,7 +267,7 @@ export const SUPPORTED_KINDS = [ @@ -267,7 +267,7 @@ export const SUPPORTED_KINDS = [
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(
(k) =>
k !== kinds.Repost &&

13
src/i18n/locales/ar.ts

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

26
src/i18n/locales/de.ts

@ -8,13 +8,14 @@ export default { @@ -8,13 +8,14 @@ export default {
Home: 'Startseite',
'Relay settings': 'Relay-Einstellungen',
Settings: 'Einstellungen',
'Account menu': 'Kontomenü',
SidebarRelays: 'Relays',
Refresh: 'Aktualisieren',
Profile: 'Profil',
Logout: 'Abmelden',
Following: 'Folgende',
followings: 'Folgekonten',
reposted: 'erneut gepostet',
boosted: 'geboostet',
'just now': 'gerade eben',
'n minutes ago': 'vor {{n}} Minuten',
'n m': 'vor {{n}}m',
@ -48,7 +49,8 @@ export default { @@ -48,7 +49,8 @@ export default {
'Failed to post': 'Posten fehlgeschlagen',
'Post successful': 'Beitrag erfolgreich',
'Your post has been published': 'Dein Beitrag wurde veröffentlicht',
Repost: 'Erneut posten',
Boost: 'Boost',
'Boost published': 'Boost veröffentlicht',
Quote: 'Zitat',
'Copy event ID': 'Ereignis-ID kopieren',
'Copy user ID': 'Benutzer-ID kopieren',
@ -314,6 +316,7 @@ export default { @@ -314,6 +316,7 @@ export default {
'no more replies': 'keine weiteren Antworten',
'Relay sets': 'Relay-Sets',
'Favorite Relays': 'Lieblings-Relays',
'Search for Relays': 'Relays suchen',
'Using app default relays': 'Standard-Relays der App',
"Following's Favorites": 'Favoriten der Folgenden',
'no more relays': 'keine weiteren Relays',
@ -493,9 +496,9 @@ export default { @@ -493,9 +496,9 @@ export default {
'No reactions yet': 'Noch keine Reaktionen',
'No more zaps': 'Keine weiteren Zaps',
'No zaps yet': 'Noch keine Zaps',
'No more reposts': 'Keine weiteren Reposts',
'No reposts yet': 'Noch keine Reposts',
Reposts: 'Reposts',
'No more boosts': 'Keine weiteren Boosts',
'No boosts yet': 'Noch keine Boosts',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -548,7 +551,7 @@ export default { @@ -548,7 +551,7 @@ export default {
'quoted your note': 'hat Ihre Notiz zitiert',
'voted in your poll': 'hat in Ihrer Umfrage abgestimmt',
'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 you': 'hat Sie gezappt',
'Mark as read': 'Als gelesen markieren',
@ -594,6 +597,14 @@ export default { @@ -594,6 +597,14 @@ export default {
'{{count}} relays': '{{count}} Relays',
'Republishing...': 'Wird erneut veröffentlicht...',
'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',
'Disconnect Wallet': 'Wallet trennen',
'Are you absolutely sure?': 'Bist du dir absolut sicher?',
@ -620,6 +631,9 @@ export default { @@ -620,6 +631,9 @@ export default {
'Richte deine Wallet ein, um Sats zu senden und zu empfangen!',
'Set up': 'Einrichten',
'help.title': 'Hilfe',
'help.tabShortcuts': 'Tastenkürzel',
'help.tabOverview': 'App-Übersicht',
'shortcuts.title': 'Tastenkürzel',
'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.',

26
src/i18n/locales/en.ts

@ -10,13 +10,14 @@ export default { @@ -10,13 +10,14 @@ export default {
'Pinned note': 'Pinned note',
'Relay settings': 'Relays and Storage Settings',
Settings: 'Settings',
'Account menu': 'Account menu',
SidebarRelays: 'Relays',
Refresh: 'Refresh',
Profile: 'Profile',
Logout: 'Logout',
Following: 'Following',
followings: 'followings',
reposted: 'reposted',
boosted: 'boosted',
'just now': 'just now',
'n minutes ago': '{{n}} minutes ago',
'n m': '{{n}}m',
@ -51,7 +52,8 @@ export default { @@ -51,7 +52,8 @@ export default {
'Failed to post': 'Failed to post',
'Post successful': 'Post successful',
'Your post has been published': 'Your post has been published',
Repost: 'Repost',
Boost: 'Boost',
'Boost published': 'Boost published',
Quote: 'Quote',
'Copy event ID': 'Copy event ID',
'Copy user ID': 'Copy user ID',
@ -381,6 +383,7 @@ export default { @@ -381,6 +383,7 @@ export default {
'no more replies': 'no more replies',
'Relay sets': 'Relay sets',
'Favorite Relays': 'Favorite Relays',
'Search for Relays': 'Search for Relays',
'Using app default relays': 'Using app default relays',
"Following's Favorites": "Following's Favorites",
'no more relays': 'no more relays',
@ -563,9 +566,9 @@ export default { @@ -563,9 +566,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 reposts',
'No reposts yet': 'No reposts yet',
Reposts: 'Reposts',
'No more boosts': 'No more boosts',
'No boosts yet': 'No boosts yet',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -615,7 +618,7 @@ export default { @@ -615,7 +618,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': 'reposted your note',
'boosted your note': 'boosted your note',
'zapped your note': 'zapped your note',
'zapped you': 'zapped you',
zapped: 'zapped',
@ -663,6 +666,14 @@ export default { @@ -663,6 +666,14 @@ export default {
'{{count}} relays': '{{count}} relays',
'Republishing...': 'Republishing...',
'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',
'Disconnect Wallet': 'Disconnect Wallet',
'Are you absolutely sure?': 'Are you absolutely sure?',
@ -706,6 +717,9 @@ export default { @@ -706,6 +717,9 @@ export default {
'Compact': 'Compact',
'Expand': 'Expand',
'help.title': 'Help',
'help.tabShortcuts': 'Keyboard shortcuts',
'help.tabOverview': 'App overview',
'shortcuts.title': 'Keyboard shortcuts',
'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.',

13
src/i18n/locales/es.ts

@ -14,7 +14,7 @@ export default { @@ -14,7 +14,7 @@ export default {
Logout: 'Cerrar sesión',
Following: 'Siguiendo',
followings: 'siguiendo',
reposted: 'retransmitido',
boosted: 'boosteado',
'just now': 'justo ahora',
'n minutes ago': 'hace {{n}} minutos',
'n m': '{{n}}m',
@ -41,7 +41,8 @@ export default { @@ -41,7 +41,8 @@ export default {
'Failed to post': 'Error al publicar',
'Post successful': 'Publicación exitosa',
'Your post has been published': 'Tu publicación ha sido publicada',
Repost: 'Reenviar',
Boost: 'Boost',
'Boost published': 'Boost publicado',
Quote: 'Citar',
'Copy event ID': 'Copiar ID del evento',
'Copy user ID': 'Copiar ID del usuario',
@ -337,9 +338,9 @@ export default { @@ -337,9 +338,9 @@ export default {
'No reactions yet': 'Sin reacciones aún',
'No more zaps': 'No hay más zaps',
'No zaps yet': 'Sin zaps aún',
'No more reposts': 'No hay más reposts',
'No reposts yet': 'Sin reposts aún',
Reposts: 'Reposts',
'No more boosts': 'No hay más boosts',
'No boosts yet': 'Sin boosts aún',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -385,7 +386,7 @@ export default { @@ -385,7 +386,7 @@ export default {
'quoted your note': 'citó tu nota',
'voted in your poll': 'votó en tu encuesta',
'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 you': 'te zappeó',
'Mark as read': 'Marcar como leído',

13
src/i18n/locales/fa.ts

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

13
src/i18n/locales/fr.ts

@ -14,7 +14,7 @@ export default { @@ -14,7 +14,7 @@ export default {
Logout: 'Déconnexion',
Following: 'Abonnements',
followings: 'abonnements',
reposted: 'republié',
boosted: 'a boosté',
'just now': "à l'instant",
'n minutes ago': 'il y a {{n}} minutes',
'n m': '{{n}}m',
@ -41,7 +41,8 @@ export default { @@ -41,7 +41,8 @@ export default {
'Failed to post': 'Publication échouée',
'Post successful': 'Publication réussie',
'Your post has been published': 'Votre publication a été publiée',
Repost: 'Reposter',
Boost: 'Boost',
'Boost published': 'Boost publié',
Quote: 'Citer',
'Copy event ID': "Copier l'ID de l'événement",
'Copy user ID': "Copier l'ID de l'utilisateur",
@ -339,9 +340,9 @@ export default { @@ -339,9 +340,9 @@ export default {
'No reactions yet': 'Pas encore de réactions',
'No more zaps': 'Plus de zaps',
'No zaps yet': 'Pas encore de zaps',
'No more reposts': 'Plus de reposts',
'No reposts yet': 'Pas encore de reposts',
Reposts: 'Reposts',
'No more boosts': 'Plus de boosts',
'No boosts yet': 'Pas encore de boosts',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -389,7 +390,7 @@ export default { @@ -389,7 +390,7 @@ export default {
'quoted your note': 'a cité votre note',
'voted in your poll': 'a voté dans votre sondage',
'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 you': 'vous a zappé',
'Mark as read': 'Marquer comme lu',

13
src/i18n/locales/hi.ts

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

13
src/i18n/locales/it.ts

@ -13,7 +13,7 @@ export default { @@ -13,7 +13,7 @@ export default {
Logout: 'Disconnetti',
Following: 'Seguendo',
followings: 'seguiti',
reposted: 'ripubblica',
boosted: 'ha boostato',
'just now': 'adesso',
'n minutes ago': '{{n}} minuti fa',
'n m': '{{n}}m',
@ -40,7 +40,8 @@ export default { @@ -40,7 +40,8 @@ export default {
'Failed to post': 'Impossibile pubblicare',
'Post successful': 'Pubblicazione riuscita',
'Your post has been published': 'Il tuo post è stato pubblicato',
Repost: 'Ripubblica',
Boost: 'Boost',
'Boost published': 'Boost pubblicato',
Quote: 'Quota',
'Copy event ID': 'Copia ID evento',
'Copy user ID': 'Copia ID utente',
@ -337,9 +338,9 @@ export default { @@ -337,9 +338,9 @@ export default {
'No reactions yet': 'Ancora nessuna reazione',
'No more zaps': 'Non ci sono più zaps',
'No zaps yet': 'Ancora nessuno zap',
'No more reposts': 'Non ci sono più repost',
'No reposts yet': 'Ancora nessun repost',
Reposts: 'Repost',
'No more boosts': 'Non ci sono più boost',
'No boosts yet': 'Ancora nessun boost',
Boosts: 'Boost',
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.',
MuteListNotFoundConfirmation:
@ -385,7 +386,7 @@ export default { @@ -385,7 +386,7 @@ export default {
'quoted your note': 'ha citato la tua nota',
'voted in your poll': 'ha votato nel tuo sondaggio',
'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 you': 'ti ha zappato',
'Mark as read': 'Segna come letto',

13
src/i18n/locales/ja.ts

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

13
src/i18n/locales/ko.ts

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

13
src/i18n/locales/pl.ts

@ -13,7 +13,7 @@ export default { @@ -13,7 +13,7 @@ export default {
Logout: 'Wyloguj',
Following: 'Obserwowani',
followings: 'niżej wymienionych',
reposted: 'Udostępnił',
boosted: 'zboostował',
'just now': 'teraz',
'n minutes ago': '{{n}} m',
'n m': '{{n}}m',
@ -40,7 +40,8 @@ export default { @@ -40,7 +40,8 @@ export default {
'Failed to post': 'Nie udało się opublikować',
'Post successful': 'Twój wpis został wysłany.',
'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',
'Copy event ID': 'Skopiuj ID wydarzenia',
'Copy user ID': 'Skopiuj ID użytkownika',
@ -338,9 +339,9 @@ export default { @@ -338,9 +339,9 @@ export default {
'No reactions yet': 'Brak reakcji',
'No more zaps': 'Brak kolejnych zapów',
'No zaps yet': 'Brak zapów',
'No more reposts': 'Brak kolejnych repostów',
'No reposts yet': 'Brak repostów',
Reposts: 'Reposty',
'No more boosts': 'Brak kolejnych boostów',
'No boosts yet': 'Brak boostów',
Boosts: 'Boosty',
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.',
MuteListNotFoundConfirmation:
@ -386,7 +387,7 @@ export default { @@ -386,7 +387,7 @@ export default {
'quoted your note': 'zacytował twoją notatkę',
'voted in your poll': 'zagłosował w twojej ankiecie',
'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 you': 'zappował cię',
'Mark as read': 'Oznacz jako przeczytane',

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

@ -13,7 +13,7 @@ export default { @@ -13,7 +13,7 @@ export default {
Logout: 'Sair',
Following: 'Seguindo',
followings: 'Seguidos',
reposted: 'Repostado',
boosted: 'deu boost',
'just now': 'agora mesmo',
'n minutes ago': '{{n}} minutos atrás',
'n m': '{{n}}m',
@ -40,7 +40,8 @@ export default { @@ -40,7 +40,8 @@ export default {
'Failed to post': 'Falha ao postar',
'Post successful': 'Nota publicada com sucesso',
'Your post has been published': 'Sua nota foi publicada',
Repost: 'Repostar',
Boost: 'Boost',
'Boost published': 'Boost publicado',
Quote: 'Citar',
'Copy event ID': 'Copiar ID do evento',
'Copy user ID': 'Copiar ID do usuário',
@ -335,9 +336,9 @@ export default { @@ -335,9 +336,9 @@ export default {
'No reactions yet': 'Ainda sem reações',
'No more zaps': 'Sem mais zaps',
'No zaps yet': 'Ainda sem zaps',
'No more reposts': 'Sem mais reposts',
'No reposts yet': 'Ainda sem reposts',
Reposts: 'Reposts',
'No more boosts': 'Sem mais boosts',
'No boosts yet': 'Ainda sem boosts',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -382,7 +383,7 @@ export default { @@ -382,7 +383,7 @@ export default {
'quoted your note': 'citou sua nota',
'voted in your poll': 'votou na sua enquete',
'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 you': 'zappeou você',
'Mark as read': 'Marcar como lida',

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

@ -14,7 +14,7 @@ export default { @@ -14,7 +14,7 @@ export default {
Logout: 'Sair',
Following: 'Seguindo',
followings: 'seguidos',
reposted: 'repostado',
boosted: 'deu boost',
'just now': 'agora mesmo',
'n minutes ago': '{{n}} minutos atrás',
'n m': '{{n}}m',
@ -41,7 +41,8 @@ export default { @@ -41,7 +41,8 @@ export default {
'Failed to post': 'Falha ao postar',
'Post successful': 'Postagem bem-sucedida',
'Your post has been published': 'Sua postagem foi publicada',
Repost: 'Repostar',
Boost: 'Boost',
'Boost published': 'Boost publicado',
Quote: 'Citar',
'Copy event ID': 'Copiar ID do evento',
'Copy user ID': 'Copiar ID do usuário',
@ -337,9 +338,9 @@ export default { @@ -337,9 +338,9 @@ export default {
'No reactions yet': 'Ainda sem reações',
'No more zaps': 'Sem mais zaps',
'No zaps yet': 'Ainda sem zaps',
'No more reposts': 'Sem mais reposts',
'No reposts yet': 'Ainda sem reposts',
Reposts: 'Reposts',
'No more boosts': 'Sem mais boosts',
'No boosts yet': 'Ainda sem boosts',
Boosts: 'Boosts',
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.',
MuteListNotFoundConfirmation:
@ -385,7 +386,7 @@ export default { @@ -385,7 +386,7 @@ export default {
'quoted your note': 'citou a sua nota',
'voted in your poll': 'votou na sua sondagem',
'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 you': 'zappeou-o',
'Mark as read': 'Marcar como lida',

13
src/i18n/locales/ru.ts

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

13
src/i18n/locales/th.ts

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

13
src/i18n/locales/zh.ts

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

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

@ -109,6 +109,7 @@ const ExplorePage = forwardRef((_, ref) => { @@ -109,6 +109,7 @@ const ExplorePage = forwardRef((_, ref) => {
{tab === 'explore' && (
<>
<ExploreFavoriteRelays />
<ExploreRelaySearchSection />
<Explore />
</>
)}
@ -122,6 +123,33 @@ export default ExplorePage @@ -122,6 +123,33 @@ export default ExplorePage
function ExplorePageTitlebar() {
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 [relayQuery, setRelayQuery] = useState('')
const [monitoringRelays, setMonitoringRelays] = useState<string[]>([])
@ -175,35 +203,58 @@ function ExplorePageTitlebar() { @@ -175,35 +203,58 @@ function ExplorePageTitlebar() {
}
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">
<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>
<div className="relative min-w-0 max-w-xl flex-1 basis-full sm:basis-64">
<section className="min-w-0 px-2 pb-4 pt-0" aria-label={t('Search for Relays')}>
<h2 className="mb-2 px-2 text-base font-semibold tracking-tight">{t('Search for Relays')}</h2>
<div className="max-w-xl px-2">
<form className="flex items-center gap-1.5" onSubmit={onSubmitRelay}>
<Input
type="text"
inputMode="url"
autoComplete="off"
placeholder={t('Relay URL…')}
className="h-9 min-w-0 flex-1 font-mono text-sm"
value={relayQuery}
onChange={(e) => setRelayQuery(e.target.value)}
aria-label={t('Relay URL…')}
aria-autocomplete="list"
aria-expanded={suggestOpen && relaySuggestions.length > 0}
aria-controls="explore-relay-suggestions"
role="combobox"
onFocus={() => {
clearBlurTimer()
setSuggestOpen(true)
}}
onBlur={() => {
clearBlurTimer()
blurCloseTimer.current = setTimeout(() => setSuggestOpen(false), 200)
}}
/>
<div className="relative min-w-0 flex-1">
<Input
type="text"
inputMode="url"
autoComplete="off"
placeholder={t('Relay URL…')}
className="h-9 w-full font-mono text-sm"
value={relayQuery}
onChange={(e) => setRelayQuery(e.target.value)}
aria-label={t('Relay URL…')}
aria-autocomplete="list"
aria-expanded={suggestOpen && relaySuggestions.length > 0}
aria-controls="explore-relay-suggestions"
role="combobox"
onFocus={() => {
clearBlurTimer()
setSuggestOpen(true)
}}
onBlur={() => {
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
type="submit"
variant="secondary"
@ -214,45 +265,7 @@ function ExplorePageTitlebar() { @@ -214,45 +265,7 @@ function ExplorePageTitlebar() {
<ArrowRight className="size-4" />
</Button>
</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>
<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>
</section>
)
}

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

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

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

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

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

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

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

@ -3,7 +3,7 @@ import { @@ -3,7 +3,7 @@ import {
ExtendedKind,
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE,
SUPPORTED_KINDS,
PROFILE_FEED_KINDS,
StorageKey
} from '@/constants'
import { kinds } from 'nostr-tools'
@ -223,15 +223,7 @@ class LocalStorageService { @@ -223,15 +223,7 @@ class LocalStorageService {
const showKindsStr = window.localStorage.getItem(StorageKey.SHOW_KINDS)
if (!showKindsStr) {
// Default: show all supported kinds except reposts, publications, publication content, and NIP-89 handler 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
)
this.showKinds = [...PROFILE_FEED_KINDS]
} else {
const showKindsVersionStr = window.localStorage.getItem(StorageKey.SHOW_KINDS_VERSION)
const showKindsVersion = showKindsVersionStr ? parseInt(showKindsVersionStr) : 0
@ -243,7 +235,7 @@ class LocalStorageService { @@ -243,7 +235,7 @@ class LocalStorageService {
showKinds.push(ExtendedKind.ZAP_RECEIPT)
}
if (showKindsVersion < 3) {
// Remove reposts from existing users' filters
// Remove boosts (kind 6) from existing users' filters
const repostIndex = showKinds.indexOf(kinds.Repost)
if (repostIndex !== -1) {
showKinds.splice(repostIndex, 1)
@ -290,10 +282,19 @@ class LocalStorageService { @@ -290,10 +282,19 @@ class LocalStorageService {
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.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)
const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs)

5
src/vite-env.d.ts vendored

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

Loading…
Cancel
Save