feat: improve mobile experience

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

View file

@ -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} />
))}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>
)

View file

@ -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" />

View file

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

View file

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
const LIMIT = 50
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} />
))}

View file

@ -1,27 +0,0 @@
import { formatNpub } from '@/lib/pubkey'
import { Check, Copy } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { useMemo, useState } from 'react'
export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
const [copied, setCopied] = useState(false)
const copyNpub = () => {
if (!npub) return
navigator.clipboard.writeText(npub)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
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"
onClick={() => copyNpub()}
>
<div>{formatNpub(npub, 24)}</div>
{copied ? <Check size={14} /> : <Copy size={14} />}
</div>
)
}

View file

@ -1,23 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { QrCode } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { QRCodeSVG } from 'qrcode.react'
export default function QrCodePopover({ pubkey }: { pubkey: string }) {
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
if (!npub) return null
return (
<Popover>
<PopoverTrigger>
<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>
</PopoverTrigger>
<PopoverContent className="w-fit h-fit">
<QRCodeSVG value={`nostr:${npub}`} />
</PopoverContent>
</Popover>
)
}

View file

@ -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>
)

View file

@ -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>

View file

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