Browse Source

Expanded home options to all relays.

Use inboxes when responding
imwald
Silberengel 5 months ago
parent
commit
9433982062
  1. 22
      src/components/FavoriteRelaysSetting/AddNewRelay.tsx
  2. 28
      src/components/FavoriteRelaysSetting/AddNewRelaySet.tsx
  3. 40
      src/components/FavoriteRelaysSetting/RelayUrl.tsx
  4. 20
      src/components/FeedSwitcher/index.tsx
  5. 7
      src/components/PostEditor/Mentions.tsx
  6. 1
      src/components/PostEditor/PostContent.tsx
  7. 289
      src/components/PostEditor/PostRelaySelector.tsx
  8. 30
      src/components/SaveRelayDropdownMenu/index.tsx
  9. 5
      src/pages/primary/NoteListPage/FeedButton.tsx
  10. 2
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  11. 23
      src/providers/FeedProvider.tsx
  12. 2
      src/types/index.d.ts

22
src/components/FavoriteRelaysSetting/AddNewRelay.tsx

@ -10,9 +10,10 @@ export default function AddNewRelay() {
const { favoriteRelays, addFavoriteRelays } = useFavoriteRelays() const { favoriteRelays, addFavoriteRelays } = useFavoriteRelays()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [isLoading, setIsLoading] = useState(false)
const saveRelay = async () => { const saveRelay = async () => {
if (!input) return if (!input || isLoading) return
const normalizedUrl = normalizeUrl(input) const normalizedUrl = normalizeUrl(input)
if (!normalizedUrl) { if (!normalizedUrl) {
setErrorMsg(t('Invalid URL')) setErrorMsg(t('Invalid URL'))
@ -22,8 +23,19 @@ export default function AddNewRelay() {
setErrorMsg(t('Already saved')) setErrorMsg(t('Already saved'))
return return
} }
await addFavoriteRelays([normalizedUrl])
setInput('') setIsLoading(true)
setErrorMsg('')
try {
await addFavoriteRelays([normalizedUrl])
setInput('')
} catch (error) {
console.error('Failed to add favorite relay:', error)
setErrorMsg(t('Failed to add relay. Please try again.'))
} finally {
setIsLoading(false)
}
} }
const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNewRelayInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -48,7 +60,9 @@ export default function AddNewRelay() {
onKeyDown={handleNewRelayInputKeyDown} onKeyDown={handleNewRelayInputKeyDown}
className={errorMsg ? 'border-destructive' : ''} className={errorMsg ? 'border-destructive' : ''}
/> />
<Button onClick={saveRelay}>{t('Add')}</Button> <Button onClick={saveRelay} disabled={isLoading || !input.trim()}>
{isLoading ? t('Adding...') : t('Add')}
</Button>
</div> </div>
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>} {errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
</div> </div>

28
src/components/FavoriteRelaysSetting/AddNewRelaySet.tsx

@ -8,15 +8,29 @@ export default function AddNewRelaySet() {
const { t } = useTranslation() const { t } = useTranslation()
const { createRelaySet } = useFavoriteRelays() const { createRelaySet } = useFavoriteRelays()
const [newRelaySetName, setNewRelaySetName] = useState('') const [newRelaySetName, setNewRelaySetName] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const saveRelaySet = () => { const saveRelaySet = async () => {
if (!newRelaySetName) return if (!newRelaySetName || isLoading) return
createRelaySet(newRelaySetName)
setNewRelaySetName('') setIsLoading(true)
setErrorMsg('')
try {
await createRelaySet(newRelaySetName)
setNewRelaySetName('')
} catch (error) {
console.error('Failed to create relay set:', error)
setErrorMsg(t('Failed to create relay set. Please try again.'))
} finally {
setIsLoading(false)
}
} }
const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNewRelaySetNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewRelaySetName(e.target.value) setNewRelaySetName(e.target.value)
setErrorMsg('')
} }
const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleNewRelaySetNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
@ -34,9 +48,13 @@ export default function AddNewRelaySet() {
value={newRelaySetName} value={newRelaySetName}
onChange={handleNewRelaySetNameChange} onChange={handleNewRelaySetNameChange}
onKeyDown={handleNewRelaySetNameKeyDown} onKeyDown={handleNewRelaySetNameKeyDown}
className={errorMsg ? 'border-destructive' : ''}
/> />
<Button onClick={saveRelaySet}>{t('Add')}</Button> <Button onClick={saveRelaySet} disabled={isLoading || !newRelaySetName.trim()}>
{isLoading ? t('Adding...') : t('Add')}
</Button>
</div> </div>
{errorMsg && <div className="text-destructive text-sm pl-8">{errorMsg}</div>}
</div> </div>
) )
} }

