24 changed files with 179 additions and 1702 deletions
@ -1,34 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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