2023-12-10 15:55:41 +01:00
|
|
|
import {
|
|
|
|
Button,
|
|
|
|
Checkbox,
|
|
|
|
CustomFlowbiteTheme,
|
|
|
|
Dropdown,
|
|
|
|
Flowbite,
|
|
|
|
Label,
|
|
|
|
Radio,
|
|
|
|
Select,
|
|
|
|
Spinner,
|
|
|
|
TextInput,
|
|
|
|
Textarea,
|
|
|
|
ToggleSwitch
|
|
|
|
} from 'flowbite-react'
|
2023-12-15 16:48:49 +01:00
|
|
|
import { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
|
2023-11-21 16:56:26 +01:00
|
|
|
import { FormattedMessage, useIntl } from 'react-intl'
|
2023-12-10 15:55:41 +01:00
|
|
|
import { FaEnvelope, FaFaceLaughBeam, FaGlobe, FaListCheck, FaLock, FaLockOpen, FaPaperPlane, FaPaperclip, FaXmark } from 'react-icons/fa6'
|
2023-11-23 06:07:48 +01:00
|
|
|
import { Entity, MegalodonInterface } from 'megalodon'
|
|
|
|
import { useToast } from '@/utils/toast'
|
2023-12-10 15:55:41 +01:00
|
|
|
import Picker from '@emoji-mart/react'
|
|
|
|
import { data } from '@/utils/emojiData'
|
2023-11-21 16:56:26 +01:00
|
|
|
|
|
|
|
type Props = {
|
|
|
|
client: MegalodonInterface
|
2023-11-28 16:37:21 +01:00
|
|
|
in_reply_to?: Entity.Status
|
2023-11-23 06:07:48 +01:00
|
|
|
}
|
2023-11-21 16:56:26 +01:00
|
|
|
|
2023-11-30 16:38:55 +01:00
|
|
|
type Poll = {
|
|
|
|
options: Array<string>
|
|
|
|
expires_in: number
|
|
|
|
multiple: boolean
|
|
|
|
}
|
|
|
|
|
2023-12-10 15:55:41 +01:00
|
|
|
const customTheme: CustomFlowbiteTheme = {
|
|
|
|
dropdown: {
|
|
|
|
content: 'focus:outline-none',
|
|
|
|
floating: {
|
|
|
|
item: {
|
2023-12-11 16:00:44 +01:00
|
|
|
base: 'hidden'
|
2023-12-10 15:55:41 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-11-21 16:56:26 +01:00
|
|
|
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('')
|
2023-11-23 06:07:48 +01:00
|
|
|
const [attachments, setAttachments] = useState<Array<Entity.Attachment | Entity.AsyncAttachment>>([])
|
2023-11-30 16:38:55 +01:00
|
|
|
const [poll, setPoll] = useState<Poll | null>(null)
|
2023-11-23 06:07:48 +01:00
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
2023-11-21 16:56:26 +01:00
|
|
|
const { formatMessage } = useIntl()
|
2023-11-23 06:07:48 +01:00
|
|
|
const uploaderRef = useRef(null)
|
|
|
|
const showToast = useToast()
|
2023-12-15 16:48:49 +01:00
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
2023-11-21 16:56:26 +01:00
|
|
|
|
|
|
|
useEffect(() => {
|
2023-11-23 06:07:48 +01:00
|
|
|
if (!cw) {
|
2023-11-21 16:56:26 +01:00
|
|
|
setSpoiler('')
|
|
|
|
}
|
|
|
|
}, [cw])
|
|
|
|
|
2023-11-28 16:37:21 +01:00
|
|
|
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])
|
|
|
|
|
2023-11-21 16:56:26 +01:00
|
|
|
const post = async () => {
|
|
|
|
if (body.length === 0) return
|
|
|
|
let options = { visibility: visibility }
|
|
|
|
if (cw) {
|
|
|
|
options = Object.assign({}, options, {
|
|
|
|
spoiler_text: spoiler
|
|
|
|
})
|
|
|
|
}
|
2023-11-23 06:07:48 +01:00
|
|
|
if (attachments.length > 0) {
|
|
|
|
options = Object.assign({}, options, {
|
|
|
|
media_ids: attachments.map(m => m.id)
|
|
|
|
})
|
|
|
|
}
|
2023-11-23 07:06:46 +01:00
|
|
|
const sensitive = document.getElementById('sensitive') as HTMLInputElement
|
2023-11-25 06:50:46 +01:00
|
|
|
if (sensitive && sensitive.checked) {
|
2023-11-23 07:06:46 +01:00
|
|
|
options = Object.assign({}, options, {
|
|
|
|
sensitive: sensitive.checked
|
|
|
|
})
|
|
|
|
}
|
2023-11-30 16:38:55 +01:00
|
|
|
if (poll && poll.options.length > 0) {
|
|
|
|
options = Object.assign({}, options, {
|
|
|
|
poll: poll
|
|
|
|
})
|
|
|
|
}
|
2023-11-23 06:07:48 +01:00
|
|
|
setLoading(true)
|
|
|
|
try {
|
|
|
|
await props.client.postStatus(body, options)
|
|
|
|
reset()
|
|
|
|
} finally {
|
|
|
|
setLoading(false)
|
|
|
|
}
|
2023-11-21 16:56:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
setBody('')
|
|
|
|
setSpoiler('')
|
|
|
|
setCW(false)
|
2023-11-23 06:07:48 +01:00
|
|
|
setAttachments([])
|
2023-11-30 16:38:55 +01:00
|
|
|
setPoll(null)
|
2023-11-23 06:07:48 +01:00
|
|
|
}
|
|
|
|
|
2023-12-15 16:48:49 +01:00
|
|
|
const handleKeyPress = useCallback(
|
|
|
|
(event: KeyboardEvent) => {
|
|
|
|
if (event.ctrlKey === true && event.key === 'Enter') {
|
|
|
|
post()
|
|
|
|
}
|
|
|
|
},
|
|
|
|
[post]
|
|
|
|
)
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
textareaRef.current?.addEventListener('keydown', handleKeyPress)
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
textareaRef.current?.removeEventListener('keydown', handleKeyPress)
|
|
|
|
}
|
|
|
|
}, [handleKeyPress])
|
|
|
|
|
2023-11-23 06:07:48 +01:00
|
|
|
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 (!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))
|
2023-11-21 16:56:26 +01:00
|
|
|
}
|
|
|
|
|
2023-11-30 16:38:55 +01:00
|
|
|
const togglePoll = () => {
|
|
|
|
if (poll) {
|
|
|
|
setPoll(null)
|
|
|
|
} else {
|
|
|
|
setPoll(defaultPoll())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-10 15:55:41 +01:00
|
|
|
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)}`)
|
|
|
|
}
|
2023-12-11 16:00:44 +01:00
|
|
|
const dummy = document.getElementById('dummy-emoji-picker')
|
|
|
|
dummy.click()
|
2023-12-10 15:55:41 +01:00
|
|
|
}
|
|
|
|
|
2023-11-21 16:56:26 +01:00
|
|
|
return (
|
2023-11-23 06:07:48 +01:00
|
|
|
<div className="px-4 pb-4">
|
|
|
|
<form id="form">
|
|
|
|
{cw && (
|
|
|
|
<TextInput
|
|
|
|
id="spoiler"
|
|
|
|
type="text"
|
|
|
|
sizing="sm"
|
|
|
|
className="mb-2"
|
|
|
|
value={spoiler}
|
|
|
|
onChange={ev => setSpoiler(ev.target.value)}
|
|
|
|
placeholder={formatMessage({ id: 'compose.spoiler.placeholder' })}
|
2023-11-21 16:56:26 +01:00
|
|
|
/>
|
2023-11-23 06:07:48 +01:00
|
|
|
)}
|
2023-12-10 15:55:41 +01:00
|
|
|
<div className="relative">
|
|
|
|
<Textarea
|
|
|
|
id="body"
|
|
|
|
className="resize-none focus:ring-0"
|
|
|
|
placeholder={formatMessage({ id: 'compose.placeholder' })}
|
|
|
|
rows={3}
|
|
|
|
value={body}
|
|
|
|
onChange={ev => setBody(ev.target.value)}
|
2023-12-15 16:48:49 +01:00
|
|
|
ref={textareaRef}
|
2023-12-10 15:55:41 +01:00
|
|
|
/>
|
|
|
|
<Flowbite theme={{ theme: customTheme }}>
|
|
|
|
<Dropdown
|
|
|
|
label=""
|
|
|
|
dismissOnClick
|
|
|
|
renderTrigger={() => (
|
|
|
|
<span className="absolute top-1 right-1 text-gray-600 cursor-pointer">
|
|
|
|
<FaFaceLaughBeam />
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
>
|
2023-12-11 16:00:44 +01:00
|
|
|
<Picker data={data} onEmojiSelect={onEmojiSelect} previewPosition="none" set="native" perLine="7" theme="light" />
|
2023-12-10 15:55:41 +01:00
|
|
|
<Dropdown.Item>
|
2023-12-11 16:00:44 +01:00
|
|
|
<span id="dummy-emoji-picker" />
|
2023-12-10 15:55:41 +01:00
|
|
|
</Dropdown.Item>
|
|
|
|
</Dropdown>
|
|
|
|
</Flowbite>
|
|
|
|
</div>
|
2023-11-23 06:07:48 +01:00
|
|
|
</form>
|
2023-11-30 16:38:55 +01:00
|
|
|
{poll && <PollForm poll={poll} setPoll={setPoll} />}
|
2023-11-23 06:07:48 +01:00
|
|
|
<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>
|
|
|
|
<img src={f.preview_url} style={{ width: '80px', height: '80px' }} className="rounded-md" />
|
2023-11-21 16:56:26 +01:00
|
|
|
</div>
|
2023-11-23 06:07:48 +01:00
|
|
|
))}
|
|
|
|
</div>
|
2023-11-23 07:06:46 +01:00
|
|
|
|
|
|
|
{attachments.length > 0 && (
|
|
|
|
<div>
|
|
|
|
<Checkbox id="sensitive" className="focus:ring-0" />
|
|
|
|
<Label htmlFor="sensitive" className="pl-2 text-gray-600">
|
|
|
|
<FormattedMessage id="compose.nsfw" />
|
|
|
|
</Label>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
|
2023-11-23 06:07:48 +01:00
|
|
|
<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} />
|
|
|
|
<FaPaperclip className="text-gray-400 hover:text-gray-600 cursor-pointer" onClick={selectFile} />
|
2023-11-30 16:38:55 +01:00
|
|
|
<FaListCheck className="text-gray-400 hover:text-gray-600 cursor-pointer" onClick={togglePoll} />
|
2023-11-23 06:07:48 +01:00
|
|
|
<Dropdown label="" dismissOnClick={true} placement="top" renderTrigger={() => visibilityIcon(visibility)}>
|
|
|
|
<Dropdown.Item onClick={() => setVisibility('public')}>
|
|
|
|
<FormattedMessage id="compose.visibility.public" />
|
|
|
|
</Dropdown.Item>
|
|
|
|
<Dropdown.Item onClick={() => setVisibility('unlisted')}>
|
|
|
|
<FormattedMessage id="compose.visibility.unlisted" />
|
|
|
|
</Dropdown.Item>
|
|
|
|
<Dropdown.Item onClick={() => setVisibility('private')}>
|
|
|
|
<FormattedMessage id="compose.visibility.private" />
|
|
|
|
</Dropdown.Item>
|
|
|
|
<Dropdown.Item onClick={() => setVisibility('direct')}>
|
|
|
|
<FormattedMessage id="compose.visibility.direct" />
|
|
|
|
</Dropdown.Item>
|
|
|
|
</Dropdown>
|
|
|
|
|
|
|
|
{cw ? (
|
|
|
|
<span className="text-blue-400 hover:text-blue-600 leading-4 cursor-pointer" onClick={() => setCW(false)}>
|
|
|
|
CW
|
|
|
|
</span>
|
|
|
|
) : (
|
|
|
|
<span className="text-gray-400 hover:text-gray-600 leading-4 cursor-pointer" onClick={() => setCW(true)}>
|
|
|
|
CW
|
|
|
|
</span>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div className="mr-1">
|
|
|
|
{loading ? <Spinner size="sm" /> : <FaPaperPlane className="text-gray-400 hover:text-gray-600 cursor-pointer" onClick={post} />}
|
2023-11-21 16:56:26 +01:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
const visibilityIcon = (visibility: 'public' | 'unlisted' | 'private' | 'direct') => {
|
|
|
|
switch (visibility) {
|
|
|
|
case 'public':
|
|
|
|
return (
|
|
|
|
<span>
|
|
|
|
<FaGlobe className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
|
|
|
</span>
|
|
|
|
)
|
|
|
|
case 'unlisted':
|
|
|
|
return (
|
|
|
|
<span>
|
|
|
|
<FaLockOpen className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
|
|
|
</span>
|
|
|
|
)
|
|
|
|
case 'private':
|
|
|
|
return (
|
|
|
|
<span>
|
|
|
|
<FaLock className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
|
|
|
</span>
|
|
|
|
)
|
|
|
|
case 'direct':
|
|
|
|
return (
|
|
|
|
<span>
|
|
|
|
<FaEnvelope className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
|
|
|
</span>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
2023-11-30 16:38:55 +01:00
|
|
|
|
|
|
|
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 = (value: boolean) => {
|
|
|
|
props.setPoll(current =>
|
|
|
|
Object.assign({}, current, {
|
|
|
|
multiple: value
|
|
|
|
})
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
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 /> : <Radio disabled />}
|
|
|
|
<TextInput sizing="sm" value={option} onChange={ev => updateOption(index, ev.target.value)} />
|
|
|
|
<FaXmark className="text-gray-400 cursor-pointer" onClick={() => removeOption(index)} />
|
|
|
|
</div>
|
|
|
|
))}
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
|
|
<Button onClick={addOption} color="light">
|
|
|
|
<FormattedMessage id="compose.poll.add" />
|
|
|
|
</Button>
|
|
|
|
<Select id="expires" onChange={e => changeExpire(parseInt(e.target.value))}>
|
|
|
|
{expiresList.map((expire, index) => (
|
|
|
|
<option value={expire.value} key={index}>
|
|
|
|
{expire.label}
|
|
|
|
</option>
|
|
|
|
))}
|
|
|
|
</Select>
|
|
|
|
</div>
|
|
|
|
<ToggleSwitch
|
|
|
|
checked={props.poll.multiple}
|
|
|
|
onChange={v => changeMultiple(v)}
|
|
|
|
className="mt-2"
|
|
|
|
label={formatMessage({ id: 'compose.poll.multiple' })}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|