Browse Source

make relay-blocking more persistent

imwald
Silberengel 5 months ago
parent
commit
94424c101c
  1. 6
      src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx
  2. 17
      src/components/FavoriteRelaysSetting/RelayItem.tsx
  3. 9
      src/components/FeedSwitcher/index.tsx
  4. 3
      src/i18n/locales/en.ts
  5. 16
      src/lib/event-metadata.ts
  6. 6
      src/providers/FavoriteRelaysProvider.tsx
  7. 34
      src/providers/FeedProvider.tsx
  8. 39
      src/providers/NostrProvider/index.tsx
  9. 8
      src/services/indexed-db.service.ts

6
src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx

@ -20,7 +20,9 @@ import RelayItem from './RelayItem'
export default function FavoriteRelayList() { export default function FavoriteRelayList() {
const { t } = useTranslation() const { t } = useTranslation()
const { favoriteRelays, reorderFavoriteRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays, reorderFavoriteRelays } = useFavoriteRelays()
// Show all relays including blocked ones (they'll be marked visually)
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -53,7 +55,7 @@ export default function FavoriteRelayList() {
<SortableContext items={favoriteRelays} strategy={verticalListSortingStrategy}> <SortableContext items={favoriteRelays} strategy={verticalListSortingStrategy}>
<div className="grid gap-2"> <div className="grid gap-2">
{favoriteRelays.map((relay) => ( {favoriteRelays.map((relay) => (
<RelayItem key={relay} relay={relay} /> <RelayItem key={relay} relay={relay} isBlocked={blockedRelays.includes(relay)} />
))} ))}
</div> </div>
</SortableContext> </SortableContext>

17
src/components/FavoriteRelaysSetting/RelayItem.tsx

@ -3,10 +3,12 @@ import { useSecondaryPage } from '@/PageManager'
import { useSortable } from '@dnd-kit/sortable' import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react' import { GripVertical } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu' import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
export default function RelayItem({ relay }: { relay: string }) { export default function RelayItem({ relay, isBlocked = false }: { relay: string; isBlocked?: boolean }) {
const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: relay id: relay
@ -20,7 +22,7 @@ export default function RelayItem({ relay }: { relay: string }) {
return ( return (
<div <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 gap-2 border rounded-lg p-2 pr-2.5 items-center justify-between select-none ${isBlocked ? 'opacity-60' : ''}`}
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
onClick={() => push(toRelay(relay))} onClick={() => push(toRelay(relay))}
@ -33,9 +35,16 @@ export default function RelayItem({ relay }: { relay: string }) {
> >
<GripVertical className="size-4 text-muted-foreground" /> <GripVertical className="size-4 text-muted-foreground" />
</div> </div>
<div className="flex gap-2 items-center flex-1"> <div className="flex gap-2 items-center flex-1 min-w-0">
<RelayIcon url={relay} /> <RelayIcon url={relay} />
<div className="flex-1 w-0 truncate font-semibold">{relay}</div> <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>
</div> </div>
</div> </div>
<SaveRelayDropdownMenu urls={[relay]} /> <SaveRelayDropdownMenu urls={[relay]} />

9
src/components/FeedSwitcher/index.tsx

@ -12,9 +12,12 @@ import RelaySetCard from '../RelaySetCard'
export default function FeedSwitcher({ close }: { close?: () => void }) { export default function FeedSwitcher({ close }: { close?: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const { feedInfo, switchFeed } = useFeed() const { feedInfo, switchFeed } = useFeed()
// Filter out blocked relays for display
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{pubkey && ( {pubkey && (
@ -53,7 +56,7 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
</FeedSwitcherItem> </FeedSwitcherItem>
)} )}
{favoriteRelays.length > 0 && ( {visibleRelays.length > 0 && (
<FeedSwitcherItem <FeedSwitcherItem
isActive={feedInfo.feedType === 'all-favorites'} isActive={feedInfo.feedType === 'all-favorites'}
onClick={() => { onClick={() => {
@ -94,7 +97,7 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
}} }}
/> />
))} ))}
{favoriteRelays.map((relay) => ( {visibleRelays.map((relay) => (
<FeedSwitcherItem <FeedSwitcherItem
key={relay} key={relay}
isActive={feedInfo.feedType === 'relay' && feedInfo.id === relay} isActive={feedInfo.feedType === 'relay' && feedInfo.id === relay}

3
src/i18n/locales/en.ts

@ -151,6 +151,9 @@ export default {
Muted: 'Muted', Muted: 'Muted',
Unmute: 'Unmute', Unmute: 'Unmute',
'Unmute user': 'Unmute user', 'Unmute user': 'Unmute user',
Block: 'Block',
Unblock: 'Unblock',
blocked: 'blocked',
'Append n relays': 'Append {{n}} relays', 'Append n relays': 'Append {{n}} relays',
Append: 'Append', Append: 'Append',
'Select relays to append': 'Select relays to append', 'Select relays to append': 'Select relays to append',

16
src/lib/event-metadata.ts

@ -9,19 +9,26 @@ import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } fro
import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url' import { isWebsocketUrl, normalizeHttpUrl, normalizeUrl } from './url'
import { isTorBrowser } from './utils' import { isTorBrowser } from './utils'
export function getRelayListFromEvent(event?: Event | null) { export function getRelayListFromEvent(event?: Event | null, blockedRelays?: string[]) {
if (!event) { if (!event) {
return { write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] } return { write: BIG_RELAY_URLS, read: BIG_RELAY_URLS, originalRelays: [] }
} }
const torBrowserDetected = isTorBrowser() const torBrowserDetected = isTorBrowser()
const relayList = { write: [], read: [], originalRelays: [] } as TRelayList const relayList = { write: [], read: [], originalRelays: [] } as TRelayList
// Normalize blocked relays for comparison
const normalizedBlockedRelays = (blockedRelays || []).map(url => normalizeUrl(url) || url)
event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => { event.tags.filter(tagNameEquals('r')).forEach(([, url, type]) => {
if (!url || !isWebsocketUrl(url)) return if (!url || !isWebsocketUrl(url)) return
const normalizedUrl = normalizeUrl(url) const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) return if (!normalizedUrl) return
// Filter out blocked relays
if (normalizedBlockedRelays.includes(normalizedUrl)) return
const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both' const scope = type === 'read' ? 'read' : type === 'write' ? 'write' : 'both'
relayList.originalRelays.push({ url: normalizedUrl, scope }) relayList.originalRelays.push({ url: normalizedUrl, scope })
@ -79,13 +86,18 @@ export function getProfileFromEvent(event: Event) {
} }
} }
export function getRelaySetFromEvent(event: Event): TRelaySet { export function getRelaySetFromEvent(event: Event, blockedRelays?: string[]): TRelaySet {
const id = getReplaceableEventIdentifier(event) const id = getReplaceableEventIdentifier(event)
// Normalize blocked relays for comparison
const normalizedBlockedRelays = (blockedRelays || []).map(url => normalizeUrl(url) || url)
const relayUrls = event.tags const relayUrls = event.tags
.filter(tagNameEquals('relay')) .filter(tagNameEquals('relay'))
.map((tag) => tag[1]) .map((tag) => tag[1])
.filter((url) => url && isWebsocketUrl(url)) .filter((url) => url && isWebsocketUrl(url))
.map((url) => normalizeUrl(url)) .map((url) => normalizeUrl(url))
.filter((url) => !normalizedBlockedRelays.includes(url)) // Filter out blocked relays
let name = event.tags.find(tagNameEquals('title'))?.[1] let name = event.tags.find(tagNameEquals('title'))?.[1]
if (!name) { if (!name) {

6
src/providers/FavoriteRelaysProvider.tsx

@ -92,6 +92,8 @@ 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
setFavoriteRelays(relays) setFavoriteRelays(relays)
if (!pubkey || !relaySetIds.length) { if (!pubkey || !relaySetIds.length) {
@ -164,9 +166,9 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
useEffect(() => { useEffect(() => {
setRelaySets( setRelaySets(
relaySetEvents.map((evt) => getRelaySetFromEvent(evt)).filter(Boolean) as TRelaySet[] relaySetEvents.map((evt) => getRelaySetFromEvent(evt, blockedRelays)).filter(Boolean) as TRelaySet[]
) )
}, [relaySetEvents]) }, [relaySetEvents, blockedRelays])
const addFavoriteRelays = async (relayUrls: string[]) => { const addFavoriteRelays = async (relayUrls: string[]) => {
const normalizedUrls = relayUrls const normalizedUrls = relayUrls

34
src/providers/FeedProvider.tsx

@ -31,7 +31,7 @@ export const useFeed = () => {
export function FeedProvider({ children }: { children: React.ReactNode }) { export function FeedProvider({ children }: { children: React.ReactNode }) {
const { pubkey, isInitialized } = useNostr() const { pubkey, isInitialized } = useNostr()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
const [relayUrls, setRelayUrls] = useState<string[]>([]) const [relayUrls, setRelayUrls] = useState<string[]>([])
const [isReady, setIsReady] = useState(false) const [isReady, setIsReady] = useState(false)
const [feedInfo, setFeedInfo] = useState<TFeedInfo>({ const [feedInfo, setFeedInfo] = useState<TFeedInfo>({
@ -46,9 +46,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return return
} }
// Get first visible (non-blocked) favorite relay as default
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
let feedInfo: TFeedInfo = { let feedInfo: TFeedInfo = {
feedType: 'relay', feedType: 'relay',
id: favoriteRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0] id: visibleRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
} }
if (pubkey) { if (pubkey) {
const storedFeedInfo = storage.getFeedInfo(pubkey) const storedFeedInfo = storage.getFeedInfo(pubkey)
@ -62,6 +64,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
} }
if (feedInfo.feedType === 'relay') { if (feedInfo.feedType === 'relay') {
// Check if the stored relay is blocked, if so use first visible relay instead
if (feedInfo.id && blockedRelays.includes(feedInfo.id)) {
console.log('Stored relay is blocked, using first visible relay instead')
feedInfo.id = visibleRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
}
return await switchFeed('relay', { relay: feedInfo.id }) return await switchFeed('relay', { relay: feedInfo.id })
} }
@ -86,10 +93,12 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
// Update relay URLs when favoriteRelays change and we're in all-favorites mode // Update relay URLs when favoriteRelays change and we're in all-favorites mode
useEffect(() => { useEffect(() => {
if (feedInfo.feedType === 'all-favorites') { if (feedInfo.feedType === 'all-favorites') {
console.log('Updating relay URLs for all-favorites:', favoriteRelays) // Filter out blocked relays
setRelayUrls(favoriteRelays) const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
console.log('Updating relay URLs for all-favorites:', visibleRelays)
setRelayUrls(visibleRelays)
} }
}, [favoriteRelays, feedInfo.feedType]) }, [favoriteRelays, blockedRelays, feedInfo.feedType])
const switchFeed = async ( const switchFeed = async (
feedType: TFeedType, feedType: TFeedType,
@ -107,6 +116,13 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return return
} }
// Don't allow selecting a blocked relay as feed
if (blockedRelays.includes(normalizedUrl)) {
console.warn('Cannot select blocked relay as feed:', normalizedUrl)
setIsReady(true)
return
}
const newFeedInfo = { feedType, id: normalizedUrl } const newFeedInfo = { feedType, id: normalizedUrl }
setFeedInfo(newFeedInfo) setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo feedInfoRef.current = newFeedInfo
@ -132,7 +148,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
relaySetId relaySetId
) )
if (storedRelaySetEvent) { if (storedRelaySetEvent) {
relaySet = getRelaySetFromEvent(storedRelaySetEvent) relaySet = getRelaySetFromEvent(storedRelaySetEvent, blockedRelays)
} }
} }
if (relaySet) { if (relaySet) {
@ -161,11 +177,13 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return return
} }
if (feedType === 'all-favorites') { if (feedType === 'all-favorites') {
console.log('Switching to all-favorites, favoriteRelays:', favoriteRelays) // Filter out blocked relays
const visibleRelays = favoriteRelays.filter(relay => !blockedRelays.includes(relay))
console.log('Switching to all-favorites, favoriteRelays:', visibleRelays)
const newFeedInfo = { feedType } const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo) setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo feedInfoRef.current = newFeedInfo
setRelayUrls(favoriteRelays) setRelayUrls(visibleRelays)
storage.setFeedInfo(newFeedInfo, pubkey) storage.setFeedInfo(newFeedInfo, pubkey)
setIsReady(true) setIsReady(true)
return return

39
src/providers/NostrProvider/index.tsx

@ -232,6 +232,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
storedMuteListEvent, storedMuteListEvent,
storedBookmarkListEvent, storedBookmarkListEvent,
storedFavoriteRelaysEvent, storedFavoriteRelaysEvent,
storedBlockedRelaysEvent,
storedUserEmojiListEvent storedUserEmojiListEvent
] = await Promise.all([ ] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
@ -240,10 +241,26 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist), indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList), indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.BLOCKED_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList) indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList)
]) ])
// Extract blocked relays from event
const blockedRelays: string[] = []
if (storedBlockedRelaysEvent) {
storedBlockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
const normalizedUrl = normalizeUrl(tagValue)
if (normalizedUrl && !blockedRelays.includes(normalizedUrl)) {
blockedRelays.push(normalizedUrl)
}
}
})
setBlockedRelaysEvent(storedBlockedRelaysEvent)
}
if (storedRelayListEvent) { if (storedRelayListEvent) {
setRelayList(getRelayListFromEvent(storedRelayListEvent)) setRelayList(getRelayListFromEvent(storedRelayListEvent, blockedRelays))
} }
if (storedProfileEvent) { if (storedProfileEvent) {
setProfileEvent(storedProfileEvent) setProfileEvent(storedProfileEvent)
@ -270,7 +287,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
authors: [account.pubkey] authors: [account.pubkey]
}) })
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
const relayList = getRelayListFromEvent(relayListEvent) const relayList = getRelayListFromEvent(relayListEvent, blockedRelays)
if (relayListEvent) { if (relayListEvent) {
client.updateRelayListCache(relayListEvent) client.updateRelayListCache(relayListEvent)
await indexedDb.putReplaceableEvent(relayListEvent) await indexedDb.putReplaceableEvent(relayListEvent)
@ -294,6 +311,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.BookmarkList, kinds.BookmarkList,
10015, // Interest list 10015, // Interest list
ExtendedKind.FAVORITE_RELAYS, ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST, ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList kinds.UserEmojiList
], ],
@ -369,6 +387,23 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const updatedBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent) const updatedBlockedRelaysEvent = await indexedDb.putReplaceableEvent(blockedRelaysEvent)
if (updatedBlockedRelaysEvent.id === blockedRelaysEvent.id) { if (updatedBlockedRelaysEvent.id === blockedRelaysEvent.id) {
setBlockedRelaysEvent(updatedBlockedRelaysEvent) setBlockedRelaysEvent(updatedBlockedRelaysEvent)
// Update blockedRelays array and re-filter relay list
const newBlockedRelays: string[] = []
updatedBlockedRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
const normalizedUrl = normalizeUrl(tagValue)
if (normalizedUrl && !newBlockedRelays.includes(normalizedUrl)) {
newBlockedRelays.push(normalizedUrl)
}
}
})
// Re-filter relay list with updated blocked relays
if (relayListEvent) {
const updatedRelayList = getRelayListFromEvent(relayListEvent, newBlockedRelays)
setRelayList(updatedRelayList)
}
} }
} }
if (blossomServerListEvent) { if (blossomServerListEvent) {

8
src/services/indexed-db.service.ts

@ -21,6 +21,7 @@ const StoreNames = {
USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents', USER_EMOJI_LIST_EVENTS: 'userEmojiListEvents',
EMOJI_SET_EVENTS: 'emojiSetEvents', EMOJI_SET_EVENTS: 'emojiSetEvents',
FAVORITE_RELAYS: 'favoriteRelays', FAVORITE_RELAYS: 'favoriteRelays',
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents',
RELAY_SETS: 'relaySets', RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays', FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos', RELAY_INFOS: 'relayInfos',
@ -43,7 +44,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 9) const request = window.indexedDB.open('jumble', 10)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@ -80,6 +81,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) { if (!db.objectStoreNames.contains(StoreNames.FAVORITE_RELAYS)) {
db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' }) db.createObjectStore(StoreNames.FAVORITE_RELAYS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.BLOCKED_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.BLOCKED_RELAYS_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) { if (!db.objectStoreNames.contains(StoreNames.RELAY_SETS)) {
db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' }) db.createObjectStore(StoreNames.RELAY_SETS, { keyPath: 'key' })
} }
@ -461,6 +465,8 @@ class IndexedDbService {
return StoreNames.RELAY_SETS return StoreNames.RELAY_SETS
case ExtendedKind.FAVORITE_RELAYS: case ExtendedKind.FAVORITE_RELAYS:
return StoreNames.FAVORITE_RELAYS return StoreNames.FAVORITE_RELAYS
case ExtendedKind.BLOCKED_RELAYS:
return StoreNames.BLOCKED_RELAYS_EVENTS
case kinds.BookmarkList: case kinds.BookmarkList:
return StoreNames.BOOKMARK_LIST_EVENTS return StoreNames.BOOKMARK_LIST_EVENTS
case kinds.UserEmojiList: case kinds.UserEmojiList:

Loading…
Cancel
Save