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

@ -50,6 +50,22 @@ docker compose up --build -d
After finishing, access: http://localhost:8089
## Community mode (Optional)
If you want to run Jumble in community mode (with pre-configured relay sets and relays), you can set the following environment variables in a `.env` file at the root of the project:
- `VITE_COMMUNITY_RELAY_SETS`: Environment variable. Set the default relay sets for Jumble. Multiple relay sets can be configured. If configured, the first preset group will be displayed to visitors by default upon opening. Visitors cannot delete relay sets preset by administrators. This is ideal for communities wishing to host their own Jumble instances or for setting default feeds for family members. Examples:
```
VITE_COMMUNITY_RELAY_SETS=[{"id": "example.com", "name": "The Example Feed", "relayUrls": ["wss://relay.example.com/", "wss://relay.example.org/"]},{"id": "dailynews", "name": "News", "relayUrls": ["wss://news.example.com/", "wss://news.example.org/"]}]
```
- `VITE_COMMUNITY_RELAYS`: Environment variable. Set additional default relays for Jumble. Multiple relays can be configured, separated by commas. These relays will be added to the preset relay sets and cannot be removed by visitors. Examples:
```
VITE_COMMUNITY_RELAYS="wss://relay.example.com/,wss://relay.example.org/"
```
## Sponsors
<a target="_blank" href="https://opensats.org/">

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
}

View file

@ -1,9 +1,10 @@
import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
import path from 'path'
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import packageJson from './package.json'
import { normalizeUrl } from './src/lib/url'
const getGitHash = () => {
try {
@ -24,70 +25,81 @@ const getAppVersion = () => {
}
// https://vite.dev/config/
export default defineConfig({
define: {
'import.meta.env.GIT_COMMIT': getGitHash(),
'import.meta.env.APP_VERSION': getAppVersion()
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,png,jpg,svg}'],
globDirectory: 'dist/',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
cleanupOutdatedCaches: true
},
devOptions: {
enabled: true
},
manifest: {
name: 'Jumble',
short_name: 'Jumble',
icons: [
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{
src: '/pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/pwa-monochrome.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'monochrome'
}
],
start_url: '/',
display: 'standalone',
background_color: '#FFFFFF',
theme_color: '#FFFFFF',
description: packageJson.description
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
define: {
'import.meta.env.GIT_COMMIT': getGitHash(),
'import.meta.env.APP_VERSION': getAppVersion(),
'import.meta.env.VITE_COMMUNITY_RELAY_SETS': JSON.parse(
JSON.stringify(env.VITE_COMMUNITY_RELAY_SETS ?? '[]')
),
'import.meta.env.VITE_COMMUNITY_RELAYS': (env.VITE_COMMUNITY_RELAYS ?? '')
.split(',')
.map((url) => normalizeUrl(url))
.filter(Boolean)
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
})
]
},
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,png,jpg,svg}'],
globDirectory: 'dist/',
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
cleanupOutdatedCaches: true
},
devOptions: {
enabled: true
},
manifest: {
name: 'Jumble',
short_name: 'Jumble',
icons: [
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{
src: '/pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/pwa-monochrome.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'monochrome'
}
],
start_url: '/',
display: 'standalone',
background_color: '#FFFFFF',
theme_color: '#FFFFFF',
description: packageJson.description
}
})
]
}
})