feat: community mode (#738)

Co-authored-by: CXPLAY <62034099+cxplay@users.noreply.github.com>
This commit is contained in:
Cody Tseng 2026-01-24 00:09:10 +08:00 committed by GitHub
parent 686b1f9998
commit ed8a22d5bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 303 additions and 101 deletions

View file

@ -0,0 +1,16 @@
import { usePrimaryPage } from '@/PageManager'
import { UsersRound } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function FollowingButton() {
const { navigate, current, display } = usePrimaryPage()
return (
<BottomNavigationBarItem
active={current === 'following' && display}
onClick={() => navigate('following')}
>
<UsersRound />
</BottomNavigationBarItem>
)
}

View file

@ -1,7 +1,9 @@
import { IS_COMMUNITY_MODE } from '@/constants'
import { cn } from '@/lib/utils'
import BackgroundAudio from '../BackgroundAudio'
import AccountButton from './AccountButton'
import ExploreButton from './ExploreButton'
import FollowingButton from './FollowingButton'
import HomeButton from './HomeButton'
import NotificationsButton from './NotificationsButton'
@ -16,7 +18,8 @@ export default function BottomNavigationBar() {
<BackgroundAudio className="rounded-none border-x-0 border-b border-t-0 bg-background" />
<div className="flex w-full items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0">
<HomeButton />
<ExploreButton />
{!IS_COMMUNITY_MODE && <ExploreButton />}
{IS_COMMUNITY_MODE && <FollowingButton />}
<NotificationsButton />
<AccountButton />
</div>

View file

@ -1,3 +1,4 @@
import { IS_COMMUNITY_MODE } from '@/constants'
import { toRelay } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useSortable } from '@dnd-kit/sortable'
@ -38,7 +39,7 @@ export default function RelayItem({ relay }: { relay: string }) {
<div className="w-0 flex-1 truncate font-semibold">{relay}</div>
</div>
</div>
<SaveRelayDropdownMenu urls={[relay]} />
{!IS_COMMUNITY_MODE && <SaveRelayDropdownMenu urls={[relay]} />}
</div>
)
}

View file

