24 changed files with 179 additions and 1702 deletions
@ -1,34 +0,0 @@ |
|||||||
import { |
|
||||||
DropdownMenuLabel, |
|
||||||
DropdownMenuSeparator |
|
||||||
} from '@/components/ui/dropdown-menu' |
|
||||||
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { ActiveRelaysIconGrid } from './ActiveRelaysIconGrid' |
|
||||||
|
|
||||||
/** Compact active-relay icons in the account (user badge) dropdown. */ |
|
||||||
export function ActiveRelaysDropdownSection() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { rows, connectedCount } = useRelayConnectionRows() |
|
||||||
|
|
||||||
if (rows.length === 0) return null |
|
||||||
|
|
||||||
const countSummary = `${connectedCount}/${rows.length}` |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<DropdownMenuSeparator /> |
|
||||||
<DropdownMenuLabel className="flex items-baseline justify-between gap-2 text-xs font-normal"> |
|
||||||
<span>{t('Active relays')}</span> |
|
||||||
<span className="tabular-nums text-muted-foreground">{countSummary}</span> |
|
||||||
</DropdownMenuLabel> |
|
||||||
<div |
|
||||||
className="px-2 pb-2" |
|
||||||
onClick={(e) => e.stopPropagation()} |
|
||||||
onPointerDown={(e) => e.stopPropagation()} |
|
||||||
> |
|
||||||
<ActiveRelaysIconGrid /> |
|
||||||
</div> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,106 +0,0 @@ |
|||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
DropdownMenu, |
|
||||||
DropdownMenuContent, |
|
||||||
DropdownMenuLabel, |
|
||||||
DropdownMenuSeparator, |
|
||||||
DropdownMenuTrigger |
|
||||||
} from '@/components/ui/dropdown-menu' |
|
||||||
import { useRelayConnectionRows } from '@/hooks/useRelayConnectionRows' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import RelayIcon from '../RelayIcon' |
|
||||||
import { |
|
||||||
ACTIVE_RELAYS_MAX_ICONS, |
|
||||||
activeRelayRowMuted, |
|
||||||
activeRelayRowTitle |
|
||||||
} from './active-relays-display' |
|
||||||
|
|
||||||
/** |
|
||||||
* Compact relay status: icon buttons only (no hostname labels). |
|
||||||
*/ |
|
||||||
export function ActiveRelaysIconGrid({ className }: { className?: string }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const { rows } = useRelayConnectionRows() |
|
||||||
const shown = rows.slice(0, ACTIVE_RELAYS_MAX_ICONS) |
|
||||||
const overflowRows = rows.slice(ACTIVE_RELAYS_MAX_ICONS) |
|
||||||
const overflow = overflowRows.length |
|
||||||
|
|
||||||
if (rows.length === 0) { |
|
||||||
return ( |
|
||||||
<p className={cn('text-xs text-muted-foreground', className)} title={t('Active relays')}> |
|
||||||
— |
|
||||||
</p> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('flex flex-wrap gap-1', className)} title={t('Active relays')}> |
|
||||||
{shown.map(({ url, connected }) => ( |
|
||||||
<Button |
|
||||||
key={url} |
|
||||||
type="button" |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className={cn( |
|
||||||
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80', |
|
||||||
activeRelayRowMuted(connected) && 'opacity-40 grayscale' |
|
||||||
)} |
|
||||||
title={activeRelayRowTitle(url, connected, t)} |
|
||||||
aria-label={activeRelayRowTitle(url, connected, t)} |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<RelayIcon url={url} className="h-6 w-6" iconSize={12} /> |
|
||||||
</Button> |
|
||||||
))} |
|
||||||
{overflow > 0 ? ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild> |
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className="h-7 min-h-7 min-w-7 shrink-0 rounded-full bg-muted px-1.5 py-0 text-[0.65rem] font-medium tabular-nums text-muted-foreground hover:bg-muted/80 hover:text-foreground" |
|
||||||
title={t('More relays', { count: overflow })} |
|
||||||
aria-label={t('More relays', { count: overflow })} |
|
||||||
> |
|
||||||
+{overflow} |
|
||||||
</Button> |
|
||||||
</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent |
|
||||||
align="start" |
|
||||||
side="right" |
|
||||||
className="w-auto max-w-[min(18rem,calc(100vw-1.5rem))] p-2" |
|
||||||
> |
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground py-1"> |
|
||||||
{t('More relays', { count: overflow })} |
|
||||||
</DropdownMenuLabel> |
|
||||||
<DropdownMenuSeparator /> |
|
||||||
<div className="flex flex-wrap gap-1 max-w-[16rem]"> |
|
||||||
{overflowRows.map(({ url, connected }) => ( |
|
||||||
<Button |
|
||||||
key={url} |
|
||||||
type="button" |
|
||||||
variant="ghost" |
|
||||||
size="sm" |
|
||||||
className={cn( |
|
||||||
'h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80', |
|
||||||
activeRelayRowMuted(connected) && 'opacity-40 grayscale' |
|
||||||
)} |
|
||||||
title={activeRelayRowTitle(url, connected, t)} |
|
||||||
aria-label={activeRelayRowTitle(url, connected, t)} |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<RelayIcon url={url} className="h-6 w-6" iconSize={12} /> |
|
||||||
</Button> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,17 +0,0 @@ |
|||||||
import { relaySessionStrikes } from '@/lib/relay-strikes' |
|
||||||
import { simplifyUrl } from '@/lib/url' |
|
||||||
|
|
||||||
export const ACTIVE_RELAYS_MAX_ICONS = 14 |
|
||||||
|
|
||||||
export function activeRelayRowMuted(connected: boolean) { |
|
||||||
return !connected |
|
||||||
} |
|
||||||
|
|
||||||
export function activeRelayRowTitle(url: string, connected: boolean, t: (k: string) => string) { |
|
||||||
const base = simplifyUrl(url) |
|
||||||
if (!connected) return `${base} — ${t('Not connected')}` |
|
||||||
if (relaySessionStrikes.isSessionStrikeActiveForUrl(url)) { |
|
||||||
return `${base} — ${t('Session relay strikes')}` |
|
||||||
} |
|
||||||
return base |
|
||||||
} |
|
||||||
@ -1,136 +0,0 @@ |
|||||||
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { DEFAULT_FAVORITE_RELAYS } from '@/constants' |
|
||||||
import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay' |
|
||||||
import { useGlobalRelayBootstrapDefaults } from '@/hooks/use-global-relay-bootstrap-defaults' |
|
||||||
import { useFetchRelayInfo } from '@/hooks' |
|
||||||
import { toRelay, toRelaySettings } from '@/lib/link' |
|
||||||
import { normalizeUrl, simplifyUrl } from '@/lib/url' |
|
||||||
import { usePrimaryPage } from '@/contexts/primary-page-context' |
|
||||||
import { useSecondaryPage, useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { Newspaper, Settings } from 'lucide-react' |
|
||||||
import { useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
function FavoriteRelayCard({ url }: { url: string }) { |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const { relayInfo, isFetching } = useFetchRelayInfo(url) |
|
||||||
|
|
||||||
if (isFetching) { |
|
||||||
return ( |
|
||||||
<RelaySimpleInfoSkeleton className="h-full min-h-[5.5rem] rounded-lg border bg-card p-3 shadow-sm" /> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (!relayInfo) { |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className={cn( |
|
||||||
'clickable flex h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 flex-col justify-center rounded-lg border bg-card p-3 text-left shadow-sm', |
|
||||||
'transition-colors hover:bg-accent/40' |
|
||||||
)} |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<div className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</div> |
|
||||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{url}</div> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<RelaySimpleInfo |
|
||||||
relayInfo={relayInfo} |
|
||||||
className={cn( |
|
||||||
'clickable h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 rounded-lg border bg-card p-3 shadow-sm', |
|
||||||
'transition-colors hover:bg-accent/40' |
|
||||||
)} |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
navigateToRelay(toRelay(relayInfo.url)) |
|
||||||
}} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Horizontal strip of favorite relays (non-blocked), or {@link DEFAULT_FAVORITE_RELAYS} when none. |
|
||||||
*/ |
|
||||||
export default function ExploreFavoriteRelays() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { navigate } = usePrimaryPage() |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
||||||
const useGlobalRelayBootstrap = useGlobalRelayBootstrapDefaults() |
|
||||||
|
|
||||||
const blockedSet = useMemo( |
|
||||||
() => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)), |
|
||||||
[blockedRelays] |
|
||||||
) |
|
||||||
|
|
||||||
const { urls, usingDefaults } = useMemo(() => { |
|
||||||
const visible = ensureTrendingInFavoriteRelayList(favoriteRelays).filter((r) => { |
|
||||||
const k = normalizeUrl(r) || r |
|
||||||
return k && !blockedSet.has(k) |
|
||||||
}) |
|
||||||
if (visible.length > 0) { |
|
||||||
return { urls: visible, usingDefaults: false } |
|
||||||
} |
|
||||||
if (!useGlobalRelayBootstrap) { |
|
||||||
return { urls: [], usingDefaults: false } |
|
||||||
} |
|
||||||
const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => { |
|
||||||
const k = normalizeUrl(r) || r |
|
||||||
return k && !blockedSet.has(k) |
|
||||||
}) |
|
||||||
return { |
|
||||||
urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS, |
|
||||||
usingDefaults: true |
|
||||||
} |
|
||||||
}, [favoriteRelays, blockedSet, useGlobalRelayBootstrap]) |
|
||||||
|
|
||||||
if (urls.length === 0) return null |
|
||||||
|
|
||||||
return ( |
|
||||||
<section className="min-w-0 px-2 pb-4 pt-1" aria-label={t('Favorite Relays')}> |
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-2"> |
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2"> |
|
||||||
<h2 className="text-base font-semibold tracking-tight">{t('Favorite Relays')}</h2> |
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="outline" |
|
||||||
size="sm" |
|
||||||
className="h-8 gap-1.5 px-2.5 font-medium" |
|
||||||
onClick={() => navigate('feed')} |
|
||||||
> |
|
||||||
<Newspaper className="size-4 shrink-0" strokeWidth={2.5} /> |
|
||||||
<span>{t('Favorite Relays')}</span> |
|
||||||
</Button> |
|
||||||
<Button |
|
||||||
type="button" |
|
||||||
variant="outline" |
|
||||||
size="icon" |
|
||||||
className="h-8 w-8 shrink-0" |
|
||||||
aria-label={t('Relays and Storage Settings')} |
|
||||||
title={t('Relays and Storage Settings')} |
|
||||||
onClick={() => push(toRelaySettings('favorite-relays'))} |
|
||||||
> |
|
||||||
<Settings className="size-4 shrink-0" strokeWidth={2.5} /> |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
{usingDefaults ? ( |
|
||||||
<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-4 pt-0.5 snap-x snap-mandatory [scrollbar-gutter:stable]"> |
|
||||||
{urls.map((url) => ( |
|
||||||
<div key={url} className="snap-start"> |
|
||||||
<FavoriteRelayCard url={url} /> |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</section> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,77 +0,0 @@ |
|||||||
import { buildExplorePopularRelayUrls } from '@/lib/explore-popular-relays' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { normalizeAnyRelayUrl, simplifyUrl } from '@/lib/url' |
|
||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import indexedDb from '@/services/indexed-db.service' |
|
||||||
import { useEffect, useMemo, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
/** |
|
||||||
* Lightweight Explore relay list: URLs from the viewer's NIP-65 / favorites / defaults and optional |
|
||||||
* cached NIP-66 data — no GitHub collections fetch and no NIP-11 storm on mount. |
|
||||||
*/ |
|
||||||
export default function ExplorePopularRelays() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { relayList } = useNostr() |
|
||||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const [nip66Cached, setNip66Cached] = useState<string[]>([]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
let cancelled = false |
|
||||||
void indexedDb |
|
||||||
.getPublicLivelyRelayUrlsCache() |
|
||||||
.then((c) => { |
|
||||||
if (!cancelled && c?.urls?.length) setNip66Cached(c.urls) |
|
||||||
}) |
|
||||||
.catch(() => {}) |
|
||||||
return () => { |
|
||||||
cancelled = true |
|
||||||
} |
|
||||||
}, []) |
|
||||||
|
|
||||||
const urls = useMemo( |
|
||||||
() => |
|
||||||
buildExplorePopularRelayUrls({ |
|
||||||
relayList, |
|
||||||
favoriteRelays, |
|
||||||
blockedRelays, |
|
||||||
nip66CachedUrls: nip66Cached |
|
||||||
}), |
|
||||||
[relayList, favoriteRelays, blockedRelays, nip66Cached] |
|
||||||
) |
|
||||||
|
|
||||||
if (urls.length === 0) { |
|
||||||
return ( |
|
||||||
<p className="px-4 py-6 text-sm text-muted-foreground">{t('No relays in your lists yet.')}</p> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<section className="min-w-0 pb-6" aria-label={t('Popular relays')}> |
|
||||||
<h2 className="mb-2 px-4 text-base font-semibold tracking-tight">{t('Popular relays')}</h2> |
|
||||||
<p className="mb-3 px-4 text-sm text-muted-foreground"> |
|
||||||
{t('From your mailbox, favorites, and cached relay lists on this device.')} |
|
||||||
</p> |
|
||||||
<ul className="grid min-w-0 gap-2 px-2 md:grid-cols-2 md:px-4"> |
|
||||||
{urls.map((url) => { |
|
||||||
const key = normalizeAnyRelayUrl(url) || url |
|
||||||
return ( |
|
||||||
<li key={key}> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex w-full min-w-0 flex-col rounded-lg border bg-card px-3 py-2.5 text-left shadow-sm transition-colors hover:bg-accent/40" |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<span className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</span> |
|
||||||
<span className="mt-0.5 truncate text-xs text-muted-foreground">{url}</span> |
|
||||||
</button> |
|
||||||
</li> |
|
||||||
) |
|
||||||
})} |
|
||||||
</ul> |
|
||||||
</section> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,239 +0,0 @@ |
|||||||
import RelayIcon from '@/components/RelayIcon' |
|
||||||
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { useFetchRelayInfo } from '@/hooks' |
|
||||||
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' |
|
||||||
import { |
|
||||||
dedupeRelayReviewsNewestFirst, |
|
||||||
loadCachedRelayReviews |
|
||||||
} from '@/lib/explore-relay-reviews' |
|
||||||
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' |
|
||||||
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
||||||
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' |
|
||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import type { Event } from 'nostr-tools' |
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
function RelayGroupHeader({ url, reviewCount }: { url: string; reviewCount: number }) { |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const { relayInfo } = useFetchRelayInfo(url) |
|
||||||
return ( |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className="flex w-full min-w-0 items-center gap-2 px-4 md:px-4 pt-4 pb-2 border-b text-left hover:opacity-75 transition-opacity" |
|
||||||
onClick={() => navigateToRelay(toRelay(url))} |
|
||||||
> |
|
||||||
<RelayIcon url={url} skipRelayInfoFetch className="h-8 w-8 shrink-0 rounded-sm" iconSize={16} /> |
|
||||||
<div className="min-w-0 flex-1"> |
|
||||||
{relayInfo?.name && ( |
|
||||||
<div className="truncate font-semibold text-sm leading-tight">{relayInfo.name}</div> |
|
||||||
)} |
|
||||||
<div className="flex items-center gap-1.5 min-w-0"> |
|
||||||
<div className="truncate font-mono text-xs text-muted-foreground leading-tight">{url}</div> |
|
||||||
<span className="shrink-0 text-xs text-muted-foreground"> |
|
||||||
· {reviewCount} {reviewCount === 1 ? 'review' : 'reviews'} |
|
||||||
</span> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</button> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
const REVIEW_QUERY_LIMIT = 100 |
|
||||||
const SHOW_COUNT = 20 |
|
||||||
/** Fewer sockets + faster aggregate EOSE than full inbox stack; read-only mirrors prepended then capped. */ |
|
||||||
const EXPLORE_REVIEWS_MAX_RELAYS = 12 |
|
||||||
/** After all relays EOSE, wait longer than default so slow mirrors can flush events (default query eose is 500ms). */ |
|
||||||
const EXPLORE_REVIEWS_EOSE_TAIL_MS = 4500 |
|
||||||
|
|
||||||
function stableRelayInputsKey( |
|
||||||
favoriteRelays: string[], |
|
||||||
blockedRelays: string[], |
|
||||||
relayList: { read?: string[]; write?: string[]; httpRead?: string[] } | null | undefined, |
|
||||||
cacheRelayListEvent: Event | null | undefined |
|
||||||
): string { |
|
||||||
const normSortJoin = (urls: string[]) => |
|
||||||
[...urls] |
|
||||||
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
|
||||||
.filter(Boolean) |
|
||||||
.sort((a, b) => a.localeCompare(b)) |
|
||||||
.join('|') |
|
||||||
return [ |
|
||||||
normSortJoin(favoriteRelays), |
|
||||||
normSortJoin(blockedRelays), |
|
||||||
normSortJoin(userReadInboxUrls(relayList, cacheRelayListEvent)), |
|
||||||
normSortJoin(userWriteOutboxUrls(relayList, cacheRelayListEvent)) |
|
||||||
].join('::') |
|
||||||
} |
|
||||||
|
|
||||||
export default function ExploreRelayReviews() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
|
||||||
const { relayList, cacheRelayListEvent } = useNostr() |
|
||||||
|
|
||||||
const relayInputsKey = useMemo( |
|
||||||
() => stableRelayInputsKey(favoriteRelays, blockedRelays, relayList, cacheRelayListEvent), |
|
||||||
[favoriteRelays, blockedRelays, relayList, cacheRelayListEvent] |
|
||||||
) |
|
||||||
|
|
||||||
const relayUrls = useMemo(() => { |
|
||||||
const stacked = appendCuratedReadOnlyRelays( |
|
||||||
getRelayUrlsWithFavoritesFastReadAndInbox( |
|
||||||
favoriteRelays, |
|
||||||
blockedRelays, |
|
||||||
userReadInboxUrls(relayList, cacheRelayListEvent), |
|
||||||
{ |
|
||||||
userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), |
|
||||||
maxRelays: EXPLORE_REVIEWS_MAX_RELAYS, |
|
||||||
applySocialKindBlockedFilter: false |
|
||||||
} |
|
||||||
), |
|
||||||
blockedRelays |
|
||||||
) |
|
||||||
const sliced = stacked.slice(0, EXPLORE_REVIEWS_MAX_RELAYS) |
|
||||||
const normalized = sliced |
|
||||||
.map((u) => normalizeAnyRelayUrl(u) || u.trim()) |
|
||||||
.filter((u): u is string => Boolean(u) && isExploreBrowsableRelayUrl(u)) |
|
||||||
normalized.sort((a, b) => a.localeCompare(b)) |
|
||||||
return normalized |
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- relayInputsKey is a content hash of favorites/blocked/NIP-65; relayList identity churn must not re-open REQ sockets.
|
|
||||||
}, [relayInputsKey]) |
|
||||||
|
|
||||||
const relayUrlsKey = relayInputsKey |
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true) |
|
||||||
const [events, setEvents] = useState<Event[]>([]) |
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT) |
|
||||||
const bottomRef = useRef<HTMLDivElement>(null) |
|
||||||
const fetchGenRef = useRef(0) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const gen = ++fetchGenRef.current |
|
||||||
let cancelled = false |
|
||||||
setLoading(true) |
|
||||||
setEvents([]) |
|
||||||
setShowCount(SHOW_COUNT) |
|
||||||
|
|
||||||
void (async () => { |
|
||||||
const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT) |
|
||||||
if (!cancelled && fetchGenRef.current === gen && cached.length > 0) { |
|
||||||
setEvents(cached) |
|
||||||
} |
|
||||||
try { |
|
||||||
const raw = await client.fetchEvents( |
|
||||||
relayUrls, |
|
||||||
{ kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT }, |
|
||||||
{ |
|
||||||
onevent: (e) => { |
|
||||||
if (cancelled || fetchGenRef.current !== gen) return |
|
||||||
if (e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e)) { |
|
||||||
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e])) |
|
||||||
} |
|
||||||
}, |
|
||||||
firstRelayResultGraceMs: false, |
|
||||||
globalTimeout: 12_000, |
|
||||||
eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS, |
|
||||||
cache: true |
|
||||||
} |
|
||||||
) |
|
||||||
if (cancelled || fetchGenRef.current !== gen) return |
|
||||||
const withRelay = raw.filter( |
|
||||||
(e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e) |
|
||||||
) |
|
||||||
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, ...withRelay])) |
|
||||||
} catch { |
|
||||||
if (!cancelled && fetchGenRef.current === gen) setEvents([]) |
|
||||||
} finally { |
|
||||||
if (!cancelled && fetchGenRef.current === gen) setLoading(false) |
|
||||||
} |
|
||||||
})() |
|
||||||
|
|
||||||
return () => { |
|
||||||
cancelled = true |
|
||||||
} |
|
||||||
}, [relayUrlsKey]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const options = { root: null, rootMargin: '120px', threshold: 0 } |
|
||||||
const observer = new IntersectionObserver((entries) => { |
|
||||||
if (entries[0]?.isIntersecting && showCount < events.length) { |
|
||||||
setShowCount((prev) => prev + SHOW_COUNT) |
|
||||||
} |
|
||||||
}, options) |
|
||||||
const el = bottomRef.current |
|
||||||
if (el) observer.observe(el) |
|
||||||
return () => { |
|
||||||
if (el) observer.unobserve(el) |
|
||||||
} |
|
||||||
}, [showCount, events.length]) |
|
||||||
|
|
||||||
const visible = events.slice(0, showCount) |
|
||||||
|
|
||||||
const groupedVisible = useMemo(() => { |
|
||||||
const groups = new Map<string, Event[]>() |
|
||||||
for (const event of visible) { |
|
||||||
const url = getRelayUrlFromRelayReviewEvent(event) |
|
||||||
if (!url || !isExploreBrowsableRelayUrl(url)) continue |
|
||||||
if (!groups.has(url)) groups.set(url, []) |
|
||||||
groups.get(url)!.push(event) |
|
||||||
} |
|
||||||
return Array.from(groups.entries()) |
|
||||||
}, [visible]) |
|
||||||
|
|
||||||
const showInitialSkeleton = loading && events.length === 0 |
|
||||||
const showEmptyAfterLoad = !loading && events.length === 0 |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="min-w-0 pt-1 pb-8"> |
|
||||||
{showInitialSkeleton ? ( |
|
||||||
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3"> |
|
||||||
{Array.from({ length: 6 }).map((_, i) => ( |
|
||||||
<Skeleton key={i} className="h-40 rounded-lg border md:border" /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) : showEmptyAfterLoad ? ( |
|
||||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t('no relays found')}</p> |
|
||||||
) : ( |
|
||||||
<> |
|
||||||
{groupedVisible.map(([relayUrl, relayEvents]) => ( |
|
||||||
<div key={relayUrl} className="mb-4"> |
|
||||||
<RelayGroupHeader url={relayUrl} reviewCount={relayEvents.length} /> |
|
||||||
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3 mt-2"> |
|
||||||
{relayEvents.map((event) => ( |
|
||||||
<RelayReviewCard |
|
||||||
key={event.id} |
|
||||||
event={event} |
|
||||||
showRelayInfo={false} |
|
||||||
className="border-b md:border md:border-border" |
|
||||||
/> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
))} |
|
||||||
{loading ? ( |
|
||||||
<div |
|
||||||
className="mt-4 grid min-w-0 gap-3 md:grid-cols-2 md:px-4" |
|
||||||
aria-busy="true" |
|
||||||
aria-live="polite" |
|
||||||
> |
|
||||||
{Array.from({ length: 4 }).map((_, i) => ( |
|
||||||
<Skeleton key={i} className="h-28 rounded-lg border md:border" /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
{showCount < events.length ? <div ref={bottomRef} className="h-4" aria-hidden /> : null} |
|
||||||
{!loading && showCount >= events.length ? ( |
|
||||||
<p className="mt-3 text-center text-sm text-muted-foreground">{t('no more relays')}</p> |
|
||||||
) : null} |
|
||||||
</> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,146 +0,0 @@ |
|||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { useFetchRelayInfo } from '@/hooks' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import relayInfoService from '@/services/relay-info.service' |
|
||||||
import { TAwesomeRelayCollection } from '@/types' |
|
||||||
import { useEffect, useState } from 'react' |
|
||||||
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
|
|
||||||
export default function Explore() { |
|
||||||
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null) |
|
||||||
const [error, setError] = useState<string | null>(null) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
let cancelled = false |
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null |
|
||||||
|
|
||||||
// Add timeout to prevent hanging forever
|
|
||||||
timeoutId = setTimeout(() => { |
|
||||||
if (!cancelled) { |
|
||||||
logger.warn('[Explore] Timeout loading relay collections after 10 seconds') |
|
||||||
setError('Timeout loading relay collections') |
|
||||||
setCollections([]) // Set empty array to stop showing skeletons
|
|
||||||
} |
|
||||||
}, 10000) // 10 second timeout
|
|
||||||
|
|
||||||
logger.debug('[Explore] Fetching awesome relay collections') |
|
||||||
relayInfoService.getAwesomeRelayCollections() |
|
||||||
.then((data) => { |
|
||||||
if (!cancelled) { |
|
||||||
if (timeoutId) clearTimeout(timeoutId) |
|
||||||
logger.debug('[Explore] Loaded collections', { count: data?.length || 0 }) |
|
||||||
setCollections(data || []) |
|
||||||
} |
|
||||||
}) |
|
||||||
.catch((err) => { |
|
||||||
if (!cancelled) { |
|
||||||
if (timeoutId) clearTimeout(timeoutId) |
|
||||||
logger.error('[Explore] Error loading collections', { error: err }) |
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load relay collections') |
|
||||||
setCollections([]) // Set empty array to stop showing skeletons
|
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
return () => { |
|
||||||
cancelled = true |
|
||||||
if (timeoutId) clearTimeout(timeoutId) |
|
||||||
} |
|
||||||
}, []) |
|
||||||
|
|
||||||
if (collections === null) { |
|
||||||
return ( |
|
||||||
<div> |
|
||||||
<div className="p-4 max-md:border-b"> |
|
||||||
<Skeleton className="h-6 w-20" /> |
|
||||||
</div> |
|
||||||
<div className="grid md:px-4 md:grid-cols-2 md:gap-2"> |
|
||||||
<RelaySimpleInfoSkeleton className="h-auto px-4 py-3 md:rounded-lg md:border" /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (error) { |
|
||||||
return ( |
|
||||||
<div className="p-4"> |
|
||||||
<div className="text-red-500 mb-2">Error: {error}</div> |
|
||||||
<button
|
|
||||||
onClick={() => { |
|
||||||
setCollections(null) |
|
||||||
setError(null) |
|
||||||
// Trigger reload
|
|
||||||
relayInfoService.getAwesomeRelayCollections() |
|
||||||
.then(setCollections) |
|
||||||
.catch((err) => { |
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load') |
|
||||||
setCollections([]) |
|
||||||
}) |
|
||||||
}} |
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" |
|
||||||
> |
|
||||||
Retry |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (collections.length === 0) { |
|
||||||
return ( |
|
||||||
<div className="p-4 text-center text-muted-foreground"> |
|
||||||
No relay collections available |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="min-w-0 w-full overflow-x-hidden space-y-6 pb-8"> |
|
||||||
{collections.map((collection) => ( |
|
||||||
<RelayCollection key={collection.id} collection={collection} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayCollection({ collection }: { collection: TAwesomeRelayCollection }) { |
|
||||||
return ( |
|
||||||
<div className="min-w-0"> |
|
||||||
<div className="px-4 pt-3 pb-3.5 text-2xl font-semibold max-md:border-b min-w-0 break-words"> |
|
||||||
{collection.name} |
|
||||||
</div> |
|
||||||
<div className="grid min-w-0 md:px-4 md:grid-cols-2 md:gap-3"> |
|
||||||
{collection.relays.map((url) => ( |
|
||||||
<RelayItem key={url} url={url} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayItem({ url }: { url: string }) { |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const { relayInfo, isFetching } = useFetchRelayInfo(url) |
|
||||||
|
|
||||||
if (isFetching) { |
|
||||||
return <RelaySimpleInfoSkeleton className="h-auto px-4 py-3 border-b md:rounded-lg md:border" /> |
|
||||||
} |
|
||||||
|
|
||||||
if (!relayInfo) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="min-w-0"> |
|
||||||
<RelaySimpleInfo |
|
||||||
key={relayInfo.url} |
|
||||||
className="clickable h-auto px-4 py-3 border-b md:rounded-lg md:border min-w-0" |
|
||||||
relayInfo={relayInfo} |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
navigateToRelay(toRelay(relayInfo.url)) |
|
||||||
}} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,94 +0,0 @@ |
|||||||
import { useFetchRelayInfo } from '@/hooks' |
|
||||||
import { isExploreBrowsableRelayUrl } from '@/lib/explore-popular-relays' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { useEffect, useRef, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo' |
|
||||||
|
|
||||||
const SHOW_COUNT = 10 |
|
||||||
|
|
||||||
export default function FollowingFavoriteRelayList() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const [loading, setLoading] = useState(true) |
|
||||||
const [relays, setRelays] = useState<[string, string[]][]>([]) |
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT) |
|
||||||
const bottomRef = useRef<HTMLDivElement>(null) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setLoading(true) |
|
||||||
|
|
||||||
const init = async () => { |
|
||||||
if (!pubkey) return |
|
||||||
|
|
||||||
const relays = ((await client.fetchFollowingFavoriteRelays(pubkey)) ?? []).filter(([url]) => |
|
||||||
isExploreBrowsableRelayUrl(url) |
|
||||||
) |
|
||||||
setRelays(relays) |
|
||||||
} |
|
||||||
init().finally(() => { |
|
||||||
setLoading(false) |
|
||||||
}) |
|
||||||
}, [pubkey]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const options = { |
|
||||||
root: null, |
|
||||||
rootMargin: '10px', |
|
||||||
threshold: 1 |
|
||||||
} |
|
||||||
|
|
||||||
const observerInstance = new IntersectionObserver((entries) => { |
|
||||||
if (entries[0].isIntersecting && showCount < relays.length) { |
|
||||||
setShowCount((prev) => prev + SHOW_COUNT) |
|
||||||
} |
|
||||||
}, options) |
|
||||||
|
|
||||||
const currentBottomRef = bottomRef.current |
|
||||||
if (currentBottomRef) { |
|
||||||
observerInstance.observe(currentBottomRef) |
|
||||||
} |
|
||||||
|
|
||||||
return () => { |
|
||||||
if (observerInstance && currentBottomRef) { |
|
||||||
observerInstance.unobserve(currentBottomRef) |
|
||||||
} |
|
||||||
} |
|
||||||
}, [showCount, relays]) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="pb-8"> |
|
||||||
{relays.slice(0, showCount).map(([url, users]) => ( |
|
||||||
<RelayItem key={url} url={url} users={users} /> |
|
||||||
))} |
|
||||||
{showCount < relays.length && <div ref={bottomRef} />} |
|
||||||
{loading && <RelaySimpleInfoSkeleton className="p-4" />} |
|
||||||
{!loading && ( |
|
||||||
<div className="text-center text-muted-foreground text-sm mt-2"> |
|
||||||
{relays.length === 0 ? t('no relays found') : t('no more relays')} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function RelayItem({ url, users }: { url: string; users: string[] }) { |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const { relayInfo } = useFetchRelayInfo(url) |
|
||||||
|
|
||||||
return ( |
|
||||||
<RelaySimpleInfo |
|
||||||
key={url} |
|
||||||
relayInfo={relayInfo} |
|
||||||
users={users} |
|
||||||
className="clickable p-4 border-b" |
|
||||||
onClick={(e) => { |
|
||||||
e.stopPropagation() |
|
||||||
navigateToRelay(toRelay(url)) |
|
||||||
}} |
|
||||||
/> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,110 +0,0 @@ |
|||||||
import { useSmartRelayNavigation } from '@/PageManager' |
|
||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { |
|
||||||
drawerMenuButtonClassName, |
|
||||||
drawerMenuContentClassName, |
|
||||||
drawerMenuScrollClassName |
|
||||||
} from '@/components/DrawerMenuItem' |
|
||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerOverlay } from '@/components/ui/drawer' |
|
||||||
import { |
|
||||||
DropdownMenu, |
|
||||||
DropdownMenuContent, |
|
||||||
DropdownMenuItem, |
|
||||||
DropdownMenuLabel, |
|
||||||
DropdownMenuSeparator, |
|
||||||
DropdownMenuTrigger |
|
||||||
} from '@/components/ui/dropdown-menu' |
|
||||||
import { useSeenOnRelays } from '@/hooks/useSeenOnRelays' |
|
||||||
import { toRelay } from '@/lib/link' |
|
||||||
import { simplifyUrl } from '@/lib/url' |
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
||||||
import { Server } from 'lucide-react' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import RelayIcon from '../RelayIcon' |
|
||||||
|
|
||||||
export default function SeenOnButton({ |
|
||||||
event, |
|
||||||
/** When set (home favorites feed), only list relays from the feed allowlist. */ |
|
||||||
allowedRelays |
|
||||||
}: { |
|
||||||
event: Event |
|
||||||
allowedRelays?: readonly string[] |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { isSmallScreen } = useScreenSize() |
|
||||||
const { navigateToRelay } = useSmartRelayNavigation() |
|
||||||
const relays = useSeenOnRelays(event.id, allowedRelays) |
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false) |
|
||||||
|
|
||||||
const trigger = ( |
|
||||||
<button |
|
||||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-primary pl-3 h-full" |
|
||||||
title={t('Seen on')} |
|
||||||
disabled={relays.length === 0} |
|
||||||
onClick={() => { |
|
||||||
if (isSmallScreen) { |
|
||||||
setIsDrawerOpen(true) |
|
||||||
} |
|
||||||
}} |
|
||||||
> |
|
||||||
<Server /> |
|
||||||
{relays.length > 0 ? <span className="text-sm">{relays.length}</span> : null} |
|
||||||
</button> |
|
||||||
) |
|
||||||
|
|
||||||
if (isSmallScreen) { |
|
||||||
return ( |
|
||||||
<> |
|
||||||
{trigger} |
|
||||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> |
|
||||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} /> |
|
||||||
<DrawerContent hideOverlay className={drawerMenuContentClassName}> |
|
||||||
<DrawerHeader className="sr-only"> |
|
||||||
<DrawerTitle>Seen on</DrawerTitle> |
|
||||||
</DrawerHeader> |
|
||||||
<div className={drawerMenuScrollClassName}> |
|
||||||
{relays.map((relay) => ( |
|
||||||
<Button |
|
||||||
className={drawerMenuButtonClassName} |
|
||||||
variant="ghost" |
|
||||||
key={relay} |
|
||||||
onClick={() => { |
|
||||||
setIsDrawerOpen(false) |
|
||||||
setTimeout(() => { |
|
||||||
navigateToRelay(toRelay(relay)) |
|
||||||
}, 50) |
|
||||||
}} |
|
||||||
> |
|
||||||
<RelayIcon url={relay} className="size-5 shrink-0" /> |
|
||||||
<span className="min-w-0 flex-1 text-left">{simplifyUrl(relay)}</span> |
|
||||||
</Button> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</DrawerContent> |
|
||||||
</Drawer> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
return ( |
|
||||||
<DropdownMenu> |
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> |
|
||||||
<DropdownMenuContent> |
|
||||||
<DropdownMenuLabel>{t('Seen on')}</DropdownMenuLabel> |
|
||||||
<DropdownMenuSeparator /> |
|
||||||
{relays.map((relay) => ( |
|
||||||
<DropdownMenuItem |
|
||||||
key={relay} |
|
||||||
onSelect={(e) => e.preventDefault()} |
|
||||||
onClick={() => navigateToRelay(toRelay(relay))} |
|
||||||
className="min-w-52" |
|
||||||
> |
|
||||||
<RelayIcon url={relay} /> |
|
||||||
{simplifyUrl(relay)} |
|
||||||
</DropdownMenuItem> |
|
||||||
))} |
|
||||||
</DropdownMenuContent> |
|
||||||
</DropdownMenu> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,106 +0,0 @@ |
|||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useNotificationThreadWatchOptional } from '@/providers/NotificationThreadWatchProvider' |
|
||||||
import { Bell, BellOff } from 'lucide-react' |
|
||||||
import type { Event } from 'nostr-tools' |
|
||||||
import { useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
|
|
||||||
export default function NotificationThreadWatchButtons({ event }: { event: Event }) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey, checkLogin, canManageIdentity } = useNostr() |
|
||||||
const watch = useNotificationThreadWatchOptional() |
|
||||||
const [busy, setBusy] = useState<'follow' | 'mute' | null>(null) |
|
||||||
|
|
||||||
// Show for your own notes too (e.g. notifications feed): you may still want follow/mute on that anchor.
|
|
||||||
if (!watch || !pubkey || !canManageIdentity) return null |
|
||||||
|
|
||||||
const followed = watch.isFollowedForNotifications(event) |
|
||||||
const muted = watch.isMutedForNotifications(event) |
|
||||||
|
|
||||||
const onFollow = (e: React.MouseEvent) => { |
|
||||||
e.stopPropagation() |
|
||||||
void checkLogin(async () => { |
|
||||||
setBusy('follow') |
|
||||||
try { |
|
||||||
if (followed) { |
|
||||||
const ok = await watch.unfollowThreadForNotifications(event) |
|
||||||
if (ok) { |
|
||||||
toast.success(t('Unfollowed thread notifications')) |
|
||||||
} else { |
|
||||||
toast.error(t('Thread notification list update failed')) |
|
||||||
} |
|
||||||
} else { |
|
||||||
await watch.followThreadForNotifications(event) |
|
||||||
toast.success(t('Following thread for notifications')) |
|
||||||
} |
|
||||||
} catch (err) { |
|
||||||
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) |
|
||||||
} finally { |
|
||||||
setBusy(null) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const onMute = (e: React.MouseEvent) => { |
|
||||||
e.stopPropagation() |
|
||||||
void checkLogin(async () => { |
|
||||||
setBusy('mute') |
|
||||||
try { |
|
||||||
if (muted) { |
|
||||||
const ok = await watch.unmuteThreadForNotifications(event) |
|
||||||
if (ok) { |
|
||||||
toast.success(t('Unmuted thread notifications')) |
|
||||||
} else { |
|
||||||
toast.error(t('Thread notification list update failed')) |
|
||||||
} |
|
||||||
} else { |
|
||||||
await watch.muteThreadForNotifications(event) |
|
||||||
toast.success(t('Muted thread for notifications')) |
|
||||||
} |
|
||||||
} catch (err) { |
|
||||||
toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) |
|
||||||
} finally { |
|
||||||
setBusy(null) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className={cn( |
|
||||||
'rounded p-1 transition-colors enabled:hover:bg-muted', |
|
||||||
followed |
|
||||||
? 'bg-primary/15 text-primary ring-1 ring-inset ring-primary/35' |
|
||||||
: 'text-muted-foreground' |
|
||||||
)} |
|
||||||
disabled={busy !== null} |
|
||||||
aria-pressed={followed} |
|
||||||
title={followed ? t('Unfollow thread notifications') : t('Follow this')} |
|
||||||
aria-label={followed ? t('Unfollow thread notifications') : t('Follow this')} |
|
||||||
onClick={onFollow} |
|
||||||
> |
|
||||||
<Bell className={cn('size-4', followed && 'fill-current')} /> |
|
||||||
</button> |
|
||||||
<button |
|
||||||
type="button" |
|
||||||
className={cn( |
|
||||||
'rounded p-1 transition-colors enabled:hover:bg-muted', |
|
||||||
muted |
|
||||||
? 'bg-destructive/15 text-destructive ring-1 ring-inset ring-destructive/30' |
|
||||||
: 'text-muted-foreground' |
|
||||||
)} |
|
||||||
disabled={busy !== null} |
|
||||||
aria-pressed={muted} |
|
||||||
title={muted ? t('Unmute thread notifications') : t('Mute this')} |
|
||||||
aria-label={muted ? t('Unmute thread notifications') : t('Mute this')} |
|
||||||
onClick={onMute} |
|
||||||
> |
|
||||||
<BellOff className={cn('size-4', muted && 'fill-current')} /> |
|
||||||
</button> |
|
||||||
</> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,211 +0,0 @@ |
|||||||
import NoteCard from '@/components/NoteCard' |
|
||||||
import { CALENDAR_EVENT_KINDS } from '@/constants' |
|
||||||
import { RefreshCw } from 'lucide-react' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef } from 'react' |
|
||||||
import { useProfileTimeline, type ProfileTimelineRelayUrlsBuilder } from '@/hooks/useProfileTimeline' |
|
||||||
|
|
||||||
const INITIAL_SHOW_COUNT = 25 |
|
||||||
const LOAD_MORE_COUNT = 25 |
|
||||||
|
|
||||||
interface ProfileTimelineProps { |
|
||||||
pubkey: string |
|
||||||
topSpace?: number |
|
||||||
searchQuery?: string |
|
||||||
kindFilter?: string |
|
||||||
onEventsChange?: (events: Event[]) => void |
|
||||||
kinds: number[] |
|
||||||
cacheKey: string |
|
||||||
filterPredicate?: (event: Event) => boolean |
|
||||||
relayUrlsBuilder?: ProfileTimelineRelayUrlsBuilder |
|
||||||
getKindLabel: (kindValue: string) => string |
|
||||||
refreshLabel: string |
|
||||||
emptyLabel: string |
|
||||||
emptySearchLabel: string |
|
||||||
} |
|
||||||
|
|
||||||
const ProfileTimeline = forwardRef< |
|
||||||
{ refresh: () => void; getEvents?: () => Event[] }, |
|
||||||
ProfileTimelineProps |
|
||||||
>( |
|
||||||
( |
|
||||||
{ |
|
||||||
pubkey, |
|
||||||
topSpace, |
|
||||||
searchQuery = '', |
|
||||||
kindFilter = 'all', |
|
||||||
onEventsChange, |
|
||||||
kinds: timelineKinds, |
|
||||||
cacheKey, |
|
||||||
filterPredicate, |
|
||||||
relayUrlsBuilder, |
|
||||||
getKindLabel, |
|
||||||
refreshLabel, |
|
||||||
emptyLabel, |
|
||||||
emptySearchLabel |
|
||||||
}, |
|
||||||
ref |
|
||||||
) => { |
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false) |
|
||||||
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT) |
|
||||||
const bottomRef = useRef<HTMLDivElement>(null) |
|
||||||
|
|
||||||
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({ |
|
||||||
pubkey, |
|
||||||
cacheKey, |
|
||||||
kinds: timelineKinds, |
|
||||||
limit: 200, |
|
||||||
filterPredicate, |
|
||||||
relayUrlsBuilder |
|
||||||
}) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
onEventsChange?.(timelineEvents) |
|
||||||
}, [timelineEvents, onEventsChange]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!isLoading) { |
|
||||||
setIsRefreshing(false) |
|
||||||
} |
|
||||||
}, [isLoading]) |
|
||||||
|
|
||||||
useImperativeHandle( |
|
||||||
ref, |
|
||||||
() => ({ |
|
||||||
refresh: () => { |
|
||||||
setIsRefreshing(true) |
|
||||||
refresh() |
|
||||||
}, |
|
||||||
getEvents: () => timelineEvents |
|
||||||
}), |
|
||||||
[refresh, timelineEvents] |
|
||||||
) |
|
||||||
|
|
||||||
const eventsFilteredByKind = useMemo(() => { |
|
||||||
if (kindFilter === 'all') { |
|
||||||
return timelineEvents |
|
||||||
} |
|
||||||
const kindNumber = parseInt(kindFilter, 10) |
|
||||||
if (Number.isNaN(kindNumber)) { |
|
||||||
return timelineEvents |
|
||||||
} |
|
||||||
return timelineEvents.filter((event) => |
|
||||||
event.kind === kindNumber || |
|
||||||
(CALENDAR_EVENT_KINDS.includes(kindNumber) && CALENDAR_EVENT_KINDS.includes(event.kind)) |
|
||||||
) |
|
||||||
}, [timelineEvents, kindFilter]) |
|
||||||
|
|
||||||
const filteredEvents = useMemo(() => { |
|
||||||
if (!searchQuery.trim()) { |
|
||||||
return eventsFilteredByKind |
|
||||||
} |
|
||||||
// Pre-compute lowercase query once
|
|
||||||
const query = searchQuery.toLowerCase().trim() |
|
||||||
// Pre-compute lowercase content and tags for each event to avoid repeated conversions
|
|
||||||
return eventsFilteredByKind.filter((event) => { |
|
||||||
const contentLower = event.content.toLowerCase() |
|
||||||
if (contentLower.includes(query)) return true |
|
||||||
// Only check tags if content doesn't match
|
|
||||||
return event.tags.some((tag) => { |
|
||||||
if (tag.length <= 1) return false |
|
||||||
const tagValue = tag[1] |
|
||||||
return tagValue && tagValue.toLowerCase().includes(query) |
|
||||||
}) |
|
||||||
}) |
|
||||||
}, [eventsFilteredByKind, searchQuery]) |
|
||||||
|
|
||||||
// Reset showCount when filters change
|
|
||||||
useEffect(() => { |
|
||||||
setShowCount(INITIAL_SHOW_COUNT) |
|
||||||
}, [searchQuery, kindFilter, pubkey]) |
|
||||||
|
|
||||||
// Pagination: slice to showCount for display
|
|
||||||
const displayedEvents = useMemo(() => { |
|
||||||
return filteredEvents.slice(0, showCount) |
|
||||||
}, [filteredEvents, showCount]) |
|
||||||
|
|
||||||
// IntersectionObserver for infinite scroll
|
|
||||||
useEffect(() => { |
|
||||||
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return |
|
||||||
|
|
||||||
const observer = new IntersectionObserver( |
|
||||||
(entries) => { |
|
||||||
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) { |
|
||||||
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length)) |
|
||||||
} |
|
||||||
}, |
|
||||||
{ threshold: 0.1 } |
|
||||||
) |
|
||||||
|
|
||||||
observer.observe(bottomRef.current) |
|
||||||
|
|
||||||
return () => { |
|
||||||
observer.disconnect() |
|
||||||
} |
|
||||||
}, [displayedEvents.length, filteredEvents.length, isLoading]) |
|
||||||
|
|
||||||
if (!pubkey) { |
|
||||||
return ( |
|
||||||
<div className="flex justify-center items-center py-8"> |
|
||||||
<div className="text-sm text-muted-foreground">No profile selected</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (isLoading && timelineEvents.length === 0) { |
|
||||||
return ( |
|
||||||
<div className="space-y-2"> |
|
||||||
{Array.from({ length: 3 }).map((_, i) => ( |
|
||||||
<Skeleton key={i} className="h-32 w-full" /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
if (!filteredEvents.length && !isLoading) { |
|
||||||
return ( |
|
||||||
<div className="flex justify-center items-center py-8"> |
|
||||||
<div className="text-sm text-muted-foreground"> |
|
||||||
{searchQuery.trim() ? emptySearchLabel : emptyLabel} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div style={{ marginTop: topSpace || 0 }}> |
|
||||||
{isRefreshing && ( |
|
||||||
<div |
|
||||||
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500" |
|
||||||
role="status" |
|
||||||
aria-live="polite" |
|
||||||
> |
|
||||||
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden /> |
|
||||||
{refreshLabel} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( |
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
|
||||||
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
<div className="space-y-2"> |
|
||||||
{displayedEvents.map((event) => ( |
|
||||||
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
{displayedEvents.length < filteredEvents.length && ( |
|
||||||
<div ref={bottomRef} className="h-10 flex items-center justify-center"> |
|
||||||
<div className="text-sm text-muted-foreground">Loading more...</div> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
) |
|
||||||
|
|
||||||
ProfileTimeline.displayName = 'ProfileTimeline' |
|
||||||
|
|
||||||
export default ProfileTimeline |
|
||||||
|
|
||||||
@ -1,184 +0,0 @@ |
|||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
|
||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export type TabDefinition = { |
|
||||||
value: string |
|
||||||
label: string |
|
||||||
icon?: ReactNode |
|
||||||
} |
|
||||||
|
|
||||||
export default function Tabs({ |
|
||||||
tabs, |
|
||||||
value, |
|
||||||
onTabChange, |
|
||||||
threshold = 800, |
|
||||||
options = null, |
|
||||||
/** When true, tabs live in layout chrome (subHeader) — no sticky offset or deep-scroll collapse. */ |
|
||||||
pinnedToLayout = false |
|
||||||
}: { |
|
||||||
tabs: TabDefinition[] |
|
||||||
value: string |
|
||||||
onTabChange?: (tab: string) => void |
|
||||||
threshold?: number |
|
||||||
options?: ReactNode |
|
||||||
pinnedToLayout?: boolean |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { deepBrowsing, lastScrollTop } = useDeepBrowsing() |
|
||||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]) |
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null) |
|
||||||
const tabsContainerRef = useRef<HTMLDivElement | null>(null) |
|
||||||
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0, top: 0 }) |
|
||||||
const isUpdatingRef = useRef(false) |
|
||||||
const lastStyleRef = useRef({ width: 0, left: 0, top: 0 }) |
|
||||||
|
|
||||||
const updateIndicatorPosition = useCallback(() => { |
|
||||||
// Prevent multiple simultaneous updates
|
|
||||||
if (isUpdatingRef.current) return |
|
||||||
|
|
||||||
const activeIndex = tabs.findIndex((tab) => tab.value === value) |
|
||||||
if (activeIndex >= 0 && tabRefs.current[activeIndex] && tabsContainerRef.current) { |
|
||||||
const activeTab = tabRefs.current[activeIndex] |
|
||||||
const tabsContainer = tabsContainerRef.current |
|
||||||
const { offsetWidth, offsetLeft, offsetHeight } = activeTab |
|
||||||
const padding = Math.min(24, Math.max(8, offsetWidth * 0.12)) |
|
||||||
|
|
||||||
// Get the container's top position relative to the viewport
|
|
||||||
const containerTop = tabsContainer.getBoundingClientRect().top |
|
||||||
const tabTop = activeTab.getBoundingClientRect().top |
|
||||||
|
|
||||||
// Calculate the indicator's top position relative to the container
|
|
||||||
// Position it at the bottom of the active tab's row
|
|
||||||
const relativeTop = tabTop - containerTop + offsetHeight |
|
||||||
const newWidth = offsetWidth - padding |
|
||||||
const newLeft = offsetLeft + padding / 2 |
|
||||||
const newTop = relativeTop - 4 // 4px for the indicator height (1px) + spacing
|
|
||||||
|
|
||||||
// Only update if values actually changed
|
|
||||||
if ( |
|
||||||
lastStyleRef.current.width !== newWidth || |
|
||||||
lastStyleRef.current.left !== newLeft || |
|
||||||
lastStyleRef.current.top !== newTop |
|
||||||
) { |
|
||||||
isUpdatingRef.current = true |
|
||||||
lastStyleRef.current = { width: newWidth, left: newLeft, top: newTop } |
|
||||||
|
|
||||||
setIndicatorStyle({ width: newWidth, left: newLeft, top: newTop }) |
|
||||||
|
|
||||||
// Reset flag after state update completes
|
|
||||||
requestAnimationFrame(() => { |
|
||||||
isUpdatingRef.current = false |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
}, [tabs, value]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const animationId = requestAnimationFrame(() => { |
|
||||||
updateIndicatorPosition() |
|
||||||
}) |
|
||||||
|
|
||||||
return () => { |
|
||||||
cancelAnimationFrame(animationId) |
|
||||||
} |
|
||||||
}, [updateIndicatorPosition]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!containerRef.current || !tabsContainerRef.current) return |
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => { |
|
||||||
requestAnimationFrame(() => { |
|
||||||
updateIndicatorPosition() |
|
||||||
}) |
|
||||||
}) |
|
||||||
|
|
||||||
const intersectionObserver = new IntersectionObserver( |
|
||||||
(entries) => { |
|
||||||
entries.forEach((entry) => { |
|
||||||
if (entry.isIntersecting) { |
|
||||||
requestAnimationFrame(() => { |
|
||||||
updateIndicatorPosition() |
|
||||||
}) |
|
||||||
} |
|
||||||
}) |
|
||||||
}, |
|
||||||
{ threshold: 0 } |
|
||||||
) |
|
||||||
|
|
||||||
intersectionObserver.observe(containerRef.current) |
|
||||||
|
|
||||||
tabRefs.current.forEach((tab) => { |
|
||||||
if (tab) resizeObserver.observe(tab) |
|
||||||
}) |
|
||||||
|
|
||||||
if (tabsContainerRef.current) { |
|
||||||
resizeObserver.observe(tabsContainerRef.current) |
|
||||||
} |
|
||||||
|
|
||||||
return () => { |
|
||||||
resizeObserver.disconnect() |
|
||||||
intersectionObserver.disconnect() |
|
||||||
} |
|
||||||
}, [updateIndicatorPosition]) |
|
||||||
|
|
||||||
const collapseOnDeepBrowse = |
|
||||||
!pinnedToLayout && deepBrowsing && lastScrollTop > threshold |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
ref={containerRef} |
|
||||||
className={cn( |
|
||||||
// Single row: flex-nowrap (Firefox grid + w-max/min-w-full wrapped the tool column). Tabs scroll in flex-1.
|
|
||||||
'flex w-full min-w-0 flex-nowrap items-end gap-0.5 border-b bg-background px-1 sm:gap-1', |
|
||||||
pinnedToLayout |
|
||||||
? 'z-10' |
|
||||||
: 'sticky top-12 z-30 transition-transform', |
|
||||||
collapseOnDeepBrowse ? '-translate-y-[calc(100%+12rem)]' : '' |
|
||||||
)} |
|
||||||
> |
|
||||||
<div className="min-h-0 min-w-0 flex-1 basis-0 overflow-x-auto overscroll-x-contain scrollbar-hide"> |
|
||||||
<div |
|
||||||
ref={tabsContainerRef} |
|
||||||
role="tablist" |
|
||||||
className="relative inline-flex w-max max-w-none gap-0.5 sm:gap-1" |
|
||||||
> |
|
||||||
{tabs.map((tab, index) => ( |
|
||||||
<button |
|
||||||
key={tab.value} |
|
||||||
type="button" |
|
||||||
role="tab" |
|
||||||
aria-selected={value === tab.value} |
|
||||||
ref={(el) => { |
|
||||||
tabRefs.current[index] = el |
|
||||||
}} |
|
||||||
className={cn( |
|
||||||
'flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border-0 bg-transparent px-1.5 py-1.5 text-center text-xs font-semibold shadow-none transition-colors sm:gap-2 sm:px-3 sm:py-2 sm:text-sm md:px-5 md:text-base', |
|
||||||
'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', |
|
||||||
value === tab.value ? 'text-foreground' : 'text-muted-foreground' |
|
||||||
)} |
|
||||||
onClick={() => { |
|
||||||
onTabChange?.(tab.value) |
|
||||||
}} |
|
||||||
> |
|
||||||
{tab.icon && <span className="shrink-0">{tab.icon}</span>} |
|
||||||
{t(tab.label)} |
|
||||||
</button> |
|
||||||
))} |
|
||||||
<div |
|
||||||
className="absolute h-1 rounded-full bg-primary transition-all duration-500" |
|
||||||
style={{ |
|
||||||
width: `${indicatorStyle.width}px`, |
|
||||||
left: `${indicatorStyle.left}px`, |
|
||||||
top: `${indicatorStyle.top}px` |
|
||||||
}} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{options ? ( |
|
||||||
<div className="flex shrink-0 flex-nowrap items-center gap-0 py-1 pl-0.5">{options}</div> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,75 +0,0 @@ |
|||||||
import { SEARCH_QUERY_DEBOUNCE_MS } from '@/constants' |
|
||||||
import { Input } from '@/components/ui/input' |
|
||||||
import { Search, X } from 'lucide-react' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useState, useEffect } from 'react' |
|
||||||
|
|
||||||
interface ProfileSearchBarProps { |
|
||||||
onSearch: (query: string) => void |
|
||||||
placeholder?: string |
|
||||||
className?: string |
|
||||||
disabled?: boolean |
|
||||||
} |
|
||||||
|
|
||||||
export default function ProfileSearchBar({
|
|
||||||
onSearch,
|
|
||||||
placeholder = "Search...", |
|
||||||
className, |
|
||||||
disabled = false |
|
||||||
}: ProfileSearchBarProps) { |
|
||||||
const [query, setQuery] = useState('') |
|
||||||
const [isFocused, setIsFocused] = useState(false) |
|
||||||
|
|
||||||
// Debounce search to avoid too many calls
|
|
||||||
useEffect(() => { |
|
||||||
const timer = setTimeout(() => { |
|
||||||
onSearch(query) |
|
||||||
}, SEARCH_QUERY_DEBOUNCE_MS) |
|
||||||
|
|
||||||
return () => clearTimeout(timer) |
|
||||||
}, [query, onSearch]) |
|
||||||
|
|
||||||
const handleClear = () => { |
|
||||||
setQuery('') |
|
||||||
onSearch('') |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className={cn('relative flex items-center', className)}> |
|
||||||
<div className="relative flex-1"> |
|
||||||
<Search
|
|
||||||
className={cn( |
|
||||||
'absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors', |
|
||||||
isFocused && 'text-green-500' |
|
||||||
)}
|
|
||||||
/> |
|
||||||
<Input |
|
||||||
type="text" |
|
||||||
placeholder={placeholder} |
|
||||||
value={query} |
|
||||||
onChange={(e) => setQuery(e.target.value)} |
|
||||||
onFocus={() => setIsFocused(true)} |
|
||||||
onBlur={() => setIsFocused(false)} |
|
||||||
disabled={disabled} |
|
||||||
className={cn( |
|
||||||
'pl-10 pr-10 h-10', |
|
||||||
'border-2 border-muted-foreground/20 focus:border-green-500', |
|
||||||
'bg-background text-foreground', |
|
||||||
'transition-all duration-200', |
|
||||||
'rounded-lg', |
|
||||||
disabled && 'opacity-50 cursor-not-allowed' |
|
||||||
)} |
|
||||||
/> |
|
||||||
{query && ( |
|
||||||
<button |
|
||||||
onClick={handleClear} |
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" |
|
||||||
disabled={disabled} |
|
||||||
> |
|
||||||
<X className="h-4 w-4" /> |
|
||||||
</button> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -1,19 +0,0 @@ |
|||||||
import { fetchBtcUsdRate } from '@/lib/btc-usd-rate' |
|
||||||
import { useEffect, useState } from 'react' |
|
||||||
|
|
||||||
/** BTC/USD spot for zap amount hints (null while loading or if fetch failed). */ |
|
||||||
export function useBtcUsdRate() { |
|
||||||
const [btcUsd, setBtcUsd] = useState<number | null>(null) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
let cancelled = false |
|
||||||
fetchBtcUsdRate().then((rate) => { |
|
||||||
if (!cancelled) setBtcUsd(rate) |
|
||||||
}) |
|
||||||
return () => { |
|
||||||
cancelled = true |
|
||||||
} |
|
||||||
}, []) |
|
||||||
|
|
||||||
return btcUsd |
|
||||||
} |
|
||||||
@ -1,51 +0,0 @@ |
|||||||
import { canonicalRelaySessionKey, normalizeAnyRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' |
|
||||||
import client from '@/services/client.service' |
|
||||||
import { useEffect, useMemo, useState } from 'react' |
|
||||||
|
|
||||||
const POLL_MS = 1500 |
|
||||||
|
|
||||||
function normalizeRelayRowUrl(raw: string): string { |
|
||||||
const t = raw.trim() |
|
||||||
if (/^https?:\/\//i.test(t)) return normalizeHttpRelayUrl(t) || t |
|
||||||
return normalizeAnyRelayUrl(t) || t |
|
||||||
} |
|
||||||
|
|
||||||
function rowCanon(url: string): string { |
|
||||||
return (canonicalRelaySessionKey(url) || normalizeRelayRowUrl(url)).trim().toLowerCase() |
|
||||||
} |
|
||||||
|
|
||||||
export type TRelayConnectionRow = { |
|
||||||
url: string |
|
||||||
/** WebSocket open in the pool. */ |
|
||||||
connected: boolean |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Relays for “active relays” UI: only relays with an open WebSocket in the pool right now. |
|
||||||
*/ |
|
||||||
export function useRelayConnectionRows(): { |
|
||||||
rows: TRelayConnectionRow[] |
|
||||||
connectedCount: number |
|
||||||
} { |
|
||||||
const [connectedUrls, setConnectedUrls] = useState<string[]>(() => client.getConnectedRelayUrls()) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const tick = () => setConnectedUrls(client.getConnectedRelayUrls()) |
|
||||||
tick() |
|
||||||
const id = window.setInterval(tick, POLL_MS) |
|
||||||
return () => clearInterval(id) |
|
||||||
}, []) |
|
||||||
|
|
||||||
return useMemo(() => { |
|
||||||
const seen = new Set<string>() |
|
||||||
const rows: TRelayConnectionRow[] = [] |
|
||||||
for (const raw of connectedUrls) { |
|
||||||
const url = normalizeRelayRowUrl(raw) |
|
||||||
const k = rowCanon(url) |
|
||||||
if (!k || seen.has(k)) continue |
|
||||||
seen.add(k) |
|
||||||
rows.push({ url, connected: true }) |
|
||||||
} |
|
||||||
return { rows, connectedCount: rows.length } |
|
||||||
}, [connectedUrls]) |
|
||||||
} |
|
||||||
@ -0,0 +1,13 @@ |
|||||||
|
/** How long after the last scroll event we treat the user as still scrolling. */ |
||||||
|
const SCROLL_ACTIVITY_MS = 450 |
||||||
|
|
||||||
|
let scrollingUntil = 0 |
||||||
|
|
||||||
|
export const scrollActivity = { |
||||||
|
markScrolling() { |
||||||
|
scrollingUntil = Date.now() + SCROLL_ACTIVITY_MS |
||||||
|
}, |
||||||
|
get isActive() { |
||||||
|
return Date.now() < scrollingUntil |
||||||
|
} |
||||||
|
} |
||||||
@ -1,21 +0,0 @@ |
|||||||
import { Label } from '@/components/ui/label' |
|
||||||
import { Switch } from '@/components/ui/switch' |
|
||||||
import { useZap } from '@/providers/ZapProvider' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
|
|
||||||
export default function QuickZapSwitch() { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { quickZap, updateQuickZap } = useZap() |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="w-full flex justify-between items-center"> |
|
||||||
<Label htmlFor="quick-zap-switch"> |
|
||||||
<div className="text-base font-medium">{t('Quick zap')}</div> |
|
||||||
<div className="text-muted-foreground text-sm"> |
|
||||||
{t('If enabled, you can zap with a single click. Click and hold for custom amounts')} |
|
||||||
</div> |
|
||||||
</Label> |
|
||||||
<Switch id="quick-zap-switch" checked={quickZap} onCheckedChange={updateQuickZap} /> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue