feat: improve mobile experience

This commit is contained in:
codytseng
2025-01-02 21:57:14 +08:00
parent 8ec0d46d58
commit 3946e603b3
98 changed files with 2508 additions and 1058 deletions

View File

@@ -2,18 +2,21 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Jumble</title> <title>Jumble</title>
<meta name="description" content="A beautiful nostr client focused on browsing relay feeds"> <meta name="description" content="A beautiful nostr client focused on browsing relay feeds" />
<meta name="keywords" content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean"> <meta
name="keywords"
content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean"
/>
<meta name="apple-mobile-web-app-title" content="Jumble" /> <meta name="apple-mobile-web-app-title" content="Jumble" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" sizes="48x48"> <link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)"> <meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
<meta property="og:url" content="https://jumble.social" /> <meta property="og:url" content="https://jumble.social" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
@@ -28,7 +31,7 @@
/> />
<style> <style>
body { body {
background-color: #FFFFFF; background-color: #ffffff;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { body {

93
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@nextui-org/image": "^2.2.3", "@nextui-org/image": "^2.2.3",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
@@ -18,6 +19,7 @@
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-scroll-area": "1.2.0", "@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
@@ -29,7 +31,6 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
@@ -43,6 +44,7 @@
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"yet-another-react-lightbox": "^3.21.7", "yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
@@ -2545,6 +2547,33 @@
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
}, },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz",
"integrity": "sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
@@ -3170,6 +3199,48 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
"integrity": "sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.1",
"@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-visually-hidden": "1.1.1",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.6.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz",
@@ -6504,14 +6575,6 @@
} }
} }
}, },
"node_modules/i18next-browser-languagedetector": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.2.tgz",
"integrity": "sha512-shBvPmnIyZeD2VU5jVGIOWP7u9qNG3Lj7mpaiPFpbJ3LVfHZJvVzKR4v1Cb91wAOFpNw442N+LGPzHOHsten2g==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/idb": { "node_modules/idb": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -9326,6 +9389,18 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
}, },
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz",

View File

@@ -21,6 +21,7 @@
"dependencies": { "dependencies": {
"@nextui-org/image": "^2.2.3", "@nextui-org/image": "^2.2.3",
"@noble/hashes": "^1.6.1", "@noble/hashes": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
@@ -28,6 +29,7 @@
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-scroll-area": "1.2.0", "@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
@@ -39,7 +41,6 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"lru-cache": "^11.0.2", "lru-cache": "^11.0.2",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.10.4",
@@ -53,6 +54,7 @@
"react-string-replace": "^1.1.1", "react-string-replace": "^1.1.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"yet-another-react-lightbox": "^3.21.7", "yet-another-react-lightbox": "^3.21.7",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },

View File

@@ -4,7 +4,7 @@ import './index.css'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { ThemeProvider } from '@/providers/ThemeProvider' import { ThemeProvider } from '@/providers/ThemeProvider'
import { PageManager } from './PageManager' import { PageManager } from './PageManager'
import NoteListPage from './pages/primary/NoteListPage' import { FeedProvider } from './providers/FeedProvider'
import { FollowListProvider } from './providers/FollowListProvider' import { FollowListProvider } from './providers/FollowListProvider'
import { NostrProvider } from './providers/NostrProvider' import { NostrProvider } from './providers/NostrProvider'
import { NoteStatsProvider } from './providers/NoteStatsProvider' import { NoteStatsProvider } from './providers/NoteStatsProvider'
@@ -13,23 +13,21 @@ import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
export default function App(): JSX.Element { export default function App(): JSX.Element {
return ( return (
<div className="h-screen"> <ThemeProvider>
<ThemeProvider> <ScreenSizeProvider>
<ScreenSizeProvider> <FeedProvider>
<RelaySettingsProvider> <RelaySettingsProvider>
<NostrProvider> <NostrProvider>
<FollowListProvider> <FollowListProvider>
<NoteStatsProvider> <NoteStatsProvider>
<PageManager> <PageManager />
<NoteListPage />
</PageManager>
<Toaster /> <Toaster />
</NoteStatsProvider> </NoteStatsProvider>
</FollowListProvider> </FollowListProvider>
</NostrProvider> </NostrProvider>
</RelaySettingsProvider> </RelaySettingsProvider>
</ScreenSizeProvider> </FeedProvider>
</ThemeProvider> </ScreenSizeProvider>
</div> </ThemeProvider>
) )
} }

View File

@@ -1,18 +1,26 @@
import Sidebar from '@/components/Sidebar' import Sidebar from '@/components/Sidebar'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable' import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import NoteListPage from '@/pages/primary/NoteListPage'
import HomePage from '@/pages/secondary/HomePage' import HomePage from '@/pages/secondary/HomePage'
import { cloneElement, createContext, useContext, useEffect, useState } from 'react' import { cloneElement, createContext, useContext, useEffect, useState } from 'react'
import MePage from './pages/primary/MePage'
import NotificationListPage from './pages/primary/NotificationListPage'
import { useScreenSize } from './providers/ScreenSizeProvider' import { useScreenSize } from './providers/ScreenSizeProvider'
import { routes } from './routes' import { routes } from './routes'
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
type TPrimaryPageContext = { type TPrimaryPageContext = {
refresh: () => void navigate: (page: TPrimaryPageName) => void
current: TPrimaryPageName | null
} }
type TSecondaryPageContext = { type TSecondaryPageContext = {
push: (url: string) => void push: (url: string) => void
pop: () => void pop: () => void
currentIndex: number
} }
type TStackItem = { type TStackItem = {
@@ -21,6 +29,12 @@ type TStackItem = {
component: React.ReactNode | null component: React.ReactNode | null
} }
const PRIMARY_PAGE_MAP = {
home: <NoteListPage />,
notifications: <NotificationListPage />,
me: <MePage />
}
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined) const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined) const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
@@ -41,39 +55,42 @@ export function useSecondaryPage() {
return context return context
} }
export function PageManager({ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
children, const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
maxStackSize = 5
}: {
children: React.ReactNode
maxStackSize?: number
}) {
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([]) const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
useEffect(() => { useEffect(() => {
if (window.location.pathname !== '/') { if (window.location.pathname !== '/') {
pushSecondary(window.location.pathname + window.location.search) pushSecondaryPage(window.location.pathname + window.location.search)
} }
const onPopState = (e: PopStateEvent) => { const onPopState = (e: PopStateEvent) => {
const state = e.state ?? { index: -1, url: '/' } const state = e.state ?? { index: -1, url: '/' }
setSecondaryStack((pre) => { setSecondaryStack((pre) => {
const currentItem = pre[pre.length - 1] const currentItem = pre[pre.length - 1]
const currentIndex = currentItem ? currentItem.index : -1 const currentIndex = currentItem ? currentItem.index : 0
if (state.index === currentIndex) { if (state.index === currentIndex) {
return pre if (currentIndex !== 0) return pre
window.history.replaceState(null, '', '/')
return []
} }
// Go back
if (state.index < currentIndex) { if (state.index < currentIndex) {
const newStack = pre.filter((item) => item.index <= state.index) const newStack = pre.filter((item) => item.index <= state.index)
const topItem = newStack[newStack.length - 1] const topItem = newStack[newStack.length - 1]
// Load the component if it's not cached
if (topItem && !topItem.component) { if (topItem && !topItem.component) {
topItem.component = findAndCreateComponent(topItem.url) topItem.component = findAndCreateComponent(topItem.url, state.index)
}
if (newStack.length === 0) {
window.history.replaceState(null, '', '/')
} }
return newStack return newStack
} }
// Go forward
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize) const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
return newStack return newStack
}) })
@@ -86,9 +103,14 @@ export function PageManager({
} }
}, []) }, [])
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1) const navigatePrimaryPage = (page: TPrimaryPageName) => {
setCurrentPrimaryPage(page)
if (isSmallScreen) {
clearSecondaryPages()
}
}
const pushSecondary = (url: string) => { const pushSecondaryPage = (url: string) => {
setSecondaryStack((prevStack) => { setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return prevStack if (isCurrentPage(prevStack, url)) return prevStack
@@ -100,62 +122,96 @@ export function PageManager({
}) })
} }
const popSecondary = () => { const popSecondaryPage = () => {
window.history.back() window.history.go(-1)
}
const clearSecondaryPages = () => {
if (secondaryStack.length === 0) return
window.history.go(-secondaryStack.length)
} }
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}> <PrimaryPageContext.Provider
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}> value={{
<div className="h-full"> navigate: navigatePrimaryPage,
{!!secondaryStack.length && current: secondaryStack.length === 0 ? currentPrimaryPage : null
secondaryStack.map((item, index) => ( }}
<div >
key={item.index} <SecondaryPageContext.Provider
className="absolute top-0 left-0 w-full h-full bg-background" value={{
style={{ push: pushSecondaryPage,
display: index === secondaryStack.length - 1 ? 'block' : 'none' pop: popSecondaryPage,
}} currentIndex: secondaryStack.length
> ? secondaryStack[secondaryStack.length - 1].index
{item.component} : 0
</div> }}
))} >
{!!secondaryStack.length &&
secondaryStack.map((item, index) => (
<div
key={item.index}
style={{
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
>
{item.component}
</div>
))}
{Object.entries(PRIMARY_PAGE_MAP).map(([pageName, page]) => (
<div <div
key={primaryPageKey} key={pageName}
className="absolute top-0 left-0 w-full h-full bg-background" style={{
style={{ display: !secondaryStack.length ? 'block' : 'none' }} display:
secondaryStack.length === 0 && currentPrimaryPage === pageName ? 'block' : 'none'
}}
> >
{children} {page}
</div> </div>
</div> ))}
</SecondaryPageContext.Provider> </SecondaryPageContext.Provider>
</PrimaryPageContext.Provider> </PrimaryPageContext.Provider>
) )
} }
return ( return (
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}> <PrimaryPageContext.Provider
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}> value={{
<div className="flex h-full"> navigate: navigatePrimaryPage,
current: currentPrimaryPage
}}
>
<SecondaryPageContext.Provider
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
}}
>
<div className="flex h-screen overflow-hidden">
<Sidebar /> <Sidebar />
<Separator orientation="vertical" />
<ResizablePanelGroup direction="horizontal"> <ResizablePanelGroup direction="horizontal">
<ResizablePanel minSize={30}> <ResizablePanel minSize={30}>
<div key={primaryPageKey} className="h-full"> <div
{children} style={{
display: !currentPrimaryPage || currentPrimaryPage === 'home' ? 'block' : 'none'
}}
>
<NoteListPage />
</div>
<div style={{ display: currentPrimaryPage === 'notifications' ? 'block' : 'none' }}>
<NotificationListPage />
</div> </div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle /> <ResizableHandle />
<ResizablePanel minSize={30} className="relative"> <ResizablePanel minSize={30}>
{secondaryStack.length ? ( {secondaryStack.length ? (
secondaryStack.map((item, index) => ( secondaryStack.map((item, index) => (
<div <div
key={item.index} key={item.index}
className="absolute top-0 left-0 w-full h-full bg-background" style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
style={{
zIndex: index + 1,
display: index === secondaryStack.length - 1 ? 'block' : 'none'
}}
> >
{item.component} {item.component}
</div> </div>
@@ -206,26 +262,29 @@ function isCurrentPage(stack: TStackItem[], url: string) {
return currentPage.url === url return currentPage.url === url
} }
function findAndCreateComponent(url: string) { function findAndCreateComponent(url: string, index: number) {
const path = url.split('?')[0] const path = url.split('?')[0]
for (const { matcher, element } of routes) { for (const { matcher, element } of routes) {
const match = matcher(path) const match = matcher(path)
if (!match) continue if (!match) continue
if (!element) return null if (!element) return null
return cloneElement(element, match.params) return cloneElement(element, { ...match.params, index } as any)
} }
return null return null
} }
function pushNewPageToStack(stack: TStackItem[], url: string, maxStackSize = 5) { function pushNewPageToStack(stack: TStackItem[], url: string, maxStackSize = 5) {
const component = findAndCreateComponent(url) const currentItem = stack[stack.length - 1]
const currentIndex = currentItem ? currentItem.index + 1 : 0
const component = findAndCreateComponent(url, currentIndex)
if (!component) return { newStack: stack, newItem: null } if (!component) return { newStack: stack, newItem: null }
const currentStack = stack[stack.length - 1] const newItem = { component, url, index: currentItem ? currentItem.index + 1 : 0 }
const newItem = { component, url, index: currentStack ? currentStack.index + 1 : 0 }
const newStack = [...stack, newItem] const newStack = [...stack, newItem]
const lastCachedIndex = newStack.findIndex((stack) => stack.component) const lastCachedIndex = newStack.findIndex((stack) => stack.component)
// Clear the oldest cached component if there are too many cached components
if (newStack.length - lastCachedIndex > maxStackSize) { if (newStack.length - lastCachedIndex > maxStackSize) {
newStack[lastCachedIndex].component = null newStack[lastCachedIndex].component = null
} }

24
src/assets/Icon.tsx Normal file
View File

@@ -0,0 +1,24 @@
export default function Icon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 1080 1228"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
style={{
fill: 'currentcolor',
fillRule: 'evenodd',
clipRule: 'evenodd',
strokeLinejoin: 'round',
strokeMiterlimit: 2
}}
className={className}
>
<path
id="Icon-Curve-Cut"
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
/>
</svg>
)
}

File diff suppressed because one or more lines are too long

View File

@@ -1,48 +1,61 @@
import { import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
Dialog, import { useScreenSize } from '@/providers/ScreenSizeProvider'
DialogContent, import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer'
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import Username from '../Username' import Username from '../Username'
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) { export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
const { isSmallScreen } = useScreenSize()
const content = (
<>
<div className="text-xl font-semibold">Jumble</div>
<div className="text-muted-foreground">
A beautiful nostr client focused on browsing relay feeds
</div>
<div>
Made by{' '}
<Username
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
className="inline-block text-primary"
showAt
/>
</div>
<div>
Source code:{' '}
<a
href="https://github.com/CodyTseng/jumble"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
GitHub
</a>
</div>
<div>
If you like this project, you can buy me a coffee <br />
<div className="font-semibold"> codytseng@getalby.com </div>
</div>
<div className="text-muted-foreground">
Version: v{__APP_VERSION__} ({__GIT_COMMIT__})
</div>
</>
)
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<div className="p-4">{content}</div>
</DrawerContent>
</Drawer>
)
}
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent> <DialogContent>{content}</DialogContent>
<DialogHeader>
<DialogTitle>Jumble</DialogTitle>
<DialogDescription>
A beautiful nostr client focused on browsing relay feeds
</DialogDescription>
</DialogHeader>
<div>
Made by{' '}
<Username
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
className="inline-block text-primary"
showAt
/>
</div>
<div>
Source code:{' '}
<a
href="https://github.com/CodyTseng/jumble"
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
GitHub
</a>
</div>
<div>
If you like this project, you can buy me a coffee <br />
<div className="font-semibold"> codytseng@getalby.com </div>
</div>
</DialogContent>
</Dialog> </Dialog>
) )
} }

View File

@@ -1,29 +0,0 @@
import { Button } from '@/components/ui/button'
import { useNostr } from '@/providers/NostrProvider'
import { LogIn } from 'lucide-react'
export default function LoginButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { checkLogin } = useNostr()
let triggerComponent: React.ReactNode
if (variant === 'titlebar' || variant === 'small-screen-titlebar') {
triggerComponent = <LogIn />
} else {
triggerComponent = (
<>
<LogIn size={16} />
<div>Login</div>
</>
)
}
return (
<Button variant={variant} size={variant} onClick={() => checkLogin()}>
{triggerComponent}
</Button>
)
}

View File

@@ -1,91 +0,0 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
export default function ProfileButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { removeAccount, account } = useNostr()
const pubkey = account?.pubkey
const { profile } = useFetchProfile(pubkey)
const { push } = useSecondaryPage()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
if (!pubkey) return null
const defaultAvatar = generateImageByPubkey(pubkey)
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
let triggerComponent: React.ReactNode
if (variant === 'titlebar') {
triggerComponent = (
<button>
<Avatar className="ml-2 w-6 h-6 hover:opacity-90">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
</button>
)
} else if (variant === 'small-screen-titlebar') {
triggerComponent = (
<button>
<Avatar className="w-8 h-8 hover:opacity-90">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
</button>
)
} else {
triggerComponent = (
<Button variant="sidebar" size="sidebar" className="border hover:bg-muted px-2">
<div className="flex gap-2 items-center flex-1 w-0">
<Avatar className="w-10 h-10">
<AvatarImage src={avatar} />
<AvatarFallback>
<img src={defaultAvatar} />
</AvatarFallback>
</Avatar>
<div className="truncate font-semibold text-lg">{username}</div>
</div>
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
{t('Accounts')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => removeAccount(account)}
>
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
</DropdownMenu>
)
}

View File

@@ -1,17 +0,0 @@
import { useNostr } from '@/providers/NostrProvider'
import LoginButton from './LoginButton'
import ProfileButton from './ProfileButton'
export default function AccountButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { pubkey } = useNostr()
if (pubkey) {
return <ProfileButton variant={variant} />
} else {
return <LoginButton variant={variant} />
}
}

View File

@@ -4,13 +4,13 @@ import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { TAccountPointer, TSignerType } from '@/types' import { TAccountPointer, TSignerType } from '@/types'
import { Loader, Trash2 } from 'lucide-react' import { Loader } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { SimpleUserAvatar } from '../UserAvatar' import { SimpleUserAvatar } from '../UserAvatar'
import { SimpleUsername } from '../Username' import { SimpleUsername } from '../Username'
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) { export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) {
const { accounts, account, switchAccount, removeAccount } = useNostr() const { accounts, account, switchAccount } = useNostr()
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null) const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
return ( return (
@@ -20,9 +20,7 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
key={`${act.pubkey}-${act.signerType}`} key={`${act.pubkey}-${act.signerType}`}
className={cn( className={cn(
'relative rounded-lg', 'relative rounded-lg',
isSameAccount(act, account) isSameAccount(act, account) ? 'border border-primary' : 'clickable'
? 'border border-primary'
: 'cursor-pointer hover:bg-muted/60'
)} )}
onClick={() => { onClick={() => {
if (isSameAccount(act, account)) return if (isSameAccount(act, account)) return
@@ -44,14 +42,6 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<SignerTypeBadge signerType={act.signerType} /> <SignerTypeBadge signerType={act.signerType} />
<Trash2
size={16}
className="text-muted-foreground hover:text-destructive cursor-pointer"
onClick={(e) => {
e.stopPropagation()
removeAccount(act)
}}
/>
</div> </div>
</div> </div>
{switchingAccount && isSameAccount(act, switchingAccount) && ( {switchingAccount && isSameAccount(act, switchingAccount) && (

View File

@@ -8,15 +8,15 @@ import AccountList from '../AccountList'
import BunkerLogin from './BunkerLogin' import BunkerLogin from './BunkerLogin'
import PrivateKeyLogin from './NsecLogin' import PrivateKeyLogin from './NsecLogin'
export default function AccountManager({ close }: { close: () => void }) { export default function AccountManager({ close }: { close?: () => void }) {
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null) const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
return ( return (
<> <>
{loginMethod === 'nsec' ? ( {loginMethod === 'nsec' ? (
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} /> <PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
) : loginMethod === 'bunker' ? ( ) : loginMethod === 'bunker' ? (
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} /> <BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
) : ( ) : (
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} /> <AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
)} )}
@@ -29,18 +29,18 @@ function AccountManagerNav({
close close
}: { }: {
setLoginMethod: (method: TSignerType) => void setLoginMethod: (method: TSignerType) => void
close: () => void close?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { nip07Login, accounts } = useNostr() const { nip07Login, accounts } = useNostr()
return ( return (
<> <div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-4">
<div className="text-center text-muted-foreground text-sm font-semibold"> <div className="text-center text-muted-foreground text-sm font-semibold">
{t('Add an Account')} {t('Add an Account')}
</div> </div>
{!!window.nostr && ( {!!window.nostr && (
<Button onClick={() => nip07Login().then(() => close())} className="w-full"> <Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
{t('Login with Browser Extension')} {t('Login with Browser Extension')}
</Button> </Button>
)} )}
@@ -56,9 +56,9 @@ function AccountManagerNav({
<div className="text-center text-muted-foreground text-sm font-semibold"> <div className="text-center text-muted-foreground text-sm font-semibold">
{t('Logged in Accounts')} {t('Logged in Accounts')}
</div> </div>
<AccountList afterSwitch={() => close()} /> <AccountList afterSwitch={() => close?.()} />
</> </>
)} )}
</> </div>
) )
} }

View File

@@ -5,10 +5,10 @@ import { useTranslation } from 'react-i18next'
export default function BackButton({ export default function BackButton({
hide = false, hide = false,
variant = 'titlebar' children
}: { }: {
hide?: boolean hide?: boolean
variant?: 'titlebar' | 'small-screen-titlebar' children?: React.ReactNode
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pop } = useSecondaryPage() const { pop } = useSecondaryPage()
@@ -16,8 +16,15 @@ export default function BackButton({
return ( return (
<> <>
{!hide && ( {!hide && (
<Button variant={variant} size={variant} title={t('back')} onClick={() => pop()}> <Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
variant="ghost"
size="titlebar-icon"
title={t('back')}
onClick={() => pop()}
>
<ChevronLeft /> <ChevronLeft />
<div className="truncate text-lg font-semibold">{children}</div>
</Button> </Button>
)} )}
</> </>

View File

@@ -0,0 +1,29 @@
import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { UserRound } from 'lucide-react'
import { SimpleUserAvatar } from '../UserAvatar'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function AccountButton() {
const { navigate, current } = usePrimaryPage()
const { pubkey } = useNostr()
return (
<BottomNavigationBarItem
onClick={() => {
navigate('me')
}}
active={current === 'me'}
>
{pubkey ? (
<SimpleUserAvatar
userId={pubkey}
size="small"
className={current === 'me' ? 'ring-primary ring-1' : ''}
/>
) : (
<UserRound />
)}
</BottomNavigationBarItem>
)
}

View File

@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { MouseEventHandler } from 'react'
export default function BottomNavigationBarItem({
children,
active = false,
onClick
}: {
children: React.ReactNode
active?: boolean
onClick: MouseEventHandler
}) {
return (
<Button
className={cn(
'flex shadow-none items-center bg-transparent w-full h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4',
active && 'text-primary disabled:opacity-100'
)}
disabled={active}
variant="ghost"
onClick={onClick}
>
{children}
</Button>
)
}

View File

@@ -0,0 +1,13 @@
import { usePrimaryPage } from '@/PageManager'
import { Home } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function HomeButton() {
const { navigate, current } = usePrimaryPage()
return (
<BottomNavigationBarItem active={current === 'home'} onClick={() => navigate('home')}>
<Home />
</BottomNavigationBarItem>
)
}

View File

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

View File

@@ -0,0 +1,22 @@
import PostEditor from '@/components/PostEditor'
import { PencilLine } from 'lucide-react'
import { useState } from 'react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function PostButton() {
const [open, setOpen] = useState(false)
return (
<>
<BottomNavigationBarItem
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<PencilLine />
</BottomNavigationBarItem>
<PostEditor open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
import HomeButton from './HomeButton'
import NotificationsButton from './NotificationsButton'
import PostButton from './PostButton'
import AccountButton from './AccountButton'
export default function BottomNavigationBar({ visible = true }: { visible?: boolean }) {
return (
<div
className={cn(
'fixed bottom-0 w-full z-20 bg-background/90 backdrop-blur-xl duration-700 transition-transform flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0',
visible ? '' : 'translate-y-full'
)}
style={{
height: 'calc(3rem + env(safe-area-inset-bottom))',
paddingBottom: 'env(safe-area-inset-bottom)'
}}
>
<HomeButton />
<PostButton />
<NotificationsButton />
<AccountButton />
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { toRelaySettings } from '@/lib/link'
import { simplifyUrl } from '@/lib/url'
import { SecondaryPageLink } from '@/PageManager'
import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { Circle, CircleCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function FeedSwitcher({ close }: { close?: () => void }) {
const { t } = useTranslation()
const { feedType, setFeedType } = useFeed()
const { pubkey } = useNostr()
const { relayGroups, temporaryRelayUrls, switchRelayGroup } = useRelaySettings()
return (
<div className="space-y-4">
{pubkey && (
<FeedSwitcherItem
itemName={t('Following')}
isActive={feedType === 'following'}
onClick={() => {
setFeedType('following')
close?.()
}}
/>
)}
<div className="space-y-2">
<div className="flex justify-between px-2">
<div className="text-muted-foreground text-sm font-semibold">{t('relay feeds')}</div>
<SecondaryPageLink
to={toRelaySettings()}
className="text-highlight text-sm font-semibold"
onClick={() => close?.()}
>
{t('edit')}
</SecondaryPageLink>
</div>
{temporaryRelayUrls.length > 0 && (
<FeedSwitcherItem
key="temporary"
itemName={
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary')
}
isActive={feedType === 'relays'}
temporary
onClick={() => {
setFeedType('relays')
close?.()
}}
/>
)}
{relayGroups
.filter((group) => group.relayUrls.length > 0)
.map((group) => (
<FeedSwitcherItem
key={group.groupName}
itemName={
group.relayUrls.length === 1 ? simplifyUrl(group.relayUrls[0]) : group.groupName
}
isActive={feedType === 'relays' && group.isActive && temporaryRelayUrls.length === 0}
onClick={() => {
switchRelayGroup(group.groupName)
close?.()
}}
/>
))}
</div>
</div>
)
}
function FeedSwitcherItem({
itemName,
isActive,
temporary = false,
onClick
}: {
itemName: string
isActive: boolean
temporary?: boolean
onClick: () => void
}) {
return (
<div
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : 'clickable'} ${temporary ? 'border-dashed' : ''}`}
onClick={onClick}
>
<div className="flex space-x-2 items-center">
<FeedToggle isActive={isActive} />
<div className="font-semibold">{itemName}</div>
</div>
</div>
)
}
function FeedToggle({ isActive }: { isActive: boolean }) {
return isActive ? (
<CircleCheck size={18} className="text-highlight shrink-0" />
) : (
<Circle size={18} className="text-muted-foreground shrink-0" />
)
}

View File

@@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { toast } = useToast() const { toast } = useToast()
const { pubkey: accountPubkey, checkLogin } = useNostr() const { pubkey: accountPubkey, checkLogin } = useNostr()
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList() const { followListEvent, followings, isFetching, follow, unfollow } = useFollowList()
const [updating, setUpdating] = useState(false) const [updating, setUpdating] = useState(false)
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey]) const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null if (!accountPubkey || isFetching || (pubkey && pubkey === accountPubkey)) return null
const handleFollow = async (e: React.MouseEvent) => { const handleFollow = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()

View File

@@ -5,6 +5,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle DialogTitle
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Dispatch } from 'react' import { Dispatch } from 'react'
import AccountManager from '../AccountManager' import AccountManager from '../AccountManager'
@@ -15,6 +17,25 @@ export default function LoginDialog({
open: boolean open: boolean
setOpen: Dispatch<boolean> setOpen: Dispatch<boolean>
}) { }) {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent
className="max-h-[90vh]"
style={{
paddingBottom: 'env(safe-area-inset-bottom)'
}}
>
<div className="flex flex-col p-4 gap-4 overflow-auto">
<AccountManager close={() => setOpen(false)} />
</div>
</DrawerContent>
</Drawer>
)
}
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-96"> <DialogContent className="w-96">

View File

@@ -0,0 +1,88 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useTranslation } from 'react-i18next'
export default function LogoutDialog({
open = false,
setOpen
}: {
open: boolean
setOpen: (open: boolean) => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { account, removeAccount } = useNostr()
if (isSmallScreen) {
return (
<Drawer defaultOpen={false} open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t('Logout')}</DrawerTitle>
<DrawerDescription>{t('Are you sure you want to logout?')}</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button variant="outline" onClick={() => setOpen(false)} className="w-full">
{t('Cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
if (account) {
setOpen(false)
removeAccount(account)
}
}}
className="w-full"
>
{t('Logout')}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<AlertDialog defaultOpen={false} open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Logout')}</AlertDialogTitle>
<AlertDialogDescription>{t('Are you sure you want to logout?')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (account) {
removeAccount(account)
}
}}
>
{t('Logout')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,3 +1,4 @@
import { Separator } from '@/components/ui/separator'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import { getParentEventId, getRootEventId } from '@/lib/event' import { getParentEventId, getRootEventId } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@@ -32,11 +33,10 @@ export default function ShortTextNoteCard({
push(toNote(event)) push(toNote(event))
}} }}
> >
<RepostDescription reposter={reposter} className="max-sm:hidden pl-4" />
<div <div
className={`hover:bg-muted/50 text-left cursor-pointer ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3 sm:py-4 sm:border sm:rounded-lg max-sm:border-b'}`} className={`clickable text-left ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3'}`}
> >
<RepostDescription reposter={reposter} className="sm:hidden" /> <RepostDescription reposter={reposter} />
<Note <Note
size={embedded ? 'small' : 'normal'} size={embedded ? 'small' : 'normal'}
event={event} event={event}
@@ -44,6 +44,7 @@ export default function ShortTextNoteCard({
hideStats={embedded} hideStats={embedded}
/> />
</div> </div>
{!embedded && <Separator />}
</div> </div>
) )
} }

View File

@@ -1,10 +1,8 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useFetchRelayInfos } from '@/hooks' import { useFetchRelayInfos } from '@/hooks'
import { isReplyNoteEvent } from '@/lib/event' import { isReplyNoteEvent } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, Filter, kinds } from 'nostr-tools' import { Event, Filter, kinds } from 'nostr-tools'
@@ -33,7 +31,7 @@ export default function NoteList({
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [initialized, setInitialized] = useState(false) const [refreshing, setRefreshing] = useState(true)
const [displayReplies, setDisplayReplies] = useState(false) const [displayReplies, setDisplayReplies] = useState(false)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const noteFilter = useMemo(() => { const noteFilter = useMemo(() => {
@@ -48,16 +46,20 @@ export default function NoteList({
if (isFetchingRelayInfo || relayUrls.length === 0) return if (isFetchingRelayInfo || relayUrls.length === 0) return
async function init() { async function init() {
setInitialized(false) setRefreshing(true)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
setHasMore(true) setHasMore(true)
let eventCount = 0
const { closer, timelineKey } = await client.subscribeTimeline( const { closer, timelineKey } = await client.subscribeTimeline(
[...relayUrls], [...relayUrls],
noteFilter, noteFilter,
{ {
onEvents: (events, eosed) => { onEvents: (events, eosed) => {
if (eventCount > events.length) return
eventCount = events.length
if (events.length > 0) { if (events.length > 0) {
setEvents(events) setEvents(events)
} }
@@ -65,7 +67,7 @@ export default function NoteList({
setHasMore(false) setHasMore(false)
} }
if (eosed) { if (eosed) {
setInitialized(true) setRefreshing(false)
setHasMore(events.length > 0) setHasMore(events.length > 0)
} }
}, },
@@ -100,7 +102,7 @@ export default function NoteList({
]) ])
useEffect(() => { useEffect(() => {
if (!initialized) return if (refreshing) return
const options = { const options = {
root: null, root: null,
@@ -125,10 +127,10 @@ export default function NoteList({
observerInstance.unobserve(currentBottomRef) observerInstance.unobserve(currentBottomRef)
} }
} }
}, [initialized, hasMore, events, timelineKey]) }, [refreshing, hasMore, events, timelineKey])
const loadMore = async () => { const loadMore = async () => {
if (!timelineKey) return if (!timelineKey || refreshing) return
const newEvents = await client.loadMoreTimeline( const newEvents = await client.loadMoreTimeline(
timelineKey, timelineKey,
@@ -148,36 +150,35 @@ export default function NoteList({
} }
return ( return (
<div className={cn('space-y-2 sm:space-y-4', className)}> <div className={cn('space-y-2 sm:space-y-2', className)}>
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} /> <DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
<PullToRefresh <div className="space-y-2 sm:space-y-2">
onRefresh={async () => {newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
new Promise((resolve) => { <div className="flex justify-center w-full max-sm:mt-2">
setRefreshCount((pre) => pre + 1) <Button size="lg" onClick={showNewEvents}>
setTimeout(resolve, 1000) {t('show new notes')}
}) </Button>
} </div>
pullingContent="" )}
>
<div className="space-y-2 sm:space-y-4"> <PullToRefresh
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && ( onRefresh={async () => {
<div className="flex justify-center w-full max-sm:mt-2"> setRefreshCount((count) => count + 1)
<Button size="lg" onClick={showNewEvents}> await new Promise((resolve) => setTimeout(resolve, 1000))
{t('show new notes')} }}
</Button> pullingContent=""
</div> >
)} <div>
<div className="flex flex-col sm:gap-4">
{events {events
.filter((event) => displayReplies || !isReplyNoteEvent(event)) .filter((event) => displayReplies || !isReplyNoteEvent(event))
.map((event) => ( .map((event) => (
<NoteCard key={event.id} className="w-full" event={event} /> <NoteCard key={event.id} className="w-full" event={event} />
))} ))}
</div> </div>
</div> </PullToRefresh>
</PullToRefresh> </div>
<div className="text-center text-sm text-muted-foreground"> <div className="text-center text-sm text-muted-foreground">
{hasMore ? ( {hasMore || refreshing ? (
<div ref={bottomRef}>{t('loading...')}</div> <div ref={bottomRef}>{t('loading...')}</div>
) : events.length ? ( ) : events.length ? (
t('no more notes') t('no more notes')
@@ -201,38 +202,28 @@ function DisplayRepliesSwitch({
setDisplayReplies: (value: boolean) => void setDisplayReplies: (value: boolean) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<div>
<div className="flex">
<div
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
onClick={() => setDisplayReplies(false)}
>
{t('Notes')}
</div>
<div
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
onClick={() => setDisplayReplies(true)}
>
{t('Notes & Replies')}
</div>
</div>
<div
className={`w-1/2 px-4 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
>
<div className="w-full h-1 bg-primary rounded-full" />
</div>
</div>
)
}
return ( return (
<div className="flex justify-end gap-2"> <div>
<div>{t('Display replies')}</div> <div className="flex">
<Switch checked={displayReplies} onCheckedChange={setDisplayReplies} /> <div
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
onClick={() => setDisplayReplies(false)}
>
{t('Notes')}
</div>
<div
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
onClick={() => setDisplayReplies(true)}
>
{t('Notes & Replies')}
</div>
</div>
<div
className={`w-1/2 px-4 sm:px-6 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
>
<div className="w-full h-1 bg-primary rounded-full" />
</div>
</div> </div>
) )
} }

View File

@@ -1,16 +1,14 @@
import { useNostr } from '@/providers/NostrProvider'
import { useNoteStats } from '@/providers/NoteStatsProvider' import { useNoteStats } from '@/providers/NoteStatsProvider'
import { MessageCircle } from 'lucide-react' import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import PostDialog from '../PostDialog'
import { formatCount } from './utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import { formatCount } from './utils'
export default function ReplyButton({ event }: { event: Event }) { export default function ReplyButton({ event }: { event: Event }) {
const { t } = useTranslation() const { t } = useTranslation()
const { noteStatsMap } = useNoteStats() const { noteStatsMap } = useNoteStats()
const { pubkey } = useNostr()
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id]) const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -18,7 +16,6 @@ export default function ReplyButton({ event }: { event: Event }) {
<> <>
<button <button
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400" className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
disabled={!pubkey}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setOpen(true) setOpen(true)
@@ -28,7 +25,7 @@ export default function ReplyButton({ event }: { event: Event }) {
<MessageCircle size={16} /> <MessageCircle size={16} />
<div className="text-sm">{formatCount(replyCount)}</div> <div className="text-sm">{formatCount(replyCount)}</div>
</button> </button>
<PostDialog parentEvent={event} open={open} setOpen={setOpen} /> <PostEditor parentEvent={event} open={open} setOpen={setOpen} />
</> </>
) )
} }

View File

@@ -13,7 +13,7 @@ import client from '@/services/client.service'
import { Loader, PencilLine, Repeat } from 'lucide-react' import { Loader, PencilLine, Repeat } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import PostDialog from '../PostDialog' import PostEditor from '../PostEditor'
import { formatCount } from './utils' import { formatCount } from './utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -111,7 +111,7 @@ export default function RepostButton({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<PostDialog <PostEditor
open={isPostDialogOpen} open={isPostDialogOpen}
setOpen={setIsPostDialogOpen} setOpen={setIsPostDialogOpen}
defaultContent={'\nnostr:' + getSharableEventId(event)} defaultContent={'\nnostr:' + getSharableEventId(event)}

View File

@@ -16,7 +16,7 @@ export default function NoteStats({
}) { }) {
return ( return (
<div className={cn('flex justify-between', className)}> <div className={cn('flex justify-between', className)}>
<div className="flex gap-4 h-4 items-center"> <div className="flex gap-4 h-4 items-center" onClick={(e) => e.stopPropagation()}>
<ReplyButton event={event} /> <ReplyButton event={event} />
<RepostButton event={event} canFetch={fetchIfNotExisting} /> <RepostButton event={event} canFetch={fetchIfNotExisting} />
<LikeButton event={event} canFetch={fetchIfNotExisting} /> <LikeButton event={event} canFetch={fetchIfNotExisting} />

View File

@@ -1,39 +0,0 @@
import { Button } from '@/components/ui/button'
import { toNotifications } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { Bell } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function NotificationButton({
variant = 'titlebar'
}: {
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
if (variant === 'sidebar') {
return (
<Button
variant={variant}
size={variant}
title={t('notifications')}
onClick={() => push(toNotifications())}
>
<Bell />
{t('Notifications')}
</Button>
)
}
return (
<Button
variant={variant}
size={variant}
title={t('notifications')}
onClick={() => push(toNotifications())}
>
<Bell />
</Button>
)
}

View File

@@ -9,18 +9,18 @@ import { Heart, MessageCircle, Repeat } from 'lucide-react'
import { Event, kinds, nip19, validateEvent } from 'nostr-tools' import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import PullToRefresh from 'react-simple-pull-to-refresh'
const LIMIT = 50 const LIMIT = 50
export default function NotificationList() { export default function NotificationList() {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
const [initialized, setInitialized] = useState(false) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [refreshing, setRefreshing] = useState(true)
const [notifications, setNotifications] = useState<Event[]>([]) const [notifications, setNotifications] = useState<Event[]>([])
const [until, setUntil] = useState<number | undefined>(dayjs().unix()) const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
@@ -32,7 +32,9 @@ export default function NotificationList() {
} }
const init = async () => { const init = async () => {
setRefreshing(true)
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
let eventCount = 0
const { closer, timelineKey } = await client.subscribeTimeline( const { closer, timelineKey } = await client.subscribeTimeline(
relayList.read.length >= 4 relayList.read.length >= 4
? relayList.read ? relayList.read
@@ -44,10 +46,12 @@ export default function NotificationList() {
}, },
{ {
onEvents: (events, eosed) => { onEvents: (events, eosed) => {
setNotifications(events.filter((event) => event.pubkey !== pubkey)) if (eventCount > events.length) return
eventCount = events.length
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined) setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
setNotifications(events.filter((event) => event.pubkey !== pubkey))
if (eosed) { if (eosed) {
setInitialized(true) setRefreshing(false)
} }
}, },
onNew: (event) => { onNew: (event) => {
@@ -67,7 +71,7 @@ export default function NotificationList() {
}, [pubkey, refreshCount]) }, [pubkey, refreshCount])
useEffect(() => { useEffect(() => {
if (!initialized) return if (refreshing) return
const options = { const options = {
root: null, root: null,
@@ -92,10 +96,10 @@ export default function NotificationList() {
observerInstance.unobserve(currentBottomRef) observerInstance.unobserve(currentBottomRef)
} }
} }
}, [until, initialized, timelineKey]) }, [until, refreshing, timelineKey])
const loadMore = async () => { const loadMore = async () => {
if (!pubkey || !timelineKey || !until) return if (!pubkey || !timelineKey || !until || refreshing) return
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT) const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
if (notifications.length === 0) { if (notifications.length === 0) {
setUntil(undefined) setUntil(undefined)
@@ -111,12 +115,10 @@ export default function NotificationList() {
return ( return (
<PullToRefresh <PullToRefresh
onRefresh={async () => onRefresh={async () => {
new Promise((resolve) => { setRefreshCount((count) => count + 1)
setRefreshCount((pre) => pre + 1) await new Promise((resolve) => setTimeout(resolve, 1000))
setTimeout(resolve, 1000) }}
})
}
pullingContent="" pullingContent=""
> >
<div> <div>
@@ -124,7 +126,11 @@ export default function NotificationList() {
<NotificationItem key={notification.id} notification={notification} /> <NotificationItem key={notification.id} notification={notification} />
))} ))}
<div className="text-center text-sm text-muted-foreground"> <div className="text-center text-sm text-muted-foreground">
{until ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')} {until || refreshing ? (
<div ref={bottomRef}>{t('loading...')}</div>
) : (
t('no more notifications')
)}
</div> </div>
</div> </div>
</PullToRefresh> </PullToRefresh>

View File

@@ -1,32 +0,0 @@
import PostDialog from '@/components/PostDialog'
import { Button } from '@/components/ui/button'
import { PencilLine } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function PostButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<Button
variant={variant}
size={variant}
title={t('New post')}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<PencilLine />
{variant === 'sidebar' && <div>{t('Post')}</div>}
</Button>
<PostDialog open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -1,187 +0,0 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { StorageKey } from '@/constants'
import { useToast } from '@/hooks/use-toast'
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { ChevronDown, LoaderCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { Dispatch, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
import Mentions from './Metions'
import Preview from './Preview'
import Uploader from './Uploader'
export default function PostDialog({
defaultContent = '',
parentEvent,
open,
setOpen
}: {
defaultContent?: string
parentEvent?: Event
open: boolean
setOpen: Dispatch<boolean>
}) {
const { t } = useTranslation()
const { toast } = useToast()
const { publish, checkLogin } = useNostr()
const [content, setContent] = useState(defaultContent)
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const canPost = !!content && !posting
useEffect(() => {
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
}, [])
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
}
const post = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canPost) {
setOpen(false)
return
}
setPosting(true)
try {
const additionalRelayUrls: string[] = []
if (parentEvent) {
const relayList = await client.fetchRelayList(parentEvent.pubkey)
additionalRelayUrls.push(...relayList.read.slice(0, 5))
}
const draftEvent = await createShortTextNoteDraftEvent(content, {
parentEvent,
addClientTag
})
await publish(draftEvent, additionalRelayUrls)
setContent('')
setOpen(false)
} catch (error) {
if (error instanceof AggregateError) {
error.errors.forEach((e) =>
toast({
variant: 'destructive',
title: t('Failed to post'),
description: e.message
})
)
} else if (error instanceof Error) {
toast({
variant: 'destructive',
title: t('Failed to post'),
description: error.message
})
}
console.error(error)
return
} finally {
setPosting(false)
}
toast({
title: t('Post successful'),
description: t('Your post has been published')
})
})
}
const onAddClientTagChange = (checked: boolean) => {
setAddClientTag(checked)
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0" withoutClose>
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<DialogHeader>
<DialogTitle>
{parentEvent ? (
<div className="flex gap-2 items-center max-w-full">
<div className="shrink-0">{t('Reply to')}</div>
<UserAvatar userId={parentEvent.pubkey} size="tiny" />
<div className="truncate">{parentEvent.content}</div>
</div>
) : (
t('New post')
)}
</DialogTitle>
<DialogDescription className="hidden" />
</DialogHeader>
<Textarea
className="h-32"
onChange={handleTextareaChange}
value={content}
placeholder={t('Write something...')}
/>
{content && <Preview content={content} />}
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
<Uploader setContent={setContent} />
<Button
variant="link"
className="text-foreground gap-0 px-0"
onClick={() => setShowMoreOptions((pre) => !pre)}
>
{t('More options')}
<ChevronDown
className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`}
/>
</Button>
</div>
<div className="flex gap-2 items-center">
<Mentions content={content} parentEvent={parentEvent} />
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
setOpen(false)
}}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')}
</Button>
</div>
</div>
{showMoreOptions && (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')}
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,174 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { StorageKey } from '@/constants'
import { useToast } from '@/hooks/use-toast'
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { ChevronDown, LoaderCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Mentions from './Mentions'
import Preview from './Preview'
import Uploader from './Uploader'
export default function PostContent({
defaultContent = '',
parentEvent,
close
}: {
defaultContent?: string
parentEvent?: Event
close: () => void
}) {
const { t } = useTranslation()
const { toast } = useToast()
const { publish, checkLogin } = useNostr()
const [content, setContent] = useState(defaultContent)
const [posting, setPosting] = useState(false)
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(false)
const canPost = !!content && !posting
useEffect(() => {
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
}, [])
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
}
const post = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!canPost) {
close()
return
}
setPosting(true)
try {
const additionalRelayUrls: string[] = []
if (parentEvent) {
const relayList = await client.fetchRelayList(parentEvent.pubkey)
additionalRelayUrls.push(...relayList.read.slice(0, 5))
}
const draftEvent = await createShortTextNoteDraftEvent(content, {
parentEvent,
addClientTag
})
await publish(draftEvent, additionalRelayUrls)
setContent('')
close()
} catch (error) {
if (error instanceof AggregateError) {
error.errors.forEach((e) =>
toast({
variant: 'destructive',
title: t('Failed to post'),
description: e.message
})
)
} else if (error instanceof Error) {
toast({
variant: 'destructive',
title: t('Failed to post'),
description: error.message
})
}
console.error(error)
return
} finally {
setPosting(false)
}
toast({
title: t('Post successful'),
description: t('Your post has been published')
})
})
}
const onAddClientTagChange = (checked: boolean) => {
setAddClientTag(checked)
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
}
return (
<div className="space-y-4">
<Textarea
className="h-32"
onChange={handleTextareaChange}
value={content}
placeholder={t('Write something...')}
/>
{content && <Preview content={content} />}
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
<Uploader setContent={setContent} />
<Button
variant="link"
className="text-foreground gap-0 px-0"
onClick={() => setShowMoreOptions((pre) => !pre)}
>
{t('More options')}
<ChevronDown
className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`}
/>
</Button>
</div>
<div className="flex gap-2 items-center">
<Mentions content={content} parentEvent={parentEvent} />
<div className="flex gap-2 items-center max-sm:hidden">
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')}
</Button>
</div>
</div>
</div>
{showMoreOptions && (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')}
</div>
</div>
)}
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button
className="w-full"
variant="secondary"
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import { SimpleUserAvatar } from '../UserAvatar'
export default function Title({ parentEvent }: { parentEvent?: Event }) {
const { t } = useTranslation()
return parentEvent ? (
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">{t('Reply to')}</div>
<SimpleUserAvatar userId={parentEvent.pubkey} size="tiny" />
<div className="flex-1 w-0 truncate">{parentEvent.content}</div>
</div>
) : (
t('New post')
)
}

View File

@@ -0,0 +1,78 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle
} from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Dispatch } from 'react'
import PostContent from './PostContent'
import Title from './Title'
export default function PostEditor({
defaultContent = '',
parentEvent,
open,
setOpen
}: {
defaultContent?: string
parentEvent?: Event
open: boolean
setOpen: Dispatch<boolean>
}) {
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="h-full">
<DrawerHeader>
<DrawerTitle className="text-start">
<Title parentEvent={parentEvent} />
</DrawerTitle>
<DrawerDescription className="hidden" />
</DrawerHeader>
<div className="overflow-auto py-2 px-4">
<PostContent
defaultContent={defaultContent}
parentEvent={parentEvent}
close={() => setOpen(false)}
/>
</div>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0" withoutClose>
<ScrollArea className="px-4 h-full max-h-screen">
<div className="space-y-4 px-2 py-6">
<DialogHeader>
<DialogTitle>
<Title parentEvent={parentEvent} />
</DialogTitle>
<DialogDescription className="hidden" />
</DialogHeader>
<PostContent
defaultContent={defaultContent}
parentEvent={parentEvent}
close={() => setOpen(false)}
/>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@@ -17,7 +17,7 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
return ( return (
<div <div
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full hover:text-foreground cursor-pointer" className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable"
onClick={() => copyNpub()} onClick={() => copyNpub()}
> >
<div>{formatNpub(npub, 24)}</div> <div>{formatNpub(npub, 24)}</div>

View File

@@ -1,13 +1,33 @@
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { QrCode } from 'lucide-react' import { QrCode } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { useMemo } from 'react'
export default function QrCodePopover({ pubkey }: { pubkey: string }) { export default function QrCodePopover({ pubkey }: { pubkey: string }) {
const { isSmallScreen } = useScreenSize()
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey]) const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
if (!npub) return null if (!npub) return null
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger>
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
<QrCode size={14} />
</div>
</DrawerTrigger>
<DrawerContent className="h-1/2">
<div className="flex justify-center items-center h-full">
<QRCodeSVG size={300} value={`nostr:${npub}`} />
</div>
</DrawerContent>
</Drawer>
)
}
return ( return (
<Popover> <Popover>
<PopoverTrigger> <PopoverTrigger>

View File

@@ -1,19 +0,0 @@
import { Button } from '@/components/ui/button'
import { usePrimaryPage } from '@/PageManager'
import { RefreshCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function RefreshButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar'
}) {
const { t } = useTranslation()
const { refresh } = usePrimaryPage()
return (
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}>
<RefreshCcw />
{variant === 'sidebar' && <div>{t('Refresh')}</div>}
</Button>
)
}

View File

@@ -54,7 +54,7 @@ export default function RelaySettings({ hideTitle = false }: { hideTitle?: boole
<RelayGroup key={index} group={group} /> <RelayGroup key={index} group={group} />
))} ))}
</div> </div>
{relayGroups.length < 5 && ( {relayGroups.length < 10 && (
<> <>
<Separator className="my-4" /> <Separator className="my-4" />
<div className="w-full border rounded-lg p-4"> <div className="w-full border rounded-lg p-4">

View File

@@ -1,50 +0,0 @@
import RelaySettings from '@/components/RelaySettings'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { toRelaySettings } from '@/lib/link'
import { SecondaryPageLink } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Server } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function RelaySettingsButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
if (isSmallScreen) {
return (
<SecondaryPageLink to={toRelaySettings()}>
<Button variant={variant} size={variant} title={t('Relay settings')}>
<Server />
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
</Button>
</SecondaryPageLink>
)
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant={variant} size={variant} title={t('Relay settings')}>
<Server />
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-96 h-[450px] p-0"
side={variant === 'titlebar' ? 'bottom' : 'right'}
>
<ScrollArea className="h-full">
<div className="p-4">
<RelaySettings />
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)
}

View File

@@ -5,7 +5,7 @@ import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import LikeButton from '../NoteStats/LikeButton' import LikeButton from '../NoteStats/LikeButton'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import PostDialog from '../PostDialog' import PostEditor from '../PostEditor'
import UserAvatar from '../UserAvatar' import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
@@ -51,7 +51,7 @@ export default function ReplyNote({
</div> </div>
</div> </div>
<LikeButton event={event} variant="reply" /> <LikeButton event={event} variant="reply" />
<PostDialog parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} /> <PostEditor parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
</div> </div>
) )
} }

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ChevronUp } from 'lucide-react' import { ChevronUp } from 'lucide-react'
export default function ScrollToTopButton({ export default function ScrollToTopButton({
@@ -11,20 +12,35 @@ export default function ScrollToTopButton({
className?: string className?: string
visible?: boolean visible?: boolean
}) { }) {
const { isSmallScreen } = useScreenSize()
const handleScrollToTop = () => { const handleScrollToTop = () => {
if (isSmallScreen) {
window.scrollTo({ top: 0, behavior: 'smooth' })
return
}
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
} }
return ( return (
<Button <div
variant="secondary-2"
className={cn( className={cn(
`absolute bottom-6 right-6 rounded-full w-12 h-12 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-20'}`, `sticky z-20 flex justify-end pr-3 transition-opacity duration-700 ${visible ? '' : 'opacity-0'}`,
className className
)} )}
onClick={handleScrollToTop} style={{
bottom: isSmallScreen
? 'calc(env(safe-area-inset-bottom) + 3.75rem)'
: 'calc(env(safe-area-inset-bottom) + 0.75rem)'
}}
> >
<ChevronUp /> <Button
</Button> variant="secondary-2"
className="rounded-full w-12 h-12 p-0 hover:text-background"
onClick={handleScrollToTop}
>
<ChevronUp />
</Button>
</div>
) )
} }

View File

@@ -1,24 +0,0 @@
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchDialog } from '../SearchDialog'
export default function RefreshButton({
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
<Search />
{variant === 'sidebar' && <div>{t('Search')}</div>}
</Button>
<SearchDialog open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -0,0 +1,89 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useFetchProfile } from '@/hooks'
import { toProfile, toSettings } from '@/lib/link'
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { LogIn } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import LoginDialog from '../LoginDialog'
import LogoutDialog from '../LogoutDialog'
import SidebarItem from './SidebarItem'
export default function AccountButton() {
const { pubkey } = useNostr()
if (pubkey) {
return <ProfileButton />
} else {
return <LoginButton />
}
}
function ProfileButton() {
const { t } = useTranslation()
const { account } = useNostr()
const pubkey = account?.pubkey
const { profile } = useFetchProfile(pubkey)
const { push } = useSecondaryPage()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
if (!pubkey) return null
const defaultAvatar = generateImageByPubkey(pubkey)
const { username, avatar } = profile || { username: formatPubkey(pubkey), 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>
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toSettings())}>{t('Settings')}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
{t('Switch account')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
>
{t('Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</DropdownMenu>
)
}
function LoginButton() {
const { checkLogin } = useNostr()
return (
<SidebarItem onClick={() => checkLogin()} title="Login">
<LogIn strokeWidth={3} />
</SidebarItem>
)
}

View File

@@ -0,0 +1,13 @@
import { usePrimaryPage } from '@/PageManager'
import { Home } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function HomeButton() {
const { navigate, current } = usePrimaryPage()
return (
<SidebarItem title="Home" onClick={() => navigate('home')} active={current === 'home'}>
<Home strokeWidth={3} />
</SidebarItem>
)
}

View File

@@ -0,0 +1,17 @@
import { usePrimaryPage } from '@/PageManager'
import { Bell } from 'lucide-react'
import SidebarItem from './SidebarItem'
export default function NotificationsButton() {
const { navigate, current } = usePrimaryPage()
return (
<SidebarItem
title="Notifications"
onClick={() => navigate('notifications')}
active={current === 'notifications'}
>
<Bell strokeWidth={3} />
</SidebarItem>
)
}

View File

@@ -0,0 +1,24 @@
import PostEditor from '@/components/PostEditor'
import { PencilLine } from 'lucide-react'
import { useState } from 'react'
import SidebarItem from './SidebarItem'
export default function PostButton() {
const [open, setOpen] = useState(false)
return (
<>
<SidebarItem
title="New post"
description="Post"
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<PencilLine strokeWidth={3} />
</SidebarItem>
<PostEditor open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -0,0 +1,24 @@
import { Search } from 'lucide-react'
import { useState } from 'react'
import { SearchDialog } from '../SearchDialog'
import SidebarItem from './SidebarItem'
export default function SearchButton() {
const [open, setOpen] = useState(false)
return (
<>
<SidebarItem
title="Search"
description="Search"
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<Search strokeWidth={3} />
</SidebarItem>
<SearchDialog open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -0,0 +1,31 @@
import { Button, ButtonProps } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
const SidebarItem = forwardRef<
HTMLButtonElement,
ButtonProps & { title: string; description?: string; active?: boolean }
>(({ children, title, description, className, active, ...props }, ref) => {
const { t } = useTranslation()
return (
<Button
className={cn(
'flex shadow-none items-center bg-transparent w-12 h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start gap-4 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4',
active && 'text-primary disabled:opacity-100',
className
)}
disabled={active}
variant="ghost"
title={t(title)}
ref={ref}
{...props}
>
{children}
<div className="max-xl:hidden">{t(description ?? title)}</div>
</Button>
)
})
SidebarItem.displayName = 'SidebarItem'
export default SidebarItem

View File

@@ -1,35 +1,25 @@
import Icon from '@/assets/Icon'
import Logo from '@/assets/Logo' import Logo from '@/assets/Logo'
import { Button } from '@/components/ui/button' import AccountButton from './AccountButton'
import { Info } from 'lucide-react' import HomeButton from './HomeButton'
import { useTranslation } from 'react-i18next' import NotificationsButton from './NotificationButton'
import AboutInfoDialog from '../AboutInfoDialog' import PostButton from './PostButton'
import AccountButton from '../AccountButton' import SearchButton from './SearchButton'
import NotificationButton from '../NotificationButton'
import PostButton from '../PostButton'
import RelaySettingsButton from '../RelaySettingsButton'
import SearchButton from '../SearchButton'
export default function PrimaryPageSidebar() { export default function PrimaryPageSidebar() {
const { t } = useTranslation()
return ( return (
<div className="w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-10 pl-4 justify-between relative"> <div className="w-16 xl:w-52 hidden sm:flex flex-col pb-2 pt-4 px-2 justify-between h-full shrink-0">
<div className="absolute top-0 left-0 h-11 w-full" />
<div className="space-y-2"> <div className="space-y-2">
<div className="ml-4 mb-8 w-40"> <div className="px-2 mb-10 w-full">
<Logo /> <Icon className="xl:hidden" />
<Logo className="max-xl:hidden" />
</div> </div>
<PostButton variant="sidebar" /> <HomeButton />
<RelaySettingsButton variant="sidebar" /> <NotificationsButton />
<NotificationButton variant="sidebar" /> <SearchButton />
<SearchButton variant="sidebar" /> <PostButton />
<AboutInfoDialog>
<Button variant="sidebar" size="sidebar">
<Info />
{t('About')}
</Button>
</AboutInfoDialog>
</div> </div>
<AccountButton variant="sidebar" /> <AccountButton />
</div> </div>
) )
} }

View File

@@ -3,11 +3,7 @@ import { useTheme } from '@/providers/ThemeProvider'
import { Moon, Sun, SunMoon } from 'lucide-react' import { Moon, Sun, SunMoon } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function ThemeToggle({ export default function ThemeToggle() {
variant = 'titlebar'
}: {
variant?: 'titlebar' | 'small-screen-titlebar'
}) {
const { t } = useTranslation() const { t } = useTranslation()
const { themeSetting, setThemeSetting } = useTheme() const { themeSetting, setThemeSetting } = useTheme()
@@ -15,8 +11,8 @@ export default function ThemeToggle({
<> <>
{themeSetting === 'system' ? ( {themeSetting === 'system' ? (
<Button <Button
variant={variant} variant="ghost"
size={variant} size="titlebar-icon"
onClick={() => setThemeSetting('light')} onClick={() => setThemeSetting('light')}
title={t('switch to light theme')} title={t('switch to light theme')}
> >
@@ -24,8 +20,8 @@ export default function ThemeToggle({
</Button> </Button>
) : themeSetting === 'light' ? ( ) : themeSetting === 'light' ? (
<Button <Button
variant={variant} variant="ghost"
size={variant} size="titlebar-icon"
onClick={() => setThemeSetting('dark')} onClick={() => setThemeSetting('dark')}
title={t('switch to dark theme')} title={t('switch to dark theme')}
> >
@@ -33,8 +29,8 @@ export default function ThemeToggle({
</Button> </Button>
) : ( ) : (
<Button <Button
variant={variant} variant="ghost"
size={variant} size="titlebar-icon"
onClick={() => setThemeSetting('system')} onClick={() => setThemeSetting('system')}
title={t('switch to system theme')} title={t('switch to system theme')}
> >

View File

@@ -12,7 +12,7 @@ export function Titlebar({
return ( return (
<div <div
className={cn( className={cn(
'absolute top-0 w-full h-9 max-sm:h-11 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold gap-1 px-2 duration-700 transition-transform', 'sticky top-0 w-full z-20 bg-background/90 backdrop-blur-xl duration-700 transition-transform [&_svg]:size-4 [&_svg]:shrink-0',
visible ? '' : '-translate-y-full', visible ? '' : '-translate-y-full',
className className
)} )}

View File

@@ -11,6 +11,7 @@ import { useMemo } from 'react'
const UserAvatarSizeCnMap = { const UserAvatarSizeCnMap = {
large: 'w-24 h-24', large: 'w-24 h-24',
big: 'w-16 h-16',
normal: 'w-10 h-10', normal: 'w-10 h-10',
small: 'w-7 h-7', small: 'w-7 h-7',
tiny: 'w-4 h-4' tiny: 'w-4 h-4'
@@ -23,7 +24,7 @@ export default function UserAvatar({
}: { }: {
userId: string userId: string
className?: string className?: string
size?: 'large' | 'normal' | 'small' | 'tiny' size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
}) { }) {
const { profile } = useFetchProfile(userId) const { profile } = useFetchProfile(userId)
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
@@ -62,7 +63,7 @@ export function SimpleUserAvatar({
onClick onClick
}: { }: {
userId: string userId: string
size?: 'large' | 'normal' | 'small' | 'tiny' size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
className?: string className?: string
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
}) { }) {

View File

@@ -1,6 +1,7 @@
import { Image } from '@nextui-org/image'
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Image } from '@nextui-org/image'
import { useMemo } from 'react' import { useMemo } from 'react'
export default function WebPreview({ export default function WebPreview({
@@ -12,6 +13,7 @@ export default function WebPreview({
className?: string className?: string
size?: 'normal' | 'small' size?: 'normal' | 'small'
}) { }) {
const { isSmallScreen } = useScreenSize()
const { title, description, image } = useFetchWebMetadata(url) const { title, description, image } = useFetchWebMetadata(url)
const hostname = useMemo(() => { const hostname = useMemo(() => {
try { try {
@@ -25,9 +27,21 @@ export default function WebPreview({
return null return null
} }
if (isSmallScreen && image) {
return (
<div className="relative border rounded-lg w-full h-44">
<Image src={image} className="rounded-lg object-cover w-full h-full" removeWrapper />
<div className="absolute bottom-0 z-10 bg-muted/70 px-2 py-1 w-full rounded-b-lg">
<div className="text-xs text-muted-foreground">{hostname}</div>
<div className="font-semibold line-clamp-1">{title}</div>
</div>
</div>
)
}
return ( return (
<div <div
className={cn('p-0 hover:bg-muted/50 cursor-pointer flex w-full', className)} className={cn('p-0 clickable flex w-full border rounded-lg', className)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
window.open(url, '_blank') window.open(url, '_blank')
@@ -36,11 +50,11 @@ export default function WebPreview({
{image && ( {image && (
<Image <Image
src={image} src={image}
className={`rounded-l-lg object-cover w-2/5 ${size === 'normal' ? 'h-44' : 'h-24'}`} className={`rounded-l-lg object-cover ${size === 'normal' ? 'h-44' : 'h-24'}`}
removeWrapper removeWrapper
/> />
)} )}
<div className={`flex-1 w-0 p-2 border ${image ? 'rounded-r-lg' : 'rounded-lg'}`}> <div className="flex-1 w-0 p-2">
<div className="text-xs text-muted-foreground">{hostname}</div> <div className="text-xs text-muted-foreground">{hostname}</div>
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}> <div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
{title} {title}

View File

@@ -0,0 +1,121 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { VariantProps } from 'class-variance-authority'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> &
VariantProps<typeof buttonVariants>
>(({ className, variant, size, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
}

View File

@@ -15,20 +15,15 @@ const buttonVariants = cva(
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight', 'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'clickable hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline'
titlebar: 'hover:bg-accent hover:text-accent-foreground',
sidebar: 'hover:bg-accent hover:text-accent-foreground',
'small-screen-titlebar': 'hover:bg-accent hover:text-accent-foreground'
}, },
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9', icon: 'h-9 w-9',
titlebar: 'h-7 w-7 rounded-full', 'titlebar-icon': 'h-10 w-10 rounded-lg'
sidebar: 'w-full flex py-2 px-4 rounded-full justify-start gap-4 text-lg font-semibold',
'small-screen-titlebar': 'h-8 w-8 rounded-full'
} }
}, },
defaultVariants: { defaultVariants: {

View File

@@ -0,0 +1,101 @@
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/lib/utils'
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
)
Drawer.displayName = 'Drawer'
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] sm:border bg-background',
className
)}
style={{
paddingBottom: 'env(safe-area-inset-bottom)'
}}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = 'DrawerContent'
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)} {...props} />
)
DrawerHeader.displayName = 'DrawerHeader'
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />
)
DrawerFooter.displayName = 'DrawerFooter'
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription
}

View File

@@ -1,23 +1,18 @@
import * as React from "react" import * as React from 'react'
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
) )
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<LabelPrimitive.Root <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)) ))
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName

View File

@@ -0,0 +1,150 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}

119
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,119 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
}
},
defaultVariants: {
side: 'right'
}
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription
}

View File

@@ -6,26 +6,32 @@ import { useEffect, useState } from 'react'
export function useFetchFollowings(pubkey?: string | null) { export function useFetchFollowings(pubkey?: string | null) {
const [followListEvent, setFollowListEvent] = useState<Event | null>(null) const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
const [followings, setFollowings] = useState<string[]>([]) const [followings, setFollowings] = useState<string[]>([])
const [isFetching, setIsFetching] = useState(true)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
if (!pubkey) return try {
setIsFetching(true)
if (!pubkey) return
const event = await client.fetchFollowListEvent(pubkey) const event = await client.fetchFollowListEvent(pubkey)
if (!event) return if (!event) return
setFollowListEvent(event) setFollowListEvent(event)
setFollowings( setFollowings(
event.tags event.tags
.filter(tagNameEquals('p')) .filter(tagNameEquals('p'))
.map(([, pubkey]) => pubkey) .map(([, pubkey]) => pubkey)
.filter(Boolean) .filter(Boolean)
.reverse() .reverse()
) )
} finally {
setIsFetching(false)
}
} }
init() init()
}, [pubkey]) }, [pubkey])
return { followings, followListEvent } return { followings, followListEvent, isFetching }
} }

View File

@@ -4,7 +4,9 @@ export default {
About: 'About', About: 'About',
'New post': 'New post', 'New post': 'New post',
Post: 'Post', Post: 'Post',
Home: 'Home',
'Relay settings': 'Relay settings', 'Relay settings': 'Relay settings',
Settings: 'Settings',
SidebarRelays: 'Relays', SidebarRelays: 'Relays',
Refresh: 'Refresh', Refresh: 'Refresh',
Profile: 'Profile', Profile: 'Profile',
@@ -44,9 +46,8 @@ export default {
'switch to light theme': 'switch to light theme', 'switch to light theme': 'switch to light theme',
'switch to dark theme': 'switch to dark theme', 'switch to dark theme': 'switch to dark theme',
'switch to system theme': 'switch to system theme', 'switch to system theme': 'switch to system theme',
note: 'note', Note: 'Note',
"username's following": "{{username}}'s following", "username's following": "{{username}}'s following",
following: 'following',
Login: 'Login', Login: 'Login',
'Follows you': 'Follows you', 'Follows you': 'Follows you',
'relay collection name already exists': 'relay collection name already exists', 'relay collection name already exists': 'relay collection name already exists',
@@ -67,16 +68,14 @@ export default {
'no replies': 'no replies', 'no replies': 'no replies',
'Reply to': 'Reply to', 'Reply to': 'Reply to',
Search: 'Search', Search: 'Search',
search: 'search',
'The relays you are connected to do not support search': 'The relays you are connected to do not support search':
'The relays you are connected to do not support search', 'The relays you are connected to do not support search',
'supports search': 'supports search', 'supports search': 'supports search',
'Show more...': 'Show more...', 'Show more...': 'Show more...',
'all users': 'all users', 'All users': 'All users',
'Display replies': 'Display replies', 'Display replies': 'Display replies',
Notes: 'Notes', Notes: 'Notes',
'Notes & Replies': 'Notes & Replies', 'Notes & Replies': 'Notes & Replies',
notifications: 'notifications',
Notifications: 'Notifications', Notifications: 'Notifications',
'no more notifications': 'no more notifications', 'no more notifications': 'no more notifications',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
@@ -87,9 +86,21 @@ export default {
'reload notes': 'reload notes', 'reload notes': 'reload notes',
'Logged in Accounts': 'Logged in Accounts', 'Logged in Accounts': 'Logged in Accounts',
'Add an Account': 'Add an Account', 'Add an Account': 'Add an Account',
Accounts: 'Accounts',
'More options': 'More options', 'More options': 'More options',
'Add client tag': 'Add client tag', 'Add client tag': 'Add client tag',
'Show others this was sent via Jumble': 'Show others this was sent via Jumble' 'Show others this was sent via Jumble': 'Show others this was sent via Jumble',
'Are you sure you want to logout?': 'Are you sure you want to logout?',
'relay feeds': 'relay feeds',
edit: 'edit',
Languages: 'Languages',
English: 'English',
Chinese: 'Chinese',
Theme: 'Theme',
System: 'System',
Light: 'Light',
Dark: 'Dark',
Temporary: 'Temporary',
'Choose a relay collection': 'Choose a relay collection',
'Switch account': 'Switch account'
} }
} }

View File

@@ -1,9 +1,8 @@
import dayjs from 'dayjs'
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './en' import en from './en'
import zh from './zh' import zh from './zh'
import dayjs from 'dayjs'
const resources = { const resources = {
en, en,
@@ -11,7 +10,21 @@ const resources = {
} }
i18n i18n
.use(LanguageDetector) .use({
type: 'languageDetector',
detect: function () {
const lng = localStorage.getItem('i18nextLng')
if (lng === 'zh' || lng === 'en') {
return lng
}
return undefined
},
cacheUserLanguage: function (lng: string) {
if (lng === 'zh' || lng === 'en') {
localStorage.setItem('i18nextLng', lng)
}
}
})
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: 'en', fallbackLng: 'en',

View File

@@ -4,7 +4,9 @@ export default {
About: '关于', About: '关于',
'New post': '发布新笔记', 'New post': '发布新笔记',
Post: '发布笔记', Post: '发布笔记',
Home: '主页',
'Relay settings': '服务器设置', 'Relay settings': '服务器设置',
Settings: '设置',
SidebarRelays: '服务器', SidebarRelays: '服务器',
Refresh: '刷新列表', Refresh: '刷新列表',
Profile: '个人资料', Profile: '个人资料',
@@ -44,9 +46,8 @@ export default {
'switch to light theme': '切换到浅色主题', 'switch to light theme': '切换到浅色主题',
'switch to dark theme': '切换到深色主题', 'switch to dark theme': '切换到深色主题',
'switch to system theme': '切换到系统主题', 'switch to system theme': '切换到系统主题',
note: '笔记', Note: '笔记',
"username's following": '{{username}} 的关注', "username's following": '{{username}} 的关注',
following: '关注',
Login: '登录', Login: '登录',
'Follows you': '关注了你', 'Follows you': '关注了你',
'relay collection name already exists': '服务器组名已存在', 'relay collection name already exists': '服务器组名已存在',
@@ -67,15 +68,13 @@ export default {
'no replies': '暂无回复', 'no replies': '暂无回复',
'Reply to': '回复', 'Reply to': '回复',
Search: '搜索', Search: '搜索',
search: '搜索',
'The relays you are connected to do not support search': '您连接的服务器不支持搜索', 'The relays you are connected to do not support search': '您连接的服务器不支持搜索',
'supports search': '支持搜索', 'supports search': '支持搜索',
'Show more...': '查看更多...', 'Show more...': '查看更多...',
'all users': '所有用户', 'All users': '所有用户',
'Display replies': '显示回复', 'Display replies': '显示回复',
Notes: '笔记', Notes: '笔记',
'Notes & Replies': '笔记 & 回复', 'Notes & Replies': '笔记 & 回复',
notifications: '通知',
Notifications: '通知', Notifications: '通知',
'no more notifications': '到底了', 'no more notifications': '到底了',
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.': 'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
@@ -86,9 +85,21 @@ export default {
'reload notes': '重新加载笔记', 'reload notes': '重新加载笔记',
'Logged in Accounts': '已登录账户', 'Logged in Accounts': '已登录账户',
'Add an Account': '添加账户', 'Add an Account': '添加账户',
Accounts: '多帐户',
'More options': '更多选项', 'More options': '更多选项',
'Add client tag': '添加客户端标签', 'Add client tag': '添加客户端标签',
'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的' 'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的',
'Are you sure you want to logout?': '确定要退出登录吗?',
'relay feeds': '服务器信息流',
edit: '编辑',
Languages: '语言',
English: '英语',
Chinese: '中文',
Theme: '主题',
System: '系统',
Light: '浅色',
Dark: '深色',
Temporary: '临时',
'Choose a relay collection': '选择一个服务器组',
'Switch account': '切换账户'
} }
} }

View File

@@ -3,6 +3,7 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
* { * {
@apply border-border;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -18,13 +19,28 @@
} }
body { body {
@apply bg-background text-foreground;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
overflow: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
text-size-adjust: 100%; text-size-adjust: 100%;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }
.clickable {
cursor: pointer;
transition: background-color 0.2s ease;
&:active {
background-color: hsl(var(--muted) / 0.5);
}
}
@media (hover: hover) and (pointer: fine) {
.clickable:hover {
background-color: hsl(var(--muted) / 0.5);
}
}
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 240 10% 3.9%; --foreground: 240 10% 3.9%;
@@ -81,11 +97,3 @@
--highlight: 259 43% 56%; --highlight: 259 43% 56%;
} }
} }
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,70 +1,112 @@
import Logo from '@/assets/Logo' import BottomNavigationBar from '@/components/BottomNavigationBar'
import AccountButton from '@/components/AccountButton'
import NotificationButton from '@/components/NotificationButton'
import PostButton from '@/components/PostButton'
import RelaySettingsButton from '@/components/RelaySettingsButton'
import ScrollToTopButton from '@/components/ScrollToTopButton' import ScrollToTopButton from '@/components/ScrollToTopButton'
import SearchButton from '@/components/SearchButton'
import ThemeToggle from '@/components/ThemeToggle'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => { const PrimaryPageLayout = forwardRef(
const scrollAreaRef = useRef<HTMLDivElement>(null) (
const [visible, setVisible] = useState(true) {
const [lastScrollTop, setLastScrollTop] = useState(0) children,
titlebar,
pageName,
displayScrollToTopButton = false
}: {
children?: React.ReactNode
titlebar?: React.ReactNode
pageName: TPrimaryPageName
displayScrollToTopButton?: boolean
},
ref
) => {
const scrollAreaRef = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(true)
const [lastScrollTop, setLastScrollTop] = useState(0)
const { isSmallScreen } = useScreenSize()
const { current } = usePrimaryPage()
useImperativeHandle( useImperativeHandle(
ref, ref,
() => ({ () => ({
scrollToTop: () => { scrollToTop: () => {
scrollAreaRef.current?.scrollTo({ top: 0 }) if (isSmallScreen) {
} window.scrollTo({ top: 0 })
}), return
[] }
) scrollAreaRef.current?.scrollTo({ top: 0 })
}
}),
[]
)
useEffect(() => { useEffect(() => {
const handleScroll = () => { if (isSmallScreen) {
const scrollTop = scrollAreaRef.current?.scrollTop || 0 window.scrollTo({ top: 0 })
const diff = scrollTop - lastScrollTop
if (scrollTop <= 100) {
setVisible(true) setVisible(true)
setLastScrollTop(scrollTop)
return return
} }
}, [current])
if (diff > 20) { useEffect(() => {
setVisible(false) if (current !== pageName) return
setLastScrollTop(scrollTop)
} else if (diff < -20) { const handleScroll = () => {
setVisible(true) const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
setLastScrollTop(scrollTop) const diff = scrollTop - lastScrollTop
if (scrollTop <= 800) {
setVisible(true)
setLastScrollTop(scrollTop)
return
}
if (diff > 20) {
setVisible(false)
setLastScrollTop(scrollTop)
} else if (diff < -20) {
setVisible(true)
setLastScrollTop(scrollTop)
}
} }
}
const scrollArea = scrollAreaRef.current if (isSmallScreen) {
scrollArea?.addEventListener('scroll', handleScroll) window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}
return () => { scrollAreaRef.current?.addEventListener('scroll', handleScroll)
scrollArea?.removeEventListener('scroll', handleScroll) return () => {
} scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
}, [lastScrollTop]) }
}, [lastScrollTop, isSmallScreen, current])
return ( return (
<ScrollArea <ScrollArea
ref={scrollAreaRef} className="sm:h-screen sm:overflow-auto"
className="h-full w-full" scrollBarClassName="sm:z-50"
scrollBarClassName="pt-9 xl:pt-0 max-sm:pt-11" ref={scrollAreaRef}
> style={{
<PrimaryPageTitlebar visible={visible} /> paddingBottom: 'env(safe-area-inset-bottom)'
<div className="sm:px-4 pb-4 pt-11 xl:pt-4">{children}</div> }}
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} /> >
</ScrollArea> {titlebar && (
) <PrimaryPageTitlebar visible={!isSmallScreen || visible}>{titlebar}</PrimaryPageTitlebar>
}) )}
<div className="overflow-x-hidden">{children}</div>
{displayScrollToTopButton && (
<ScrollToTopButton
scrollAreaRef={scrollAreaRef}
visible={visible && lastScrollTop > 500}
/>
)}
{isSmallScreen && <BottomNavigationBar visible={visible} />}
</ScrollArea>
)
}
)
PrimaryPageLayout.displayName = 'PrimaryPageLayout' PrimaryPageLayout.displayName = 'PrimaryPageLayout'
export default PrimaryPageLayout export default PrimaryPageLayout
@@ -72,43 +114,16 @@ export type TPrimaryPageLayoutRef = {
scrollToTop: () => void scrollToTop: () => void
} }
function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) { function PrimaryPageTitlebar({
const { isSmallScreen } = useScreenSize() children,
visible = true
if (isSmallScreen) { }: {
return ( children?: React.ReactNode
<Titlebar visible?: boolean
className="justify-between px-4 transition-transform duration-500" }) {
visible={visible}
>
<div className="flex gap-1 items-center">
<div className="-translate-y-0.5">
<Logo className="h-8" />
</div>
<ThemeToggle variant="small-screen-titlebar" />
</div>
<div className="flex gap-1 items-center">
<SearchButton variant="small-screen-titlebar" />
<PostButton variant="small-screen-titlebar" />
<RelaySettingsButton variant="small-screen-titlebar" />
<NotificationButton variant="small-screen-titlebar" />
<AccountButton variant="small-screen-titlebar" />
</div>
</Titlebar>
)
}
return ( return (
<Titlebar className="justify-between xl:hidden"> <Titlebar className="h-12 p-1" visible={visible}>
<div className="flex gap-2 items-center"> {children}
<AccountButton />
<PostButton />
<SearchButton />
</div>
<div className="flex gap-2 items-center">
<RelaySettingsButton />
<NotificationButton />
</div>
</Titlebar> </Titlebar>
) )
} }

View File

@@ -1,29 +1,45 @@
import BackButton from '@/components/BackButton' import BackButton from '@/components/BackButton'
import BottomNavigationBar from '@/components/BottomNavigationBar'
import ScrollToTopButton from '@/components/ScrollToTopButton' import ScrollToTopButton from '@/components/ScrollToTopButton'
import ThemeToggle from '@/components/ThemeToggle' import ThemeToggle from '@/components/ThemeToggle'
import { Titlebar } from '@/components/Titlebar' import { Titlebar } from '@/components/Titlebar'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
export default function SecondaryPageLayout({ export default function SecondaryPageLayout({
children, children,
index,
titlebarContent, titlebarContent,
hideBackButton = false, hideBackButton = false,
hideScrollToTopButton = false displayScrollToTopButton = false
}: { }: {
children?: React.ReactNode children?: React.ReactNode
index?: number
titlebarContent?: React.ReactNode titlebarContent?: React.ReactNode
hideBackButton?: boolean hideBackButton?: boolean
hideScrollToTopButton?: boolean displayScrollToTopButton?: boolean
}): JSX.Element { }): JSX.Element {
const scrollAreaRef = useRef<HTMLDivElement>(null) const scrollAreaRef = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(true) const [visible, setVisible] = useState(true)
const [lastScrollTop, setLastScrollTop] = useState(0) const [lastScrollTop, setLastScrollTop] = useState(0)
const { isSmallScreen } = useScreenSize()
const { currentIndex } = useSecondaryPage()
useEffect(() => { useEffect(() => {
if (isSmallScreen) {
window.scrollTo({ top: 0 })
setVisible(true)
return
}
}, [])
useEffect(() => {
if (currentIndex !== index) return
const handleScroll = () => { const handleScroll = () => {
const scrollTop = scrollAreaRef.current?.scrollTop || 0 const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
const diff = scrollTop - lastScrollTop const diff = scrollTop - lastScrollTop
if (scrollTop <= 100) { if (scrollTop <= 100) {
setVisible(true) setVisible(true)
@@ -40,26 +56,38 @@ export default function SecondaryPageLayout({
} }
} }
const scrollArea = scrollAreaRef.current if (isSmallScreen) {
scrollArea?.addEventListener('scroll', handleScroll) window.addEventListener('scroll', handleScroll)
return () => {
return () => { window.removeEventListener('scroll', handleScroll)
scrollArea?.removeEventListener('scroll', handleScroll) }
} }
}, [lastScrollTop])
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
return () => {
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
}
}, [lastScrollTop, isSmallScreen, currentIndex])
return ( return (
<ScrollArea ref={scrollAreaRef} className="h-full" scrollBarClassName="sm:pt-9 pt-11"> <ScrollArea
className="sm:h-screen sm:overflow-auto"
scrollBarClassName="sm:z-50"
ref={scrollAreaRef}
style={{
paddingBottom: 'env(safe-area-inset-bottom)'
}}
>
<SecondaryPageTitlebar <SecondaryPageTitlebar
content={titlebarContent} content={titlebarContent}
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
visible={visible} visible={visible}
/> />
<div className="sm:px-4 pb-4 pt-11 w-full h-full">{children}</div> <div className="pb-4 mt-2">{children}</div>
<ScrollToTopButton {displayScrollToTopButton && (
scrollAreaRef={scrollAreaRef} <ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} />
visible={!hideScrollToTopButton && visible && lastScrollTop > 500} )}
/> {isSmallScreen && <BottomNavigationBar visible={visible} />}
</ScrollArea> </ScrollArea>
) )
} }
@@ -77,18 +105,16 @@ export function SecondaryPageTitlebar({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<Titlebar className="pl-2" visible={visible}> <Titlebar className="h-12 flex gap-1 p-1 items-center font-semibold" visible={visible}>
<BackButton hide={hideBackButton} variant="small-screen-titlebar" /> <BackButton hide={hideBackButton}>{content}</BackButton>
<div className="truncate text-lg">{content}</div>
</Titlebar> </Titlebar>
) )
} }
return ( return (
<Titlebar className="justify-between"> <Titlebar className="h-12 flex gap-1 p-1 justify-between items-center font-semibold">
<div className="flex items-center gap-1 flex-1 w-0"> <div className="flex items-center gap-1 flex-1 w-0">
<BackButton hide={hideBackButton} /> <BackButton hide={hideBackButton}>{content}</BackButton>
<div className="truncate text-lg">{content}</div>
</div> </div>
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">
<ThemeToggle /> <ThemeToggle />

View File

@@ -38,7 +38,7 @@ export const toFollowingList = (pubkey: string) => {
return `/users/${npub}/following` return `/users/${npub}/following`
} }
export const toRelaySettings = () => '/relay-settings' export const toRelaySettings = () => '/relay-settings'
export const toNotifications = () => '/notifications' export const toSettings = () => '/settings'
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`

View File

@@ -14,3 +14,7 @@ export function normalizeUrl(url: string): string {
p.hash = '' p.hash = ''
return p.toString() return p.toString()
} }
export function simplifyUrl(url: string): string {
return url.replace('wss://', '').replace('ws://', '').replace(/\/$/, '')
}

View File

@@ -0,0 +1,107 @@
import AccountManager from '@/components/AccountManager'
import LoginDialog from '@/components/LoginDialog'
import LogoutDialog from '@/components/LogoutDialog'
import PubkeyCopy from '@/components/PubkeyCopy'
import QrCodePopover from '@/components/QrCodePopover'
import { Separator } from '@/components/ui/separator'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { toProfile, toSettings } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound } from 'lucide-react'
import { HTMLProps, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function MePage() {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { pubkey } = useNostr()
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
if (!pubkey) {
return (
<PrimaryPageLayout pageName="home">
<div className="flex flex-col p-4 gap-4 overflow-auto">
<AccountManager />
</div>
</PrimaryPageLayout>
)
}
return (
<PrimaryPageLayout pageName="home">
<div className="flex gap-4 items-center p-4">
<SimpleUserAvatar userId={pubkey} size="big" />
<div className="space-y-1">
<SimpleUsername
className="text-xl font-semibold truncate"
userId={pubkey}
skeletonClassName="h-6 w-32"
/>
<div className="flex gap-1 mt-1">
<PubkeyCopy pubkey={pubkey} />
<QrCodePopover pubkey={pubkey} />
</div>
</div>
</div>
<div className="mt-4">
<ItemGroup>
<Item onClick={() => push(toProfile(pubkey))}>
<UserRound />
{t('Profile')}
</Item>
</ItemGroup>
<ItemGroup>
<Item onClick={() => push(toSettings())}>
<Settings />
{t('Settings')}
</Item>
</ItemGroup>
<ItemGroup>
<Item onClick={() => setLoginDialogOpen(true)}>
<ArrowDownUp /> {t('Switch account')}
</Item>
<Separator className="bg-background" />
<Item
className="text-destructive focus:text-destructive"
onClick={() => setLogoutDialogOpen(true)}
hideChevron
>
<LogOut />
{t('Logout')}
</Item>
</ItemGroup>
</div>
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
</PrimaryPageLayout>
)
}
function Item({
children,
className,
hideChevron = false,
...props
}: HTMLProps<HTMLDivElement> & { hideChevron?: boolean }) {
return (
<div
className={cn(
'flex items-center justify-between px-4 py-2 w-full clickable rounded-lg [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
>
<div className="flex items-center gap-4">{children}</div>
{!hideChevron && <ChevronRight />}
</div>
)
}
function ItemGroup({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg m-4 bg-muted/40">{children}</div>
}

View File

@@ -0,0 +1,74 @@
import FeedSwitcher from '@/components/FeedSwitcher'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { simplifyUrl } from '@/lib/url'
import { useFeed } from '@/providers/FeedProvider'
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { ChevronDown, Server, UsersRound } from 'lucide-react'
import { forwardRef, HTMLAttributes, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function FeedButton() {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
if (isSmallScreen) {
return (
<>
<FeedSwitcherTrigger onClick={() => setOpen(true)} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="max-h-[80vh]">
<div className="p-4 overflow-auto">
<FeedSwitcher close={() => setOpen(false)} />
</div>
</DrawerContent>
</Drawer>
</>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FeedSwitcherTrigger />
</PopoverTrigger>
<PopoverContent side="bottom" className="w-96 p-4 max-h-[80vh] overflow-auto">
<FeedSwitcher close={() => setOpen(false)} />
</PopoverContent>
</Popover>
)
}
const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
(props, ref) => {
const { t } = useTranslation()
const { feedType } = useFeed()
const { relayGroups, temporaryRelayUrls } = useRelaySettings()
const activeGroup = relayGroups.find((group) => group.isActive)
const title =
feedType === 'following'
? t('Following')
: temporaryRelayUrls.length > 0
? temporaryRelayUrls.length === 1
? simplifyUrl(temporaryRelayUrls[0])
: t('Temporary')
: activeGroup
? activeGroup.relayUrls.length === 1
? simplifyUrl(activeGroup.relayUrls[0])
: activeGroup.groupName
: t('Choose a relay collection')
return (
<div
className="flex items-center gap-2 clickable px-3 h-full rounded-lg"
ref={ref}
{...props}
>
{feedType === 'following' ? <UsersRound /> : <Server />}
<div className="text-lg font-semibold">{title}</div>
<ChevronDown />
</div>
)
}
)

View File

@@ -0,0 +1,17 @@
import { SearchDialog } from '@/components/SearchDialog'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { useState } from 'react'
export default function SearchButton() {
const [open, setOpen] = useState(false)
return (
<>
<Button variant="ghost" size="titlebar-icon" onClick={() => setOpen(true)}>
<Search />
</Button>
<SearchDialog open={open} setOpen={setOpen} />
</>
)
}

View File

@@ -1,48 +1,69 @@
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import RelaySettings from '@/components/RelaySettings' import { BIG_RELAY_URLS } from '@/constants'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { useFeed } from '@/providers/FeedProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useRelaySettings } from '@/providers/RelaySettingsProvider' import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import FeedButton from './FeedButton'
import SearchButton from './SearchButton'
export default function NoteListPage() { export default function NoteListPage() {
const { t } = useTranslation()
const layoutRef = useRef<{ scrollToTop: () => void }>(null) const layoutRef = useRef<{ scrollToTop: () => void }>(null)
const { relayUrls } = useRelaySettings() const { feedType } = useFeed()
const relayUrlsString = JSON.stringify(relayUrls) const { relayUrls, temporaryRelayUrls } = useRelaySettings()
const { pubkey, relayList, followings } = useNostr()
const urls = useMemo(() => {
return feedType === 'following'
? relayList?.read.length
? relayList.read.slice(0, 4)
: BIG_RELAY_URLS
: temporaryRelayUrls.length > 0
? temporaryRelayUrls
: relayUrls
}, [feedType, relayUrls, relayList, temporaryRelayUrls])
useEffect(() => { useEffect(() => {
if (layoutRef.current) { if (layoutRef.current) {
layoutRef.current.scrollToTop() layoutRef.current.scrollToTop()
} }
}, [relayUrlsString]) }, [JSON.stringify(relayUrls), feedType])
if (!relayUrls.length) {
return (
<PrimaryPageLayout>
<div className="w-full text-center">
<Popover>
<PopoverTrigger asChild>
<Button title="relay settings" size="lg">
Choose a relay group
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 h-[450px] p-0">
<ScrollArea className="h-full">
<div className="p-4">
<RelaySettings />
</div>
</ScrollArea>
</PopoverContent>
</Popover>
</div>
</PrimaryPageLayout>
)
}
return ( return (
<PrimaryPageLayout ref={layoutRef}> <PrimaryPageLayout
<NoteList relayUrls={relayUrls} /> pageName="home"
ref={layoutRef}
titlebar={<NoteListPageTitlebar />}
displayScrollToTopButton
>
{!!urls.length && (feedType === 'relays' || (relayList && followings)) ? (
<NoteList
relayUrls={urls}
filter={
feedType === 'following'
? {
authors:
pubkey && !followings?.includes(pubkey)
? [...(followings ?? []), pubkey]
: (followings ?? [])
}
: {}
}
/>
) : (
<div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
)}
</PrimaryPageLayout> </PrimaryPageLayout>
) )
} }
function NoteListPageTitlebar() {
return (
<div className="flex gap-1 items-center h-full justify-between">
<FeedButton />
<SearchButton />
</div>
)
}

View File

@@ -0,0 +1,29 @@
import NotificationList from '@/components/NotificationList'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { Bell } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function NotificationListPage() {
return (
<PrimaryPageLayout
pageName="notifications"
titlebar={<NotificationListPageTitlebar />}
displayScrollToTopButton
>
<div className="px-4">
<NotificationList />
</div>
</PrimaryPageLayout>
)
}
function NotificationListPageTitlebar() {
const { t } = useTranslation()
return (
<div className="flex gap-2 items-center h-full pl-3">
<Bell />
<div className="text-lg font-semibold">{t('Notifications')}</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function FollowingListPage({ id }: { id?: string }) { export default function FollowingListPage({ id, index }: { id?: string; index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
const { followings } = useFetchFollowings(profile?.pubkey) const { followings } = useFetchFollowings(profile?.pubkey)
@@ -45,13 +45,15 @@ export default function FollowingListPage({ id }: { id?: string }) {
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
index={index}
titlebarContent={ titlebarContent={
profile?.username profile?.username
? t("username's following", { username: profile.username }) ? t("username's following", { username: profile.username })
: t('following') : t('Following')
} }
displayScrollToTopButton
> >
<div className="space-y-2 max-sm:px-4"> <div className="space-y-2 px-4">
{visibleFollowings.map((pubkey, index) => ( {visibleFollowings.map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))} ))}

View File

@@ -1,11 +1,11 @@
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function HomePage() { export default function HomePage({ index }: { index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<SecondaryPageLayout hideBackButton hideScrollToTopButton> <SecondaryPageLayout index={index} hideBackButton>
<div className="text-muted-foreground w-full h-full flex items-center justify-center"> <div className="text-muted-foreground w-full h-screen flex items-center justify-center">
{t('Welcome! 🥳')} {t('Welcome! 🥳')}
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

View File

@@ -1,8 +1,8 @@
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
export default function LoadingPage({ title }: { title?: string }) { export default function LoadingPage({ title, index }: { title?: string; index?: number }) {
return ( return (
<SecondaryPageLayout titlebarContent={title}> <SecondaryPageLayout index={index} titlebarContent={title}>
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<div>Loading...</div> <div>Loading...</div>
</div> </div>

View File

@@ -1,11 +1,11 @@
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function NotFoundPage() { export default function NotFoundPage({ index }: { index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<SecondaryPageLayout hideBackButton> <SecondaryPageLayout index={index} hideBackButton>
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2"> <div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
<div>{t('Lost in the void')} 🌌</div> <div>{t('Lost in the void')} 🌌</div>
<div>(404)</div> <div>(404)</div>

View File

@@ -1,13 +1,13 @@
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import { useSearchParams } from '@/hooks' import { useSearchParams } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { isWebsocketUrl } from '@/lib/url' import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
import { useRelaySettings } from '@/providers/RelaySettingsProvider' import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { Filter } from 'nostr-tools' import { Filter } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function NoteListPage() { export default function NoteListPage({ index }: { index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const { relayUrls, searchableRelayUrls } = useRelaySettings() const { relayUrls, searchableRelayUrls } = useRelaySettings()
const { searchParams } = useSearchParams() const { searchParams } = useSearchParams()
@@ -27,18 +27,18 @@ export default function NoteListPage() {
} }
const search = searchParams.get('s') const search = searchParams.get('s')
if (search) { if (search) {
return { title: `${t('search')}: ${search}`, filter: { search }, urls: searchableRelayUrls } return { title: `${t('Search')}: ${search}`, filter: { search }, urls: searchableRelayUrls }
} }
const relayUrl = searchParams.get('relay') const relayUrl = searchParams.get('relay')
if (relayUrl && isWebsocketUrl(relayUrl)) { if (relayUrl && isWebsocketUrl(relayUrl)) {
return { title: relayUrl, urls: [relayUrl] } return { title: simplifyUrl(relayUrl), urls: [relayUrl] }
} }
return { urls: relayUrls } return { urls: relayUrls }
}, [searchParams, relayUrlsString]) }, [searchParams, relayUrlsString])
if (filter?.search && searchableRelayUrls.length === 0) { if (filter?.search && searchableRelayUrls.length === 0) {
return ( return (
<SecondaryPageLayout titlebarContent={title}> <SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
<div className="text-center text-sm text-muted-foreground"> <div className="text-center text-sm text-muted-foreground">
{t('The relays you are connected to do not support search')} {t('The relays you are connected to do not support search')}
</div> </div>
@@ -47,7 +47,7 @@ export default function NoteListPage() {
} }
return ( return (
<SecondaryPageLayout titlebarContent={title}> <SecondaryPageLayout index={index} titlebarContent={title}>
<NoteList key={title} filter={filter} relayUrls={urls} /> <NoteList key={title} filter={filter} relayUrls={urls} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )

View File

@@ -14,7 +14,7 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
export default function NotePage({ id }: { id?: string }) { export default function NotePage({ id, index }: { id?: string; index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(id) const { event, isFetching } = useFetchEvent(id)
const parentEventId = useMemo(() => getParentEventId(event), [event]) const parentEventId = useMemo(() => getParentEventId(event), [event])
@@ -22,8 +22,8 @@ export default function NotePage({ id }: { id?: string }) {
if (!event && isFetching) { if (!event && isFetching) {
return ( return (
<SecondaryPageLayout titlebarContent={t('note')}> <SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
<div className="max-sm:px-4"> <div className="px-4">
<Skeleton className="w-10 h-10 rounded-full" /> <Skeleton className="w-10 h-10 rounded-full" />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
@@ -32,14 +32,14 @@ export default function NotePage({ id }: { id?: string }) {
if (!event) return <NotFoundPage /> if (!event) return <NotFoundPage />
return ( return (
<SecondaryPageLayout titlebarContent={t('note')}> <SecondaryPageLayout index={index} titlebarContent={t('Note')}>
<div className="max-sm:px-4"> <div className="px-4">
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} /> <ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} /> <ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
<Note key={`note-${event.id}`} event={event} fetchNoteStats /> <Note key={`note-${event.id}`} event={event} fetchNoteStats />
</div> </div>
<Separator className="mb-2 mt-4" /> <Separator className="mb-2 mt-4" />
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="max-sm:px-2" /> <ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="px-2" />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
@@ -52,7 +52,7 @@ function ParentNote({ eventId }: { eventId?: string }) {
return ( return (
<div> <div>
<Card <Card
className="flex space-x-1 p-1 items-center hover:bg-muted/50 cursor-pointer text-sm text-muted-foreground hover:text-foreground" className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
onClick={() => push(toNote(event))} onClick={() => push(toNote(event))}
> >
<UserAvatar userId={event.pubkey} size="tiny" /> <UserAvatar userId={event.pubkey} size="tiny" />

View File

@@ -1,15 +0,0 @@
import NotificationList from '@/components/NotificationList'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useTranslation } from 'react-i18next'
export default function NotificationListPage() {
const { t } = useTranslation()
return (
<SecondaryPageLayout titlebarContent={t('notifications')}>
<div className="max-sm:px-4">
<NotificationList />
</div>
</SecondaryPageLayout>
)
}

View File

@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
const LIMIT = 50 const LIMIT = 50
export default function ProfileListPage() { export default function ProfileListPage({ index }: { index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const { searchParams } = useSearchParams() const { searchParams } = useSearchParams()
const { relayUrls, searchableRelayUrls } = useRelaySettings() const { relayUrls, searchableRelayUrls } = useRelaySettings()
@@ -30,7 +30,7 @@ export default function ProfileListPage() {
return filter.search ? searchableRelayUrls : relayUrls return filter.search ? searchableRelayUrls : relayUrls
}, [relayUrls, searchableRelayUrls, filter]) }, [relayUrls, searchableRelayUrls, filter])
const title = useMemo(() => { const title = useMemo(() => {
return filter.search ? `${t('search')}: ${filter.search}` : t('all users') return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
}, [filter]) }, [filter])
useEffect(() => { useEffect(() => {
@@ -78,8 +78,8 @@ export default function ProfileListPage() {
} }
return ( return (
<SecondaryPageLayout titlebarContent={title}> <SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
<div className="space-y-2 max-sm:px-4"> <div className="space-y-2 px-4">
{Array.from(pubkeySet).map((pubkey, index) => ( {Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} /> <UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))} ))}

View File

@@ -3,8 +3,9 @@ import Nip05 from '@/components/Nip05'
import NoteList from '@/components/NoteList' import NoteList from '@/components/NoteList'
import ProfileAbout from '@/components/ProfileAbout' import ProfileAbout from '@/components/ProfileAbout'
import ProfileBanner from '@/components/ProfileBanner' import ProfileBanner from '@/components/ProfileBanner'
import PubkeyCopy from '@/components/PubkeyCopy'
import QrCodePopover from '@/components/QrCodePopover'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchFollowings, useFetchProfile } from '@/hooks' import { useFetchFollowings, useFetchProfile } from '@/hooks'
import { useFetchRelayList } from '@/hooks/useFetchRelayList' import { useFetchRelayList } from '@/hooks/useFetchRelayList'
@@ -18,10 +19,8 @@ import { useRelaySettings } from '@/providers/RelaySettingsProvider'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
import PubkeyCopy from './PubkeyCopy'
import QrCodePopover from './QrCodePopover'
export default function ProfilePage({ id }: { id?: string }) { export default function ProfilePage({ id, index }: { id?: string; index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
const { profile, isFetching } = useFetchProfile(id) const { profile, isFetching } = useFetchProfile(id)
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey) const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
@@ -46,8 +45,8 @@ export default function ProfilePage({ id }: { id?: string }) {
if (!profile && isFetching) { if (!profile && isFetching) {
return ( return (
<SecondaryPageLayout> <SecondaryPageLayout index={index}>
<div className="max-sm:px-4"> <div className="px-4">
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2"> <div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
<Skeleton className="w-full h-full object-cover rounded-lg" /> <Skeleton className="w-full h-full object-cover rounded-lg" />
<Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" /> <Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" />
@@ -62,8 +61,8 @@ export default function ProfilePage({ id }: { id?: string }) {
const { banner, username, nip05, about, avatar, pubkey } = profile const { banner, username, nip05, about, avatar, pubkey } = profile
return ( return (
<SecondaryPageLayout titlebarContent={username}> <SecondaryPageLayout index={index} titlebarContent={username} displayScrollToTopButton>
<div className="max-sm:px-4"> <div className="px-4">
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2"> <div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
<ProfileBanner <ProfileBanner
banner={banner} banner={banner}
@@ -102,9 +101,8 @@ export default function ProfilePage({ id }: { id?: string }) {
</SecondaryPageLink> </SecondaryPageLink>
</div> </div>
</div> </div>
<Separator className="hidden sm:block mt-4 sm:my-4" />
{!isFetchingRelayInfo && ( {!isFetchingRelayInfo && (
<NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="max-sm:mt-2" /> <NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="mt-2" />
)} )}
</SecondaryPageLayout> </SecondaryPageLayout>
) )

View File

@@ -2,12 +2,12 @@ import RelaySettings from '@/components/RelaySettings'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function RelaySettingsPage() { export default function RelaySettingsPage({ index }: { index?: number }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<SecondaryPageLayout titlebarContent={t('Relay settings')}> <SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
<div className="max-sm:px-4"> <div className="px-4">
<RelaySettings hideTitle /> <RelaySettings hideTitle />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>

View File

@@ -0,0 +1,70 @@
import AboutInfoDialog from '@/components/AboutInfoDialog'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { useTheme } from '@/providers/ThemeProvider'
import { TLanguage } from '@/types'
import { SelectValue } from '@radix-ui/react-select'
import { ChevronRight, Info, Languages, SunMoon } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function SettingsPage({ index }: { index?: number }) {
const { t, i18n } = useTranslation()
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
const { themeSetting, setThemeSetting } = useTheme()
const handleLanguageChange = (value: TLanguage) => {
i18n.changeLanguage(value)
setLanguage(value)
}
return (
<SecondaryPageLayout index={index} titlebarContent={t('Settings')}>
<div className="flex justify-between items-center px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0">
<div className="flex items-center gap-4">
<Languages />
<div>{t('Languages')}</div>
</div>
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t('English')}</SelectItem>
<SelectItem value="zh">{t('Chinese')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-between items-center px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0">
<div className="flex items-center gap-4">
<SunMoon />
<div>{t('Theme')}</div>
</div>
<Select defaultValue="system" value={themeSetting} onValueChange={setThemeSetting}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">{t('System')}</SelectItem>
<SelectItem value="light">{t('Light')}</SelectItem>
<SelectItem value="dark">{t('Dark')}</SelectItem>
</SelectContent>
</Select>
</div>
<AboutInfoDialog>
<div className="flex clickable justify-between items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0">
<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{__APP_VERSION__} ({__GIT_COMMIT__})
</div>
<ChevronRight />
</div>
</div>
</AboutInfoDialog>
</SecondaryPageLayout>
)
}

View File

@@ -0,0 +1,23 @@
import { TFeedType } from '@/types'
import { createContext, useContext, useState } from 'react'
type TFeedContext = {
feedType: TFeedType
setFeedType: (feedType: TFeedType) => void
}
const FeedContext = createContext<TFeedContext | undefined>(undefined)
export const useFeed = () => {
const context = useContext(FeedContext)
if (!context) {
throw new Error('useFeed must be used within a FeedProvider')
}
return context
}
export function FeedProvider({ children }: { children: React.ReactNode }) {
const [feedType, setFeedType] = useState<TFeedType>('relays')
return <FeedContext.Provider value={{ feedType, setFeedType }}>{children}</FeedContext.Provider>
}

View File

@@ -9,7 +9,7 @@ import { useNostr } from './NostrProvider'
type TFollowListContext = { type TFollowListContext = {
followListEvent: Event | undefined followListEvent: Event | undefined
followings: string[] followings: string[]
isReady: boolean isFetching: boolean
follow: (pubkey: string) => Promise<void> follow: (pubkey: string) => Promise<void>
unfollow: (pubkey: string) => Promise<void> unfollow: (pubkey: string) => Promise<void>
} }
@@ -27,33 +27,35 @@ export const useFollowList = () => {
export function FollowListProvider({ children }: { children: React.ReactNode }) { export function FollowListProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey, publish } = useNostr() const { pubkey: accountPubkey, publish } = useNostr()
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined) const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
const [isReady, setIsReady] = useState(false) const [isFetching, setIsFetching] = useState(true)
const followings = useMemo( const followings = useMemo(() => {
() => return Array.from(
followListEvent?.tags new Set(
.filter(tagNameEquals('p')) followListEvent?.tags
.map(([, pubkey]) => pubkey) .filter(tagNameEquals('p'))
.filter(Boolean) .map(([, pubkey]) => pubkey)
.reverse() ?? [], .filter(Boolean)
[followListEvent] .reverse() ?? []
) )
)
}, [followListEvent])
useEffect(() => { useEffect(() => {
if (!accountPubkey) return if (!accountPubkey) return
const init = async () => { const init = async () => {
setIsReady(false) setIsFetching(true)
setFollowListEvent(undefined) setFollowListEvent(undefined)
const event = await client.fetchFollowListEvent(accountPubkey) const event = await client.fetchFollowListEvent(accountPubkey)
setFollowListEvent(event) setFollowListEvent(event)
setIsReady(true) setIsFetching(false)
} }
init() init()
}, [accountPubkey]) }, [accountPubkey])
const follow = async (pubkey: string) => { const follow = async (pubkey: string) => {
if (!isReady || !accountPubkey) return if (isFetching || !accountPubkey) return
const newFollowListDraftEvent: TDraftEvent = { const newFollowListDraftEvent: TDraftEvent = {
kind: kinds.Contacts, kind: kinds.Contacts,
@@ -67,7 +69,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
} }
const unfollow = async (pubkey: string) => { const unfollow = async (pubkey: string) => {
if (!isReady || !accountPubkey || !followListEvent) return if (isFetching || !accountPubkey || !followListEvent) return
const newFollowListDraftEvent: TDraftEvent = { const newFollowListDraftEvent: TDraftEvent = {
kind: kinds.Contacts, kind: kinds.Contacts,
@@ -87,7 +89,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
value={{ value={{
followListEvent, followListEvent,
followings, followings,
isReady, isFetching,
follow, follow,
unfollow unfollow
}} }}

View File

@@ -1,9 +1,9 @@
import LoginDialog from '@/components/LoginDialog' import LoginDialog from '@/components/LoginDialog'
import { useToast } from '@/hooks' import { useFetchFollowings, useToast } from '@/hooks'
import { useFetchRelayList } from '@/hooks/useFetchRelayList' import { useFetchRelayList } from '@/hooks/useFetchRelayList'
import client from '@/services/client.service' import client from '@/services/client.service'
import storage from '@/services/storage.service' import storage from '@/services/storage.service'
import { ISigner, TAccount, TAccountPointer, TDraftEvent } from '@/types' import { ISigner, TAccount, TAccountPointer, TDraftEvent, TRelayList } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
@@ -14,6 +14,8 @@ import { NsecSigner } from './nsec.signer'
type TNostrContext = { type TNostrContext = {
pubkey: string | null pubkey: string | null
relayList: TRelayList | null
followings: string[] | null
account: TAccountPointer | null account: TAccountPointer | null
accounts: TAccountPointer[] accounts: TAccountPointer[]
switchAccount: (account: TAccountPointer | null) => Promise<void> switchAccount: (account: TAccountPointer | null) => Promise<void>
@@ -46,7 +48,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [signer, setSigner] = useState<ISigner | null>(null) const [signer, setSigner] = useState<ISigner | null>(null)
const [openLoginDialog, setOpenLoginDialog] = useState(false) const [openLoginDialog, setOpenLoginDialog] = useState(false)
const { relayUrls: currentRelayUrls } = useRelaySettings() const { relayUrls: currentRelayUrls } = useRelaySettings()
const { relayList } = useFetchRelayList(account?.pubkey) const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey)
const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -224,6 +227,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
<NostrContext.Provider <NostrContext.Provider
value={{ value={{
pubkey: account?.pubkey ?? null, pubkey: account?.pubkey ?? null,
relayList: isFetchingRelayList ? null : relayList,
followings: isFetchingFollowings ? null : followings,
account, account,
accounts: storage accounts: storage
.getAccounts() .getAccounts()

View File

@@ -5,6 +5,7 @@ import client from '@/services/client.service'
import storage from '@/services/storage.service' import storage from '@/services/storage.service'
import { TRelayGroup } from '@/types' import { TRelayGroup } from '@/types'
import { createContext, Dispatch, useContext, useEffect, useState } from 'react' import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
import { useFeed } from './FeedProvider'
type TRelaySettingsContext = { type TRelaySettingsContext = {
relayGroups: TRelayGroup[] relayGroups: TRelayGroup[]
@@ -31,6 +32,7 @@ export const useRelaySettings = () => {
} }
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) { export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
const { setFeedType } = useFeed()
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([]) const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([]) const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
const [relayUrls, setRelayUrls] = useState<string[]>( const [relayUrls, setRelayUrls] = useState<string[]>(
@@ -49,6 +51,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
.map((url) => normalizeUrl(url)) .map((url) => normalizeUrl(url))
if (tempRelays.length) { if (tempRelays.length) {
setTemporaryRelayUrls(tempRelays) setTemporaryRelayUrls(tempRelays)
setFeedType('relays')
} }
const storedGroups = storage.getRelayGroups() const storedGroups = storage.getRelayGroups()
setRelayGroups(storedGroups) setRelayGroups(storedGroups)
@@ -93,6 +96,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
isActive: group.groupName === groupName isActive: group.groupName === groupName
})) }))
) )
setFeedType('relays')
setTemporaryRelayUrls([]) setTemporaryRelayUrls([])
} }

View File

@@ -12,7 +12,7 @@ type ThemeProviderState = {
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void> setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
} }
async function getSystemTheme() { function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
} }
@@ -26,9 +26,9 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
const themeSetting = await storage.getThemeSetting() const themeSetting = storage.getThemeSetting()
if (themeSetting === 'system') { if (themeSetting === 'system') {
setTheme(await getSystemTheme()) setTheme(getSystemTheme())
return return
} }
setTheme(themeSetting) setTheme(themeSetting)
@@ -65,10 +65,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const value = { const value = {
themeSetting: themeSetting, themeSetting: themeSetting,
setThemeSetting: async (themeSetting: TThemeSetting) => { setThemeSetting: async (themeSetting: TThemeSetting) => {
await storage.setThemeSetting(themeSetting) storage.setThemeSetting(themeSetting)
setThemeSetting(themeSetting) setThemeSetting(themeSetting)
if (themeSetting === 'system') { if (themeSetting === 'system') {
setTheme(await getSystemTheme()) setTheme(getSystemTheme())
return return
} }
setTheme(themeSetting) setTheme(themeSetting)

View File

@@ -4,10 +4,10 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
import HomePage from './pages/secondary/HomePage' import HomePage from './pages/secondary/HomePage'
import NoteListPage from './pages/secondary/NoteListPage' import NoteListPage from './pages/secondary/NoteListPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import NotificationListPage from './pages/secondary/NotificationListPage'
import ProfileListPage from './pages/secondary/ProfileListPage' import ProfileListPage from './pages/secondary/ProfileListPage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
import RelaySettingsPage from './pages/secondary/RelaySettingsPage' import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
import SettingsPage from './pages/secondary/SettingsPage'
const ROUTES = [ const ROUTES = [
{ path: '/', element: <HomePage /> }, { path: '/', element: <HomePage /> },
@@ -17,7 +17,7 @@ const ROUTES = [
{ path: '/users/:id', element: <ProfilePage /> }, { path: '/users/:id', element: <ProfilePage /> },
{ path: '/users/:id/following', element: <FollowingListPage /> }, { path: '/users/:id/following', element: <FollowingListPage /> },
{ path: '/relay-settings', element: <RelaySettingsPage /> }, { path: '/relay-settings', element: <RelaySettingsPage /> },
{ path: '/notifications', element: <NotificationListPage /> } { path: '/settings', element: <SettingsPage /> }
] ]
export const routes = ROUTES.map(({ path, element }) => ({ export const routes = ROUTES.map(({ path, element }) => ({

View File

@@ -63,3 +63,7 @@ export type TAccount = {
} }
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'> export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
export type TFeedType = 'following' | 'relays'
export type TLanguage = 'en' | 'zh'

3
src/vite-env.d.ts vendored
View File

@@ -5,4 +5,7 @@ declare global {
interface Window { interface Window {
nostr?: TNip07 nostr?: TNip07
} }
const __GIT_COMMIT__: string
const __APP_VERSION__: string
} }

View File

@@ -1,10 +1,15 @@
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { execSync } from 'child_process'
import path from 'path' import path from 'path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
define: {
__GIT_COMMIT__: JSON.stringify(execSync('git rev-parse --short HEAD').toString().trim()),
__APP_VERSION__: JSON.stringify(require('./package.json').version)
},
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src') '@': path.resolve(__dirname, './src')