53 changed files with 444 additions and 136 deletions
@ -0,0 +1,277 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { |
||||||
|
Select, |
||||||
|
SelectContent, |
||||||
|
SelectItem, |
||||||
|
SelectLabel, |
||||||
|
SelectSeparator, |
||||||
|
SelectTrigger, |
||||||
|
SelectValue |
||||||
|
} from '@/components/ui/select' |
||||||
|
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' |
||||||
|
import { toRelaySettings } from '@/lib/link' |
||||||
|
import { normalizeUrl, simplifyUrl } from '@/lib/url' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import { useFeed } from '@/providers/FeedProvider' |
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider' |
||||||
|
import { SquarePen } from 'lucide-react' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
const ALL_FAVORITES_VALUE = '__all_favorites__' |
||||||
|
|
||||||
|
function relaySetToSelectValue(id: string) { |
||||||
|
return `rs:${encodeURIComponent(id)}` |
||||||
|
} |
||||||
|
|
||||||
|
function selectValueToRelaySetId(v: string) { |
||||||
|
if (!v.startsWith('rs:')) return null |
||||||
|
return decodeURIComponent(v.slice(3)) |
||||||
|
} |
||||||
|
|
||||||
|
/** Top-of-feed control: all favorites, single relays, and relay sets. */ |
||||||
|
export default function FavoriteRelaysFeedPicker() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { isSmallScreen } = useScreenSize() |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() |
||||||
|
const { feedInfo, switchFeed } = useFeed() |
||||||
|
|
||||||
|
const openFavoriteRelaySettings = () => { |
||||||
|
push(toRelaySettings('favorite-relays')) |
||||||
|
} |
||||||
|
|
||||||
|
const settingsLabel = t('Relay settings') |
||||||
|
|
||||||
|
const urls = useMemo( |
||||||
|
() => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays), |
||||||
|
[favoriteRelays, blockedRelays] |
||||||
|
) |
||||||
|
|
||||||
|
const currentRelayKey = |
||||||
|
feedInfo.feedType === 'relay' && feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : null |
||||||
|
|
||||||
|
const allActive = feedInfo.feedType === 'all-favorites' |
||||||
|
|
||||||
|
const relaySetIdActive = feedInfo.feedType === 'relays' && feedInfo.id ? feedInfo.id : null |
||||||
|
|
||||||
|
const orphanRelaySetId = |
||||||
|
relaySetIdActive && !relaySets.some((s) => s.id === relaySetIdActive) ? relaySetIdActive : null |
||||||
|
|
||||||
|
const selectValue = allActive |
||||||
|
? ALL_FAVORITES_VALUE |
||||||
|
: relaySetIdActive |
||||||
|
? relaySetToSelectValue(relaySetIdActive) |
||||||
|
: currentRelayKey |
||||||
|
? currentRelayKey |
||||||
|
: ALL_FAVORITES_VALUE |
||||||
|
|
||||||
|
/** Values that exist in the mobile Select (for controlled `value` validation). */ |
||||||
|
const selectItems = useMemo(() => { |
||||||
|
const items: { value: string }[] = [{ value: ALL_FAVORITES_VALUE }] |
||||||
|
for (const url of urls) { |
||||||
|
items.push({ value: normalizeUrl(url) || url }) |
||||||
|
} |
||||||
|
if ( |
||||||
|
!allActive && |
||||||
|
feedInfo.feedType === 'relay' && |
||||||
|
feedInfo.id && |
||||||
|
!items.some((i) => i.value === currentRelayKey) |
||||||
|
) { |
||||||
|
items.push({ value: normalizeUrl(feedInfo.id) || feedInfo.id }) |
||||||
|
} |
||||||
|
for (const set of relaySets) { |
||||||
|
items.push({ value: relaySetToSelectValue(set.id) }) |
||||||
|
} |
||||||
|
if (orphanRelaySetId) { |
||||||
|
items.push({ value: relaySetToSelectValue(orphanRelaySetId) }) |
||||||
|
} |
||||||
|
return items |
||||||
|
}, [ |
||||||
|
urls, |
||||||
|
allActive, |
||||||
|
feedInfo.feedType, |
||||||
|
feedInfo.id, |
||||||
|
currentRelayKey, |
||||||
|
relaySets, |
||||||
|
orphanRelaySetId |
||||||
|
]) |
||||||
|
|
||||||
|
const resolvedSelectValue = selectItems.some((i) => i.value === selectValue) |
||||||
|
? selectValue |
||||||
|
: ALL_FAVORITES_VALUE |
||||||
|
|
||||||
|
const resolveRelayUrl = (value: string) => { |
||||||
|
if (value === ALL_FAVORITES_VALUE) return null |
||||||
|
const fromList = urls.find((u) => (normalizeUrl(u) || u) === value) |
||||||
|
return fromList ?? value |
||||||
|
} |
||||||
|
|
||||||
|
const onPickValue = (v: string) => { |
||||||
|
if (v === ALL_FAVORITES_VALUE) { |
||||||
|
void switchFeed('all-favorites') |
||||||
|
return |
||||||
|
} |
||||||
|
const setId = selectValueToRelaySetId(v) |
||||||
|
if (setId) { |
||||||
|
void switchFeed('relays', { activeRelaySetId: setId }) |
||||||
|
return |
||||||
|
} |
||||||
|
const relay = resolveRelayUrl(v) |
||||||
|
if (relay) void switchFeed('relay', { relay }) |
||||||
|
} |
||||||
|
|
||||||
|
if (urls.length === 0 && relaySets.length === 0) return null |
||||||
|
|
||||||
|
const editSettingsButton = ( |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="outline" |
||||||
|
size="icon" |
||||||
|
className="h-9 w-9 shrink-0" |
||||||
|
title={settingsLabel} |
||||||
|
aria-label={settingsLabel} |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
openFavoriteRelaySettings() |
||||||
|
}} |
||||||
|
> |
||||||
|
<SquarePen className="size-4" /> |
||||||
|
</Button> |
||||||
|
) |
||||||
|
|
||||||
|
if (isSmallScreen) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/80 bg-background px-2 py-1.5" |
||||||
|
aria-label={t('Favorite Relays')} |
||||||
|
> |
||||||
|
<div className="min-w-0 flex-1"> |
||||||
|
<Select value={resolvedSelectValue} onValueChange={onPickValue}> |
||||||
|
<SelectTrigger className="h-9 w-full font-mono text-xs"> |
||||||
|
<SelectValue placeholder={t('Favorite Relays')} /> |
||||||
|
</SelectTrigger> |
||||||
|
<SelectContent position="popper" className="z-[120] max-h-[min(24rem,70vh)]"> |
||||||
|
<SelectItem value={ALL_FAVORITES_VALUE} className="text-xs"> |
||||||
|
{t('All favorite relays')} |
||||||
|
</SelectItem> |
||||||
|
{urls.map((url) => { |
||||||
|
const v = normalizeUrl(url) || url |
||||||
|
return ( |
||||||
|
<SelectItem key={v} value={v} className="font-mono text-xs" title={url}> |
||||||
|
{simplifyUrl(url)} |
||||||
|
</SelectItem> |
||||||
|
) |
||||||
|
})} |
||||||
|
{relaySets.length > 0 || orphanRelaySetId ? ( |
||||||
|
<> |
||||||
|
<SelectSeparator /> |
||||||
|
<SelectLabel className="pl-2">{t('Relay sets')}</SelectLabel> |
||||||
|
{relaySets.map((set) => ( |
||||||
|
<SelectItem |
||||||
|
key={set.id} |
||||||
|
value={relaySetToSelectValue(set.id)} |
||||||
|
className="text-xs font-sans" |
||||||
|
> |
||||||
|
{set.name} |
||||||
|
</SelectItem> |
||||||
|
))} |
||||||
|
{orphanRelaySetId ? ( |
||||||
|
<SelectItem |
||||||
|
value={relaySetToSelectValue(orphanRelaySetId)} |
||||||
|
className="font-mono text-xs" |
||||||
|
> |
||||||
|
{orphanRelaySetId} |
||||||
|
</SelectItem> |
||||||
|
) : null} |
||||||
|
</> |
||||||
|
) : null} |
||||||
|
</SelectContent> |
||||||
|
</Select> |
||||||
|
</div> |
||||||
|
{editSettingsButton} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/80 bg-background px-2 py-1.5" |
||||||
|
role="toolbar" |
||||||
|
aria-label={t('Favorite Relays')} |
||||||
|
> |
||||||
|
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto pb-0.5 scrollbar-hide [scrollbar-gutter:stable]"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'shrink-0 rounded-full border px-3 py-1 text-xs font-semibold transition-colors', |
||||||
|
allActive |
||||||
|
? 'border-primary bg-primary/15 text-foreground' |
||||||
|
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent' |
||||||
|
)} |
||||||
|
onClick={() => void switchFeed('all-favorites')} |
||||||
|
> |
||||||
|
{t('All favorite relays')} |
||||||
|
</button> |
||||||
|
{urls.map((url) => { |
||||||
|
const key = normalizeUrl(url) || url |
||||||
|
const active = feedInfo.feedType === 'relay' && currentRelayKey === key |
||||||
|
return ( |
||||||
|
<button |
||||||
|
key={key} |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'max-w-[11rem] shrink-0 truncate rounded-full border px-3 py-1 font-mono text-xs font-semibold transition-colors', |
||||||
|
active |
||||||
|
? 'border-primary bg-primary/15 text-foreground' |
||||||
|
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent' |
||||||
|
)} |
||||||
|
title={url} |
||||||
|
onClick={() => void switchFeed('relay', { relay: url })} |
||||||
|
> |
||||||
|
{simplifyUrl(url)} |
||||||
|
</button> |
||||||
|
) |
||||||
|
})} |
||||||
|
{(relaySets.length > 0 || orphanRelaySetId) && ( |
||||||
|
<div className="mx-0.5 shrink-0 self-stretch border-l border-border/80" aria-hidden /> |
||||||
|
)} |
||||||
|
{relaySets.map((set) => { |
||||||
|
const active = feedInfo.feedType === 'relays' && feedInfo.id === set.id |
||||||
|
return ( |
||||||
|
<button |
||||||
|
key={set.id} |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'max-w-[10rem] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-semibold transition-colors', |
||||||
|
active |
||||||
|
? 'border-primary bg-primary/15 text-foreground' |
||||||
|
: 'border-border bg-muted/40 text-muted-foreground hover:bg-accent' |
||||||
|
)} |
||||||
|
title={set.name} |
||||||
|
onClick={() => void switchFeed('relays', { activeRelaySetId: set.id })} |
||||||
|
> |
||||||
|
{set.name} |
||||||
|
</button> |
||||||
|
) |
||||||
|
})} |
||||||
|
{orphanRelaySetId ? ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'max-w-[10rem] shrink-0 truncate rounded-full border px-3 py-1 font-mono text-xs font-semibold transition-colors', |
||||||
|
'border-primary bg-primary/15 text-foreground' |
||||||
|
)} |
||||||
|
title={orphanRelaySetId} |
||||||
|
onClick={() => void switchFeed('relays', { activeRelaySetId: orphanRelaySetId })} |
||||||
|
> |
||||||
|
{orphanRelaySetId} |
||||||
|
</button> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
{editSettingsButton} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
import { createContext, useContext } from 'react' |
||||||
|
|
||||||
|
export type TMuteListContext = { |
||||||
|
mutePubkeySet: Set<string> |
||||||
|
changing: boolean |
||||||
|
getMutePubkeys: () => string[] |
||||||
|
getMuteType: (pubkey: string) => 'public' | 'private' | null |
||||||
|
mutePubkeyPublicly: (pubkey: string) => Promise<void> |
||||||
|
mutePubkeyPrivately: (pubkey: string) => Promise<void> |
||||||
|
unmutePubkey: (pubkey: string) => Promise<void> |
||||||
|
switchToPublicMute: (pubkey: string) => Promise<void> |
||||||
|
switchToPrivateMute: (pubkey: string) => Promise<void> |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Dedicated module so lazy chunks share the same context as MuteListProvider (avoids duplicate |
||||||
|
* createContext when useMuteList is imported from MuteListProvider.tsx in a lazy-loaded bundle). |
||||||
|
*/ |
||||||
|
export const MuteListContext = createContext<TMuteListContext | undefined>(undefined) |
||||||
|
|
||||||
|
export function useMuteList(): TMuteListContext { |
||||||
|
const context = useContext(MuteListContext) |
||||||
|
if (!context) { |
||||||
|
throw new Error('useMuteList must be used within a MuteListProvider') |
||||||
|
} |
||||||
|
return context |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
import { createContext, useContext } from 'react' |
||||||
|
|
||||||
|
export type TUserTrustContext = { |
||||||
|
hideUntrustedInteractions: boolean |
||||||
|
hideUntrustedNotifications: boolean |
||||||
|
hideUntrustedNotes: boolean |
||||||
|
updateHideUntrustedInteractions: (hide: boolean) => void |
||||||
|
updateHideUntrustedNotifications: (hide: boolean) => void |
||||||
|
updateHideUntrustedNotes: (hide: boolean) => void |
||||||
|
isUserTrusted: (pubkey: string) => boolean |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Lives in a dedicated module so lazy chunks (e.g. NoteListPage → NormalFeed) share the same |
||||||
|
* context instance as App’s UserTrustProvider. Importing useUserTrust from UserTrustProvider into |
||||||
|
* those chunks can duplicate the module and break Provider matching. |
||||||
|
*/ |
||||||
|
export const UserTrustContext = createContext<TUserTrustContext | undefined>(undefined) |
||||||
|
|
||||||
|
export function useUserTrust(): TUserTrustContext { |
||||||
|
const context = useContext(UserTrustContext) |
||||||
|
if (!context) { |
||||||
|
throw new Error('useUserTrust must be used within a UserTrustProvider') |
||||||
|
} |
||||||
|
return context |
||||||
|
} |
||||||
Loading…
Reference in new issue