Browse Source

Fix PWA

imwald
Silberengel 2 weeks ago
parent
commit
71a62772a3
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 3
      src/components/Explore/ExploreFavoriteRelays.tsx
  4. 8
      src/components/FavoriteRelaysSetting/AddNewRelay.tsx
  5. 10
      src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx
  6. 16
      src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx
  7. 39
      src/components/FavoriteRelaysSetting/RelayItem.tsx
  8. 18
      src/components/FavoriteRelaysSetting/RelaySet.tsx
  9. 35
      src/components/FavoriteRelaysSetting/RelayUrl.tsx
  10. 2
      src/components/FavoriteRelaysSetting/index.tsx
  11. 13
      src/components/FeedRelaysIconRow/index.tsx
  12. 1
      src/components/NormalFeed/index.tsx
  13. 16
      src/components/NoteList/index.tsx
  14. 15
      src/components/Tabs/index.tsx
  15. 7
      src/lib/favorites-feed-relays.ts
  16. 42
      src/lib/home-feed-relays.ts
  17. 14
      src/lib/relay-url-priority.test.ts
  18. 42
      src/lib/wisp-trending-relay.test.ts
  19. 44
      src/lib/wisp-trending-relay.ts
  20. 7
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  21. 7
      src/pages/secondary/RelaySettingsPage/index.tsx
  22. 96
      src/providers/FavoriteRelaysProvider.tsx
  23. 9
      src/providers/FeedProvider.test.ts
  24. 55
      src/providers/FeedProvider.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "imwald",
"version": "23.17.3",
"version": "23.17.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.17.3",
"version": "23.17.4",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "imwald",
"version": "23.17.3",
"version": "23.17.4",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true,
"type": "module",

3
src/components/Explore/ExploreFavoriteRelays.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
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'
@ -70,7 +71,7 @@ export default function ExploreFavoriteRelays() { @@ -70,7 +71,7 @@ export default function ExploreFavoriteRelays() {
)
const { urls, usingDefaults } = useMemo(() => {
const visible = favoriteRelays.filter((r) => {
const visible = ensureTrendingInFavoriteRelayList(favoriteRelays).filter((r) => {
const k = normalizeUrl(r) || r
return k && !blockedSet.has(k)
})

8
src/components/FavoriteRelaysSetting/AddNewRelay.tsx

@ -53,19 +53,19 @@ export default function AddNewRelay() { @@ -53,19 +53,19 @@ export default function AddNewRelay() {
return (
<div className="space-y-1">
<div className="flex gap-2 items-center">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Input
placeholder={t('Add a new relay')}
value={input}
onChange={handleNewRelayInputChange}
onKeyDown={handleNewRelayInputKeyDown}
className={errorMsg ? 'border-destructive' : ''}
className={`min-w-0 flex-1 ${errorMsg ? 'border-destructive' : ''}`}
/>
<Button onClick={saveRelay} disabled={isLoading || !input.trim()}>
<Button className="shrink-0 sm:w-auto" onClick={saveRelay} disabled={isLoading || !input.trim()}>
{isLoading ? t('Adding...') : t('Add')}
</Button>
</div>
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
{errorMsg && <div className="text-destructive text-sm">{errorMsg}</div>}
</div>
)
}

10
src/components/FavoriteRelaysSetting/BlockedRelayItem.tsx

@ -29,19 +29,19 @@ export default function BlockedRelayItem({ relay }: { relay: string }) { @@ -29,19 +29,19 @@ export default function BlockedRelayItem({ relay }: { relay: string }) {
return (
<div
className="relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none"
className="relative group clickable flex min-w-0 items-start gap-2 rounded-lg border p-2 select-none sm:items-center"
onClick={() => push(toRelay(relay))}
>
<div className="flex items-center gap-2 flex-1">
<RelayIcon url={relay} skipRelayInfoFetch />
<div className="flex-1 w-0 truncate font-semibold">{relay}</div>
<div className="flex min-w-0 flex-1 items-start gap-2">
<RelayIcon url={relay} skipRelayInfoFetch className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 break-all text-sm font-semibold leading-snug">{relay}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleUnblock}
disabled={isLoading}
className="h-8 w-8 p-0"
className="h-8 w-8 shrink-0 p-0"
>
{isLoading ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />

16
src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx

@ -16,6 +16,9 @@ import { @@ -16,6 +16,9 @@ import {
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import RelayItem from './RelayItem'
@ -24,8 +27,11 @@ export default function FavoriteRelayList() { @@ -24,8 +27,11 @@ export default function FavoriteRelayList() {
const { pubkey } = useNostr()
const { favoriteRelays, blockedRelays, reorderFavoriteRelays, favoriteRelaysFromPublishedList } =
useFavoriteRelays()
// Show all relays including blocked ones (they'll be marked visually)
const displayRelays = useMemo(
() => ensureTrendingInFavoriteRelayList(favoriteRelays),
[favoriteRelays]
)
const sensors = useSensors(
useSensor(PointerSensor),
@ -66,9 +72,9 @@ export default function FavoriteRelayList() { @@ -66,9 +72,9 @@ export default function FavoriteRelayList() {
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={favoriteRelays} strategy={verticalListSortingStrategy}>
<div className="grid gap-2">
{favoriteRelays.map((relay) => (
<SortableContext items={displayRelays} strategy={verticalListSortingStrategy}>
<div className="grid min-w-0 gap-2">
{displayRelays.map((relay) => (
<RelayItem key={relay} relay={relay} isBlocked={blockedRelays.includes(relay)} />
))}
</div>

39
src/components/FavoriteRelaysSetting/RelayItem.tsx

@ -22,32 +22,33 @@ export default function RelayItem({ relay, isBlocked = false }: { relay: string; @@ -22,32 +22,33 @@ export default function RelayItem({ relay, isBlocked = false }: { relay: string;
return (
<div
className={`relative group clickable flex gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none ${isBlocked ? 'opacity-60' : ''}`}
className={`relative group clickable flex min-w-0 gap-1 rounded-lg border p-2 select-none sm:gap-2 ${isBlocked ? 'opacity-60' : ''}`}
ref={setNodeRef}
style={style}
onClick={() => push(toRelay(relay))}
>
<div className="flex items-center gap-1 flex-1">
<div
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex gap-2 items-center flex-1 min-w-0">
<RelayIcon url={relay} skipRelayInfoFetch={isBlocked} />
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-1 truncate font-semibold">{relay}</div>
{isBlocked && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
({t('blocked')})
</span>
)}
<div
className="shrink-0 cursor-grab touch-none rounded p-2 hover:bg-muted active:cursor-grabbing"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-2">
<RelayIcon url={relay} skipRelayInfoFetch={isBlocked} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="break-all text-sm font-semibold leading-snug">{relay}</div>
{isBlocked ? (
<span className="mt-0.5 block text-xs text-muted-foreground">({t('blocked')})</span>
) : null}
</div>
</div>
<div className="shrink-0 self-end sm:self-center" onClick={(e) => e.stopPropagation()}>
<SaveRelayDropdownMenu urls={[relay]} />
</div>
</div>
<SaveRelayDropdownMenu urls={[relay]} />
</div>
)
}

18
src/components/FavoriteRelaysSetting/RelaySet.tsx

@ -42,25 +42,25 @@ export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) { @@ -42,25 +42,25 @@ export default function RelaySet({ relaySet }: { relaySet: TRelaySet }) {
}
return (
<div ref={setNodeRef} style={style} className="relative group">
<div className="w-full border rounded-lg px-2 py-2.5">
<div className="flex justify-between items-center">
<div className="flex items-center">
<div ref={setNodeRef} style={style} className="group relative min-w-0">
<div className="w-full min-w-0 rounded-lg border px-2 py-2.5">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center">
<div
className="cursor-grab active:cursor-grabbing p-2 hover:bg-muted rounded touch-none"
className="cursor-grab touch-none rounded p-2 hover:bg-muted active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="size-4 text-muted-foreground" />
</div>
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
<FolderClosed className="size-4" />
</div>
<RelaySetName relaySet={relaySet} />
</div>
</div>
<div className="flex gap-1">
<div className="flex shrink-0 items-center justify-end gap-1 self-end sm:self-auto">
<RelayUrlsExpandToggle relaySetId={relaySet.id}>
{t('n relays', { n: relaySet.relayUrls.length })}
</RelayUrlsExpandToggle>
@ -111,7 +111,7 @@ function RelaySetName({ relaySet }: { relaySet: TRelaySet }) { @@ -111,7 +111,7 @@ function RelaySetName({ relaySet }: { relaySet: TRelaySet }) {
</Button>
</div>
) : (
<div className="h-8 font-semibold flex items-center select-none">{relaySet.name}</div>
<div className="flex min-h-8 min-w-0 items-center break-words font-semibold select-none">{relaySet.name}</div>
)
}

35
src/components/FavoriteRelaysSetting/RelayUrl.tsx

@ -81,16 +81,20 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) { @@ -81,16 +81,20 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
<RelayUrl key={index} url={url} onRemove={() => removeRelayUrl(url)} />
))}
</div>
<div className="mt-2 flex gap-2">
<div className="mt-2 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Input
className={newRelayUrlError ? 'border-destructive' : ''}
className={`min-w-0 flex-1 ${newRelayUrlError ? 'border-destructive' : ''}`}
placeholder={t('Add a new relay')}
value={newRelayUrl}
onKeyDown={handleRelayUrlInputKeyDown}
onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl}
/>
<Button onClick={saveNewRelayUrl} disabled={isLoading || !newRelayUrl.trim()}>
<Button
className="shrink-0 sm:w-auto"
onClick={saveNewRelayUrl}
disabled={isLoading || !newRelayUrl.trim()}
>
{isLoading ? t('Adding...') : t('Add')}
</Button>
</div>
@ -103,21 +107,22 @@ function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) { @@ -103,21 +107,22 @@ function RelayUrl({ url, onRemove }: { url: string; onRemove: () => void }) {
const { push } = useSecondaryPage()
return (
<div className="flex items-center justify-between pl-1 pr-3">
<div
className="flex gap-3 items-center flex-1 w-0 cursor-pointer hover:bg-muted rounded px-2 py-1 -mx-2 -my-1"
<div className="flex min-w-0 items-start gap-2 py-0.5 pl-1 pr-1">
<div
className="-mx-2 -my-1 flex min-w-0 flex-1 cursor-pointer items-start gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => push(toRelay(url))}
>
<RelayIcon url={url} className="w-4 h-4" iconSize={10} />
<div className="text-muted-foreground text-sm truncate">{url}</div>
</div>
<div className="shrink-0">
<CircleX
size={16}
onClick={onRemove}
className="text-muted-foreground hover:text-destructive cursor-pointer"
/>
<RelayIcon url={url} className="mt-0.5 h-4 w-4 shrink-0" iconSize={10} />
<div className="min-w-0 flex-1 break-all text-sm leading-snug text-muted-foreground">{url}</div>
</div>
<button
type="button"
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
aria-label="Remove relay"
>
<CircleX size={16} />
</button>
</div>
)
}

2
src/components/FavoriteRelaysSetting/index.tsx

@ -9,7 +9,7 @@ import RelaySetList from './RelaySetList' @@ -9,7 +9,7 @@ import RelaySetList from './RelaySetList'
export default function FavoriteRelaysSetting() {
return (
<RelaySetsSettingComponentProvider>
<div className="space-y-4">
<div className="min-w-0 w-full space-y-4">
<RelaySetList />
<AddNewRelaySet />
<FavoriteRelayList />

13
src/components/FeedRelaysIconRow/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import RelayIcon from '@/components/RelayIcon'
import { Button } from '@/components/ui/button'
import { toRelay } from '@/lib/link'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useSmartRelayNavigation } from '@/PageManager'
@ -25,6 +26,10 @@ export function FeedRelaysIconRow({ @@ -25,6 +26,10 @@ export function FeedRelaysIconRow({
>
{urls.map((url) => {
const label = simplifyUrl(url)
const isTrending = isWispTrendingNotesRelayUrl(url)
const title = isTrending
? t('Trending on Nostr', { defaultValue: 'Trending on Nostr' })
: label
return (
<Button
key={url}
@ -32,8 +37,12 @@ export function FeedRelaysIconRow({ @@ -32,8 +37,12 @@ export function FeedRelaysIconRow({
variant="ghost"
size="sm"
className="h-7 w-7 min-h-7 min-w-7 shrink-0 rounded-full p-0 hover:bg-muted/80"
title={label}
aria-label={t('Open relay feed', { relay: label, defaultValue: `Open ${label} feed` })}
title={title}
aria-label={
isTrending
? t('Open trending feed', { defaultValue: 'Open trending feed' })
: t('Open relay feed', { relay: label, defaultValue: `Open ${label} feed` })
}
onClick={() => navigateToRelay(toRelay(url))}
>
<RelayIcon url={url} className="h-6 w-6" iconSize={12} />

1
src/components/NormalFeed/index.tsx

@ -325,6 +325,7 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -325,6 +325,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
tabs={tabs}
onTabChange={handleListModeChange}
options={kindRowOptions}
pinnedToLayout={isMainFeed && !!setSubHeader}
/>
)
}, [

16
src/components/NoteList/index.tsx

@ -111,7 +111,10 @@ import { @@ -111,7 +111,10 @@ import {
stableFeedKindKey
} from '@/features/feed/descriptor'
import { mapNoteListSubRequestsForTimeline } from '@/features/feed/note-list-requests'
import { stripNostrLandAggrFromTimelineSubRequests } from '@/lib/home-feed-relays'
import {
ensureHomeFeedTrendingRelay,
stripNostrLandAggrFromTimelineSubRequests
} from '@/lib/home-feed-relays'
import { createFetchEventsFeedRuntimeLoader } from '@/features/feed/client-loader'
import { FeedRuntime } from '@/features/feed/runtime'
import { buildFeedDiagnosticsSnapshot, logFeedDiagnostics } from '@/features/feed/diagnostics'
@ -1055,10 +1058,13 @@ const NoteList = forwardRef( @@ -1055,10 +1058,13 @@ const NoteList = forwardRef(
// Memoize subRequests serialization to avoid expensive JSON.stringify on every render
const subRequestsKey = useMemo(() => legacyFeedSubscriptionKey(subRequests), [subRequests])
const feedRelayUrls = useMemo(
() => uniqueRelayUrlsFromSubRequests(subRequests),
[subRequestsKey]
)
const feedRelayUrls = useMemo(() => {
const urls = uniqueRelayUrlsFromSubRequests(subRequests)
if (feedSubscriptionKey === 'home-all-favorites') {
return ensureHomeFeedTrendingRelay(urls)
}
return urls
}, [subRequestsKey, feedSubscriptionKey])
const feedAttestedSuperchatIds = useFeedAttestedSuperchatIds(feedRelayUrls)

15
src/components/Tabs/index.tsx

@ -14,13 +14,16 @@ export default function Tabs({ @@ -14,13 +14,16 @@ export default function Tabs({
value,
onTabChange,
threshold = 800,
options = null
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()
@ -120,12 +123,18 @@ export default function Tabs({ @@ -120,12 +123,18 @@ export default function Tabs({
}
}, [updateIndicatorPosition])
const collapseOnDeepBrowse =
!pinnedToLayout && deepBrowsing && lastScrollTop > threshold
return (
<div
ref={containerRef}
className={cn(
'sticky top-12 z-30 flex w-full min-w-0 items-end justify-between border-b bg-background px-1 transition-transform',
deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : ''
'flex w-full min-w-0 items-end justify-between border-b bg-background px-1',
pinnedToLayout
? 'z-10'
: 'sticky top-12 z-30 transition-transform',
collapseOnDeepBrowse ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>
<div className="min-w-0 w-0 flex-1">

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

@ -16,6 +16,7 @@ import { @@ -16,6 +16,7 @@ import {
MAX_REQ_RELAY_URLS,
relayUrlsLocalsFirst
} from '@/lib/relay-url-priority'
import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay'
import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy'
import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize'
import { relaySessionStrikes } from '@/lib/relay-strikes'
@ -78,9 +79,11 @@ export function getFavoritesFeedRelayUrls( @@ -78,9 +79,11 @@ export function getFavoritesFeedRelayUrls(
const k = normalizeAnyRelayUrl(r) || r
return k && !isBlockedRelay(r, blockedRelays)
})
const base = visible.length > 0 ? visible : useGlobalFavoriteDefaults ? DEFAULT_FAVORITE_RELAYS : []
const base =
visible.length > 0 ? visible : useGlobalFavoriteDefaults ? [...DEFAULT_FAVORITE_RELAYS] : []
const withTrending = ensureTrendingInFavoriteRelayList(base, { forFeed: true })
return feedRelayPolicyUrls(
[{ source: 'favorites', urls: base }],
[{ source: 'favorites', urls: withTrending }],
{
operation: 'favorites-feed',
blockedRelays,

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

@ -2,7 +2,10 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' @@ -2,7 +2,10 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { stripNostrLandAggrFromRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import {
ensureTrendingInFavoriteRelayList,
isWispTrendingNotesRelayUrl
} from '@/lib/wisp-trending-relay'
export { stripNostrLandAggrFromRelayUrls }
@ -24,6 +27,11 @@ export function stripNostrLandAggrFromTimelineSubRequests<T extends { urls: stri @@ -24,6 +27,11 @@ export function stripNostrLandAggrFromTimelineSubRequests<T extends { urls: stri
})) as T[]
}
/** Home Notes / Replies / Gallery: always include the Wisp trending path relay (deduped). */
export function ensureHomeFeedTrendingRelay(urls: readonly string[]): string[] {
return ensureTrendingInFavoriteRelayList(urls, { forFeed: true })
}
export function buildAllFavoritesFeedRelayUrls(
favoriteRelays: string[],
blockedRelays: string[],
@ -33,22 +41,24 @@ export function buildAllFavoritesFeedRelayUrls( @@ -33,22 +41,24 @@ export function buildAllFavoritesFeedRelayUrls(
const extras = isMetadataRelaysOnlyPolicyActive()
? extraFeedRelayUrls.filter((u) => !isWispTrendingNotesRelayUrl(u))
: extraFeedRelayUrls
return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
return ensureHomeFeedTrendingRelay(
stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
{
source: 'favorites',
urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults)
},
{ source: 'fallback', urls: extras }
],
{
source: 'favorites',
urls: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults)
},
{ source: 'fallback', urls: extras }
],
{
operation: 'favorites-feed',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
operation: 'favorites-feed',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
)
)
)
}

14
src/lib/relay-url-priority.test.ts

@ -88,7 +88,19 @@ describe('nostr.land aggregator feed relay policy', () => { @@ -88,7 +88,19 @@ describe('nostr.land aggregator feed relay policy', () => {
[]
)
expect(out).toEqual(['wss://relay.example.com/'])
expect(out).toEqual([
'wss://relay.example.com/',
'wss://feeds.nostrarchives.com/notes/trending/reactions/today'
])
})
it('uses DEFAULT_FAVORITE_RELAYS when favorites are empty and global defaults apply', () => {
const out = getFavoritesFeedRelayUrls([], [], true)
expect(out).toEqual([
'wss://theforest.nostr1.com/',
'wss://nostr.land/',
'wss://feeds.nostrarchives.com/notes/trending/reactions/today'
])
})
})

42
src/lib/wisp-trending-relay.test.ts

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
buildWispTrendingNotesRelayUrl,
ensureTrendingInFavoriteRelayList,
isWispTrendingNotesRelayUrl
} from '@/lib/wisp-trending-relay'
import { setViewerPersonalRelayKeys } from '@/lib/read-only-relay-personal'
describe('ensureTrendingInFavoriteRelayList', () => {
const defaultTrending = buildWispTrendingNotesRelayUrl()
it('appends default trending when missing', () => {
const out = ensureTrendingInFavoriteRelayList(['wss://relay.example.com/'])
expect(out).toEqual(['wss://relay.example.com/', defaultTrending])
})
it('does not duplicate when default trending is already listed', () => {
const out = ensureTrendingInFavoriteRelayList([
'wss://relay.example.com/',
defaultTrending
])
expect(out).toHaveLength(2)
expect(out.some(isWispTrendingNotesRelayUrl)).toBe(true)
})
it('keeps a single trending entry when another Wisp path is already present', () => {
const customTrending = buildWispTrendingNotesRelayUrl('replies', '7d')
const out = ensureTrendingInFavoriteRelayList([
'wss://relay.example.com/',
customTrending,
defaultTrending
])
expect(out.filter(isWispTrendingNotesRelayUrl)).toEqual([customTrending])
})
it('forFeed skips injection under metadata-relays-only policy', () => {
setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true })
const out = ensureTrendingInFavoriteRelayList(['wss://relay.example.com/'], { forFeed: true })
expect(out).toEqual(['wss://relay.example.com/'])
setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
})
})

44
src/lib/wisp-trending-relay.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { ExtendedKind } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { isMetadataRelaysOnlyPolicyActive } from '@/lib/read-only-relay-personal'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
/**
* Trending notes stream from nostrarchives (path-based relay URL). The WebSocket speaks **standard NIP-01**
@ -29,6 +30,47 @@ export const WISP_TRENDING_FEED_KINDS: readonly number[] = [ @@ -29,6 +30,47 @@ export const WISP_TRENDING_FEED_KINDS: readonly number[] = [
ExtendedKind.VIDEO_ADDRESSABLE
]
/**
* Ensure the default nostrarchives trending notes relay is present in a favorite-relay list.
* Skips when any Wisp trending URL is already listed (dedupes duplicate trending paths).
* When `forFeed` is true, omits injection under the metadata-relays-only read policy.
*/
export function ensureTrendingInFavoriteRelayList(
relayUrls: readonly string[],
options?: { forFeed?: boolean }
): string[] {
if (options?.forFeed && isMetadataRelaysOnlyPolicyActive()) {
return [...relayUrls]
}
const out: string[] = []
const seen = new Set<string>()
let hasTrending = false
for (const raw of relayUrls) {
const normalized = normalizeAnyRelayUrl(raw) || raw.trim()
if (!normalized) continue
const key = normalized.toLowerCase()
if (seen.has(key)) continue
if (isWispTrendingNotesRelayUrl(normalized)) {
if (hasTrending) continue
hasTrending = true
}
seen.add(key)
out.push(normalized)
}
if (!hasTrending) {
const trending = normalizeUrl(buildWispTrendingNotesRelayUrl()) || buildWispTrendingNotesRelayUrl()
const key = trending.toLowerCase()
if (!seen.has(key)) {
out.push(trending)
}
}
return out
}
/** True when `url` is any nostrarchives notes trending WebSocket feed (path `/notes/trending/...`). */
export function isWispTrendingNotesRelayUrl(url: string): boolean {
const raw = (normalizeUrl(url) || url).trim()

7
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList'
import { ensureHomeFeedTrendingRelay } from '@/lib/home-feed-relays'
import { checkAlgoRelay } from '@/lib/relay'
import { normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/feed-context'
@ -98,7 +99,7 @@ const RelaysFeed = forwardRef< @@ -98,7 +99,7 @@ const RelaysFeed = forwardRef<
if (!canRenderFeed) return []
return [
{
urls: stableRelayUrls,
urls: ensureHomeFeedTrendingRelay(stableRelayUrls),
filter: {
kinds: defaultKinds
}
@ -107,9 +108,11 @@ const RelaysFeed = forwardRef< @@ -107,9 +108,11 @@ const RelaysFeed = forwardRef<
}, [canRenderFeed, relayUrlsKey, stableRelayUrls, defaultKindsKey, defaultKinds])
const repliesSubRequests = useMemo(() => {
if (!canRenderFeed) return []
const replyUrls =
stableReplyRelayUrls.length > 0 ? stableReplyRelayUrls : stableRelayUrls
return [
{
urls: stableReplyRelayUrls.length > 0 ? stableReplyRelayUrls : stableRelayUrls,
urls: ensureHomeFeedTrendingRelay(replyUrls),
filter: {
kinds: defaultKinds
}

7
src/pages/secondary/RelaySettingsPage/index.tsx

@ -134,7 +134,12 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -134,7 +134,12 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
}
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<Tabs key={contentKey} value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4">
<Tabs
key={contentKey}
value={tabValue}
onValueChange={setTabValue}
className="min-w-0 w-full space-y-4 px-2 py-3 sm:px-4"
>
<TabsList className="flex-col sm:flex-row h-auto sm:h-9">
<TabsTrigger value="favorite-relays" className="w-full sm:w-auto">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox" className="w-full sm:w-auto">{t('Read & Write Relays')}</TabsTrigger>

96
src/providers/FavoriteRelaysProvider.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { FAST_READ_RELAY_URLS, DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { FAST_READ_RELAY_URLS, DEFAULT_FAVORITE_RELAYS, ExtendedKind } from '@/constants'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import storage from '@/services/local-storage.service'
import { createFavoriteRelaysDraftEvent, createBlockedRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event'
@ -26,15 +26,17 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -26,15 +26,17 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const [relaySets, setRelaySets] = useState<TRelaySet[]>([])
useEffect(() => {
if (!favoriteRelaysEvent) {
let favoriteRelays: string[] = []
let cancelled = false
const applyNoFavoriteRelaysEvent = () => {
let next: string[] = []
if (pubkey) {
const storedRelaySets = storage.getRelaySets()
storedRelaySets.forEach(({ relayUrls }) => {
relayUrls.forEach((url) => {
if (!favoriteRelays.includes(url)) {
favoriteRelays.push(url)
if (!next.includes(url)) {
next.push(url)
}
})
})
@ -42,23 +44,23 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -42,23 +44,23 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const useGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
favoriteRelayUrls: favoriteRelays,
favoriteRelayUrls: next,
relayList
})
if (favoriteRelays.length === 0 && useGlobal) {
favoriteRelays = [...DEFAULT_FAVORITE_RELAYS]
if (next.length === 0 && useGlobal) {
next = [...DEFAULT_FAVORITE_RELAYS]
}
setFavoriteRelays(favoriteRelays)
if (cancelled) return
setFavoriteRelays(next)
setRelaySetEvents([])
return
}
const init = async () => {
const applyFavoriteRelaysEvent = async (event: Event) => {
const relays: string[] = []
const relaySetIds: string[] = []
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => {
event.tags.forEach(([tagName, tagValue]) => {
if (!tagValue) return
if (tagName === 'relay') {
@ -69,7 +71,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -69,7 +71,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
} else if (tagName === 'a') {
const [kind, author, relaySetId] = tagValue.split(':')
if (kind !== kinds.Relaysets.toString()) return
if (!pubkey || author !== pubkey) return // TODO: support others relay sets
if (!pubkey || author !== pubkey) return
if (!relaySetId) return
if (!relaySetIds.includes(relaySetId)) {
@ -78,18 +80,22 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -78,18 +80,22 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
}
})
// Keep all favorites in state - don't filter blocked relays here
// Blocked relays are filtered at the relay selection service level
if (cancelled) return
setFavoriteRelays(relays)
if (!pubkey || !relaySetIds.length) {
setRelaySets([])
setRelaySetEvents([])
return
}
const storedRelaySetEvents = await Promise.all(
relaySetIds.map((id) => indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id))
)
setRelaySetEvents(storedRelaySetEvents.filter(Boolean) as Event[])
const storedRelaySetEvents = (
await Promise.all(
relaySetIds.map((id) => indexedDb.getReplaceableEvent(pubkey, kinds.Relaysets, id))
)
).filter(Boolean) as Event[]
if (cancelled) return
setRelaySetEvents(storedRelaySetEvents)
const relaySetDiscoverGlobal = viewerUsesGlobalRelayDefaults({
viewerPubkey: pubkey,
@ -110,33 +116,60 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -110,33 +116,60 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
'#d': relaySetIds
}
)
if (cancelled) return
const relaySetEventMap = new Map<string, Event>()
newRelaySetEvents.forEach((event) => {
const d = getReplaceableEventIdentifier(event)
newRelaySetEvents.forEach((fetched) => {
const d = getReplaceableEventIdentifier(fetched)
if (!d) return
const old = relaySetEventMap.get(d)
if (!old || old.created_at < event.created_at) {
relaySetEventMap.set(d, event)
if (!old || old.created_at < fetched.created_at) {
relaySetEventMap.set(d, fetched)
}
})
const uniqueNewRelaySetEvents = relaySetIds
.map((id, index) => {
const event = relaySetEventMap.get(id)
if (event) {
return event
const fetched = relaySetEventMap.get(id)
if (fetched) {
return fetched
}
return storedRelaySetEvents[index] || null
})
.filter(Boolean) as Event[]
setRelaySetEvents(uniqueNewRelaySetEvents)
await Promise.all(
uniqueNewRelaySetEvents.map((event) => {
return indexedDb.putReplaceableEvent(event)
})
uniqueNewRelaySetEvents.map((evt) => indexedDb.putReplaceableEvent(evt))
)
}
init()
if (favoriteRelaysEvent) {
void applyFavoriteRelaysEvent(favoriteRelaysEvent)
return () => {
cancelled = true
}
}
if (!pubkey) {
applyNoFavoriteRelaysEvent()
return () => {
cancelled = true
}
}
/** PWA / cold start: read kind 10012 from IndexedDB before NostrProvider finishes hydrating. */
void indexedDb.getReplaceableEvent(pubkey, ExtendedKind.FAVORITE_RELAYS).then((stored) => {
if (cancelled) return
if (stored) {
void applyFavoriteRelaysEvent(stored)
} else {
applyNoFavoriteRelaysEvent()
}
})
return () => {
cancelled = true
}
}, [favoriteRelaysEvent, pubkey, relayList])
useEffect(() => {
@ -328,6 +361,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode @@ -328,6 +361,7 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
[favoriteRelays, publish, updateFavoriteRelaysEvent]
)
/** Published kind 10012 `relay` tags (and relay sets via {@link relaySets}); trending is added in feed/UI layers. */
const contextValue = useMemo(
() => ({
favoriteRelaysFromPublishedList: !!favoriteRelaysEvent,

9
src/providers/FeedProvider.test.ts

@ -44,13 +44,18 @@ describe('home feed relay policy', () => { @@ -44,13 +44,18 @@ describe('home feed relay policy', () => {
it('personal-relay policy omits wisp trending from home feed relay list', () => {
setViewerPersonalRelayKeys(new Set(['wss://relay.example.com/']), { viewerActive: true })
const wisp = buildWispTrendingNotesRelayUrl()
const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [wisp])
const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [])
expect(urls).toContain('wss://relay.example.com/')
expect(urls.some((u) => isWispTrendingNotesRelayUrl(u))).toBe(false)
setViewerPersonalRelayKeys(new Set(), { viewerActive: false })
})
it('includes trending from favorites tier without extra feed relays', () => {
const urls = buildAllFavoritesFeedRelayUrls(['wss://relay.example.com/'], [], [])
expect(urls).toContain('wss://relay.example.com/')
expect(urls).toContain(buildWispTrendingNotesRelayUrl())
})
it('stripNostrLandAggrFromRelayUrls removes aggr with trailing slash and hostname variants', () => {
const stripped = stripNostrLandAggrFromRelayUrls([
'wss://relay.example/',

55
src/providers/FeedProvider.tsx

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { buildAllFavoritesFeedRelayUrls, stripNostrLandAggrFromRelayUrls } from '@/lib/home-feed-relays'
import {
buildAllFavoritesFeedRelayUrls,
ensureHomeFeedTrendingRelay,
stripNostrLandAggrFromRelayUrls
} from '@/lib/home-feed-relays'
import logger from '@/lib/logger'
import {
syncViewerRelayStackNostrLandAggrEligible,
@ -11,7 +15,6 @@ import { normalizeAnyRelayUrl } from '@/lib/url' @@ -11,7 +15,6 @@ import { normalizeAnyRelayUrl } from '@/lib/url'
import { viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import { collectUserReadInboxUrls } from '@/lib/viewer-read-inboxes'
import { collectUserWriteOutboxUrls } from '@/lib/viewer-write-outboxes'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
import type { Dispatch, ReactNode, SetStateAction } from 'react'
import { FeedContext } from './feed-context'
@ -36,21 +39,23 @@ function buildHomeReplyFeedRelayUrls( @@ -36,21 +39,23 @@ function buildHomeReplyFeedRelayUrls(
blockedRelays: string[]
): string[] {
/** Home Replies/Gallery: never prepend aggr (reserved for side-panel threads, profiles, spells). */
return stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
{ source: 'favorites', urls: primaryRelayUrls },
{ source: 'viewer-read', urls: inboxRelayUrls },
{ source: 'cache', urls: cacheRelayUrls },
{ source: 'http-index', urls: httpRelayUrls }
],
{
operation: 'read',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
return ensureHomeFeedTrendingRelay(
stripNostrLandAggrFromRelayUrls(
feedRelayPolicyUrls(
[
{ source: 'favorites', urls: primaryRelayUrls },
{ source: 'viewer-read', urls: inboxRelayUrls },
{ source: 'cache', urls: cacheRelayUrls },
{ source: 'http-index', urls: httpRelayUrls }
],
{
operation: 'read',
blockedRelays,
nostrLandAggr: 'never',
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
}
)
)
)
}
@ -74,12 +79,6 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -74,12 +79,6 @@ export function FeedProvider({ children }: { children: ReactNode }) {
[favoriteRelays, relaySets]
)
/**
* Mixed trending slice (nostrarchives / Wisp-style feed) so the home timeline isnt only the users
* graph keeps a finger on what the wider network is surfacing, alongside favorites / NIP-65.
*/
const primaryExtraRelayUrls = useMemo(() => [buildWispTrendingNotesRelayUrl()], [])
/** Read-side layers merged into {@link replyRelayUrls}; {@link outboxRelayUrls} is only for aggr eligibility sync. */
const replyExtraRelayLayers = useMemo(() => {
const cacheRelayUrls = getCacheRelayUrlsFromEvent(cacheRelayListEvent)
@ -111,12 +110,10 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -111,12 +110,10 @@ export function FeedProvider({ children }: { children: ReactNode }) {
}, [relayList, cacheRelayListEvent, useGlobalRelayDefaults])
/** Default relays immediately so feeds / sidebar REQ never wait on Nostr session restore. */
const [relayUrls, setRelayUrls] = useState<string[]>(() =>
buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()])
)
const [relayUrls, setRelayUrls] = useState<string[]>(() => buildAllFavoritesFeedRelayUrls([], [], []))
const [replyRelayUrls, setReplyRelayUrls] = useState<string[]>(() =>
buildHomeReplyFeedRelayUrls(
buildAllFavoritesFeedRelayUrls([], [], [buildWispTrendingNotesRelayUrl()]),
buildAllFavoritesFeedRelayUrls([], [], []),
[],
[],
[],
@ -152,7 +149,7 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -152,7 +149,7 @@ export function FeedProvider({ children }: { children: ReactNode }) {
const primaryRelays = buildAllFavoritesFeedRelayUrls(
favoriteFeedRelayUrls,
blockedRelays,
primaryExtraRelayUrls,
[],
useGlobalRelayDefaults
)
const replyRelays = buildHomeReplyFeedRelayUrls(
@ -174,7 +171,7 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -174,7 +171,7 @@ export function FeedProvider({ children }: { children: ReactNode }) {
}
setUrlStateIfChanged(setRelayUrls, primaryRelays)
setUrlStateIfChanged(setReplyRelayUrls, replyRelays)
}, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged, useGlobalRelayDefaults])
}, [favoriteFeedRelayUrls, blockedRelays, replyExtraRelayLayers, setUrlStateIfChanged, useGlobalRelayDefaults])
const favoriteRelaysIdentity = useMemo(
() =>

Loading…
Cancel
Save