@ -1,3 +1,4 @@
import { IS_COMMUNITY_MODE, COMMUNITY_RELAY_SETS, COMMUNITY_RELAYS } from '@/constants'
import { toRelaySettings } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
@ -18,12 +19,45 @@ export default function FeedSwitcher({ close }: { close?: () => void }) {
const { relaySets, favoriteRelays } = useFavoriteRelays()
const { feedInfo, switchFeed } = useFeed()
const { pinnedPubkeySet } = usePinnedUsers()
const filteredRelaySets = useMemo(
() => relaySets.filter((set) => set.relayUrls.length > 0),
[relaySets]
)
const filteredRelaySets = useMemo(() => {
return relaySets.filter((set) => set.relayUrls.length > 0)
}, [relaySets])
const hasRelays = filteredRelaySets.length > 0 || favoriteRelays.length > 0
if (IS_COMMUNITY_MODE) {
return (
<div className="space-y-1.5">
{COMMUNITY_RELAY_SETS.map((set) => (
<RelaySetCard
key={set.id}
relaySet={set}
select={feedInfo?.feedType === 'relays' && set.id === feedInfo.id}
onSelectChange={(select) => {
if (!select) return
switchFeed('relays', { activeRelaySetId: set.id })
close?.()
}}
/>
))}
{COMMUNITY_RELAYS.map((relay) => (
<FeedSwitcherItem
key={relay}
isActive={feedInfo?.feedType === 'relay' && feedInfo.id === relay}
onClick={() => {
switchFeed('relay', { relay })
close?.()
}}
>
<div className="flex w-full items-center gap-3">
<RelayIcon url={relay} className="shrink-0" />
<div className="w-0 flex-1 truncate">{simplifyUrl(relay)}</div>
</div>
</FeedSwitcherItem>
))}
</div>
)
}
return (
<div className="space-y-4">
{/* Personal Feeds Section */}

View file

@ -1,7 +1,9 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { IS_COMMUNITY_MODE } from '@/constants'
import { useFetchRelayInfo } from '@/hooks'
import { createFakeEvent } from '@/lib/event'
import { checkNip43Support } from '@/lib/relay'
import { normalizeHttpUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
@ -10,6 +12,7 @@ import { Check, Copy, GitBranch, Mail, Share2, SquareCode } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import Content from '../Content'
import PostEditor from '../PostEditor'
import RelayIcon from '../RelayIcon'
import RelayMembershipControl from '../RelayMembershipControl'
@ -17,8 +20,6 @@ import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import RelayReviewsPreview from './RelayReviewsPreview'
import Content from '../Content'
import { createFakeEvent } from '@/lib/event'
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
const { t } = useTranslation()
@ -161,7 +162,7 @@ function RelayControls({ url }: { url: string }) {
<Button variant="ghost" size="titlebar-icon" onClick={handleCopyUrl}>
{copiedUrl ? <Check /> : <Copy />}
</Button>
<SaveRelayDropdownMenu urls={[url]} bigButton />
{!IS_COMMUNITY_MODE && <SaveRelayDropdownMenu urls={[url]} bigButton />}
</div>
)
}

View file

@ -1,4 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton'
import { IS_COMMUNITY_MODE } from '@/constants'
import { cn } from '@/lib/utils'
import { TRelayInfo } from '@/types'
import { HTMLProps } from 'react'
@ -30,7 +31,7 @@ export default function RelaySimpleInfo({
)}
</div>
</div>
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
{relayInfo && !IS_COMMUNITY_MODE && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
</div>
{!!relayInfo?.description && (
<div

View file

@ -0,0 +1,20 @@
import { usePrimaryPage } from '@/PageManager'
import { Users2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem'
export default function FollowingButton({ collapse }: { collapse: boolean }) {
const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage()
return (
<SidebarItem
title={t('Following')}
onClick={() => navigate('following')}
active={display && current === 'following'}
collapse={collapse}
>
<Users2 />
</SidebarItem>
)
}

View file

@ -1,5 +1,6 @@
import Icon from '@/assets/Icon'
import Logo from '@/assets/Logo'
import { IS_COMMUNITY_MODE } from '@/constants'
import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
@ -10,6 +11,7 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'
import AccountButton from './AccountButton'
import BookmarkButton from './BookmarkButton'
import RelaysButton from './ExploreButton'
import FollowingButton from './FollowingButton'
import HomeButton from './HomeButton'
import LayoutSwitcher from './LayoutSwitcher'
import NotificationsButton from './NotificationButton'
@ -53,7 +55,8 @@ export default function PrimaryPageSidebar() {
</button>
)}
<HomeButton collapse={sidebarCollapse} />
<RelaysButton collapse={sidebarCollapse} />
{!IS_COMMUNITY_MODE && <RelaysButton collapse={sidebarCollapse} />}
{IS_COMMUNITY_MODE && <FollowingButton collapse={sidebarCollapse} />}
<NotificationsButton collapse={sidebarCollapse} />
<SearchButton collapse={sidebarCollapse} />
<ProfileButton collapse={sidebarCollapse} />

View file

@ -1,4 +1,5 @@
import { kinds } from 'nostr-tools'
import { TRelaySet } from './types'
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
@ -483,3 +484,8 @@ export const SPECIAL_TRUST_SCORE_FILTER_ID = {
NAK: 'nak',
TRENDING: 'trending'
}
export const COMMUNITY_RELAY_SETS = import.meta.env.VITE_COMMUNITY_RELAY_SETS as TRelaySet[]
export const COMMUNITY_RELAYS = import.meta.env.VITE_COMMUNITY_RELAYS as string[]
export const IS_COMMUNITY_MODE = COMMUNITY_RELAY_SETS.length > 0 || COMMUNITY_RELAYS.length > 0

View file

@ -0,0 +1,32 @@
import FollowingFeed from '@/components/FollowingFeed'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { TPageRef } from '@/types'
import { UsersRound } from 'lucide-react'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
const FollowingPage = forwardRef<TPageRef>((_, ref) => {
return (
<PrimaryPageLayout
pageName="following"
titlebar={<FollowingPageTitlebar />}
displayScrollToTopButton
ref={ref}
>
<FollowingFeed />
</PrimaryPageLayout>
)
})
FollowingPage.displayName = 'FollowingPage'
export default FollowingPage
function FollowingPageTitlebar() {
const { t } = useTranslation()
return (
<div className="flex h-full items-center gap-2 pl-3">
<UsersRound />
<div className="text-lg font-semibold">{t('Following')}</div>
</div>
)
}

View file

@ -2,6 +2,7 @@ import FeedSwitcher from '@/components/FeedSwitcher'
import RelayIcon from '@/components/RelayIcon'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { IS_COMMUNITY_MODE, COMMUNITY_RELAY_SETS, COMMUNITY_RELAYS } from '@/constants'
import { simplifyUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -15,6 +16,10 @@ export default function FeedButton({ className }: { className?: string }) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
if (IS_COMMUNITY_MODE && COMMUNITY_RELAY_SETS.length + COMMUNITY_RELAYS.length <= 1) {
return <FeedSwitcherTrigger className={className} />
}
if (isSmallScreen) {
return (
<>
@ -61,7 +66,8 @@ const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
const { relaySets } = useFavoriteRelays()
const activeRelaySet = useMemo(() => {
return feedInfo?.feedType === 'relays' && feedInfo.id
? relaySets.find((set) => set.id === feedInfo.id)
? (relaySets.find((set) => set.id === feedInfo.id) ??
COMMUNITY_RELAY_SETS.find((set) => set.id === feedInfo.id))
: undefined
}, [feedInfo, relaySets])
const title = useMemo(() => {
@ -78,7 +84,7 @@ const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
return simplifyUrl(feedInfo?.id ?? '')
}
if (feedInfo?.feedType === 'relays') {
return activeRelaySet?.name ?? activeRelaySet?.id
return feedInfo.name ?? activeRelaySet?.name ?? activeRelaySet?.id
}
}, [feedInfo, activeRelaySet])
@ -92,15 +98,22 @@ const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
return <Server />
}, [feedInfo])
const clickable =
!IS_COMMUNITY_MODE || COMMUNITY_RELAY_SETS.length + COMMUNITY_RELAYS.length > 1
return (
<div
className={cn('clickable flex h-full items-center gap-2 rounded-xl px-3', className)}
className={cn(
'flex h-full items-center gap-2 rounded-xl px-3',
clickable && 'clickable',
className
)}
ref={ref}
{...props}
>
{icon}
<div className="truncate text-lg font-semibold">{title}</div>
<ChevronDown />
{clickable && <ChevronDown />}
</div>
)
}

View file

@ -1,4 +1,5 @@
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
import FollowingFeed from '@/components/FollowingFeed'
import PostEditor from '@/components/PostEditor'
import RelayInfo from '@/components/RelayInfo'
import { Button } from '@/components/ui/button'
@ -21,7 +22,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import FeedButton from './FeedButton'
import FollowingFeed from './FollowingFeed'
import PinnedFeed from './PinnedFeed'
import RelaysFeed from './RelaysFeed'

View file

@ -1,6 +1,7 @@
import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import MailboxSetting from '@/components/MailboxSetting'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { IS_COMMUNITY_MODE } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -20,6 +21,16 @@ const RelaySettingsPage = forwardRef(({ index }: { index?: number }, ref) => {
}
}, [])
if (IS_COMMUNITY_MODE) {
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Relay settings')}>
<div className="space-y-4 px-4 py-3">
<MailboxSetting />
</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Relay settings')}>
<Tabs value={tabValue} onValueChange={setTabValue} className="space-y-4 px-4 py-3">

View file

@ -1,3 +1,4 @@
import { IS_COMMUNITY_MODE, COMMUNITY_RELAY_SETS } from '@/constants'
import { createFavoriteRelaysDraftEvent, createRelaySetDraftEvent } from '@/lib/draft-event'
import { formatError } from '@/lib/error'
import { getReplaceableEventIdentifier } from '@/lib/event'
@ -44,6 +45,10 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
const [relaySets, setRelaySets] = useState<TRelaySet[]>([])
useEffect(() => {
if (IS_COMMUNITY_MODE) {
setRelaySets(COMMUNITY_RELAY_SETS)
return
}
if (!favoriteRelaysEvent) {
const favoriteRelays: string[] = []
const storedRelaySets = storage.getRelaySets()

View file

@ -1,8 +1,9 @@
import { IS_COMMUNITY_MODE, COMMUNITY_RELAY_SETS, COMMUNITY_RELAYS } from '@/constants'
import { getRelaySetFromEvent } from '@/lib/event-metadata'
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import { TFeedInfo, TFeedType } from '@/types'
import { TFeedInfo, TFeedType, TRelaySet } from '@/types'
import { kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { useFavoriteRelays } from './FavoriteRelaysProvider'
@ -48,9 +49,21 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
if (storedFeedInfo) {
feedInfo = storedFeedInfo
} else {
feedInfo = { feedType: 'following' }
if (!IS_COMMUNITY_MODE) {
feedInfo = { feedType: 'following' }
}
}
}
if (!feedInfo && IS_COMMUNITY_MODE) {
feedInfo =
COMMUNITY_RELAY_SETS.length > 0
? {
feedType: 'relays',
id: COMMUNITY_RELAY_SETS[0].id,
name: COMMUNITY_RELAY_SETS[0].name
}
: { feedType: 'relay', id: COMMUNITY_RELAYS[0] }
}
if (feedInfo?.feedType === 'relays') {
return await switchFeed('relays', { activeRelaySetId: feedInfo.id })
@ -109,24 +122,33 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
}
if (feedType === 'relays') {
const relaySetId = options.activeRelaySetId ?? (relaySets.length > 0 ? relaySets[0].id : null)
if (!relaySetId || !pubkey) {
setIsReady(true)
return
}
let relaySet: TRelaySet | null = null
if (IS_COMMUNITY_MODE) {
relaySet =
COMMUNITY_RELAY_SETS.find((set) => set.id === relaySetId) ??
(COMMUNITY_RELAY_SETS.length > 0 ? COMMUNITY_RELAY_SETS[0] : null)
} else {
if (!relaySetId || !pubkey) {
setIsReady(true)
return
}
let relaySet =
relaySets.find((set) => set.id === relaySetId) ??
(relaySets.length > 0 ? relaySets[0] : null)
if (!relaySet) {
const storedRelaySetEvent = await indexedDb.getReplaceableEvent(
pubkey,
kinds.Relaysets,
relaySetId
)
if (storedRelaySetEvent) {
relaySet = getRelaySetFromEvent(storedRelaySetEvent)
relaySet =
relaySets.find((set) => set.id === relaySetId) ??
(relaySets.length > 0 ? relaySets[0] : null)
if (!relaySet) {
const storedRelaySetEvent = await indexedDb.getReplaceableEvent(
pubkey,
kinds.Relaysets,
relaySetId
)
if (storedRelaySetEvent) {
relaySet = getRelaySetFromEvent(storedRelaySetEvent)
}
}
}
if (relaySet) {
const newFeedInfo = { feedType, id: relaySet.id }
setFeedInfo(newFeedInfo)

View file

@ -1,5 +1,6 @@
import BookmarkPage from '@/pages/primary/BookmarkPage'
import ExplorePage from '@/pages/primary/ExplorePage'
import FollowingPage from '@/pages/primary/FollowingPage'
import MePage from '@/pages/primary/MePage'
import NoteListPage from '@/pages/primary/NoteListPage'
import NotificationListPage from '@/pages/primary/NotificationListPage'
@ -13,6 +14,7 @@ import { createRef } from 'react'
const PRIMARY_ROUTE_CONFIGS = [
{ key: 'home', component: NoteListPage },
{ key: 'explore', component: ExplorePage },
{ key: 'following', component: FollowingPage },
{ key: 'notifications', component: NotificationListPage },
{ key: 'me', component: MePage },
{ key: 'profile', component: ProfilePage },

View file

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

4
src/vite-env.d.ts vendored
View file

@ -6,3 +6,7 @@ declare global {
nostr?: TNip07
}
}
interface ImportMeta {
readonly env: ImportMetaEnv
}