40
src/components/FavoriteRelaysSetting/RelayUrl.tsx

@ -14,6 +14,7 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
const { relaySets, updateRelaySet } = useFavoriteRelays() const { relaySets, updateRelaySet } = useFavoriteRelays()
const [newRelayUrl, setNewRelayUrl] = useState('') const [newRelayUrl, setNewRelayUrl] = useState('')
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null) const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const relaySet = useMemo( const relaySet = useMemo(
() => relaySets.find((r) => r.id === relaySetId), () => relaySets.find((r) => r.id === relaySetId),
[relaySets, relaySetId] [relaySets, relaySetId]
@ -21,15 +22,19 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
if (!relaySet) return null if (!relaySet) return null
const removeRelayUrl = (url: string) => { const removeRelayUrl = async (url: string) => {
updateRelaySet({ try {
...relaySet, await updateRelaySet({
relayUrls: relaySet.relayUrls.filter((u) => u !== url) ...relaySet,
}) relayUrls: relaySet.relayUrls.filter((u) => u !== url)
})
} catch (error) {
console.error('Failed to remove relay from set:', error)
}
} }
const saveNewRelayUrl = () => { const saveNewRelayUrl = async () => {
if (newRelayUrl === '') return if (newRelayUrl === '' || isLoading) return
const normalizedUrl = normalizeUrl(newRelayUrl) const normalizedUrl = normalizeUrl(newRelayUrl)
if (!normalizedUrl) { if (!normalizedUrl) {
return setNewRelayUrlError(t('Invalid relay URL')) return setNewRelayUrlError(t('Invalid relay URL'))
@ -40,9 +45,20 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
if (!isWebsocketUrl(normalizedUrl)) { if (!isWebsocketUrl(normalizedUrl)) {
return setNewRelayUrlError(t('invalid relay URL')) return setNewRelayUrlError(t('invalid relay URL'))
} }
const newRelayUrls = [...relaySet.relayUrls, normalizedUrl]
updateRelaySet({ ...relaySet, relayUrls: newRelayUrls }) setIsLoading(true)
setNewRelayUrl('') setNewRelayUrlError(null)
try {
const newRelayUrls = [...relaySet.relayUrls, normalizedUrl]
await updateRelaySet({ ...relaySet, relayUrls: newRelayUrls })
setNewRelayUrl('')
} catch (error) {
console.error('Failed to update relay set:', error)
setNewRelayUrlError(t('Failed to add relay. Please try again.'))
} finally {
setIsLoading(false)
}
} }
const handleRelayUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleRelayUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -73,7 +89,9 @@ export default function RelayUrls({ relaySetId }: { relaySetId: string }) {
onChange={handleRelayUrlInputChange} onChange={handleRelayUrlInputChange}
onBlur={saveNewRelayUrl} onBlur={saveNewRelayUrl}
/> />
<Button onClick={saveNewRelayUrl}>{t('Add')}</Button> <Button onClick={saveNewRelayUrl} disabled={isLoading || !newRelayUrl.trim()}>
{isLoading ? t('Adding...') : t('Add')}
</Button>
</div> </div>
{newRelayUrlError && <div className="text-xs text-destructive mt-1">{newRelayUrlError}</div>} {newRelayUrlError && <div className="text-xs text-destructive mt-1">{newRelayUrlError}</div>}
</> </>

20
src/components/FeedSwitcher/index.tsx

@ -4,7 +4,7 @@ import { SecondaryPageLink } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, UsersRound } from 'lucide-react' import { BookmarkIcon, UsersRound, Server } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import RelaySetCard from '../RelaySetCard' import RelaySetCard from '../RelaySetCard'
@ -53,6 +53,24 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
</FeedSwitcherItem> </FeedSwitcherItem>
)} )}
{favoriteRelays.length > 0 && (
<FeedSwitcherItem
isActive={feedInfo.feedType === 'all-favorites'}
onClick={() => {
console.log('FeedSwitcher: Switching to all-favorites')
switchFeed('all-favorites')
close?.()
}}
>
<div className="flex gap-2 items-center">
<div className="flex justify-center items-center w-6 h-6 shrink-0">
<Server className="size-4" />
</div>
<div>{t('All favorite relays')}</div>
</div>
</FeedSwitcherItem>
)}
<div className="flex justify-end items-center text-sm"> <div className="flex justify-end items-center text-sm">
<SecondaryPageLink <SecondaryPageLink
to={toRelaySettings()} to={toRelaySettings()}

