Browse Source

more refactoring

imwald
Silberengel 1 month ago
parent
commit
0ebe5150ef
  1. 92
      README.md
  2. 87
      src/PageManager.tsx
  3. 4
      src/components/BottomNavigationBar/AccountButton.tsx
  4. 149
      src/components/ContentPreview/FollowPackPreview.tsx
  5. 117
      src/components/Explore/ExploreFavoriteRelays.tsx
  6. 2
      src/components/Note/index.tsx
  7. 12
      src/components/Profile/index.tsx
  8. 171
      src/components/Settings/SettingsMenuBody.tsx
  9. 93
      src/components/Sidebar/AccountButton.tsx
  10. 26
      src/components/Sidebar/SettingsButton.tsx
  11. 2
      src/components/Sidebar/index.tsx
  12. 4
      src/components/Titlebar/AccountButton.tsx
  13. 1
      src/constants.ts
  14. 14
      src/i18n/locales/de.ts
  15. 14
      src/i18n/locales/en.ts
  16. 1
      src/lib/link.ts
  17. 182
      src/pages/primary/ExplorePage/index.tsx
  18. 11
      src/pages/primary/MePage/index.tsx
  19. 25
      src/pages/primary/ProfilePage/index.tsx
  20. 29
      src/pages/primary/SettingsPrimaryPage/index.tsx
  21. 14
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  22. 11
      src/pages/primary/SpellsPage/index.tsx
  23. 254
      src/pages/secondary/FollowPacksPage/index.tsx
  24. 11
      src/pages/secondary/FollowPacksRedirect.tsx
  25. 172
      src/pages/secondary/SettingsPage/index.tsx
  26. 4
      src/routes.tsx

92
README.md

