feat: improve mobile experience
This commit is contained in:
18
src/App.tsx
18
src/App.tsx
@@ -4,7 +4,7 @@ import './index.css'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { PageManager } from './PageManager'
|
||||
import NoteListPage from './pages/primary/NoteListPage'
|
||||
import { FeedProvider } from './providers/FeedProvider'
|
||||
import { FollowListProvider } from './providers/FollowListProvider'
|
||||
import { NostrProvider } from './providers/NostrProvider'
|
||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||
@@ -13,23 +13,21 @@ import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<ThemeProvider>
|
||||
<ScreenSizeProvider>
|
||||
<ThemeProvider>
|
||||
<ScreenSizeProvider>
|
||||
<FeedProvider>
|
||||
<RelaySettingsProvider>
|
||||
<NostrProvider>
|
||||
<FollowListProvider>
|
||||
<NoteStatsProvider>
|
||||
<PageManager>
|
||||
<NoteListPage />
|
||||
</PageManager>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</NoteStatsProvider>
|
||||
</FollowListProvider>
|
||||
</NostrProvider>
|
||||
</RelaySettingsProvider>
|
||||
</ScreenSizeProvider>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
</FeedProvider>
|
||||
</ScreenSizeProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NoteListPage from '@/pages/primary/NoteListPage'
|
||||
import HomePage from '@/pages/secondary/HomePage'
|
||||
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 { routes } from './routes'
|
||||
|
||||
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
|
||||
|
||||
type TPrimaryPageContext = {
|
||||
refresh: () => void
|
||||
navigate: (page: TPrimaryPageName) => void
|
||||
current: TPrimaryPageName | null
|
||||
}
|
||||
|
||||
type TSecondaryPageContext = {
|
||||
push: (url: string) => void
|
||||
pop: () => void
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
type TStackItem = {
|
||||
@@ -21,6 +29,12 @@ type TStackItem = {
|
||||
component: React.ReactNode | null
|
||||
}
|
||||
|
||||
const PRIMARY_PAGE_MAP = {
|
||||
home: <NoteListPage />,
|
||||
notifications: <NotificationListPage />,
|
||||
me: <MePage />
|
||||
}
|
||||
|
||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||
|
||||
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
|
||||
@@ -41,39 +55,42 @@ export function useSecondaryPage() {
|
||||
return context
|
||||
}
|
||||
|
||||
export function PageManager({
|
||||
children,
|
||||
maxStackSize = 5
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
maxStackSize?: number
|
||||
}) {
|
||||
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
|
||||
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
|
||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.pathname !== '/') {
|
||||
pushSecondary(window.location.pathname + window.location.search)
|
||||
pushSecondaryPage(window.location.pathname + window.location.search)
|
||||
}
|
||||
|
||||
const onPopState = (e: PopStateEvent) => {
|
||||
const state = e.state ?? { index: -1, url: '/' }
|
||||
setSecondaryStack((pre) => {
|
||||
const currentItem = pre[pre.length - 1]
|
||||
const currentIndex = currentItem ? currentItem.index : -1
|
||||
const currentIndex = currentItem ? currentItem.index : 0
|
||||
if (state.index === currentIndex) {
|
||||
return pre
|
||||
if (currentIndex !== 0) return pre
|
||||
|
||||
window.history.replaceState(null, '', '/')
|
||||
return []
|
||||
}
|
||||
// Go back
|
||||
if (state.index < currentIndex) {
|
||||
const newStack = pre.filter((item) => item.index <= state.index)
|
||||
const topItem = newStack[newStack.length - 1]
|
||||
// Load the component if it's not cached
|
||||
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
|
||||
}
|
||||
|
||||
// Go forward
|
||||
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
|
||||
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) => {
|
||||
if (isCurrentPage(prevStack, url)) return prevStack
|
||||
|
||||
@@ -100,62 +122,96 @@ export function PageManager({
|
||||
})
|
||||
}
|
||||
|
||||
const popSecondary = () => {
|
||||
window.history.back()
|
||||
const popSecondaryPage = () => {
|
||||
window.history.go(-1)
|
||||
}
|
||||
|
||||
const clearSecondaryPages = () => {
|
||||
if (secondaryStack.length === 0) return
|
||||
window.history.go(-secondaryStack.length)
|
||||
}
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
||||
<div className="h-full">
|
||||
{!!secondaryStack.length &&
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={item.index}
|
||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
||||
style={{
|
||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{item.component}
|
||||
</div>
|
||||
))}
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
navigate: navigatePrimaryPage,
|
||||
current: secondaryStack.length === 0 ? currentPrimaryPage : null
|
||||
}}
|
||||
>
|
||||
<SecondaryPageContext.Provider
|
||||
value={{
|
||||
push: pushSecondaryPage,
|
||||
pop: popSecondaryPage,
|
||||
currentIndex: secondaryStack.length
|
||||
? secondaryStack[secondaryStack.length - 1].index
|
||||
: 0
|
||||
}}
|
||||
>
|
||||
{!!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
|
||||
key={primaryPageKey}
|
||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
||||
style={{ display: !secondaryStack.length ? 'block' : 'none' }}
|
||||
key={pageName}
|
||||
style={{
|
||||
display:
|
||||
secondaryStack.length === 0 && currentPrimaryPage === pageName ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{page}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SecondaryPageContext.Provider>
|
||||
</PrimaryPageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
||||
<div className="flex h-full">
|
||||
<PrimaryPageContext.Provider
|
||||
value={{
|
||||
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 />
|
||||
<Separator orientation="vertical" />
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel minSize={30}>
|
||||
<div key={primaryPageKey} className="h-full">
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
display: !currentPrimaryPage || currentPrimaryPage === 'home' ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
<NoteListPage />
|
||||
</div>
|
||||
<div style={{ display: currentPrimaryPage === 'notifications' ? 'block' : 'none' }}>
|
||||
<NotificationListPage />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={30} className="relative">
|
||||
<ResizablePanel minSize={30}>
|
||||
{secondaryStack.length ? (
|
||||
secondaryStack.map((item, index) => (
|
||||
<div
|
||||
key={item.index}
|
||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
||||
style={{
|
||||
zIndex: index + 1,
|
||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
||||
}}
|
||||
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
||||
>
|
||||
{item.component}
|
||||
</div>
|
||||
@@ -206,26 +262,29 @@ function isCurrentPage(stack: TStackItem[], url: string) {
|
||||
return currentPage.url === url
|
||||
}
|
||||
|
||||
function findAndCreateComponent(url: string) {
|
||||
function findAndCreateComponent(url: string, index: number) {
|
||||
const path = url.split('?')[0]
|
||||
for (const { matcher, element } of routes) {
|
||||
const match = matcher(path)
|
||||
if (!match) continue
|
||||
|
||||
if (!element) return null
|
||||
return cloneElement(element, match.params)
|
||||
return cloneElement(element, { ...match.params, index } as any)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
const currentStack = stack[stack.length - 1]
|
||||
const newItem = { component, url, index: currentStack ? currentStack.index + 1 : 0 }
|
||||
const newItem = { component, url, index: currentItem ? currentItem.index + 1 : 0 }
|
||||
const newStack = [...stack, newItem]
|
||||
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
|
||||
// Clear the oldest cached component if there are too many cached components
|
||||
if (newStack.length - lastCachedIndex > maxStackSize) {
|
||||
newStack[lastCachedIndex].component = null
|
||||
}
|
||||
|
||||
24
src/assets/Icon.tsx
Normal file
24
src/assets/Icon.tsx
Normal 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
@@ -1,48 +1,61 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer'
|
||||
import Username from '../Username'
|
||||
|
||||
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 (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<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>
|
||||
<DialogContent>{content}</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { formatPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TAccountPointer, TSignerType } from '@/types'
|
||||
import { Loader, Trash2 } from 'lucide-react'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
@@ -20,9 +20,7 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
|
||||
key={`${act.pubkey}-${act.signerType}`}
|
||||
className={cn(
|
||||
'relative rounded-lg',
|
||||
isSameAccount(act, account)
|
||||
? 'border border-primary'
|
||||
: 'cursor-pointer hover:bg-muted/60'
|
||||
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isSameAccount(act, account)) return
|
||||
@@ -44,14 +42,6 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<SignerTypeBadge signerType={act.signerType} />
|
||||
<Trash2
|
||||
size={16}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeAccount(act)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||
|
||||
@@ -8,15 +8,15 @@ import AccountList from '../AccountList'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
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)
|
||||
|
||||
return (
|
||||
<>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||
)}
|
||||
@@ -29,18 +29,18 @@ function AccountManagerNav({
|
||||
close
|
||||
}: {
|
||||
setLoginMethod: (method: TSignerType) => void
|
||||
close: () => void
|
||||
close?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { nip07Login, accounts } = useNostr()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-4">
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Add an Account')}
|
||||
</div>
|
||||
{!!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => close())} className="w-full">
|
||||
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -56,9 +56,9 @@ function AccountManagerNav({
|
||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||
{t('Logged in Accounts')}
|
||||
</div>
|
||||
<AccountList afterSwitch={() => close()} />
|
||||
<AccountList afterSwitch={() => close?.()} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BackButton({
|
||||
hide = false,
|
||||
variant = 'titlebar'
|
||||
children
|
||||
}: {
|
||||
hide?: boolean
|
||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
@@ -16,8 +16,15 @@ export default function BackButton({
|
||||
return (
|
||||
<>
|
||||
{!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 />
|
||||
<div className="truncate text-lg font-semibold">{children}</div>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
||||
29
src/components/BottomNavigationBar/AccountButton.tsx
Normal file
29
src/components/BottomNavigationBar/AccountButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
13
src/components/BottomNavigationBar/HomeButton.tsx
Normal file
13
src/components/BottomNavigationBar/HomeButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal file
16
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/components/BottomNavigationBar/PostButton.tsx
Normal file
22
src/components/BottomNavigationBar/PostButton.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
src/components/BottomNavigationBar/index.tsx
Normal file
25
src/components/BottomNavigationBar/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
src/components/FeedSwitcher/index.tsx
Normal file
103
src/components/FeedSwitcher/index.tsx
Normal 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" />
|
||||
)
|
||||
}
|
||||
@@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
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 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) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Dispatch } from 'react'
|
||||
import AccountManager from '../AccountManager'
|
||||
|
||||
@@ -15,6 +17,25 @@ export default function LoginDialog({
|
||||
open: 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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-96">
|
||||
|
||||
88
src/components/LogoutDialog/index.tsx
Normal file
88
src/components/LogoutDialog/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useFetchEvent } from '@/hooks'
|
||||
import { getParentEventId, getRootEventId } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
@@ -32,11 +33,10 @@ export default function ShortTextNoteCard({
|
||||
push(toNote(event))
|
||||
}}
|
||||
>
|
||||
<RepostDescription reposter={reposter} className="max-sm:hidden pl-4" />
|
||||
<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
|
||||
size={embedded ? 'small' : 'normal'}
|
||||
event={event}
|
||||
@@ -44,6 +44,7 @@ export default function ShortTextNoteCard({
|
||||
hideStats={embedded}
|
||||
/>
|
||||
</div>
|
||||
{!embedded && <Separator />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
@@ -33,7 +31,7 @@ export default function NoteList({
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(true)
|
||||
const [displayReplies, setDisplayReplies] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const noteFilter = useMemo(() => {
|
||||
@@ -48,16 +46,20 @@ export default function NoteList({
|
||||
if (isFetchingRelayInfo || relayUrls.length === 0) return
|
||||
|
||||
async function init() {
|
||||
setInitialized(false)
|
||||
setRefreshing(true)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
setHasMore(true)
|
||||
|
||||
let eventCount = 0
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
[...relayUrls],
|
||||
noteFilter,
|
||||
{
|
||||
onEvents: (events, eosed) => {
|
||||
if (eventCount > events.length) return
|
||||
eventCount = events.length
|
||||
|
||||
if (events.length > 0) {
|
||||
setEvents(events)
|
||||
}
|
||||
@@ -65,7 +67,7 @@ export default function NoteList({
|
||||
setHasMore(false)
|
||||
}
|
||||
if (eosed) {
|
||||
setInitialized(true)
|
||||
setRefreshing(false)
|
||||
setHasMore(events.length > 0)
|
||||
}
|
||||
},
|
||||
@@ -100,7 +102,7 @@ export default function NoteList({
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
if (refreshing) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
@@ -125,10 +127,10 @@ export default function NoteList({
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [initialized, hasMore, events, timelineKey])
|
||||
}, [refreshing, hasMore, events, timelineKey])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!timelineKey) return
|
||||
if (!timelineKey || refreshing) return
|
||||
|
||||
const newEvents = await client.loadMoreTimeline(
|
||||
timelineKey,
|
||||
@@ -148,36 +150,35 @@ export default function NoteList({
|
||||
}
|
||||
|
||||
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} />
|
||||
<PullToRefresh
|
||||
onRefresh={async () =>
|
||||
new Promise((resolve) => {
|
||||
setRefreshCount((pre) => pre + 1)
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
}
|
||||
pullingContent=""
|
||||
>
|
||||
<div className="space-y-2 sm:space-y-4">
|
||||
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:gap-4">
|
||||
<div className="space-y-2 sm:space-y-2">
|
||||
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PullToRefresh
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
{events
|
||||
.filter((event) => displayReplies || !isReplyNoteEvent(event))
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{hasMore ? (
|
||||
{hasMore || refreshing ? (
|
||||
<div ref={bottomRef}>{t('loading...')}</div>
|
||||
) : events.length ? (
|
||||
t('no more notes')
|
||||
@@ -201,38 +202,28 @@ function DisplayRepliesSwitch({
|
||||
setDisplayReplies: (value: boolean) => void
|
||||
}) {
|
||||
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 (
|
||||
<div className="flex justify-end gap-2">
|
||||
<div>{t('Display replies')}</div>
|
||||
<Switch checked={displayReplies} onCheckedChange={setDisplayReplies} />
|
||||
<div>
|
||||
<div className="flex">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const { pubkey } = useNostr()
|
||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -18,7 +16,6 @@ export default function ReplyButton({ event }: { event: Event }) {
|
||||
<>
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
|
||||
disabled={!pubkey}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
@@ -28,7 +25,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
||||
<MessageCircle size={16} />
|
||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||
</button>
|
||||
<PostDialog parentEvent={event} open={open} setOpen={setOpen} />
|
||||
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import client from '@/services/client.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import PostEditor from '../PostEditor'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function RepostButton({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PostDialog
|
||||
<PostEditor
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function NoteStats({
|
||||
}) {
|
||||
return (
|
||||
<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} />
|
||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,18 +9,18 @@ import { Heart, MessageCircle, Repeat } from 'lucide-react'
|
||||
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
export default function NotificationList() {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
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 [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -32,7 +32,9 @@ export default function NotificationList() {
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
setRefreshing(true)
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
let eventCount = 0
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
relayList.read.length >= 4
|
||||
? relayList.read
|
||||
@@ -44,10 +46,12 @@ export default function NotificationList() {
|
||||
},
|
||||
{
|
||||
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)
|
||||
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||
if (eosed) {
|
||||
setInitialized(true)
|
||||
setRefreshing(false)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
@@ -67,7 +71,7 @@ export default function NotificationList() {
|
||||
}, [pubkey, refreshCount])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
if (refreshing) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
@@ -92,10 +96,10 @@ export default function NotificationList() {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [until, initialized, timelineKey])
|
||||
}, [until, refreshing, timelineKey])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!pubkey || !timelineKey || !until) return
|
||||
if (!pubkey || !timelineKey || !until || refreshing) return
|
||||
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
if (notifications.length === 0) {
|
||||
setUntil(undefined)
|
||||
@@ -111,12 +115,10 @@ export default function NotificationList() {
|
||||
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={async () =>
|
||||
new Promise((resolve) => {
|
||||
setRefreshCount((pre) => pre + 1)
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
}
|
||||
onRefresh={async () => {
|
||||
setRefreshCount((count) => count + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}}
|
||||
pullingContent=""
|
||||
>
|
||||
<div>
|
||||
@@ -124,7 +126,11 @@ export default function NotificationList() {
|
||||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))}
|
||||
<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>
|
||||
</PullToRefresh>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
174
src/components/PostEditor/PostContent.tsx
Normal file
174
src/components/PostEditor/PostContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
src/components/PostEditor/Title.tsx
Normal file
17
src/components/PostEditor/Title.tsx
Normal 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')
|
||||
)
|
||||
}
|
||||
78
src/components/PostEditor/index.tsx
Normal file
78
src/components/PostEditor/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
||||
|
||||
return (
|
||||
<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()}
|
||||
>
|
||||
<div>{formatNpub(npub, 24)}</div>
|
||||
@@ -1,13 +1,33 @@
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { QrCode } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function QrCodePopover({ pubkey }: { pubkey: string }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function RelaySettings({ hideTitle = false }: { hideTitle?: boole
|
||||
<RelayGroup key={index} group={group} />
|
||||
))}
|
||||
</div>
|
||||
{relayGroups.length < 5 && (
|
||||
{relayGroups.length < 10 && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="w-full border rounded-lg p-4">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import LikeButton from '../NoteStats/LikeButton'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import PostDialog from '../PostDialog'
|
||||
import PostEditor from '../PostEditor'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function ReplyNote({
|
||||
</div>
|
||||
</div>
|
||||
<LikeButton event={event} variant="reply" />
|
||||
<PostDialog parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
||||
<PostEditor parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
|
||||
export default function ScrollToTopButton({
|
||||
@@ -11,20 +12,35 @@ export default function ScrollToTopButton({
|
||||
className?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
if (isSmallScreen) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary-2"
|
||||
<div
|
||||
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
|
||||
)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
src/components/Sidebar/AccountButton.tsx
Normal file
89
src/components/Sidebar/AccountButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
src/components/Sidebar/HomeButton.tsx
Normal file
13
src/components/Sidebar/HomeButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
src/components/Sidebar/NotificationButton.tsx
Normal file
17
src/components/Sidebar/NotificationButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/components/Sidebar/PostButton.tsx
Normal file
24
src/components/Sidebar/PostButton.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
24
src/components/Sidebar/SearchButton.tsx
Normal file
24
src/components/Sidebar/SearchButton.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
src/components/Sidebar/SidebarItem.tsx
Normal file
31
src/components/Sidebar/SidebarItem.tsx
Normal 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
|
||||
@@ -1,35 +1,25 @@
|
||||
import Icon from '@/assets/Icon'
|
||||
import Logo from '@/assets/Logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Info } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AboutInfoDialog from '../AboutInfoDialog'
|
||||
import AccountButton from '../AccountButton'
|
||||
import NotificationButton from '../NotificationButton'
|
||||
import PostButton from '../PostButton'
|
||||
import RelaySettingsButton from '../RelaySettingsButton'
|
||||
import SearchButton from '../SearchButton'
|
||||
import AccountButton from './AccountButton'
|
||||
import HomeButton from './HomeButton'
|
||||
import NotificationsButton from './NotificationButton'
|
||||
import PostButton from './PostButton'
|
||||
import SearchButton from './SearchButton'
|
||||
|
||||
export default function PrimaryPageSidebar() {
|
||||
const { t } = useTranslation()
|
||||
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="absolute top-0 left-0 h-11 w-full" />
|
||||
<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="space-y-2">
|
||||
<div className="ml-4 mb-8 w-40">
|
||||
<Logo />
|
||||
<div className="px-2 mb-10 w-full">
|
||||
<Icon className="xl:hidden" />
|
||||
<Logo className="max-xl:hidden" />
|
||||
</div>
|
||||
<PostButton variant="sidebar" />
|
||||
<RelaySettingsButton variant="sidebar" />
|
||||
<NotificationButton variant="sidebar" />
|
||||
<SearchButton variant="sidebar" />
|
||||
<AboutInfoDialog>
|
||||
<Button variant="sidebar" size="sidebar">
|
||||
<Info />
|
||||
{t('About')}
|
||||
</Button>
|
||||
</AboutInfoDialog>
|
||||
<HomeButton />
|
||||
<NotificationsButton />
|
||||
<SearchButton />
|
||||
<PostButton />
|
||||
</div>
|
||||
<AccountButton variant="sidebar" />
|
||||
<AccountButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThemeToggle({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
export default function ThemeToggle() {
|
||||
const { t } = useTranslation()
|
||||
const { themeSetting, setThemeSetting } = useTheme()
|
||||
|
||||
@@ -15,8 +11,8 @@ export default function ThemeToggle({
|
||||
<>
|
||||
{themeSetting === 'system' ? (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
onClick={() => setThemeSetting('light')}
|
||||
title={t('switch to light theme')}
|
||||
>
|
||||
@@ -24,8 +20,8 @@ export default function ThemeToggle({
|
||||
</Button>
|
||||
) : themeSetting === 'light' ? (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
onClick={() => setThemeSetting('dark')}
|
||||
title={t('switch to dark theme')}
|
||||
>
|
||||
@@ -33,8 +29,8 @@ export default function ThemeToggle({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
onClick={() => setThemeSetting('system')}
|
||||
title={t('switch to system theme')}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Titlebar({
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useMemo } from 'react'
|
||||
|
||||
const UserAvatarSizeCnMap = {
|
||||
large: 'w-24 h-24',
|
||||
big: 'w-16 h-16',
|
||||
normal: 'w-10 h-10',
|
||||
small: 'w-7 h-7',
|
||||
tiny: 'w-4 h-4'
|
||||
@@ -23,7 +24,7 @@ export default function UserAvatar({
|
||||
}: {
|
||||
userId: string
|
||||
className?: string
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
const defaultAvatar = useMemo(
|
||||
@@ -62,7 +63,7 @@ export function SimpleUserAvatar({
|
||||
onClick
|
||||
}: {
|
||||
userId: string
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Image } from '@nextui-org/image'
|
||||
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Image } from '@nextui-org/image'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function WebPreview({
|
||||
@@ -12,6 +13,7 @@ export default function WebPreview({
|
||||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { title, description, image } = useFetchWebMetadata(url)
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
@@ -25,9 +27,21 @@ export default function WebPreview({
|
||||
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 (
|
||||
<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) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, '_blank')
|
||||
@@ -36,11 +50,11 @@ export default function WebPreview({
|
||||
{image && (
|
||||
<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
|
||||
/>
|
||||
)}
|
||||
<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={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
|
||||
{title}
|
||||
|
||||
121
src/components/ui/alert-dialog.tsx
Normal file
121
src/components/ui/alert-dialog.tsx
Normal 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
|
||||
}
|
||||
@@ -15,20 +15,15 @@ const buttonVariants = cva(
|
||||
'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-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
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'
|
||||
ghost: 'clickable hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
titlebar: 'h-7 w-7 rounded-full',
|
||||
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'
|
||||
'titlebar-icon': 'h-10 w-10 rounded-lg'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
101
src/components/ui/drawer.tsx
Normal file
101
src/components/ui/drawer.tsx
Normal 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
|
||||
}
|
||||
@@ -1,23 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
|
||||
150
src/components/ui/select.tsx
Normal file
150
src/components/ui/select.tsx
Normal 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
119
src/components/ui/sheet.tsx
Normal 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
|
||||
}
|
||||
@@ -6,26 +6,32 @@ import { useEffect, useState } from 'react'
|
||||
export function useFetchFollowings(pubkey?: string | null) {
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
|
||||
const [followings, setFollowings] = useState<string[]>([])
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (!pubkey) return
|
||||
try {
|
||||
setIsFetching(true)
|
||||
if (!pubkey) return
|
||||
|
||||
const event = await client.fetchFollowListEvent(pubkey)
|
||||
if (!event) return
|
||||
const event = await client.fetchFollowListEvent(pubkey)
|
||||
if (!event) return
|
||||
|
||||
setFollowListEvent(event)
|
||||
setFollowings(
|
||||
event.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
)
|
||||
setFollowListEvent(event)
|
||||
setFollowings(
|
||||
event.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
)
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
}, [pubkey])
|
||||
|
||||
return { followings, followListEvent }
|
||||
return { followings, followListEvent, isFetching }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ export default {
|
||||
About: 'About',
|
||||
'New post': 'New post',
|
||||
Post: 'Post',
|
||||
Home: 'Home',
|
||||
'Relay settings': 'Relay settings',
|
||||
Settings: 'Settings',
|
||||
SidebarRelays: 'Relays',
|
||||
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',
|
||||
Note: 'Note',
|
||||
"username's following": "{{username}}'s following",
|
||||
following: 'following',
|
||||
Login: 'Login',
|
||||
'Follows you': 'Follows you',
|
||||
'relay collection name already exists': 'relay collection name already exists',
|
||||
@@ -67,16 +68,14 @@ export default {
|
||||
'no replies': 'no replies',
|
||||
'Reply to': 'Reply to',
|
||||
Search: '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',
|
||||
'All users': 'All users',
|
||||
'Display replies': 'Display replies',
|
||||
Notes: 'Notes',
|
||||
'Notes & Replies': 'Notes & Replies',
|
||||
notifications: '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.':
|
||||
@@ -87,9 +86,21 @@ export default {
|
||||
'reload notes': 'reload notes',
|
||||
'Logged in Accounts': 'Logged in Accounts',
|
||||
'Add an Account': 'Add an Account',
|
||||
Accounts: 'Accounts',
|
||||
'More options': 'More options',
|
||||
'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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import dayjs from 'dayjs'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import en from './en'
|
||||
import zh from './zh'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const resources = {
|
||||
en,
|
||||
@@ -11,7 +10,21 @@ const resources = {
|
||||
}
|
||||
|
||||
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)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
|
||||
@@ -4,7 +4,9 @@ export default {
|
||||
About: '关于',
|
||||
'New post': '发布新笔记',
|
||||
Post: '发布笔记',
|
||||
Home: '主页',
|
||||
'Relay settings': '服务器设置',
|
||||
Settings: '设置',
|
||||
SidebarRelays: '服务器',
|
||||
Refresh: '刷新列表',
|
||||
Profile: '个人资料',
|
||||
@@ -44,9 +46,8 @@ export default {
|
||||
'switch to light theme': '切换到浅色主题',
|
||||
'switch to dark theme': '切换到深色主题',
|
||||
'switch to system theme': '切换到系统主题',
|
||||
note: '笔记',
|
||||
Note: '笔记',
|
||||
"username's following": '{{username}} 的关注',
|
||||
following: '关注',
|
||||
Login: '登录',
|
||||
'Follows you': '关注了你',
|
||||
'relay collection name already exists': '服务器组名已存在',
|
||||
@@ -67,15 +68,13 @@ export default {
|
||||
'no replies': '暂无回复',
|
||||
'Reply to': '回复',
|
||||
Search: '搜索',
|
||||
search: '搜索',
|
||||
'The relays you are connected to do not support search': '您连接的服务器不支持搜索',
|
||||
'supports search': '支持搜索',
|
||||
'Show more...': '查看更多...',
|
||||
'all users': '所有用户',
|
||||
'All users': '所有用户',
|
||||
'Display replies': '显示回复',
|
||||
Notes: '笔记',
|
||||
'Notes & Replies': '笔记 & 回复',
|
||||
notifications: '通知',
|
||||
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.':
|
||||
@@ -86,9 +85,21 @@ export default {
|
||||
'reload notes': '重新加载笔记',
|
||||
'Logged in Accounts': '已登录账户',
|
||||
'Add an Account': '添加账户',
|
||||
Accounts: '多帐户',
|
||||
'More options': '更多选项',
|
||||
'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': '切换账户'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@tailwind utilities;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -18,13 +19,28 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', sans-serif;
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
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 {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
@@ -81,11 +97,3 @@
|
||||
--highlight: 259 43% 56%;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,112 @@
|
||||
import Logo from '@/assets/Logo'
|
||||
import AccountButton from '@/components/AccountButton'
|
||||
import NotificationButton from '@/components/NotificationButton'
|
||||
import PostButton from '@/components/PostButton'
|
||||
import RelaySettingsButton from '@/components/RelaySettingsButton'
|
||||
import BottomNavigationBar from '@/components/BottomNavigationBar'
|
||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||
import SearchButton from '@/components/SearchButton'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import { Titlebar } from '@/components/Titlebar'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
|
||||
const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [lastScrollTop, setLastScrollTop] = useState(0)
|
||||
const PrimaryPageLayout = forwardRef(
|
||||
(
|
||||
{
|
||||
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(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToTop: () => {
|
||||
scrollAreaRef.current?.scrollTo({ top: 0 })
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToTop: () => {
|
||||
if (isSmallScreen) {
|
||||
window.scrollTo({ top: 0 })
|
||||
return
|
||||
}
|
||||
scrollAreaRef.current?.scrollTo({ top: 0 })
|
||||
}
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = scrollAreaRef.current?.scrollTop || 0
|
||||
const diff = scrollTop - lastScrollTop
|
||||
if (scrollTop <= 100) {
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
window.scrollTo({ top: 0 })
|
||||
setVisible(true)
|
||||
setLastScrollTop(scrollTop)
|
||||
return
|
||||
}
|
||||
}, [current])
|
||||
|
||||
if (diff > 20) {
|
||||
setVisible(false)
|
||||
setLastScrollTop(scrollTop)
|
||||
} else if (diff < -20) {
|
||||
setVisible(true)
|
||||
setLastScrollTop(scrollTop)
|
||||
useEffect(() => {
|
||||
if (current !== pageName) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
|
||||
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
|
||||
scrollArea?.addEventListener('scroll', handleScroll)
|
||||
if (isSmallScreen) {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollArea?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [lastScrollTop])
|
||||
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [lastScrollTop, isSmallScreen, current])
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="h-full w-full"
|
||||
scrollBarClassName="pt-9 xl:pt-0 max-sm:pt-11"
|
||||
>
|
||||
<PrimaryPageTitlebar visible={visible} />
|
||||
<div className="sm:px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} />
|
||||
</ScrollArea>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<ScrollArea
|
||||
className="sm:h-screen sm:overflow-auto"
|
||||
scrollBarClassName="sm:z-50"
|
||||
ref={scrollAreaRef}
|
||||
style={{
|
||||
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||
}}
|
||||
>
|
||||
{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'
|
||||
export default PrimaryPageLayout
|
||||
|
||||
@@ -72,43 +114,16 @@ export type TPrimaryPageLayoutRef = {
|
||||
scrollToTop: () => void
|
||||
}
|
||||
|
||||
function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Titlebar
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function PrimaryPageTitlebar({
|
||||
children,
|
||||
visible = true
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
visible?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Titlebar className="justify-between xl:hidden">
|
||||
<div className="flex gap-2 items-center">
|
||||
<AccountButton />
|
||||
<PostButton />
|
||||
<SearchButton />
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<RelaySettingsButton />
|
||||
<NotificationButton />
|
||||
</div>
|
||||
<Titlebar className="h-12 p-1" visible={visible}>
|
||||
{children}
|
||||
</Titlebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
import BackButton from '@/components/BackButton'
|
||||
import BottomNavigationBar from '@/components/BottomNavigationBar'
|
||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||
import ThemeToggle from '@/components/ThemeToggle'
|
||||
import { Titlebar } from '@/components/Titlebar'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export default function SecondaryPageLayout({
|
||||
children,
|
||||
index,
|
||||
titlebarContent,
|
||||
hideBackButton = false,
|
||||
hideScrollToTopButton = false
|
||||
displayScrollToTopButton = false
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
index?: number
|
||||
titlebarContent?: React.ReactNode
|
||||
hideBackButton?: boolean
|
||||
hideScrollToTopButton?: boolean
|
||||
displayScrollToTopButton?: boolean
|
||||
}): JSX.Element {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [lastScrollTop, setLastScrollTop] = useState(0)
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { currentIndex } = useSecondaryPage()
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
window.scrollTo({ top: 0 })
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex !== index) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = scrollAreaRef.current?.scrollTop || 0
|
||||
const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
|
||||
const diff = scrollTop - lastScrollTop
|
||||
if (scrollTop <= 100) {
|
||||
setVisible(true)
|
||||
@@ -40,26 +56,38 @@ export default function SecondaryPageLayout({
|
||||
}
|
||||
}
|
||||
|
||||
const scrollArea = scrollAreaRef.current
|
||||
scrollArea?.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
scrollArea?.removeEventListener('scroll', handleScroll)
|
||||
if (isSmallScreen) {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}
|
||||
}, [lastScrollTop])
|
||||
|
||||
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [lastScrollTop, isSmallScreen, currentIndex])
|
||||
|
||||
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
|
||||
content={titlebarContent}
|
||||
hideBackButton={hideBackButton}
|
||||
visible={visible}
|
||||
/>
|
||||
<div className="sm:px-4 pb-4 pt-11 w-full h-full">{children}</div>
|
||||
<ScrollToTopButton
|
||||
scrollAreaRef={scrollAreaRef}
|
||||
visible={!hideScrollToTopButton && visible && lastScrollTop > 500}
|
||||
/>
|
||||
<div className="pb-4 mt-2">{children}</div>
|
||||
{displayScrollToTopButton && (
|
||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} />
|
||||
)}
|
||||
{isSmallScreen && <BottomNavigationBar visible={visible} />}
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
@@ -77,18 +105,16 @@ export function SecondaryPageTitlebar({
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Titlebar className="pl-2" visible={visible}>
|
||||
<BackButton hide={hideBackButton} variant="small-screen-titlebar" />
|
||||
<div className="truncate text-lg">{content}</div>
|
||||
<Titlebar className="h-12 flex gap-1 p-1 items-center font-semibold" visible={visible}>
|
||||
<BackButton hide={hideBackButton}>{content}</BackButton>
|
||||
</Titlebar>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<BackButton hide={hideBackButton} />
|
||||
<div className="truncate text-lg">{content}</div>
|
||||
<BackButton hide={hideBackButton}>{content}</BackButton>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -38,7 +38,7 @@ export const toFollowingList = (pubkey: string) => {
|
||||
return `/users/${npub}/following`
|
||||
}
|
||||
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 toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||
|
||||
@@ -14,3 +14,7 @@ export function normalizeUrl(url: string): string {
|
||||
p.hash = ''
|
||||
return p.toString()
|
||||
}
|
||||
|
||||
export function simplifyUrl(url: string): string {
|
||||
return url.replace('wss://', '').replace('ws://', '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
107
src/pages/primary/MePage/index.tsx
Normal file
107
src/pages/primary/MePage/index.tsx
Normal 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>
|
||||
}
|
||||
74
src/pages/primary/NoteListPage/FeedButton.tsx
Normal file
74
src/pages/primary/NoteListPage/FeedButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
)
|
||||
17
src/pages/primary/NoteListPage/SearchButton.tsx
Normal file
17
src/pages/primary/NoteListPage/SearchButton.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +1,69 @@
|
||||
import NoteList from '@/components/NoteList'
|
||||
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 { BIG_RELAY_URLS } from '@/constants'
|
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const layoutRef = useRef<{ scrollToTop: () => void }>(null)
|
||||
const { relayUrls } = useRelaySettings()
|
||||
const relayUrlsString = JSON.stringify(relayUrls)
|
||||
const { feedType } = useFeed()
|
||||
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(() => {
|
||||
if (layoutRef.current) {
|
||||
layoutRef.current.scrollToTop()
|
||||
}
|
||||
}, [relayUrlsString])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
}, [JSON.stringify(relayUrls), feedType])
|
||||
|
||||
return (
|
||||
<PrimaryPageLayout ref={layoutRef}>
|
||||
<NoteList relayUrls={relayUrls} />
|
||||
<PrimaryPageLayout
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteListPageTitlebar() {
|
||||
return (
|
||||
<div className="flex gap-1 items-center h-full justify-between">
|
||||
<FeedButton />
|
||||
<SearchButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
29
src/pages/primary/NotificationListPage/index.tsx
Normal file
29
src/pages/primary/NotificationListPage/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
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 { profile } = useFetchProfile(id)
|
||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||
@@ -45,13 +45,15 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout
|
||||
index={index}
|
||||
titlebarContent={
|
||||
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) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function HomePage() {
|
||||
export default function HomePage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<SecondaryPageLayout hideBackButton hideScrollToTopButton>
|
||||
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
||||
<SecondaryPageLayout index={index} hideBackButton>
|
||||
<div className="text-muted-foreground w-full h-screen flex items-center justify-center">
|
||||
{t('Welcome! 🥳')}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
|
||||
export default function LoadingPage({ title }: { title?: string }) {
|
||||
export default function LoadingPage({ title, index }: { title?: string; index?: number }) {
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<SecondaryPageLayout index={index} titlebarContent={title}>
|
||||
<div className="text-muted-foreground text-center">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
export default function NotFoundPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
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>{t('Lost in the void')} 🌌</div>
|
||||
<div>(404)</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import NoteList from '@/components/NoteList'
|
||||
import { useSearchParams } from '@/hooks'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { isWebsocketUrl } from '@/lib/url'
|
||||
import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
|
||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import { Filter } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NoteListPage() {
|
||||
export default function NoteListPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
||||
const { searchParams } = useSearchParams()
|
||||
@@ -27,18 +27,18 @@ export default function NoteListPage() {
|
||||
}
|
||||
const search = searchParams.get('s')
|
||||
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')
|
||||
if (relayUrl && isWebsocketUrl(relayUrl)) {
|
||||
return { title: relayUrl, urls: [relayUrl] }
|
||||
return { title: simplifyUrl(relayUrl), urls: [relayUrl] }
|
||||
}
|
||||
return { urls: relayUrls }
|
||||
}, [searchParams, relayUrlsString])
|
||||
|
||||
if (filter?.search && searchableRelayUrls.length === 0) {
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{t('The relays you are connected to do not support search')}
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@ export default function NoteListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<SecondaryPageLayout index={index} titlebarContent={title}>
|
||||
<NoteList key={title} filter={filter} relayUrls={urls} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { event, isFetching } = useFetchEvent(id)
|
||||
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
||||
@@ -22,8 +22,8 @@ export default function NotePage({ id }: { id?: string }) {
|
||||
|
||||
if (!event && isFetching) {
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={t('note')}>
|
||||
<div className="max-sm:px-4">
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
<Skeleton className="w-10 h-10 rounded-full" />
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
@@ -32,14 +32,14 @@ export default function NotePage({ id }: { id?: string }) {
|
||||
if (!event) return <NotFoundPage />
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={t('note')}>
|
||||
<div className="max-sm:px-4">
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Note')}>
|
||||
<div className="px-4">
|
||||
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
||||
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function ParentNote({ eventId }: { eventId?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<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))}
|
||||
>
|
||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
export default function ProfileListPage() {
|
||||
export default function ProfileListPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
const { searchParams } = useSearchParams()
|
||||
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
||||
@@ -30,7 +30,7 @@ export default function ProfileListPage() {
|
||||
return filter.search ? searchableRelayUrls : relayUrls
|
||||
}, [relayUrls, searchableRelayUrls, filter])
|
||||
const title = useMemo(() => {
|
||||
return filter.search ? `${t('search')}: ${filter.search}` : t('all users')
|
||||
return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,8 +78,8 @@ export default function ProfileListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={title}>
|
||||
<div className="space-y-2 max-sm:px-4">
|
||||
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||
<div className="space-y-2 px-4">
|
||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||
))}
|
||||
|
||||
@@ -3,8 +3,9 @@ import Nip05 from '@/components/Nip05'
|
||||
import NoteList from '@/components/NoteList'
|
||||
import ProfileAbout from '@/components/ProfileAbout'
|
||||
import ProfileBanner from '@/components/ProfileBanner'
|
||||
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||
import QrCodePopover from '@/components/QrCodePopover'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
@@ -18,10 +19,8 @@ import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { profile, isFetching } = useFetchProfile(id)
|
||||
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
||||
@@ -46,8 +45,8 @@ export default function ProfilePage({ id }: { id?: string }) {
|
||||
|
||||
if (!profile && isFetching) {
|
||||
return (
|
||||
<SecondaryPageLayout>
|
||||
<div className="max-sm:px-4">
|
||||
<SecondaryPageLayout index={index}>
|
||||
<div className="px-4">
|
||||
<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-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
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={username}>
|
||||
<div className="max-sm:px-4">
|
||||
<SecondaryPageLayout index={index} titlebarContent={username} displayScrollToTopButton>
|
||||
<div className="px-4">
|
||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||
<ProfileBanner
|
||||
banner={banner}
|
||||
@@ -102,9 +101,8 @@ export default function ProfilePage({ id }: { id?: string }) {
|
||||
</SecondaryPageLink>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="hidden sm:block mt-4 sm:my-4" />
|
||||
{!isFetchingRelayInfo && (
|
||||
<NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="max-sm:mt-2" />
|
||||
<NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="mt-2" />
|
||||
)}
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
|
||||
@@ -2,12 +2,12 @@ import RelaySettings from '@/components/RelaySettings'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelaySettingsPage() {
|
||||
export default function RelaySettingsPage({ index }: { index?: number }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={t('Relay settings')}>
|
||||
<div className="max-sm:px-4">
|
||||
<SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
|
||||
<div className="px-4">
|
||||
<RelaySettings hideTitle />
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
|
||||
70
src/pages/secondary/SettingsPage/index.tsx
Normal file
70
src/pages/secondary/SettingsPage/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
src/providers/FeedProvider.tsx
Normal file
23
src/providers/FeedProvider.tsx
Normal 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>
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useNostr } from './NostrProvider'
|
||||
type TFollowListContext = {
|
||||
followListEvent: Event | undefined
|
||||
followings: string[]
|
||||
isReady: boolean
|
||||
isFetching: boolean
|
||||
follow: (pubkey: string) => Promise<void>
|
||||
unfollow: (pubkey: string) => Promise<void>
|
||||
}
|
||||
@@ -27,33 +27,35 @@ export const useFollowList = () => {
|
||||
export function FollowListProvider({ children }: { children: React.ReactNode }) {
|
||||
const { pubkey: accountPubkey, publish } = useNostr()
|
||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const followings = useMemo(
|
||||
() =>
|
||||
followListEvent?.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse() ?? [],
|
||||
[followListEvent]
|
||||
)
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const followings = useMemo(() => {
|
||||
return Array.from(
|
||||
new Set(
|
||||
followListEvent?.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse() ?? []
|
||||
)
|
||||
)
|
||||
}, [followListEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountPubkey) return
|
||||
|
||||
const init = async () => {
|
||||
setIsReady(false)
|
||||
setIsFetching(true)
|
||||
setFollowListEvent(undefined)
|
||||
const event = await client.fetchFollowListEvent(accountPubkey)
|
||||
setFollowListEvent(event)
|
||||
setIsReady(true)
|
||||
setIsFetching(false)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [accountPubkey])
|
||||
|
||||
const follow = async (pubkey: string) => {
|
||||
if (!isReady || !accountPubkey) return
|
||||
if (isFetching || !accountPubkey) return
|
||||
|
||||
const newFollowListDraftEvent: TDraftEvent = {
|
||||
kind: kinds.Contacts,
|
||||
@@ -67,7 +69,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
const unfollow = async (pubkey: string) => {
|
||||
if (!isReady || !accountPubkey || !followListEvent) return
|
||||
if (isFetching || !accountPubkey || !followListEvent) return
|
||||
|
||||
const newFollowListDraftEvent: TDraftEvent = {
|
||||
kind: kinds.Contacts,
|
||||
@@ -87,7 +89,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
||||
value={{
|
||||
followListEvent,
|
||||
followings,
|
||||
isReady,
|
||||
isFetching,
|
||||
follow,
|
||||
unfollow
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import LoginDialog from '@/components/LoginDialog'
|
||||
import { useToast } from '@/hooks'
|
||||
import { useFetchFollowings, useToast } from '@/hooks'
|
||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
import client from '@/services/client.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 { Event, kinds } from 'nostr-tools'
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
@@ -14,6 +14,8 @@ import { NsecSigner } from './nsec.signer'
|
||||
|
||||
type TNostrContext = {
|
||||
pubkey: string | null
|
||||
relayList: TRelayList | null
|
||||
followings: string[] | null
|
||||
account: TAccountPointer | null
|
||||
accounts: TAccountPointer[]
|
||||
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 [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
||||
const { relayList } = useFetchRelayList(account?.pubkey)
|
||||
const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey)
|
||||
const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -224,6 +227,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
<NostrContext.Provider
|
||||
value={{
|
||||
pubkey: account?.pubkey ?? null,
|
||||
relayList: isFetchingRelayList ? null : relayList,
|
||||
followings: isFetchingFollowings ? null : followings,
|
||||
account,
|
||||
accounts: storage
|
||||
.getAccounts()
|
||||
|
||||
@@ -5,6 +5,7 @@ import client from '@/services/client.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { TRelayGroup } from '@/types'
|
||||
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
||||
import { useFeed } from './FeedProvider'
|
||||
|
||||
type TRelaySettingsContext = {
|
||||
relayGroups: TRelayGroup[]
|
||||
@@ -31,6 +32,7 @@ export const useRelaySettings = () => {
|
||||
}
|
||||
|
||||
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { setFeedType } = useFeed()
|
||||
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
|
||||
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
|
||||
const [relayUrls, setRelayUrls] = useState<string[]>(
|
||||
@@ -49,6 +51,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
||||
.map((url) => normalizeUrl(url))
|
||||
if (tempRelays.length) {
|
||||
setTemporaryRelayUrls(tempRelays)
|
||||
setFeedType('relays')
|
||||
}
|
||||
const storedGroups = storage.getRelayGroups()
|
||||
setRelayGroups(storedGroups)
|
||||
@@ -93,6 +96,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
||||
isActive: group.groupName === groupName
|
||||
}))
|
||||
)
|
||||
setFeedType('relays')
|
||||
setTemporaryRelayUrls([])
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type ThemeProviderState = {
|
||||
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
|
||||
}
|
||||
|
||||
async function getSystemTheme() {
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const themeSetting = await storage.getThemeSetting()
|
||||
const themeSetting = storage.getThemeSetting()
|
||||
if (themeSetting === 'system') {
|
||||
setTheme(await getSystemTheme())
|
||||
setTheme(getSystemTheme())
|
||||
return
|
||||
}
|
||||
setTheme(themeSetting)
|
||||
@@ -65,10 +65,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const value = {
|
||||
themeSetting: themeSetting,
|
||||
setThemeSetting: async (themeSetting: TThemeSetting) => {
|
||||
await storage.setThemeSetting(themeSetting)
|
||||
storage.setThemeSetting(themeSetting)
|
||||
setThemeSetting(themeSetting)
|
||||
if (themeSetting === 'system') {
|
||||
setTheme(await getSystemTheme())
|
||||
setTheme(getSystemTheme())
|
||||
return
|
||||
}
|
||||
setTheme(themeSetting)
|
||||
|
||||
@@ -4,10 +4,10 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
|
||||
import HomePage from './pages/secondary/HomePage'
|
||||
import NoteListPage from './pages/secondary/NoteListPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import NotificationListPage from './pages/secondary/NotificationListPage'
|
||||
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||
import SettingsPage from './pages/secondary/SettingsPage'
|
||||
|
||||
const ROUTES = [
|
||||
{ path: '/', element: <HomePage /> },
|
||||
@@ -17,7 +17,7 @@ const ROUTES = [
|
||||
{ path: '/users/:id', element: <ProfilePage /> },
|
||||
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
||||
{ path: '/relay-settings', element: <RelaySettingsPage /> },
|
||||
{ path: '/notifications', element: <NotificationListPage /> }
|
||||
{ path: '/settings', element: <SettingsPage /> }
|
||||
]
|
||||
|
||||
export const routes = ROUTES.map(({ path, element }) => ({
|
||||
|
||||
@@ -63,3 +63,7 @@ export type TAccount = {
|
||||
}
|
||||
|
||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||
|
||||
export type TFeedType = 'following' | 'relays'
|
||||
|
||||
export type TLanguage = 'en' | 'zh'
|
||||
|
||||
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@@ -5,4 +5,7 @@ declare global {
|
||||
interface Window {
|
||||
nostr?: TNip07
|
||||
}
|
||||
|
||||
const __GIT_COMMIT__: string
|
||||
const __APP_VERSION__: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user