Whalebird-desktop-client-ma.../renderer/components/compose/Compose.tsx

516 lines
16 KiB
TypeScript

import { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import { FormattedMessage, useIntl } from 'react-intl'
import {
FaEnvelope,
FaFaceLaughBeam,
FaGlobe,
FaListCheck,
FaLock,
FaLockOpen,
FaPaperPlane,
FaPaperclip,
FaPencil,
FaXmark
} from 'react-icons/fa6'
import { Entity, MegalodonInterface } from 'megalodon'
import { useToast } from '@/provider/toast'
import Picker from '@emoji-mart/react'
import { data } from '@/utils/emojiData'
import EditMedia from './EditMedia'
import {
Button,
Checkbox,
IconButton,
Input,
List,
ListItem,
Option,
Popover,
PopoverContent,
PopoverHandler,
Radio,
Select,
Switch,
Textarea
} from '@material-tailwind/react'
type Props = {
client: MegalodonInterface
in_reply_to?: Entity.Status
}
type Poll = {
options: Array<string>
expires_in: number
multiple: boolean
}
export default function Compose(props: Props) {
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState<'public' | 'unlisted' | 'private' | 'direct'>('public')
const [cw, setCW] = useState(false)
const [spoiler, setSpoiler] = useState('')
const [attachments, setAttachments] = useState<Array<Entity.Attachment | Entity.AsyncAttachment>>([])
const [poll, setPoll] = useState<Poll | null>(null)
const [loading, setLoading] = useState(false)
const [editMedia, setEditMedia] = useState<Entity.Attachment>()
const [maxCharacters, setMaxCharacters] = useState<number | null>(null)
const [remaining, setRemaining] = useState<number | null>(null)
const [popoverVisibility, setPopoverVisibility] = useState(false)
const [popoverEmoji, setPopoverEmoji] = useState(false)
const { formatMessage } = useIntl()
const uploaderRef = useRef(null)
const showToast = useToast()
const textareaRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!props.client) return
const f = async () => {
const instance = await props.client.getInstance()
if (instance.data.configuration.statuses.max_characters) {
setMaxCharacters(instance.data.configuration.statuses.max_characters)
}
}
f()
}, [props.client])
useEffect(() => {
if (!cw) {
setSpoiler('')
}
}, [cw])
useEffect(() => {
if (props.in_reply_to) {
const f = async () => {
const myself = await props.client.verifyAccountCredentials()
const mentionAccounts = [props.in_reply_to.account.acct, ...props.in_reply_to.mentions.map(a => a.acct)]
.filter((a, i, self) => self.indexOf(a) === i)
.filter(a => a !== myself.data.username)
setBody(`${mentionAccounts.map(m => `@${m}`).join(' ')} `)
setVisibility(props.in_reply_to.visibility)
}
f()
}
}, [props.in_reply_to])
useEffect(() => {
if (maxCharacters) {
setRemaining(maxCharacters - body.length - spoiler.length)
}
}, [maxCharacters, body, spoiler])
const post = async () => {
if (body.length === 0) return
let options = { visibility: visibility }
if (props.in_reply_to) {
options = Object.assign({}, options, {
in_reply_to_id: props.in_reply_to.id
})
}
if (cw) {
options = Object.assign({}, options, {
spoiler_text: spoiler
})
}
if (attachments.length > 0) {
options = Object.assign({}, options, {
media_ids: attachments.map(m => m.id)
})
}
const sensitive = document.getElementById('sensitive') as HTMLInputElement
if (sensitive && sensitive.checked) {
options = Object.assign({}, options, {
sensitive: sensitive.checked
})
}
if (poll && poll.options.length > 0) {
options = Object.assign({}, options, {
poll: poll
})
}
setLoading(true)
try {
await props.client.postStatus(body, options)
reset()
} catch (err) {
console.error(err)
showToast({ text: formatMessage({ id: 'alert.compose.post_failed' }), type: 'failure' })
} finally {
setLoading(false)
}
}
const reset = () => {
setBody('')
setSpoiler('')
setCW(false)
setAttachments([])
setPoll(null)
setMaxCharacters(null)
}
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
if ((event.ctrlKey === true && event.key === 'Enter') || (event.metaKey === true && event.key === 'Enter')) {
post()
}
},
[post]
)
useEffect(() => {
textareaRef.current?.addEventListener('keydown', handleKeyPress)
return () => {
textareaRef.current?.removeEventListener('keydown', handleKeyPress)
}
}, [handleKeyPress])
const selectFile = () => {
if (uploaderRef.current) {
uploaderRef.current.click()
}
}
const fileChanged = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.item(0)
if (file === null || file === undefined) {
return
}
if (attachments.length >= 4) {
showToast({ text: formatMessage({ id: 'alert.validation.attachment_length' }, { limit: 4 }), type: 'failure' })
return
}
if (!file.type.includes('image') && !file.type.includes('video')) {
showToast({ text: formatMessage({ id: 'alert.validation.attachment_type' }), type: 'failure' })
return
}
setLoading(true)
try {
const res = await props.client.uploadMedia(file)
setAttachments(current => [...current, res.data])
} catch {
showToast({ text: formatMessage({ id: 'alert.upload_error' }), type: 'failure' })
} finally {
setLoading(false)
}
}
const removeFile = (index: number) => {
setAttachments(current => current.filter((_, i) => i !== index))
}
const openDescription = (index: number) => {
setEditMedia(attachments[index])
}
const closeDescription = () => {
setEditMedia(undefined)
}
const togglePoll = () => {
if (poll) {
setPoll(null)
} else {
setPoll(defaultPoll())
}
}
const onEmojiSelect = emoji => {
const textarea = document.getElementById('body') as HTMLTextAreaElement
const cursor = textarea.selectionStart
if (emoji.native) {
setBody(current => `${current.slice(0, cursor)}${emoji.native} ${current.slice(cursor)}`)
} else if (emoji.shortcodes) {
setBody(current => `${current.slice(0, cursor)}${emoji.shortcodes} ${current.slice(cursor)}`)
}
setPopoverEmoji(false)
}
return (
<div className="px-4 pb-4">
<form id="form">
{cw && (
<Input
id="spoiler"
type="text"
color="blue-gray"
containerProps={{ className: 'mb-2' }}
value={spoiler}
onChange={ev => setSpoiler(ev.target.value)}
placeholder={formatMessage({ id: 'compose.spoiler.placeholder' })}
/>
)}
<div className="relative">
<Textarea
id="body"
color="blue-gray"
className="resize-none focus:ring-0"
placeholder={formatMessage({ id: 'compose.placeholder' })}
rows={3}
value={body}
onChange={ev => setBody(ev.target.value)}
ref={textareaRef}
/>
<Popover open={popoverEmoji} handler={setPopoverEmoji}>
<PopoverHandler>
<button className="absolute top-1 right-1 text-gray-600 cursor-pointer" title={formatMessage({ id: 'compose.emoji' })}>
<FaFaceLaughBeam />
</button>
</PopoverHandler>
<PopoverContent>
<Picker data={data} onEmojiSelect={onEmojiSelect} previewPosition="none" set="native" perLine="7" theme="light" />
</PopoverContent>
</Popover>
</div>
</form>
{poll && <PollForm poll={poll} setPoll={setPoll} />}
<div className="attachments flex gap-2">
{attachments.map((f, index) => (
<div className="py-2 relative" key={index}>
<button className="absolute bg-gray-600 rounded" onClick={() => removeFile(index)}>
<FaXmark className="text-gray-200" />
</button>
<button className="absolute right-0 bg-gray-600 rounded" onClick={() => openDescription(index)}>
<FaPencil className="text-gray-200" />
</button>
<img src={f.preview_url} style={{ width: '80px', height: '80px' }} className="rounded-md" />
</div>
))}
</div>
{attachments.length > 0 && (
<div>
<Checkbox id="sensitive" label={formatMessage({ id: 'compose.nsfw' })} />
</div>
)}
<div className="w-full flex justify-between mt-1 items-center h-5">
<div className="ml-1 flex gap-3">
<input type="file" id="file" className="hidden" ref={uploaderRef} onChange={fileChanged} />
<IconButton
variant="text"
size="sm"
onClick={selectFile}
className="text-gray-400 hover:text-gray-600 text-base"
title={formatMessage({ id: 'compose.actions.attachment' })}
>
<FaPaperclip />
</IconButton>
<IconButton
variant="text"
size="sm"
onClick={togglePoll}
className="text-gray-400 hover:text-gray-600 text-base"
title={formatMessage({ id: 'compose.actions.poll' })}
>
<FaListCheck />
</IconButton>
<Popover open={popoverVisibility} handler={setPopoverVisibility}>
<PopoverHandler>{visibilityIcon(visibility, formatMessage({ id: 'compose.actions.visibility' }))}</PopoverHandler>
<PopoverContent>
<List>
<ListItem
onClick={() => {
setVisibility('public')
setPopoverVisibility(false)
}}
>
<FormattedMessage id="compose.visibility.public" />
</ListItem>
<ListItem
onClick={() => {
setVisibility('unlisted')
setPopoverVisibility(false)
}}
>
<FormattedMessage id="compose.visibility.unlisted" />
</ListItem>
<ListItem
onClick={() => {
setVisibility('private')
setPopoverVisibility(false)
}}
>
<FormattedMessage id="compose.visibility.private" />
</ListItem>
<ListItem
onClick={() => {
setVisibility('direct')
setPopoverVisibility(false)
}}
>
<FormattedMessage id="compose.visibility.direct" />
</ListItem>
</List>
</PopoverContent>
</Popover>
{cw ? (
<IconButton variant="text" size="sm" className="theme-enabled leading-4 text-base" onClick={() => setCW(false)}>
CW
</IconButton>
) : (
<IconButton
variant="text"
size="sm"
className="text-gray-400 hover:text-gray-600 leading-4 text-base"
onClick={() => setCW(true)}
title={formatMessage({ id: 'compose.actions.cw' })}
>
CW
</IconButton>
)}
</div>
<div className="mr-1 flex items-center gap-2">
<span className="text-gray-400">{remaining}</span>
<IconButton disabled={loading} onClick={post} variant="text" size="sm" title={formatMessage({ id: 'compose.actions.post' })}>
<FaPaperPlane className="text-base text-gray-600" />
</IconButton>
</div>
</div>
<EditMedia media={editMedia} close={closeDescription} client={props.client} />
</div>
)
}
const visibilityIcon = (visibility: 'public' | 'unlisted' | 'private' | 'direct', title: string) => {
switch (visibility) {
case 'public':
return (
<IconButton variant="text" size="sm" className="text-gray-400 hover:text-gray-600 text-base" title={title}>
<FaGlobe />
</IconButton>
)
case 'unlisted':
return (
<IconButton variant="text" size="sm" className="text-gray-400 hover:text-gray-600 text-base" title={title}>
<FaLockOpen />
</IconButton>
)
case 'private':
return (
<IconButton variant="text" size="sm" className="text-gray-400 hover:text-gray-600 text-base" title={title}>
<FaLock />
</IconButton>
)
case 'direct':
return (
<IconButton variant="text" size="sm" className="text-gray-400 hover:text-gray-600 text-base" title={title}>
<FaEnvelope />
</IconButton>
)
}
}
const defaultPoll = () => ({
options: ['', ''],
expires_in: 86400,
multiple: false
})
type PollProps = {
poll: Poll
setPoll: Dispatch<SetStateAction<Poll>>
}
const PollForm = (props: PollProps) => {
const { formatMessage } = useIntl()
const expiresList = [
{ label: formatMessage({ id: 'compose.poll.5min' }), value: 300 },
{ label: formatMessage({ id: 'compose.poll.30min' }), value: 1800 },
{ label: formatMessage({ id: 'compose.poll.1h' }), value: 3600 },
{ label: formatMessage({ id: 'compose.poll.6h' }), value: 21600 },
{ label: formatMessage({ id: 'compose.poll.1d' }), value: 86400 },
{ label: formatMessage({ id: 'compose.poll.3d' }), value: 259200 },
{ label: formatMessage({ id: 'compose.poll.7d' }), value: 604800 }
]
const addOption = () => {
props.setPoll(current =>
Object.assign({}, current, {
options: [...current.options, '']
})
)
}
const removeOption = (index: number) => {
props.setPoll(current =>
Object.assign({}, current, {
options: current.options.filter((_, i) => i !== index)
})
)
}
const updateOption = (index: number, value: string) => {
props.setPoll(current =>
Object.assign({}, current, {
options: current.options.map((original, i) => (i === index ? value : original))
})
)
}
const changeMultiple = (ev: ChangeEvent<HTMLInputElement>) => {
console.log(ev)
props.setPoll(current =>
Object.assign({}, current, {
multiple: ev.target.checked
})
)
}
const changeExpire = (value: number) => {
props.setPoll(current =>
Object.assign({}, current, {
expires_in: value
})
)
}
return (
<div className="pt-1">
{props.poll.options.map((option, index) => (
<div className="flex items-center gap-3 py-1" key={index}>
{props.poll.multiple ? (
<Checkbox disabled containerProps={{ className: 'p-1' }} />
) : (
<Radio disabled containerProps={{ className: 'p-1' }} />
)}
<Input
type="text"
color="blue-gray"
value={option}
onChange={ev => updateOption(index, ev.target.value)}
containerProps={{ className: 'h-8' }}
/>
<FaXmark className="text-gray-400 cursor-pointer" onClick={() => removeOption(index)} />
</div>
))}
<div className="flex gap-3 pt-2">
<Button onClick={addOption} size="sm" color="indigo" variant="outlined">
<FormattedMessage id="compose.poll.add" />
</Button>
<Select
id="expires"
color="blue-gray"
value={`${props.poll.expires_in}`}
onChange={e => changeExpire(parseInt(e))}
containerProps={{ className: 'h-8' }}
>
{expiresList.map((expire, index) => (
<Option value={`${expire.value}`} key={index}>
{expire.label}
</Option>
))}
</Select>
</div>
<div className="mt-2">
<Switch checked={props.poll.multiple} onChange={v => changeMultiple(v)} label={formatMessage({ id: 'compose.poll.multiple' })} />
</div>
</div>
)
}