feat: improve mobile experience
This commit is contained in:
parent
8ec0d46d58
commit
3946e603b3
98 changed files with 2508 additions and 1058 deletions
57
src/components/PostEditor/Mentions.tsx
Normal file
57
src/components/PostEditor/Mentions.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { extractMentions } from '@/lib/event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Mentions({
|
||||
content,
|
||||
parentEvent
|
||||
}: {
|
||||
content: string
|
||||
parentEvent?: Event
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [pubkeys, setPubkeys] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
extractMentions(content, parentEvent).then(({ pubkeys }) =>
|
||||
setPubkeys(pubkeys.filter((p) => p !== pubkey))
|
||||
)
|
||||
}, [content, parentEvent, pubkey])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="px-3"
|
||||
variant="ghost"
|
||||
disabled={pubkeys.length === 0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('Mentions')} {pubkeys.length > 0 && `(${pubkeys.length})`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold">{t('Mentions')}:</div>
|
||||
{pubkeys.map((pubkey, index) => (
|
||||
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
|
||||
<UserAvatar userId={pubkey} size="small" />
|
||||
<Username
|
||||
userId={pubkey}
|
||||
className="font-semibold text-sm truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
21
src/components/PostEditor/Preview.tsx
Normal file
21
src/components/PostEditor/Preview.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Card } from '@/components/ui/card'
|
||||
import dayjs from 'dayjs'
|
||||
import Content from '../Content'
|
||||
|
||||
export default function Preview({ content }: { content: string }) {
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<Content
|
||||
event={{
|
||||
content,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: dayjs().unix(),
|
||||
id: '',
|
||||
pubkey: '',
|
||||
sig: ''
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
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')
|
||||
)
|
||||
}
|
||||
82
src/components/PostEditor/Uploader.tsx
Normal file
82
src/components/PostEditor/Uploader.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export default function Uploader({
|
||||
setContent
|
||||
}: {
|
||||
setContent: React.Dispatch<React.SetStateAction<string>>
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { signHttpAuth } = useNostr()
|
||||
const { toast } = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
const url = 'https://nostr.build/api/v2/nip96/upload'
|
||||
const auth = await signHttpAuth(url, 'POST')
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: auth
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status.toString())
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
||||
const imageUrl = tags.find(([tagName]) => tagName === 'url')?.[1]
|
||||
if (imageUrl) {
|
||||
setContent((prevContent) => `${prevContent}\n${imageUrl}`)
|
||||
} else {
|
||||
throw new Error('No image url found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file', error)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to upload file',
|
||||
description: (error as Error).message
|
||||
})
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
|
||||
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue