26 changed files with 838 additions and 664 deletions
@ -0,0 +1,117 @@ |
|||||||
|
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '@/components/RelaySimpleInfo' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { DEFAULT_FAVORITE_RELAYS } from '@/constants' |
||||||
|
import { useFetchRelayInfo } from '@/hooks' |
||||||
|
import { toRelay } from '@/lib/link' |
||||||
|
import { normalizeUrl, simplifyUrl } from '@/lib/url' |
||||||
|
import { usePrimaryPage, useSmartRelayNavigation } from '@/PageManager' |
||||||
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { Newspaper } from 'lucide-react' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
function FavoriteRelayCard({ url }: { url: string }) { |
||||||
|
const { navigateToRelay } = useSmartRelayNavigation() |
||||||
|
const { relayInfo, isFetching } = useFetchRelayInfo(url) |
||||||
|
|
||||||
|
if (isFetching) { |
||||||
|
return ( |
||||||
|
<RelaySimpleInfoSkeleton className="h-full min-h-[5.5rem] rounded-lg border bg-card p-3 shadow-sm" /> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (!relayInfo) { |
||||||
|
return ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className={cn( |
||||||
|
'clickable flex h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 flex-col justify-center rounded-lg border bg-card p-3 text-left shadow-sm', |
||||||
|
'transition-colors hover:bg-accent/40' |
||||||
|
)} |
||||||
|
onClick={() => navigateToRelay(toRelay(url))} |
||||||
|
> |
||||||
|
<div className="truncate font-mono text-sm font-semibold">{simplifyUrl(url)}</div> |
||||||
|
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{url}</div> |
||||||
|
</button> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<RelaySimpleInfo |
||||||
|
relayInfo={relayInfo} |
||||||
|
className={cn( |
||||||
|
'clickable h-full min-h-[5.5rem] min-w-[220px] max-w-[280px] shrink-0 rounded-lg border bg-card p-3 shadow-sm', |
||||||
|
'transition-colors hover:bg-accent/40' |
||||||
|
)} |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
navigateToRelay(toRelay(relayInfo.url)) |
||||||
|
}} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Horizontal strip of favorite relays (non-blocked), or {@link DEFAULT_FAVORITE_RELAYS} when none. |
||||||
|
*/ |
||||||
|
export default function ExploreFavoriteRelays() { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { navigate } = usePrimaryPage() |
||||||
|
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||||
|
|
||||||
|
const blockedSet = useMemo( |
||||||
|
() => new Set(blockedRelays.map((b) => normalizeUrl(b) || b)), |
||||||
|
[blockedRelays] |
||||||
|
) |
||||||
|
|
||||||
|
const { urls, usingDefaults } = useMemo(() => { |
||||||
|
const visible = favoriteRelays.filter((r) => { |
||||||
|
const k = normalizeUrl(r) || r |
||||||
|
return k && !blockedSet.has(k) |
||||||
|
}) |
||||||
|
if (visible.length > 0) { |
||||||
|
return { urls: visible, usingDefaults: false } |
||||||
|
} |
||||||
|
const defaultsFiltered = DEFAULT_FAVORITE_RELAYS.filter((r) => { |
||||||
|
const k = normalizeUrl(r) || r |
||||||
|
return k && !blockedSet.has(k) |
||||||
|
}) |
||||||
|
return { |
||||||
|
urls: defaultsFiltered.length > 0 ? defaultsFiltered : DEFAULT_FAVORITE_RELAYS, |
||||||
|
usingDefaults: true |
||||||
|
} |
||||||
|
}, [favoriteRelays, blockedSet]) |
||||||
|
|
||||||
|
if (urls.length === 0) return null |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="min-w-0 px-2 pb-4 pt-1" aria-label={t('Favorite Relays')}> |
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-2"> |
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2"> |
||||||
|
<h2 className="text-base font-semibold tracking-tight">{t('Favorite Relays')}</h2> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="outline" |
||||||
|
size="sm" |
||||||
|
className="h-8 gap-1.5 px-2.5 font-medium" |
||||||
|
onClick={() => navigate('feed')} |
||||||
|
> |
||||||
|
<Newspaper className="size-4 shrink-0" strokeWidth={2.5} /> |
||||||
|
<span>{t('Favorites Feed')}</span> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
{usingDefaults ? ( |
||||||
|
<span className="text-xs text-muted-foreground">{t('Using app default relays')}</span> |
||||||
|
) : null} |
||||||
|
</div> |
||||||
|
<div className="flex gap-3 overflow-x-auto overflow-y-hidden pb-1 pt-0.5 [scrollbar-gutter:stable] snap-x snap-mandatory"> |
||||||
|
{urls.map((url) => ( |
||||||
|
<div key={url} className="snap-start"> |
||||||
|
<FavoriteRelayCard url={url} /> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,171 @@ |
|||||||
|
import AboutInfoDialog from '@/components/AboutInfoDialog' |
||||||
|
import { |
||||||
|
toGeneralSettings, |
||||||
|
toPostSettings, |
||||||
|
toRelaySettings, |
||||||
|
toCacheSettings, |
||||||
|
toTranslation, |
||||||
|
toWallet, |
||||||
|
toRssFeedSettings |
||||||
|
} from '@/lib/link' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { useSmartSettingsNavigation } from '@/PageManager' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { |
||||||
|
Check, |
||||||
|
ChevronRight, |
||||||
|
Copy, |
||||||
|
Database, |
||||||
|
Info, |
||||||
|
KeyRound, |
||||||
|
Languages, |
||||||
|
PencilLine, |
||||||
|
Rss, |
||||||
|
Server, |
||||||
|
Settings2, |
||||||
|
Wallet |
||||||
|
} from 'lucide-react' |
||||||
|
import { forwardRef, HTMLProps, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared settings index rows (General, Relays, …). Used by the primary Settings page and |
||||||
|
* the secondary /settings route for deep links / stack restores. |
||||||
|
*/ |
||||||
|
export default function SettingsMenuBody({ className }: { className?: string }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { pubkey, nsec, ncryptsec } = useNostr() |
||||||
|
const { navigateToSettings } = useSmartSettingsNavigation() |
||||||
|
const [copiedNsec, setCopiedNsec] = useState(false) |
||||||
|
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={cn('min-w-0', className)}> |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toGeneralSettings())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Settings2 /> |
||||||
|
<div>{t('General')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toRelaySettings())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Server /> |
||||||
|
<div>{t('Relays and Storage Settings')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toCacheSettings())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Database /> |
||||||
|
<div>{t('Cache & offline storage')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
{!!pubkey && ( |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toTranslation())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Languages /> |
||||||
|
<div>{t('Translation')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
)} |
||||||
|
{!!pubkey && ( |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toWallet())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Wallet /> |
||||||
|
<div>{t('Wallet')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
)} |
||||||
|
{!!pubkey && ( |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toPostSettings())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<PencilLine /> |
||||||
|
<div>{t('Post settings')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
)} |
||||||
|
{!!pubkey && ( |
||||||
|
<SettingItem className="clickable" onClick={() => navigateToSettings(toRssFeedSettings())}> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Rss /> |
||||||
|
<div>{t('RSS Feed Settings')}</div> |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</SettingItem> |
||||||
|
)} |
||||||
|
{!!nsec && ( |
||||||
|
<SettingItem |
||||||
|
className="clickable" |
||||||
|
onClick={() => { |
||||||
|
navigator.clipboard.writeText(nsec) |
||||||
|
setCopiedNsec(true) |
||||||
|
setTimeout(() => setCopiedNsec(false), 2000) |
||||||
|
}} |
||||||
|
> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<KeyRound /> |
||||||
|
<div>{t('Copy private key')} (nsec)</div> |
||||||
|
</div> |
||||||
|
{copiedNsec ? <Check /> : <Copy />} |
||||||
|
</SettingItem> |
||||||
|
)} |
||||||
|
{!!ncryptsec && ( |
||||||
|
<SettingItem |
||||||
|
className="clickable" |
||||||
|
onClick={() => { |
||||||
|
navigator.clipboard.writeText(ncryptsec) |
||||||
|
setCopiedNcryptsec(true) |
||||||
|
setTimeout(() => setCopiedNcryptsec(false), 2000) |
||||||
|
}} |
||||||
|
> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<KeyRound /> |
||||||
|
<div>{t('Copy private key')} (ncryptsec)</div> |
||||||
|
</div> |
||||||
|
{copiedNcryptsec ? <Check /> : <Copy />} |
||||||
|
</SettingItem> |
||||||
|
)} |
||||||
|
<AboutInfoDialog> |
||||||
|
<SettingItem className="clickable"> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<Info /> |
||||||
|
<div>{t('About')}</div> |
||||||
|
</div> |
||||||
|
<div className="flex gap-2 items-center"> |
||||||
|
<div className="text-muted-foreground"> |
||||||
|
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT}) |
||||||
|
</div> |
||||||
|
<ChevronRight /> |
||||||
|
</div> |
||||||
|
</SettingItem> |
||||||
|
</AboutInfoDialog> |
||||||
|
<div className="py-6 text-center text-muted-foreground"> |
||||||
|
<div className="text-lg font-semibold">Jumble</div> |
||||||
|
<div className="font-semibold text-green-600 dark:text-green-500">Im Wald</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>( |
||||||
|
({ children, className, ...props }, ref) => { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={cn( |
||||||
|
'flex h-[52px] select-none items-center justify-between rounded-lg px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0', |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
ref={ref} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
SettingItem.displayName = 'SettingItem' |
||||||
@ -1,26 +0,0 @@ |
|||||||
import { toSettings } from '@/lib/link' |
|
||||||
import { useSmartSettingsNavigation, usePrimaryNoteView } from '@/PageManager' |
|
||||||
// DEPRECATED: useUserPreferences removed - double-panel functionality disabled
|
|
||||||
import { Settings } from 'lucide-react' |
|
||||||
import SidebarItem from './SidebarItem' |
|
||||||
|
|
||||||
export default function SettingsButton() { |
|
||||||
const { navigateToSettings } = useSmartSettingsNavigation() |
|
||||||
const { primaryViewType } = usePrimaryNoteView() |
|
||||||
// DEPRECATED: showRecommendedRelaysPanel removed - double-panel functionality disabled
|
|
||||||
|
|
||||||
// Settings is active when:
|
|
||||||
// 1. primaryViewType is 'settings' or 'settings-sub' (when side panel is off)
|
|
||||||
// 2. OR we're on a /settings URL (when side panel is on)
|
|
||||||
const url = window.location.pathname |
|
||||||
const isActive =
|
|
||||||
primaryViewType === 'settings' ||
|
|
||||||
primaryViewType === 'settings-sub' ||
|
|
||||||
url.startsWith('/settings') |
|
||||||
|
|
||||||
return ( |
|
||||||
<SidebarItem title="Settings" onClick={() => navigateToSettings(toSettings())} active={isActive}> |
|
||||||
<Settings strokeWidth={3} /> |
|
||||||
</SidebarItem> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,29 @@ |
|||||||
|
import SettingsMenuBody from '@/components/Settings/SettingsMenuBody' |
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||||
|
import { Settings } from 'lucide-react' |
||||||
|
import { forwardRef } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
|
const SettingsPrimaryPage = forwardRef<HTMLDivElement>((_, ref) => { |
||||||
|
const { t } = useTranslation() |
||||||
|
|
||||||
|
return ( |
||||||
|
<PrimaryPageLayout |
||||||
|
ref={ref} |
||||||
|
pageName="settings" |
||||||
|
titlebar={ |
||||||
|
<div className="flex h-full items-center gap-2 pl-3"> |
||||||
|
<Settings className="size-5 shrink-0" /> |
||||||
|
<div className="text-lg font-semibold">{t('Settings')}</div> |
||||||
|
</div> |
||||||
|
} |
||||||
|
displayScrollToTopButton |
||||||
|
> |
||||||
|
<div className="min-w-0 px-2 pt-2"> |
||||||
|
<SettingsMenuBody /> |
||||||
|
</div> |
||||||
|
</PrimaryPageLayout> |
||||||
|
) |
||||||
|
}) |
||||||
|
SettingsPrimaryPage.displayName = 'SettingsPrimaryPage' |
||||||
|
export default SettingsPrimaryPage |
||||||
@ -1,254 +0,0 @@ |
|||||||
import { Button } from '@/components/ui/button' |
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' |
|
||||||
import { Skeleton } from '@/components/ui/skeleton' |
|
||||||
import { useFollowList } from '@/providers/FollowListProvider' |
|
||||||
import { useMuteList } from '@/providers/MuteListProvider' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { getPubkeysFromPTags } from '@/lib/tag' |
|
||||||
import { Event } from 'nostr-tools' |
|
||||||
import { useEffect, useMemo, useState, forwardRef } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import { toast } from 'sonner' |
|
||||||
import { queryService } from '@/services/client.service' |
|
||||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
|
||||||
import { normalizeUrl } from '@/lib/url' |
|
||||||
import { Users } from 'lucide-react' |
|
||||||
import logger from '@/lib/logger' |
|
||||||
import ProfileSearchBar from '@/components/ui/ProfileSearchBar' |
|
||||||
import { SimpleUserAvatar } from '@/components/UserAvatar' |
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
|
||||||
|
|
||||||
const FollowPacksPage = forwardRef<HTMLDivElement, { index?: number; hideTitlebar?: boolean }>( |
|
||||||
({ index, hideTitlebar = false }, ref) => { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const { followings, follow } = useFollowList() |
|
||||||
const { mutePubkeySet } = useMuteList() |
|
||||||
const [packs, setPacks] = useState<Event[]>([]) |
|
||||||
const [isLoading, setIsLoading] = useState(true) |
|
||||||
const [_followingPacks, setFollowingPacks] = useState<Set<string>>(new Set()) |
|
||||||
const [searchQuery, setSearchQuery] = useState('') |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const fetchPacks = async () => { |
|
||||||
if (!pubkey) return |
|
||||||
|
|
||||||
setIsLoading(true) |
|
||||||
try { |
|
||||||
const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) |
|
||||||
|
|
||||||
// Fetch kind 39089 events (starter packs)
|
|
||||||
const events = await queryService.fetchEvents(relayUrls, [{ |
|
||||||
kinds: [39089], |
|
||||||
limit: 100 |
|
||||||
}]) |
|
||||||
|
|
||||||
// Sort by created_at descending
|
|
||||||
events.sort((a, b) => b.created_at - a.created_at) |
|
||||||
|
|
||||||
setPacks(events) |
|
||||||
|
|
||||||
// Check which packs the user is already following all members of
|
|
||||||
const followingSet = new Set(followings) |
|
||||||
const packsFollowingAll = new Set<string>() |
|
||||||
|
|
||||||
events.forEach(pack => { |
|
||||||
const packPubkeys = getPubkeysFromPTags(pack.tags) |
|
||||||
if (packPubkeys.length > 0 && packPubkeys.every(p => followingSet.has(p))) { |
|
||||||
packsFollowingAll.add(pack.id) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
setFollowingPacks(packsFollowingAll) |
|
||||||
} catch (error) { |
|
||||||
logger.error('Failed to fetch follow packs', { error }) |
|
||||||
toast.error(t('Failed to load follow packs')) |
|
||||||
} finally { |
|
||||||
setIsLoading(false) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
fetchPacks() |
|
||||||
}, [pubkey, followings]) |
|
||||||
|
|
||||||
const handleFollowPack = async (pack: Event) => { |
|
||||||
if (!pubkey) { |
|
||||||
toast.error(t('Please log in to follow')) |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const packPubkeys = getPubkeysFromPTags(pack.tags) |
|
||||||
const followingSet = new Set(followings) |
|
||||||
// Filter out users that are already followed OR muted
|
|
||||||
const toFollow = packPubkeys.filter(p => !followingSet.has(p) && !mutePubkeySet.has(p)) |
|
||||||
|
|
||||||
if (toFollow.length === 0) { |
|
||||||
const mutedCount = packPubkeys.filter(p => mutePubkeySet.has(p) && !followingSet.has(p)).length |
|
||||||
if (mutedCount > 0) { |
|
||||||
toast.info(t('All available members are already followed or muted')) |
|
||||||
} else { |
|
||||||
toast.info(t('You are already following all members of this pack')) |
|
||||||
} |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
// Follow all pubkeys in the pack (excluding muted users)
|
|
||||||
for (const pubkeyToFollow of toFollow) { |
|
||||||
await follow(pubkeyToFollow) |
|
||||||
} |
|
||||||
toast.success(t('Followed {{count}} users', { count: toFollow.length })) |
|
||||||
|
|
||||||
// Update followingPacks if all non-muted members are now followed
|
|
||||||
const nonMutedPackPubkeys = packPubkeys.filter(p => !mutePubkeySet.has(p)) |
|
||||||
if (nonMutedPackPubkeys.length > 0 && nonMutedPackPubkeys.every(p => followingSet.has(p) || toFollow.includes(p))) { |
|
||||||
setFollowingPacks(prev => new Set([...prev, pack.id])) |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
logger.error('Failed to follow pack', { error }) |
|
||||||
toast.error(t('Failed to follow pack') + ': ' + (error as Error).message) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const getPackTitle = (pack: Event): string => { |
|
||||||
const titleTag = pack.tags.find(tag => tag[0] === 'title' || tag[0] === 'name') |
|
||||||
return titleTag?.[1] || t('Follow Pack') |
|
||||||
} |
|
||||||
|
|
||||||
const getPackDescription = (pack: Event): string => { |
|
||||||
const descTag = pack.tags.find(tag => tag[0] === 'description' || tag[0] === 'd') |
|
||||||
return descTag?.[1] || '' |
|
||||||
} |
|
||||||
|
|
||||||
const filteredPacks = useMemo(() => { |
|
||||||
if (!searchQuery.trim()) { |
|
||||||
return packs |
|
||||||
} |
|
||||||
const query = searchQuery.toLowerCase().trim() |
|
||||||
return packs.filter(pack => { |
|
||||||
const titleTag = pack.tags.find(tag => tag[0] === 'title' || tag[0] === 'name') |
|
||||||
const title = (titleTag?.[1] || t('Follow Pack')).toLowerCase() |
|
||||||
const descTag = pack.tags.find(tag => tag[0] === 'description' || tag[0] === 'd') |
|
||||||
const description = (descTag?.[1] || '').toLowerCase() |
|
||||||
return title.includes(query) || description.includes(query) |
|
||||||
}) |
|
||||||
}, [packs, searchQuery, t]) |
|
||||||
|
|
||||||
if (!pubkey) { |
|
||||||
return ( |
|
||||||
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Browse Follow Packs')} hideBackButton={hideTitlebar}> |
|
||||||
<div className="flex flex-col items-center justify-center py-16"> |
|
||||||
<div className="text-lg font-semibold mb-2">{t('Please log in')}</div> |
|
||||||
<div className="text-sm text-muted-foreground">{t('You need to be logged in to browse follow packs')}</div> |
|
||||||
</div> |
|
||||||
</SecondaryPageLayout> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Browse Follow Packs')} hideBackButton={hideTitlebar} displayScrollToTopButton> |
|
||||||
<div className="space-y-4 p-4"> |
|
||||||
{!isLoading && packs.length > 0 && ( |
|
||||||
<div className="flex items-center gap-2"> |
|
||||||
<ProfileSearchBar |
|
||||||
onSearch={setSearchQuery} |
|
||||||
placeholder={t('Search follow packs by name...')} |
|
||||||
className="w-full max-w-md" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
)} |
|
||||||
|
|
||||||
{isLoading ? ( |
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> |
|
||||||
{Array.from({ length: 6 }).map((_, i) => ( |
|
||||||
<Card key={i}> |
|
||||||
<CardHeader> |
|
||||||
<Skeleton className="h-6 w-32" /> |
|
||||||
<Skeleton className="h-4 w-full mt-2" /> |
|
||||||
</CardHeader> |
|
||||||
<CardContent> |
|
||||||
<Skeleton className="h-20 w-full" /> |
|
||||||
</CardContent> |
|
||||||
</Card> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
) : packs.length === 0 ? ( |
|
||||||
<div className="flex flex-col items-center justify-center py-16"> |
|
||||||
<div className="text-lg font-semibold mb-2">{t('No follow packs found')}</div> |
|
||||||
<div className="text-sm text-muted-foreground">{t('There are no follow packs available at the moment')}</div> |
|
||||||
</div> |
|
||||||
) : filteredPacks.length === 0 ? ( |
|
||||||
<div className="flex flex-col items-center justify-center py-16"> |
|
||||||
<div className="text-lg font-semibold mb-2">{t('No packs match your search')}</div> |
|
||||||
<div className="text-sm text-muted-foreground">{t('Try a different search term')}</div> |
|
||||||
</div> |
|
||||||
) : ( |
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> |
|
||||||
{filteredPacks.map((pack) => { |
|
||||||
const packPubkeys = getPubkeysFromPTags(pack.tags) |
|
||||||
const followingSet = new Set(followings) |
|
||||||
// Exclude muted users from calculations
|
|
||||||
const availablePubkeys = packPubkeys.filter(p => !mutePubkeySet.has(p)) |
|
||||||
const alreadyFollowingAll = availablePubkeys.length > 0 && availablePubkeys.every(p => followingSet.has(p)) |
|
||||||
const toFollowCount = availablePubkeys.filter(p => !followingSet.has(p)).length |
|
||||||
|
|
||||||
return ( |
|
||||||
<Card key={pack.id}> |
|
||||||
<CardHeader> |
|
||||||
<CardTitle className="text-lg">{getPackTitle(pack)}</CardTitle> |
|
||||||
{getPackDescription(pack) && ( |
|
||||||
<CardDescription className="line-clamp-2">{getPackDescription(pack)}</CardDescription> |
|
||||||
)} |
|
||||||
</CardHeader> |
|
||||||
<CardContent className="space-y-4"> |
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground"> |
|
||||||
<Users className="size-4" /> |
|
||||||
<span>{t('{{count}} profiles', { count: availablePubkeys.length })}</span> |
|
||||||
</div> |
|
||||||
|
|
||||||
{availablePubkeys.length > 0 && ( |
|
||||||
<div className="flex -space-x-2"> |
|
||||||
{availablePubkeys.slice(0, 5).map((pubkey) => ( |
|
||||||
<SimpleUserAvatar
|
|
||||||
key={pubkey}
|
|
||||||
userId={pubkey}
|
|
||||||
size="small"
|
|
||||||
className="border-2 border-background" |
|
||||||
/> |
|
||||||
))} |
|
||||||
{availablePubkeys.length > 5 && ( |
|
||||||
<div className="size-8 rounded-full border-2 border-background bg-muted flex items-center justify-center text-xs"> |
|
||||||
+{availablePubkeys.length - 5} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
|
|
||||||
<Button |
|
||||||
className="w-full" |
|
||||||
onClick={() => handleFollowPack(pack)} |
|
||||||
disabled={alreadyFollowingAll} |
|
||||||
variant={alreadyFollowingAll ? 'secondary' : 'default'} |
|
||||||
> |
|
||||||
{alreadyFollowingAll ? ( |
|
||||||
t('Following All') |
|
||||||
) : ( |
|
||||||
<> |
|
||||||
{t('Follow')} {toFollowCount > 0 && `(${toFollowCount})`} |
|
||||||
</> |
|
||||||
)} |
|
||||||
</Button> |
|
||||||
</CardContent> |
|
||||||
</Card> |
|
||||||
) |
|
||||||
})} |
|
||||||
</div> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
</SecondaryPageLayout> |
|
||||||
) |
|
||||||
}) |
|
||||||
|
|
||||||
FollowPacksPage.displayName = 'FollowPacksPage' |
|
||||||
export default FollowPacksPage |
|
||||||
|
|
||||||
@ -0,0 +1,11 @@ |
|||||||
|
import { useSecondaryPage } from '@/PageManager' |
||||||
|
import { useEffect } from 'react' |
||||||
|
|
||||||
|
/** Legacy `/follow-packs` opens Spells → Follow Packs faux feed. */ |
||||||
|
export default function FollowPacksRedirect() { |
||||||
|
const { navigateToPrimaryPage } = useSecondaryPage() |
||||||
|
useEffect(() => { |
||||||
|
navigateToPrimaryPage('spells', { spell: 'followPacks' }) |
||||||
|
}, [navigateToPrimaryPage]) |
||||||
|
return null |
||||||
|
} |
||||||
@ -1,170 +1,18 @@ |
|||||||
import AboutInfoDialog from '@/components/AboutInfoDialog' |
import SettingsMenuBody from '@/components/Settings/SettingsMenuBody' |
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||||
import { |
import { forwardRef } from 'react' |
||||||
toGeneralSettings, |
|
||||||
toPostSettings, |
|
||||||
toRelaySettings, |
|
||||||
toCacheSettings, |
|
||||||
toTranslation, |
|
||||||
toWallet, |
|
||||||
toRssFeedSettings |
|
||||||
} from '@/lib/link' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useSmartSettingsNavigation } from '@/PageManager' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import { |
|
||||||
Check, |
|
||||||
ChevronRight, |
|
||||||
Copy, |
|
||||||
Database, |
|
||||||
Info, |
|
||||||
KeyRound, |
|
||||||
Languages, |
|
||||||
PencilLine, |
|
||||||
Rss, |
|
||||||
Server, |
|
||||||
Settings2, |
|
||||||
Wallet |
|
||||||
} from 'lucide-react' |
|
||||||
import { forwardRef, HTMLProps, useState } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
import { useTranslation } from 'react-i18next' |
||||||
|
|
||||||
const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
const SettingsPage = forwardRef( |
||||||
const { t } = useTranslation() |
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||||
const { pubkey, nsec, ncryptsec } = useNostr() |
const { t } = useTranslation() |
||||||
const { navigateToSettings } = useSmartSettingsNavigation() |
|
||||||
const [copiedNsec, setCopiedNsec] = useState(false) |
|
||||||
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false) |
|
||||||
|
|
||||||
return ( |
|
||||||
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Settings')}> |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toGeneralSettings())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Settings2 /> |
|
||||||
<div>{t('General')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toRelaySettings())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Server /> |
|
||||||
<div>{t('Relays and Storage Settings')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toCacheSettings())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Database /> |
|
||||||
<div>{t('Cache & offline storage')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
{!!pubkey && ( |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toTranslation())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Languages /> |
|
||||||
<div>{t('Translation')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
)} |
|
||||||
{!!pubkey && ( |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toWallet())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Wallet /> |
|
||||||
<div>{t('Wallet')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
)} |
|
||||||
{!!pubkey && ( |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toPostSettings())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<PencilLine /> |
|
||||||
<div>{t('Post settings')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
)} |
|
||||||
{!!pubkey && ( |
|
||||||
<SettingItem className="clickable" onClick={() => navigateToSettings(toRssFeedSettings())}> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Rss /> |
|
||||||
<div>{t('RSS Feed Settings')}</div> |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</SettingItem> |
|
||||||
)} |
|
||||||
{!!nsec && ( |
|
||||||
<SettingItem |
|
||||||
className="clickable" |
|
||||||
onClick={() => { |
|
||||||
navigator.clipboard.writeText(nsec) |
|
||||||
setCopiedNsec(true) |
|
||||||
setTimeout(() => setCopiedNsec(false), 2000) |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<KeyRound /> |
|
||||||
<div>{t('Copy private key')} (nsec)</div> |
|
||||||
</div> |
|
||||||
{copiedNsec ? <Check /> : <Copy />} |
|
||||||
</SettingItem> |
|
||||||
)} |
|
||||||
{!!ncryptsec && ( |
|
||||||
<SettingItem |
|
||||||
className="clickable" |
|
||||||
onClick={() => { |
|
||||||
navigator.clipboard.writeText(ncryptsec) |
|
||||||
setCopiedNcryptsec(true) |
|
||||||
setTimeout(() => setCopiedNcryptsec(false), 2000) |
|
||||||
}} |
|
||||||
> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<KeyRound /> |
|
||||||
<div>{t('Copy private key')} (ncryptsec)</div> |
|
||||||
</div> |
|
||||||
{copiedNcryptsec ? <Check /> : <Copy />} |
|
||||||
</SettingItem> |
|
||||||
)} |
|
||||||
<AboutInfoDialog> |
|
||||||
<SettingItem className="clickable"> |
|
||||||
<div className="flex items-center gap-4"> |
|
||||||
<Info /> |
|
||||||
<div>{t('About')}</div> |
|
||||||
</div> |
|
||||||
<div className="flex gap-2 items-center"> |
|
||||||
<div className="text-muted-foreground"> |
|
||||||
v{import.meta.env.APP_VERSION} ({import.meta.env.GIT_COMMIT}) |
|
||||||
</div> |
|
||||||
<ChevronRight /> |
|
||||||
</div> |
|
||||||
</SettingItem> |
|
||||||
</AboutInfoDialog> |
|
||||||
<div className="text-center py-6 text-muted-foreground"> |
|
||||||
<div className="text-lg font-semibold">Jumble</div> |
|
||||||
<div className="text-green-600 dark:text-green-500 font-semibold">Im Wald</div> |
|
||||||
</div> |
|
||||||
</SecondaryPageLayout> |
|
||||||
) |
|
||||||
}) |
|
||||||
SettingsPage.displayName = 'SettingsPage' |
|
||||||
export default SettingsPage |
|
||||||
|
|
||||||
const SettingItem = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>( |
|
||||||
({ children, className, ...props }, ref) => { |
|
||||||
return ( |
return ( |
||||||
<div |
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Settings')}> |
||||||
className={cn( |
<SettingsMenuBody /> |
||||||
'flex justify-between select-none items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0', |
</SecondaryPageLayout> |
||||||
className |
|
||||||
)} |
|
||||||
{...props} |
|
||||||
ref={ref} |
|
||||||
> |
|
||||||
{children} |
|
||||||
</div> |
|
||||||
) |
) |
||||||
} |
} |
||||||
) |
) |
||||||
SettingItem.displayName = 'SettingItem' |
SettingsPage.displayName = 'SettingsPage' |
||||||
|
export default SettingsPage |
||||||
|
|||||||
Loading…
Reference in new issue