@ -7,16 +7,56 @@ @@ -7,16 +7,56 @@
<p>logo designed by <a href="http://wolfertdan.com/">Daniel David</a></p>
</div>
# Jumble
# Jumble — **ImWald Edition**
A user-friendly Nostr client focused on relay feed browsing and relay discovery
**Maintainer: [Silberengel](https://github.com/Silberengel)** · Hard fork of [Cody Tseng’s Jumble](https://github.com/CodyTseng/jumble)
## Features
A Nostr web client focused on relay feeds, discovery, and spells. This repository is the **ImWald** line: same core ideas as upstream, with a substantial navigation and information-architecture rewrite (see below).
- **Relay Feeds:** Explore content directly through relays without following specific users
- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays
- **Relay Sets:** Easily manage and switch between relay sets
- **Clean Interface:** Enjoy a minimalist design and intuitive interactions
---
## Major rewrite (this fork)
High-level changes versus a “stock” Jumble-style layout:
### Home vs feed
- **Home** is the **Explore** experience: relay directory, **Following’s Favorites**, and related discovery — not a duplicate of your main timeline.
- **Feed** is a dedicated primary area for **favorite relays**, displaying their diverse social content as a feed: short text notes (microblogging), longform articles, wiki pages, media notes, calendar entries, etc.
### RSS
- **RSS** is a **separate primary page** with its own title bar, refresh, and filters
- Sidebar **RSS** opens that page directly when enabled in settings.
### Spells & faux feeds
- Built-in **faux spells** (notifications, discussions, following, follow packs, media, interests, bookmarks, calendar) all run through the **same `NoteList` path** as user-defined kind-777 spells.
- Sidebar **Notifications** and **Discussions** navigate to the correct faux feed with proper **active** states; primary page props are merged through the lazy `Suspense` boundary correctly.
- **Following** faux feed respects global kind filters and Notes/Replies mode; **bookmarks** faux uses classic **`e`-tag** ids from the bookmark list.
### Profiles
- **Pinned** notes (kind `10001` lists) appear first with a **pin** marker; the rest of the profile timeline uses **main-feed-style** kind and reply rules, with a clear split when pins exist.
- Profiles with **no pins** behave like a normal timeline (no empty pin chrome).
### Explore quality-of-life
- **Relay URL search** in the Explore title bar: paste `wss://…` or a host, submit, and open the relay page with the same navigation as the relay cards. While typing, **suggestions** come from the **NIP-66 monitoring (public lively) list** on partial or full URL/host matches; you can still submit any URL the app does not know.
### Other
- Sidebar layout tuned for **long translations** (e.g. German) so labels don’t sit on the divider.
- Branding in-app: **Im Wald**.
---
## Features (still core to Jumble)
- **Relay feeds:** Browse content through relays, sets, and favorites
- **Relay-friendly requests:** Efficient subscriptions where possible
- **Relay sets:** Switch between saved relay groups
- **Spells:** Portable filters (kind 777) plus built-in faux feeds above
## Screenshots
@ -27,46 +67,32 @@ A user-friendly Nostr client focused on relay feed browsing and relay discovery @@ -27,46 +67,32 @@ A user-friendly Nostr client focused on relay feed browsing and relay discovery
<img src="./screenshots/04.png" alt="Jumble Screenshot 04" width="200" />
</div>
## Forks
> Some interesting forks of Jumble.
## Upstream & related forks
- [https://jumble.imwald.eu/](https://jumble.imwald.eu/) Repo: [Silberengel/jumble](https://github.com/Silberengel/jumble) - Discussions support
- [https://grouped-notes.dtonon.com/](https://grouped-notes.dtonon.com/) - "Grouped Notes" mode: organizes posts by user and timeframe for cleaner browsing and easier discovery
- [https://jumblekat.shakespeare.wtf/](https://jumblekat.shakespeare.wtf/) - Supports custom styles
- **Original project:** [CodyTseng/jumble](https://github.com/CodyTseng/jumble) — design, sponsorship, and donation links below still refer to Cody’s work where applicable.
- **This fork:** [Silberengel/jumble](https://github.com/Silberengel/jumble) — Im Wald / rewrite described above.
- Other public forks (examples): [grouped-notes.dtonon.com](https://grouped-notes.dtonon.com/), [jumblekat.shakespeare.wtf](https://jumblekat.shakespeare.wtf/).
## Run Locally
## Run locally
```bash
# Clone this repository
git clone https://github.com/CodyTseng/jumble.git
# Go into the repository
git clone https://github.com/Silberengel/jumble.git
cd jumble
# Install dependencies
npm install
# Run the app
npm run dev
```
## Run Docker
## Run with Docker
```bash
# Clone this repository
git clone https://github.com/CodyTseng/jumble.git
# Go into the repository
git clone https://github.com/Silberengel/jumble.git
cd jumble
# Run the docker compose
docker compose up --build -d
```
After finishing, access: http://localhost:8089
Then open: http://localhost:8089
## Sponsors
## Sponsors (original Jumble)
<a target="_blank" href="https://opensats.org/">
<img alt="open-sats-logo" src="./resources/open-sats-logo.svg" height="44">
@ -74,7 +100,7 @@ After finishing, access: http://localhost:8089 @@ -74,7 +100,7 @@ After finishing, access: http://localhost:8089
## Donate
If you like this project, you can buy me a coffee :)
**Original author** — if you want to support the project Jumble was forked from:
lightning: ⚡ codytseng@getalby.com ⚡
@ -82,6 +108,8 @@ bitcoin: bc1qx8kvutghdhejx7vuvatmvw2ghypdungu0qm7ds @@ -82,6 +108,8 @@ bitcoin: bc1qx8kvutghdhejx7vuvatmvw2ghypdungu0qm7ds
geyser: https://geyser.fund/project/jumble
---
## License
MIT

87
src/PageManager.tsx

@ -7,14 +7,6 @@ import { NavigationService } from '@/services/navigation.service' @@ -7,14 +7,6 @@ import { NavigationService } from '@/services/navigation.service'
import NoteListPage from '@/pages/primary/NoteListPage'
import SecondaryNoteListPage from '@/pages/secondary/NoteListPage'
// Page imports needed for primary note view
import SettingsPage from '@/pages/secondary/SettingsPage'
import RelaySettingsPage from '@/pages/secondary/RelaySettingsPage'
import WalletPage from '@/pages/secondary/WalletPage'
import PostSettingsPage from '@/pages/secondary/PostSettingsPage'
import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage'
import CacheSettingsPage from '@/pages/secondary/CacheSettingsPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import NoteDrawer from '@/components/NoteDrawer'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import storage from '@/services/local-storage.service'
@ -56,6 +48,7 @@ import ProfilePage from './pages/primary/ProfilePage' @@ -56,6 +48,7 @@ import ProfilePage from './pages/primary/ProfilePage'
import RelayPage from './pages/primary/RelayPage'
import SearchPage from './pages/primary/SearchPage'
import RssPage from './pages/primary/RssPage'
import SettingsPrimaryPage from './pages/primary/SettingsPrimaryPage'
import { useScreenSize } from './providers/ScreenSizeProvider'
/** Lazy-loaded so PageManager does not synchronously import SpellsPage (avoids HMR cycle: SpellsPage → PrimaryPageLayout → PageManager → SpellsPage). */
@ -96,6 +89,7 @@ const PRIMARY_PAGE_REF_MAP = { @@ -96,6 +89,7 @@ const PRIMARY_PAGE_REF_MAP = {
relay: createRef<TPageRef>(),
search: createRef<TPageRef>(),
rss: createRef<TPageRef>(),
settings: createRef<TPageRef>(),
spells: createRef<TPageRef>()
}
@ -109,6 +103,7 @@ const getPrimaryPageMap = () => ({ @@ -109,6 +103,7 @@ const getPrimaryPageMap = () => ({
relay: <RelayPage ref={PRIMARY_PAGE_REF_MAP.relay} />,
search: <SearchPage ref={PRIMARY_PAGE_REF_MAP.search} />,
rss: <RssPage ref={PRIMARY_PAGE_REF_MAP.rss} />,
settings: <SettingsPrimaryPage ref={PRIMARY_PAGE_REF_MAP.settings} />,
spells: (
<Suspense
fallback={
@ -477,47 +472,18 @@ export function useSmartOthersRelaySettingsNavigation() { @@ -477,47 +472,18 @@ export function useSmartOthersRelaySettingsNavigation() {
return { navigateToOthersRelaySettings }
}
// Fixed: Settings navigation uses primary note view when settings is in main area; when settings list is in the right panel (Sheet), push sub-pages so they open in the panel instead of behind it.
/** Settings index is a normal primary page; sub-routes open on the secondary stack (panel / drawer). */
export function useSmartSettingsNavigation() {
const { setPrimaryNoteView, getTopSecondaryUrl } = usePrimaryNoteView()
const { navigate: navigatePrimary } = usePrimaryPage()
const { push: pushSecondaryPage } = useSecondaryPage()
const navigateToSettings = (url: string) => {
const topUrl = getTopSecondaryUrl?.()
const settingsInRightPanel = topUrl === '/settings'
// When the right panel is showing the settings list, push the sub-page so it opens in the panel instead of in the main area (behind the panel).
if (settingsInRightPanel && url !== '/settings') {
pushSecondaryPage(url)
const base = url.split('?')[0].split('#')[0]
if (base === '/settings') {
navigatePrimary('settings')
return
}
// Otherwise use primary note view (main content area)
if (url === '/settings') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<SettingsPage key="settings" index={0} hideTitlebar={true} />, 'settings')
} else if (url.startsWith('/settings/relays')) {
window.history.pushState(null, '', url)
setPrimaryNoteView(<RelaySettingsPage key="relay-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/cache') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<CacheSettingsPage key="cache-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/wallet') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<WalletPage key="wallet" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/posts') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<PostSettingsPage key="post-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/general') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<GeneralSettingsPage key="general-settings" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/translation') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<TranslationPage key="translation" index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/rss-feeds') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<RssFeedSettingsPage key="rss-feed-settings" index={0} hideTitlebar={true} />, 'settings-sub')
}
pushSecondaryPage(url)
}
return { navigateToSettings }
@ -734,24 +700,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -734,24 +700,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}
}
const goBack = () => {
// Special handling for settings sub-pages - go back to main settings page
if (primaryViewType === 'settings-sub') {
window.history.pushState(null, '', '/settings')
setPrimaryNoteView(<SettingsPage index={0} hideTitlebar={true} />, 'settings')
} else if (primaryViewType === 'following' || primaryViewType === 'mute' || primaryViewType === 'others-relay-settings') {
// Special handling for profile sub-pages - go back to main profile page
const currentPath = window.location.pathname
const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '')
const profileUrl = `/users/${profileId}`
window.history.pushState(null, '', profileUrl)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
} else {
// Use browser's back functionality for other pages
window.history.back()
}
}
// Drawer handlers
const openDrawer = useCallback((noteId: string) => {
setDrawerNoteId(noteId)
@ -1094,7 +1042,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1094,7 +1042,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// Double-pane mode: continue with stack creation
}
}
// Create a new stack item if it's a secondary route (e.g., /follow-packs, /mutes)
// Create a new stack item if it's a secondary route (e.g., /mutes)
const { component, ref } = findAndCreateComponent(state.url, state.index)
if (component) {
newStack.push({
@ -1246,6 +1194,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1246,6 +1194,21 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
// NEVER scroll to top - feed should maintain scroll position at all times
}
const goBack = () => {
if (primaryViewType === 'settings-sub') {
navigatePrimaryPage('settings')
return
}
if (primaryViewType === 'following' || primaryViewType === 'mute' || primaryViewType === 'others-relay-settings') {
const currentPath = window.location.pathname
const profileId = currentPath.replace('/users/', '').replace('/following', '').replace('/muted', '').replace('/relays', '')
const profileUrl = `/users/${profileId}`
window.history.pushState(null, '', profileUrl)
setPrimaryNoteView(<SecondaryProfilePage id={profileId} index={0} hideTitlebar={true} />, 'profile')
return
}
window.history.back()
}
const pushSecondaryPage = (url: string, index?: number) => {
logger.component('PageManager', 'pushSecondaryPage called', { url })

4
src/components/BottomNavigationBar/AccountButton.tsx

@ -15,12 +15,12 @@ export default function AccountButton() { @@ -15,12 +15,12 @@ export default function AccountButton() {
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
const active = useMemo(() => current === 'me' && display, [display, current])
const active = useMemo(() => current === 'profile' && display, [display, current])
return (
<BottomNavigationBarItem
onClick={() => {
navigate('me')
navigate(pubkey ? 'profile' : 'me')
}}
active={active}
>

149
src/components/ContentPreview/FollowPackPreview.tsx

@ -1,13 +1,16 @@ @@ -1,13 +1,16 @@
import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger'
import { cn } from '@/lib/utils'
import { useFollowList } from '@/providers/FollowListProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { Users } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { SimpleUserAvatar } from '../UserAvatar'
import { Users, ExternalLink } from 'lucide-react'
import { Button } from '../ui/button'
import { toFollowPacks } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
export default function FollowPackPreview({
event,
@ -17,76 +20,124 @@ export default function FollowPackPreview({ @@ -17,76 +20,124 @@ export default function FollowPackPreview({
className?: string
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const { followings, follow } = useFollowList()
const { mutePubkeySet } = useMuteList()
const [busy, setBusy] = useState(false)
const packPubkeys = useMemo(() => getPubkeysFromPTags(event.tags), [event.tags])
const getPackTitle = (pack: Event): string => {
const titleTag = pack.tags.find(tag => tag[0] === 'title' || tag[0] === 'name')
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')
const descTag = pack.tags.find((tag) => tag[0] === 'description' || tag[0] === 'd')
return descTag?.[1] || ''
}
const title = getPackTitle(event)
const description = getPackDescription(event)
const handleOpenInViewer = (e: React.MouseEvent) => {
e.stopPropagation()
push(toFollowPacks())
}
const followingSet = useMemo(() => new Set(followings), [followings])
const availablePubkeys = useMemo(
() => packPubkeys.filter((p) => !mutePubkeySet.has(p)),
[packPubkeys, mutePubkeySet]
)
const alreadyFollowingAll =
availablePubkeys.length > 0 && availablePubkeys.every((p) => followingSet.has(p))
const toFollowCount = availablePubkeys.filter((p) => !followingSet.has(p)).length
const handleFollowPack = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
if (!pubkey) {
toast.error(t('Please log in to follow'))
return
}
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
}
setBusy(true)
try {
for (const pubkeyToFollow of toFollow) {
await follow(pubkeyToFollow)
}
toast.success(t('Followed {{count}} users', { count: toFollow.length }))
} catch (error) {
logger.error('Failed to follow pack', { error })
toast.error(t('Failed to follow pack') + ': ' + (error as Error).message)
} finally {
setBusy(false)
}
},
[pubkey, packPubkeys, followingSet, mutePubkeySet, follow, t]
)
return (
<div className={cn('border rounded-lg p-3 bg-muted/30', className)}>
<div className="flex items-center gap-1 mb-2">
<span className="text-muted-foreground">[{t('Follow Pack')}]</span>
<span className="font-semibold text-sm">{title}</span>
<div className={cn('rounded-lg border bg-muted/30 p-3', className)}>
<div className="mb-2 flex items-center gap-1">
<span className="text-sm text-muted-foreground">[{t('Follow Pack')}]</span>
<span className="text-sm font-semibold">{title}</span>
</div>
{description && (
<div className="text-sm text-muted-foreground mb-3 line-clamp-2">
{description}
</div>
)}
<div className="flex items-center gap-3 mb-3">
{description ? (
<div className="mb-3 line-clamp-2 text-sm text-muted-foreground">{description}</div>
) : null}
<div className="mb-3 flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="size-4" />
<span>{t('{{count}} profiles', { count: packPubkeys.length })}</span>
<span>{t('{{count}} profiles', { count: availablePubkeys.length })}</span>
</div>
{packPubkeys.length > 0 && (
{availablePubkeys.length > 0 ? (
<div className="flex -space-x-2">
{packPubkeys.slice(0, 5).map((pubkey) => (
<SimpleUserAvatar
key={pubkey}
userId={pubkey}
size="small"
{availablePubkeys.slice(0, 5).map((pk) => (
<SimpleUserAvatar
key={pk}
userId={pk}
size="small"
className="border-2 border-background"
/>
))}
{packPubkeys.length > 5 && (
<div className="size-7 rounded-full border-2 border-background bg-muted flex items-center justify-center text-xs text-muted-foreground">
+{packPubkeys.length - 5}
{availablePubkeys.length > 5 ? (
<div className="flex size-7 items-center justify-center rounded-full border-2 border-background bg-muted text-xs text-muted-foreground">
+{availablePubkeys.length - 5}
</div>
)}
) : null}
</div>
)}
) : null}
</div>
<Button
variant="outline"
size="sm"
onClick={handleOpenInViewer}
className="w-full"
>
<ExternalLink className="size-4 mr-2" />
{t('Open in Follow Pack Viewer')}
</Button>
{!pubkey ? (
<p className="text-sm text-muted-foreground">{t('Please log in to follow')}</p>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={alreadyFollowingAll || busy}
onClick={handleFollowPack}
>
{alreadyFollowingAll ? (
t('Following All')
) : (
<>
{t('Follow')}
{toFollowCount > 0 ? ` (${toFollowCount})` : ''}
</>
)}
</Button>
)}
</div>
)
}

117
src/components/Explore/ExploreFavoriteRelays.tsx

@ -0,0 +1,117 @@ @@ -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>
)
}

2
src/components/Note/index.tsx

@ -126,7 +126,7 @@ export default function Note({ @@ -126,7 +126,7 @@ export default function Note({
ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT,
ExtendedKind.PUBLICATION_CONTENT, // Only for rendering embedded content, not in feeds
ExtendedKind.FOLLOW_PACK, // Only for rendering embedded content, not in feeds
ExtendedKind.FOLLOW_PACK, // Follow-pack feed + embedded previews
ExtendedKind.CITATION_INTERNAL, // Citations for rendering
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,

12
src/components/Profile/index.tsx

@ -16,7 +16,7 @@ import { kinds, type NostrEvent } from 'nostr-tools' @@ -16,7 +16,7 @@ import { kinds, type NostrEvent } from 'nostr-tools'
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
@ -27,7 +27,7 @@ import { @@ -27,7 +27,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Link, Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code } from 'lucide-react'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@ -38,7 +38,6 @@ import ProfileFeedWithPins from './ProfileFeedWithPins' @@ -38,7 +38,6 @@ import ProfileFeedWithPins from './ProfileFeedWithPins'
import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
import { toFollowPacks } from '@/lib/link'
import ZapDialog from '@/components/ZapDialog'
import PaytoLink from '@/components/PaytoLink'
import PostEditor from '@/components/PostEditor'
@ -157,6 +156,7 @@ function mergePaymentMethods( @@ -157,6 +156,7 @@ function mergePaymentMethods(
export default function Profile({ id }: { id?: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { navigate: navigatePrimary } = usePrimaryPage()
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
@ -399,9 +399,9 @@ export default function Profile({ id }: { id?: string }) { @@ -399,9 +399,9 @@ export default function Profile({ id }: { id?: string }) {
<MapPin />
{t('Schedule in-person meeting')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toFollowPacks())}>
<Link />
{t('Browse follow packs')}
<DropdownMenuItem onClick={() => navigatePrimary('spells', { spell: 'followPacks' })}>
<Gift />
{t('Follow Packs')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toProfileEditor())}>
<Pencil />

171
src/components/Settings/SettingsMenuBody.tsx

@ -0,0 +1,171 @@ @@ -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'

93
src/components/Sidebar/AccountButton.tsx

@ -11,7 +11,7 @@ import { toWallet } from '@/lib/link' @@ -11,7 +11,7 @@ import { toWallet } from '@/lib/link'
import { formatPubkey, generateImageByPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, LogIn, LogOut, UserRound, Wallet } from 'lucide-react'
import { ArrowDownUp, LogIn, LogOut, MoreVertical, Wallet } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
@ -39,55 +39,62 @@ function ProfileButton() { @@ -39,55 +39,62 @@ function ProfileButton() {
if (!pubkey) return null
const defaultAvatar = generateImageByPubkey(pubkey)
// Fallback to formatted npub if no profile
const npub = pubkeyToNpub(pubkey)
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey)
const { username, avatar } = profile || { username: fallbackUsername, avatar: defaultAvatar }
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="clickable shadow-none p-2 xl:px-2 xl:py-2 w-12 h-12 xl:w-full xl:h-auto flex items-center bg-transparent text-foreground hover:text-accent-foreground rounded-lg justify-start gap-4 text-lg font-semibold"
>
<div className="flex gap-2 items-center flex-1 w-0">
<Avatar className="w-8 h-8">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
<div className="truncate font-semibold text-lg">{username}</div>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top">
<DropdownMenuItem onClick={() => navigate('profile')}>
<UserRound />
{t('Profile')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => push(toWallet())}>
<Wallet />
{t('Wallet')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp />
{t('Switch account')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
<LogOut />
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
<div className="flex w-full min-w-0 items-center gap-0.5 xl:gap-1">
<Button
type="button"
variant="ghost"
title={t('Profile')}
className="clickable h-12 min-w-0 flex-1 justify-start gap-2 rounded-lg bg-transparent p-2 text-lg font-semibold text-foreground shadow-none hover:text-accent-foreground xl:px-2 xl:py-2"
onClick={() => navigate('profile')}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} alt="" />
</AvatarFallback>
</Avatar>
<span className="truncate max-xl:hidden">{username}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 shrink-0 rounded-lg"
title={t('Account menu')}
aria-label={t('Account menu')}
>
<MoreVertical className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end">
<DropdownMenuItem onClick={() => push(toWallet())}>
<Wallet />
{t('Wallet')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp />
{t('Switch account')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
<LogOut />
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</DropdownMenu>
</div>
)
}

26
src/components/Sidebar/SettingsButton.tsx

@ -1,26 +0,0 @@ @@ -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>
)
}

2
src/components/Sidebar/index.tsx

@ -10,7 +10,6 @@ import NotificationButton from './NotificationButton' @@ -10,7 +10,6 @@ import NotificationButton from './NotificationButton'
import PostButton from './PostButton'
import RssButton from './RssButton'
import SearchButton from './SearchButton'
import SettingsButton from './SettingsButton'
import SpellsButton from './SpellsButton'
import PaneModeToggle from './PaneModeToggle'
@ -37,7 +36,6 @@ export default function PrimaryPageSidebar() { @@ -37,7 +36,6 @@ export default function PrimaryPageSidebar() {
<SearchButton />
<SpellsButton />
<RssButton />
<SettingsButton />
<PostButton />
</div>
<div className="space-y-2">

4
src/components/Titlebar/AccountButton.tsx

@ -15,13 +15,13 @@ export default function AccountButton() { @@ -15,13 +15,13 @@ export default function AccountButton() {
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
[profile]
)
const active = useMemo(() => current === 'me' && display, [display, current])
const active = useMemo(() => current === 'profile' && display, [display, current])
return (
<Button
variant="ghost"
size="titlebar-icon"
onClick={() => navigate('me')}
onClick={() => navigate(pubkey ? 'profile' : 'me')}
className={active ? 'bg-accent/50' : ''}
>
{pubkey ? (

1
src/constants.ts

@ -282,6 +282,7 @@ export const FAUX_SPELL_ORDER = [ @@ -282,6 +282,7 @@ export const FAUX_SPELL_ORDER = [
'notifications',
'discussions',
'following',
'followPacks',
'media',
'interests',
'bookmarks',

14
src/i18n/locales/de.ts

@ -138,6 +138,8 @@ export default { @@ -138,6 +138,8 @@ export default {
Delete: 'Löschen',
'Relay already exists': 'Relay existiert bereits',
'invalid relay URL': 'Ungültige Relay-URL',
'Relay URL…': 'wss://… oder Relay-Host',
'Open relay': 'Relay öffnen',
'Add a new relay': 'Neues Relay hinzufügen',
back: 'Zurück',
'Lost in the void': 'Verloren im Nichts',
@ -312,6 +314,7 @@ export default { @@ -312,6 +314,7 @@ export default {
'no more replies': 'keine weiteren Antworten',
'Relay sets': 'Relay-Sets',
'Favorite Relays': 'Lieblings-Relays',
'Using app default relays': 'Standard-Relays der App',
"Following's Favorites": 'Favoriten der Folgenden',
'no more relays': 'keine weiteren Relays',
'Favorited by': 'Favorisiert von',
@ -327,6 +330,17 @@ export default { @@ -327,6 +330,17 @@ export default {
'no bookmarks found': 'Keine Lesezeichen gefunden',
'no more bookmarks': 'Keine weiteren Lesezeichen',
Bookmarks: 'Lesezeichen',
'Follow Packs': 'Follow-Packs',
'Follow Pack': 'Follow-Pack',
'Please log in to follow': 'Zum Folgen bitte anmelden',
'Following All': 'Allen gefolgt',
'Followed {{count}} users': '{{count}} Nutzer:innen gefolgt',
'All available members are already followed or muted':
'Alle verfügbaren Mitglieder werden bereits gefolgt oder sind stummgeschaltet',
'You are already following all members of this pack':
'Du folgst bereits allen Mitgliedern dieses Packs',
'Failed to follow pack': 'Follow-Pack fehlgeschlagen',
'{{count}} profiles': '{{count}} Profile',
'Show more': 'Mehr anzeigen',
General: 'Allgemein',
Autoplay: 'Automatische Wiedergabe',

14
src/i18n/locales/en.ts

@ -196,6 +196,8 @@ export default { @@ -196,6 +196,8 @@ export default {
Delete: 'Delete',
'Relay already exists': 'Relay already exists',
'invalid relay URL': 'invalid relay URL',
'Relay URL…': 'wss://… or relay host',
'Open relay': 'Open relay',
'Add a new relay': 'Add a new relay',
back: 'back',
'Lost in the void': 'Lost in the void',
@ -379,6 +381,7 @@ export default { @@ -379,6 +381,7 @@ export default {
'no more replies': 'no more replies',
'Relay sets': 'Relay sets',
'Favorite Relays': 'Favorite Relays',
'Using app default relays': 'Using app default relays',
"Following's Favorites": "Following's Favorites",
'no more relays': 'no more relays',
'Favorited by': 'Favorited by',
@ -394,6 +397,17 @@ export default { @@ -394,6 +397,17 @@ export default {
'no bookmarks found': 'no bookmarks found',
'no more bookmarks': 'no more bookmarks',
Bookmarks: 'Bookmarks',
'Follow Packs': 'Follow Packs',
'Follow Pack': 'Follow Pack',
'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users',
'All available members are already followed or muted':
'All available members are already followed or muted',
'You are already following all members of this pack':
'You are already following all members of this pack',
'Failed to follow pack': 'Failed to follow pack',
'{{count}} profiles': '{{count}} profiles',
'Show more': 'Show more',
General: 'General',
Autoplay: 'Autoplay',

1
src/lib/link.ts

@ -76,7 +76,6 @@ export const toProfileEditor = () => '/profile-editor' @@ -76,7 +76,6 @@ export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
export const toMuteList = () => '/mutes'
export const toFollowPacks = () => '/follow-packs'
export const toSpells = () => '/spells'
export const toChachiChat = (relay: string, d: string) => {

182
src/pages/primary/ExplorePage/index.tsx

@ -1,12 +1,59 @@ @@ -1,12 +1,59 @@
import Explore from '@/components/Explore'
import ExploreFavoriteRelays from '@/components/Explore/ExploreFavoriteRelays'
import FollowingFavoriteRelayList from '@/components/FollowingFavoriteRelayList'
import Tabs from '@/components/Tabs'
import VersionUpdateBanner from '@/components/VersionUpdateBanner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toRelay } from '@/lib/link'
import { cn } from '@/lib/utils'
import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Compass, Plus } from 'lucide-react'
import { forwardRef, useEffect, useState } from 'react'
import { useSmartRelayNavigation } from '@/PageManager'
import nip66Service from '@/services/nip66.service'
import { ArrowRight, Compass, Plus } from 'lucide-react'
import { forwardRef, FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const RELAY_SUGGESTION_LIMIT = 20
function dedupeNormalizedRelayUrls(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const k = normalizeUrl(u) || u
if (!k || seen.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}
/** Lower rank = better match for ordering suggestions. */
function relaySuggestionRank(normalizedUrl: string, queryLower: string): number {
const n = normalizedUrl.toLowerCase()
const simple = simplifyUrl(n).toLowerCase()
if (!queryLower) return 99
if (n === queryLower || simple === queryLower) return 0
if (simple.startsWith(queryLower) || n.startsWith(`wss://${queryLower}`) || n.startsWith(`ws://${queryLower}`))
return 1
if (simple.includes(queryLower) || n.includes(queryLower)) return 2
return 99
}
function filterMonitoringRelaySuggestions(urls: string[], rawQuery: string): string[] {
const q = rawQuery.trim().toLowerCase()
if (!q) return []
const matches = urls.filter((url) => relaySuggestionRank(url, q) < 99)
matches.sort((a, b) => {
const ra = relaySuggestionRank(a, q)
const rb = relaySuggestionRank(b, q)
if (ra !== rb) return ra - rb
return simplifyUrl(a).localeCompare(simplifyUrl(b), undefined, { sensitivity: 'base' })
})
return matches.slice(0, RELAY_SUGGESTION_LIMIT)
}
type TExploreTabs = 'explore' | 'following'
@ -35,7 +82,7 @@ const ExplorePage = forwardRef((_, ref) => { @@ -35,7 +82,7 @@ const ExplorePage = forwardRef((_, ref) => {
<PrimaryPageLayout
ref={ref}
pageName="home"
titlebar={<ExplorePageTitlebar t={t} />}
titlebar={<ExplorePageTitlebar />}
subHeader={
<Tabs
value={tab}
@ -59,7 +106,12 @@ const ExplorePage = forwardRef((_, ref) => { @@ -59,7 +106,12 @@ const ExplorePage = forwardRef((_, ref) => {
<div className="px-2">
<VersionUpdateBanner />
</div>
{tab === 'explore' && <Explore />}
{tab === 'explore' && (
<>
<ExploreFavoriteRelays />
<Explore />
</>
)}
{tab === 'following' && <FollowingFavoriteRelayList />}
</div>
</PrimaryPageLayout>
@ -68,17 +120,129 @@ const ExplorePage = forwardRef((_, ref) => { @@ -68,17 +120,129 @@ const ExplorePage = forwardRef((_, ref) => {
ExplorePage.displayName = 'ExplorePage'
export default ExplorePage
function ExplorePageTitlebar({ t }: { t: (key: string) => string }) {
function ExplorePageTitlebar() {
const { t } = useTranslation()
const { navigateToRelay } = useSmartRelayNavigation()
const [relayQuery, setRelayQuery] = useState('')
const [monitoringRelays, setMonitoringRelays] = useState<string[]>([])
const [suggestOpen, setSuggestOpen] = useState(false)
const blurCloseTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
nip66Service.getPublicLivelyRelayUrls().then((urls) => {
setMonitoringRelays(dedupeNormalizedRelayUrls(urls ?? []))
})
}, [])
useEffect(() => {
return () => {
if (blurCloseTimer.current != null) clearTimeout(blurCloseTimer.current)
}
}, [])
const relaySuggestions = useMemo(
() => filterMonitoringRelaySuggestions(monitoringRelays, relayQuery),
[monitoringRelays, relayQuery]
)
const clearBlurTimer = () => {
if (blurCloseTimer.current != null) {
clearTimeout(blurCloseTimer.current)
blurCloseTimer.current = null
}
}
const openRelayAndReset = (normalizedUrl: string) => {
navigateToRelay(toRelay(normalizedUrl))
setRelayQuery('')
setSuggestOpen(false)
}
const tryOpenRelay = () => {
const trimmed = relayQuery.trim()
if (!trimmed) return
const normalized = normalizeUrl(trimmed)
if (!normalized || !isWebsocketUrl(normalized)) {
toast.error(t('invalid relay URL'))
return
}
openRelayAndReset(normalized)
}
const onSubmitRelay = (e: FormEvent) => {
e.preventDefault()
tryOpenRelay()
}
return (
<div className="flex gap-2 justify-between h-full">
<div className="flex gap-2 items-center h-full pl-3">
<Compass />
<div className="flex h-full min-w-0 w-full flex-wrap items-center justify-between gap-2 gap-y-2 px-2 py-1 sm:pl-3 sm:pr-2">
<div className="flex shrink-0 items-center gap-2">
<Compass className="size-5 shrink-0" />
<div className="text-lg font-semibold">{t('Explore')}</div>
</div>
<div className="relative min-w-0 max-w-xl flex-1 basis-full sm:basis-64">
<form className="flex items-center gap-1.5" onSubmit={onSubmitRelay}>
<Input
type="text"
inputMode="url"
autoComplete="off"
placeholder={t('Relay URL…')}
className="h-9 min-w-0 flex-1 font-mono text-sm"
value={relayQuery}
onChange={(e) => setRelayQuery(e.target.value)}
aria-label={t('Relay URL…')}
aria-autocomplete="list"
aria-expanded={suggestOpen && relaySuggestions.length > 0}
aria-controls="explore-relay-suggestions"
role="combobox"
onFocus={() => {
clearBlurTimer()
setSuggestOpen(true)
}}
onBlur={() => {
clearBlurTimer()
blurCloseTimer.current = setTimeout(() => setSuggestOpen(false), 200)
}}
/>
<Button
type="submit"
variant="secondary"
size="icon"
className="h-9 w-9 shrink-0"
title={t('Open relay')}
>
<ArrowRight className="size-4" />
</Button>
</form>
{suggestOpen && relaySuggestions.length > 0 ? (
<ul
id="explore-relay-suggestions"
role="listbox"
className={cn(
'absolute left-0 right-12 top-full z-50 mt-1 max-h-60 overflow-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-md'
)}
onMouseDown={(e) => e.preventDefault()}
>
{relaySuggestions.map((url) => (
<li key={url} role="presentation">
<button
type="button"
role="option"
className="flex w-full flex-col items-stretch gap-0.5 px-3 py-2 text-left text-sm hover:bg-accent focus:bg-accent focus:outline-none"
onClick={() => openRelayAndReset(url)}
>
<span className="truncate font-mono">{simplifyUrl(url)}</span>
<span className="truncate text-xs text-muted-foreground">{url}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
<Button
variant="ghost"
size="titlebar-icon"
className="relative w-fit px-3"
className="relative w-fit shrink-0 px-3"
onClick={() => {
window.open(
'https://github.com/CodyTseng/awesome-nostr-relays/issues/new?template=add-relay.md',

11
src/pages/primary/MePage/index.tsx

@ -3,12 +3,11 @@ import LoginDialog from '@/components/LoginDialog' @@ -3,12 +3,11 @@ import LoginDialog from '@/components/LoginDialog'
import LogoutDialog from '@/components/LogoutDialog'
import PubkeyCopy from '@/components/PubkeyCopy'
import NpubQrCode from '@/components/NpubQrCode'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { toProfile, toRelaySettings, toSettings, toWallet } from '@/lib/link'
import { toProfile, toRelaySettings, toWallet } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
@ -103,12 +102,10 @@ MePage.displayName = 'MePage' @@ -103,12 +102,10 @@ MePage.displayName = 'MePage'
export default MePage
function MePageTitlebar() {
const { push } = useSecondaryPage()
const { t } = useTranslation()
return (
<div className="flex justify-end items-center">
<Button variant="ghost" size="titlebar-icon" onClick={() => push(toSettings())}>
<Settings />
</Button>
<div className="flex h-full items-center pl-3">
<div className="text-lg font-semibold">{t('YouTabName')}</div>
</div>
)
}

25
src/pages/primary/ProfilePage/index.tsx

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
import Profile from '@/components/Profile'
import { Button } from '@/components/ui/button'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { Settings, UserRound } from 'lucide-react'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
@ -26,11 +28,26 @@ export default ProfilePage @@ -26,11 +28,26 @@ export default ProfilePage
function ProfilePageTitlebar() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { navigate } = usePrimaryPage()
return (
<div className="flex gap-2 items-center h-full pl-3">
<UserRound />
<div className="text-lg font-semibold">{t('Profile')}</div>
<div className="flex h-full w-full items-center justify-between gap-2 pl-3 pr-1">
<div className="flex min-w-0 items-center gap-2">
<UserRound className="size-5 shrink-0" />
<div className="truncate text-lg font-semibold">{t('Profile')}</div>
</div>
{pubkey ? (
<Button
type="button"
variant="ghost"
size="titlebar-icon"
title={t('Settings')}
onClick={() => navigate('settings')}
>
<Settings className="size-5" />
</Button>
) : null}
</div>
)
}

29
src/pages/primary/SettingsPrimaryPage/index.tsx

@ -0,0 +1,29 @@ @@ -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

14
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -135,6 +135,20 @@ export function buildCalendarSpellFilter(): Filter { @@ -135,6 +135,20 @@ export function buildCalendarSpellFilter(): Filter {
}
}
const FOLLOW_PACK_LIMIT = 100
/** Kind 39089 follow/starter packs from fast read relays (same scope as the old Follow Packs page). */
export function buildFollowPacksSubRequests(): TFeedSubRequest[] {
const urls = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
if (!urls.length) return []
return [
{
urls,
filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FOLLOW_PACK_LIMIT }
}
]
}
/** One subrequest per topic (OR). Uses same kind set as the main profile/favorites feed. */
export function buildInterestsSubRequests(
relayUrls: string[],

11
src/pages/primary/SpellsPage/index.tsx

@ -56,6 +56,7 @@ import { @@ -56,6 +56,7 @@ import {
ChevronDown,
Copy,
FileText,
Gift,
Hash,
Image as ImageIcon,
MessageSquare,
@ -76,6 +77,7 @@ import { @@ -76,6 +77,7 @@ import {
buildBookmarksSubRequests,
buildCalendarSpellFilter,
buildDiscussionFilter,
buildFollowPacksSubRequests,
buildInterestsSubRequests,
buildMediaSpellFilter,
buildNotificationFilter,
@ -206,6 +208,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string { @@ -206,6 +208,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string {
return 'Discussions'
case 'following':
return 'Following'
case 'followPacks':
return 'Follow Packs'
case 'media':
return 'Media'
case 'interests':
@ -223,6 +227,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = { @@ -223,6 +227,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = {
notifications: Bell,
discussions: MessageSquare,
following: Users,
followPacks: Gift,
media: ImageIcon,
interests: Hash,
bookmarks: Bookmark,
@ -439,6 +444,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -439,6 +444,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const urls = notificationRelayUrls(relayList, favoriteRelays)
return buildBookmarksSubRequests(bookmarkListEvent, urls)
}
if (selectedFauxSpell === 'followPacks') {
return buildFollowPacksSubRequests()
}
return []
}, [
selectedFauxSpell,
@ -557,6 +565,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -557,6 +565,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'following') {
return kindFilterShowKinds
}
if (selectedFauxSpell === 'followPacks') {
return [ExtendedKind.FOLLOW_PACK]
}
if (selectedFauxSpell === 'media') {
return [...MEDIA_SPELL_KINDS]
}

254
src/pages/secondary/FollowPacksPage/index.tsx

@ -1,254 +0,0 @@ @@ -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

11
src/pages/secondary/FollowPacksRedirect.tsx

@ -0,0 +1,11 @@ @@ -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
}

172
src/pages/secondary/SettingsPage/index.tsx

@ -1,170 +1,18 @@ @@ -1,170 +1,18 @@
import AboutInfoDialog from '@/components/AboutInfoDialog'
import SettingsMenuBody from '@/components/Settings/SettingsMenuBody'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
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 { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { pubkey, nsec, ncryptsec } = useNostr()
const { navigateToSettings } = useSmartSettingsNavigation()
const [copiedNsec, setCopiedNsec] = useState(false)
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
const SettingsPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
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 (
<div
className={cn(
'flex justify-between select-none items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
ref={ref}
>
{children}
</div>
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Settings')}>
<SettingsMenuBody />
</SecondaryPageLayout>
)
}
)
SettingItem.displayName = 'SettingItem'
SettingsPage.displayName = 'SettingsPage'
export default SettingsPage

4
src/routes.tsx

@ -19,7 +19,7 @@ import SearchPage from './pages/secondary/SearchPage' @@ -19,7 +19,7 @@ import SearchPage from './pages/secondary/SearchPage'
import SettingsPage from './pages/secondary/SettingsPage'
import TranslationPage from './pages/secondary/TranslationPage'
import WalletPage from './pages/secondary/WalletPage'
import FollowPacksPage from './pages/secondary/FollowPacksPage'
import FollowPacksRedirect from './pages/secondary/FollowPacksRedirect'
const ROUTES = [
{ path: '/notes', element: <NoteListPage /> },
@ -52,7 +52,7 @@ const ROUTES = [ @@ -52,7 +52,7 @@ const ROUTES = [
{ path: '/settings/rss-feeds', element: <RssFeedSettingsPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> },
{ path: '/follow-packs', element: <FollowPacksPage /> }
{ path: '/follow-packs', element: <FollowPacksRedirect /> }
]
export const routes = ROUTES.map(({ path, element }) => ({

Loading…
Cancel
Save