7
src/components/PostEditor/Mentions.tsx

@ -136,12 +136,17 @@ export async function extractMentions(content: string, parentEvent?: Event) {
const parentEventPubkey = parentEvent ? parentEvent.pubkey : undefined const parentEventPubkey = parentEvent ? parentEvent.pubkey : undefined
const pubkeys: string[] = [] const pubkeys: string[] = []
const relatedPubkeys: string[] = [] const relatedPubkeys: string[] = []
// Always include parent event author in pubkeys if there's a parent event
if (parentEventPubkey) {
pubkeys.push(parentEventPubkey)
}
const matches = content.match( const matches = content.match(
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g
) )
const addToSet = (arr: string[], pubkey: string) => { const addToSet = (arr: string[], pubkey: string) => {
if (pubkey === parentEventPubkey) return
if (!arr.includes(pubkey)) arr.push(pubkey) if (!arr.includes(pubkey)) arr.push(pubkey)
} }

1
src/components/PostEditor/PostContent.tsx

@ -472,6 +472,7 @@ export default function PostContent({
setAdditionalRelayUrls={setAdditionalRelayUrls} setAdditionalRelayUrls={setAdditionalRelayUrls}
parentEvent={parentEvent} parentEvent={parentEvent}
openFrom={openFrom} openFrom={openFrom}
content={text}
/> />
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

289
src/components/PostEditor/PostRelaySelector.tsx

@ -2,12 +2,9 @@ import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerOverlay } from '@/components/ui/drawer'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import client from '@/services/client.service' import client from '@/services/client.service'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
@ -15,84 +12,123 @@ import { simplifyUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Check } from 'lucide-react' import { Check } from 'lucide-react'
import { NostrEvent } from 'nostr-tools' import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import RelayIcon from '../RelayIcon' import RelayIcon from '../RelayIcon'
import { extractMentions } from './Mentions'
type TPostTargetItem =
| {
type: 'writeRelays'
}
| {
type: 'relay'
url: string
}
| {
type: 'relaySet'
id: string
urls: string[]
}
export default function PostRelaySelector({ export default function PostRelaySelector({
parentEvent: _parentEvent, parentEvent: _parentEvent,
openFrom, openFrom,
setIsProtectedEvent, setIsProtectedEvent,
setAdditionalRelayUrls setAdditionalRelayUrls,
content: postContent = ''
}: { }: {
parentEvent?: NostrEvent parentEvent?: NostrEvent
openFrom?: string[] openFrom?: string[]
setIsProtectedEvent: Dispatch<SetStateAction<boolean>> setIsProtectedEvent: Dispatch<SetStateAction<boolean>>
setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>> setAdditionalRelayUrls: Dispatch<SetStateAction<string[]>>
content?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [isDrawerOpen, setIsDrawerOpen] = useState(false) const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const { relayUrls } = useCurrentRelays() const { relayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
const [postTargetItems, setPostTargetItems] = useState<TPostTargetItem[]>([]) const { pubkey } = useNostr()
// Privacy: Only show user's own relays + defaults, never other users' relays const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
const [mentionRelays, setMentionRelays] = useState<string[]>([])
// Fetch mention relays for regular replies (not discussion replies)
const isRegularReply = useMemo(() => {
if (!_parentEvent) return false
// Kind 1 or Kind 1111 that is not a reply to Kind 11 (discussion)
return (_parentEvent.kind === 1 || _parentEvent.kind === ExtendedKind.COMMENT) &&
_parentEvent.kind !== ExtendedKind.DISCUSSION
}, [_parentEvent])
// Get all selectable relays (write relays + favorite relays + relays from relay sets + mention relays)
const selectableRelays = useMemo(() => { const selectableRelays = useMemo(() => {
return Array.from(new Set(relayUrls.concat(favoriteRelays))) const allRelays = Array.from(new Set([
}, [relayUrls, favoriteRelays]) ...relayUrls,
...favoriteRelays,
...relaySets.flatMap(set => set.relayUrls),
...mentionRelays
]))
return allRelays
}, [relayUrls, favoriteRelays, relaySets, mentionRelays])
const description = useMemo(() => { const description = useMemo(() => {
if (postTargetItems.length === 0) { if (selectedRelayUrls.length === 0) {
return t('No relays selected') return t('No relays selected')
} }
if (postTargetItems.length === 1) { if (selectedRelayUrls.length === 1) {
const item = postTargetItems[0] return simplifyUrl(selectedRelayUrls[0])
if (item.type === 'writeRelays') {
return t('Write relays')
}
if (item.type === 'relay') {
return simplifyUrl(item.url)
}
if (item.type === 'relaySet') {
return item.urls.length > 1
? t('{{count}} relays', { count: item.urls.length })
: simplifyUrl(item.urls[0])
}
} }
const hasWriteRelays = postTargetItems.some((item) => item.type === 'writeRelays') return t('{{count}} relays', { count: selectedRelayUrls.length })
const relayCount = postTargetItems.reduce((count, item) => { }, [selectedRelayUrls])
if (item.type === 'relay') {
return count + 1 // Fetch mention relays when content changes for regular replies
} useEffect(() => {
if (item.type === 'relaySet') { if (!isRegularReply) {
return count + item.urls.length setMentionRelays([])
return
}
const fetchMentionRelays = async () => {
try {
console.log('PostRelaySelector: extractMentions called with:', { postContent, parentEvent: _parentEvent?.id })
const { pubkeys, relatedPubkeys } = await extractMentions(postContent, _parentEvent)
console.log('PostRelaySelector: extractMentions returned:', { pubkeys, relatedPubkeys })
// Combine all mentioned pubkeys and filter out current user's pubkey
const allMentionPubkeys = [...pubkeys, ...relatedPubkeys]
const filteredMentionPubkeys = allMentionPubkeys.filter(p => p !== pubkey)
console.log('PostRelaySelector: filtered mention pubkeys:', filteredMentionPubkeys)
if (filteredMentionPubkeys.length === 0) {
setMentionRelays([])
return
}
// Fetch relay lists for all mentioned users (including parent event author)
console.log('PostRelaySelector: Fetching relays for pubkeys:', filteredMentionPubkeys)
const relayListPromises = filteredMentionPubkeys.map(async (pubkey) => {
try {
const relayList = await client.fetchRelayList(pubkey)
console.log(`PostRelaySelector: Fetched relays for ${pubkey}:`, relayList?.write || [])
return relayList?.write || []
} catch (error) {
console.warn(`Failed to fetch relay list for ${pubkey}:`, error)
return []
}
})
const relayLists = await Promise.all(relayListPromises)
const allMentionRelays = relayLists.flat()
const uniqueMentionRelays = Array.from(new Set(allMentionRelays))
console.log('PostRelaySelector: Setting mention relays:', uniqueMentionRelays)
setMentionRelays(uniqueMentionRelays)
} catch (error) {
console.error('Error fetching mention relays:', error)
setMentionRelays([])
} }
return count
}, 0)
if (hasWriteRelays) {
return t('Write relays and {{count}} other relays', { count: relayCount })
} }
return t('{{count}} relays', { count: relayCount })
}, [postTargetItems])
// Debounce the fetch
const timeoutId = setTimeout(fetchMentionRelays, 300)
return () => clearTimeout(timeoutId)
}, [postContent, isRegularReply, _parentEvent])
// Initialize selected relays based on context
useEffect(() => { useEffect(() => {
if (openFrom && openFrom.length) { if (openFrom && openFrom.length) {
setPostTargetItems(Array.from(new Set(openFrom)).map((url) => ({ type: 'relay', url }))) // If called with specific relay URLs (e.g., from a discussion thread)
setSelectedRelayUrls(Array.from(new Set(openFrom)))
return return
} }
@ -126,112 +162,71 @@ export default function PostRelaySelector({
if (relayHint && isWebsocketUrl(relayHint)) { if (relayHint && isWebsocketUrl(relayHint)) {
const normalizedRelayHint = normalizeUrl(relayHint) const normalizedRelayHint = normalizeUrl(relayHint)
if (normalizedRelayHint) { if (normalizedRelayHint) {
setPostTargetItems([{ type: 'relay', url: normalizedRelayHint }]) setSelectedRelayUrls([normalizedRelayHint])
return return
} }
} }
} }
// Default to write relays for all other cases // Default to write relays + mention relays for regular replies, or just write relays for other cases
setPostTargetItems([{ type: 'writeRelays' }]) if (isRegularReply) {
}, [openFrom, _parentEvent]) // For regular replies, include write relays and mention relays
const defaultRelays = Array.from(new Set([...relayUrls, ...mentionRelays]))
console.log('PostRelaySelector: Setting default relays for regular reply:', {
relayUrls,
mentionRelays,
defaultRelays,
isRegularReply
})
setSelectedRelayUrls(defaultRelays)
} else {
// For other cases, just use write relays
console.log('PostRelaySelector: Setting default relays for non-regular reply:', relayUrls)
setSelectedRelayUrls(relayUrls)
}
}, [openFrom, _parentEvent, relayUrls, isRegularReply, mentionRelays])
// Update parent component with selected relays
useEffect(() => { useEffect(() => {
const isProtectedEvent = postTargetItems.every((item) => item.type !== 'writeRelays') const isProtectedEvent = selectedRelayUrls.length > 0 && !selectedRelayUrls.some(url => relayUrls.includes(url))
const relayUrls = postTargetItems.flatMap((item) => {
if (item.type === 'relay') {
return [item.url]
}
if (item.type === 'relaySet') {
return item.urls
}
return []
})
setIsProtectedEvent(isProtectedEvent) setIsProtectedEvent(isProtectedEvent)
setAdditionalRelayUrls(relayUrls) setAdditionalRelayUrls(selectedRelayUrls)
}, [postTargetItems]) }, [selectedRelayUrls, relayUrls, setIsProtectedEvent, setAdditionalRelayUrls])
const handleWriteRelaysCheckedChange = useCallback((checked: boolean) => { const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => {
if (checked) { if (checked) {
setPostTargetItems((prev) => [...prev, { type: 'writeRelays' }]) setSelectedRelayUrls(prev => [...prev, url])
} else { } else {
setPostTargetItems((prev) => prev.filter((item) => item.type !== 'writeRelays')) setSelectedRelayUrls(prev => prev.filter(selectedUrl => selectedUrl !== url))
} }
}, []) }, [])
const handleRelayCheckedChange = useCallback((checked: boolean, url: string) => { const content = useMemo(() => {
if (checked) { if (selectableRelays.length === 0) {
setPostTargetItems((prev) => [...prev, { type: 'relay', url }]) return (
} else { <div className="px-4 py-3 text-sm text-muted-foreground text-center">
setPostTargetItems((prev) => {t('No relays available')}
prev.filter((item) => !(item.type === 'relay' && item.url === url)) </div>
) )
} }
}, [])
const handleRelaySetCheckedChange = useCallback(
(checked: boolean, id: string, urls: string[]) => {
if (checked) {
setPostTargetItems((prev) => [...prev, { type: 'relaySet', id, urls }])
} else {
setPostTargetItems((prev) =>
prev.filter((item) => !(item.type === 'relaySet' && item.id === id))
)
}
},
[]
)
const content = useMemo(() => {
return ( return (
<> <div className="space-y-1 max-h-64 overflow-y-auto">
<MenuItem {selectableRelays.map((url) => (
checked={postTargetItems.some((item) => item.type === 'writeRelays')} <MenuItem
onCheckedChange={handleWriteRelaysCheckedChange} key={url}
> checked={selectedRelayUrls.includes(url)}
{t('Write relays')} onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)}
</MenuItem> >
{relaySets.length > 0 && ( <div className="flex items-center gap-2">
<> <RelayIcon url={url} />
<MenuSeparator /> <div className="truncate">{simplifyUrl(url)}</div>
{relaySets </div>
.filter(({ relayUrls }) => relayUrls.length) </MenuItem>
.map(({ id, name, relayUrls }) => ( ))}
<MenuItem </div>
key={id}
checked={postTargetItems.some(
(item) => item.type === 'relaySet' && item.id === id
)}
onCheckedChange={(checked) => handleRelaySetCheckedChange(checked, id, relayUrls)}
>
<div className="truncate">
{name} ({relayUrls.length})
</div>
</MenuItem>
))}
</>
)}
{selectableRelays.length > 0 && (
<>
<MenuSeparator />
{selectableRelays.map((url) => (
<MenuItem
key={url}
checked={postTargetItems.some((item) => item.type === 'relay' && item.url === url)}
onCheckedChange={(checked) => handleRelayCheckedChange(checked, url)}
>
<div className="flex items-center gap-2">
<RelayIcon url={url} />
<div className="truncate">{simplifyUrl(url)}</div>
</div>
</MenuItem>
))}
</>
)}
</>
) )
}, [postTargetItems, relaySets, selectableRelays]) }, [selectedRelayUrls, selectableRelays])
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
@ -278,13 +273,6 @@ export default function PostRelaySelector({
) )
} }
function MenuSeparator() {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return <Separator />
}
return <DropdownMenuSeparator />
}
function MenuItem({ function MenuItem({
children, children,
@ -312,13 +300,14 @@ function MenuItem({
} }
return ( return (
<DropdownMenuCheckboxItem <div
checked={checked} onClick={() => onCheckedChange(!checked)}
onSelect={(e) => e.preventDefault()} className="flex items-center gap-2 px-2 py-2 hover:bg-muted cursor-pointer rounded-sm"
onCheckedChange={onCheckedChange}
className="flex items-center gap-2"
> >
<div className="flex items-center justify-center size-4 shrink-0">
{checked && <Check className="size-4" />}
</div>
{children} {children}
</DropdownMenuCheckboxItem> </div>
) )
} }

30
src/components/SaveRelayDropdownMenu/index.tsx

@ -109,32 +109,42 @@ function RelayItem({ urls }: { urls: string[] }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { favoriteRelays, addFavoriteRelays, deleteFavoriteRelays } = useFavoriteRelays() const { favoriteRelays, addFavoriteRelays, deleteFavoriteRelays } = useFavoriteRelays()
const [isLoading, setIsLoading] = useState(false)
const saved = useMemo( const saved = useMemo(
() => urls.every((url) => favoriteRelays.includes(url)), () => urls.every((url) => favoriteRelays.includes(url)),
[favoriteRelays, urls] [favoriteRelays, urls]
) )
const handleClick = async () => { const handleClick = async () => {
if (saved) { if (isLoading) return
await deleteFavoriteRelays(urls)
} else { setIsLoading(true)
await addFavoriteRelays(urls) try {
if (saved) {
await deleteFavoriteRelays(urls)
} else {
await addFavoriteRelays(urls)
}
} catch (error) {
console.error('Failed to toggle favorite relay:', error)
} finally {
setIsLoading(false)
} }
} }
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<DrawerMenuItem onClick={handleClick}> <DrawerMenuItem onClick={handleClick} disabled={isLoading}>
{saved ? <Check /> : <Plus />} {isLoading ? '...' : (saved ? <Check /> : <Plus />)}
{saved ? t('Unfavorite') : t('Favorite')} {isLoading ? t('Loading...') : (saved ? t('Unfavorite') : t('Favorite'))}
</DrawerMenuItem> </DrawerMenuItem>
) )
} }
return ( return (
<DropdownMenuItem className="flex gap-2" onClick={handleClick}> <DropdownMenuItem className="flex gap-2" onClick={handleClick} disabled={isLoading}>
{saved ? <Check /> : <Plus />} {isLoading ? '...' : (saved ? <Check /> : <Plus />)}
{saved ? t('Unfavorite') : t('Favorite')} {isLoading ? t('Loading...') : (saved ? t('Unfavorite') : t('Favorite'))}
</DropdownMenuItem> </DropdownMenuItem>
) )
} }

5
src/pages/primary/NoteListPage/FeedButton.tsx

@ -65,6 +65,9 @@ const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
if (feedInfo.feedType === 'bookmarks') { if (feedInfo.feedType === 'bookmarks') {
return t('Bookmarks') return t('Bookmarks')
} }
if (feedInfo.feedType === 'all-favorites') {
return t('All favorite relays')
}
if (relayUrls.length === 0) { if (relayUrls.length === 0) {
return t('Choose a relay') return t('Choose a relay')
} }
@ -86,6 +89,8 @@ const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
<UsersRound /> <UsersRound />
) : feedInfo.feedType === 'bookmarks' ? ( ) : feedInfo.feedType === 'bookmarks' ? (
<BookmarkIcon /> <BookmarkIcon />
) : feedInfo.feedType === 'all-favorites' ? (
<Server />
) : ( ) : (
<Server /> <Server />
)} )}

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

@ -22,7 +22,7 @@ export default function RelaysFeed() {
return null return null
} }
if (feedInfo.feedType !== 'relay' && feedInfo.feedType !== 'relays') { if (feedInfo.feedType !== 'relay' && feedInfo.feedType !== 'relays' && feedInfo.feedType !== 'all-favorites') {
return null return null
} }

23
src/providers/FeedProvider.tsx

@ -73,11 +73,24 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
if (feedInfo.feedType === 'bookmarks' && pubkey) { if (feedInfo.feedType === 'bookmarks' && pubkey) {
return await switchFeed('bookmarks', { pubkey }) return await switchFeed('bookmarks', { pubkey })
} }
if (feedInfo.feedType === 'all-favorites') {
console.log('Initializing all-favorites feed')
return await switchFeed('all-favorites')
}
} }
init() init()
}, [pubkey, isInitialized]) }, [pubkey, isInitialized])
// Update relay URLs when favoriteRelays change and we're in all-favorites mode
useEffect(() => {
if (feedInfo.feedType === 'all-favorites') {
console.log('Updating relay URLs for all-favorites:', favoriteRelays)
setRelayUrls(favoriteRelays)
}
}, [favoriteRelays, feedInfo.feedType])
const switchFeed = async ( const switchFeed = async (
feedType: TFeedType, feedType: TFeedType,
options: { options: {
@ -147,6 +160,16 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
setIsReady(true) setIsReady(true)
return return
} }
if (feedType === 'all-favorites') {
console.log('Switching to all-favorites, favoriteRelays:', favoriteRelays)
const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo
setRelayUrls(favoriteRelays)
storage.setFeedInfo(newFeedInfo, pubkey)
setIsReady(true)
return
}
if (feedType === 'bookmarks') { if (feedType === 'bookmarks') {
if (!options.pubkey) { if (!options.pubkey) {
setIsReady(true) setIsReady(true)

2
src/types/index.d.ts vendored

@ -106,7 +106,7 @@ export type TAccount = {
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'> export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
export type TFeedType = 'following' | 'relays' | 'relay' | 'bookmarks' export type TFeedType = 'following' | 'relays' | 'relay' | 'bookmarks' | 'all-favorites'
export type TFeedInfo = { feedType: TFeedType; id?: string } export type TFeedInfo = { feedType: TFeedType; id?: string }
export type TLanguage = 'en' | 'zh' | 'pl' export type TLanguage = 'en' | 'zh' | 'pl'

Loading…
Cancel
Save