mirror of
https://github.com/h3poteto/whalebird-desktop
synced 2025-02-02 02:16:46 +01:00
Implement media attachments
This commit is contained in:
parent
c0aad72fdb
commit
ee1e699b50
@ -76,5 +76,11 @@
|
||||
"private": "Private",
|
||||
"direct": "Direct"
|
||||
}
|
||||
},
|
||||
"alert": {
|
||||
"validation": {
|
||||
"attachment_type": "You can attach only images or videos"
|
||||
},
|
||||
"upload_error": "Failed to upload the file"
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +1,28 @@
|
||||
import { Dropdown, TextInput, Textarea } from 'flowbite-react'
|
||||
import { Dispatch, HTMLAttributes, SetStateAction, useEffect, useState } from 'react'
|
||||
import { Dropdown, FileInput, Spinner, TextInput, Textarea } from 'flowbite-react'
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react'
|
||||
import { FormattedMessage, useIntl } from 'react-intl'
|
||||
import { FaEnvelope, FaGlobe, FaListCheck, FaLock, FaLockOpen, FaPaperPlane, FaPaperclip } from 'react-icons/fa6'
|
||||
import { MegalodonInterface } from 'megalodon'
|
||||
import { FaEnvelope, FaGlobe, FaListCheck, FaLock, FaLockOpen, FaPaperPlane, FaPaperclip, FaXmark } from 'react-icons/fa6'
|
||||
import { Entity, MegalodonInterface } from 'megalodon'
|
||||
import { useToast } from '@/utils/toast'
|
||||
|
||||
type Props = {
|
||||
client: MegalodonInterface
|
||||
setComposeHeight: Dispatch<SetStateAction<number>>
|
||||
} & HTMLAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
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 [loading, setLoading] = useState(false)
|
||||
|
||||
const { formatMessage } = useIntl()
|
||||
const uploaderRef = useRef(null)
|
||||
const showToast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (cw) {
|
||||
props.setComposeHeight(162)
|
||||
} else {
|
||||
props.setComposeHeight(120)
|
||||
if (!cw) {
|
||||
setSpoiler('')
|
||||
}
|
||||
}, [cw])
|
||||
@ -33,72 +35,122 @@ export default function Compose(props: Props) {
|
||||
spoiler_text: spoiler
|
||||
})
|
||||
}
|
||||
await props.client.postStatus(body, options)
|
||||
reset()
|
||||
if (attachments.length > 0) {
|
||||
options = Object.assign({}, options, {
|
||||
media_ids: attachments.map(m => m.id)
|
||||
})
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
await props.client.postStatus(body, options)
|
||||
reset()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setBody('')
|
||||
setSpoiler('')
|
||||
setCW(false)
|
||||
setAttachments([])
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={props.style} className="pb-4">
|
||||
<div className="mx-4 mb-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' })}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
id="body"
|
||||
className="resize-none focus:ring-0"
|
||||
placeholder={formatMessage({ id: 'compose.placeholder' })}
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={ev => setBody(ev.target.value)}
|
||||
<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' })}
|
||||
/>
|
||||
</form>
|
||||
<div className="w-full flex justify-between mt-1 items-center">
|
||||
<div className="ml-1 flex gap-3">
|
||||
<FaPaperclip className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
||||
<FaListCheck className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
||||
<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>
|
||||
)}
|
||||
<Textarea
|
||||
id="body"
|
||||
className="resize-none focus:ring-0"
|
||||
placeholder={formatMessage({ id: 'compose.placeholder' })}
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={ev => setBody(ev.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<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" />
|
||||
</div>
|
||||
))}
|
||||
</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} />
|
||||
<FaPaperclip className="text-gray-400 hover:text-gray-600 cursor-pointer" onClick={selectFile} />
|
||||
<FaListCheck className="text-gray-400 hover:text-gray-600 cursor-pointer" />
|
||||
<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">
|
||||
<FaPaperPlane className="text-gray-400 hover:text-gray-600 cursor-pointer" onClick={post} />
|
||||
</div>
|
||||
{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} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -27,6 +27,21 @@ export default function Timeline(props: Props) {
|
||||
const { formatMessage } = useIntl()
|
||||
const scrollerRef = useRef<HTMLElement | null>(null)
|
||||
const streaming = useRef<WebSocketInterface | null>(null)
|
||||
const composeRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
entries.forEach(el => {
|
||||
setComposeHeight(el.contentRect.height)
|
||||
})
|
||||
})
|
||||
if (composeRef.current) {
|
||||
observer.observe(composeRef.current)
|
||||
}
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
@ -171,7 +186,9 @@ export default function Timeline(props: Props) {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Compose style={{ height: `${composeHeight}px` }} client={props.client} setComposeHeight={setComposeHeight} />
|
||||
<div ref={composeRef}>
|
||||
<Compose client={props.client} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Detail client={props.client} className="detail" />
|
||||
|
@ -3,15 +3,18 @@ import '../app.css'
|
||||
import AccountLayout from '@/components/layouts/account'
|
||||
import TimelineLayout from '@/components/layouts/timelines'
|
||||
import { IntlProviderWrapper } from '@/utils/i18n'
|
||||
import { ToastProvider } from '@/utils/toast'
|
||||
|
||||
export default function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<IntlProviderWrapper>
|
||||
<AccountLayout>
|
||||
<TimelineLayout>
|
||||
<Component {...pageProps} />
|
||||
</TimelineLayout>
|
||||
</AccountLayout>
|
||||
<ToastProvider>
|
||||
<AccountLayout>
|
||||
<TimelineLayout>
|
||||
<Component {...pageProps} />
|
||||
</TimelineLayout>
|
||||
</AccountLayout>
|
||||
</ToastProvider>
|
||||
</IntlProviderWrapper>
|
||||
)
|
||||
}
|
||||
|
41
renderer/utils/toast.tsx
Normal file
41
renderer/utils/toast.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Alert } from 'flowbite-react'
|
||||
import React, { useState, createContext, useContext } from 'react'
|
||||
|
||||
type ToastTypes = 'info' | 'success' | 'failure' | 'warning'
|
||||
|
||||
const ToastContext = createContext(({}: { text: string; type?: ToastTypes }) => {})
|
||||
ToastContext.displayName = 'ToastContext'
|
||||
|
||||
export const useToast = () => {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<Props> = ({ children }) => {
|
||||
const [showable, setShowable] = useState(false)
|
||||
const [toastText, setToastText] = useState('')
|
||||
const [toastType, setToastType] = useState<ToastTypes>('info')
|
||||
|
||||
const showToast = ({ text, type = 'info' }: { text: string; type?: ToastTypes }) => {
|
||||
setToastText(text)
|
||||
setToastType(type)
|
||||
setShowable(true)
|
||||
setTimeout(() => {
|
||||
setShowable(false)
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={showToast}>
|
||||
{children}
|
||||
<div className={`${showable ? 'block' : 'hidden'} fixed top-2 -translate-x-1/2`} style={{ left: '50%' }}>
|
||||
<Alert color={toastType} className="w96">
|
||||
<span>{toastText}</span>
|
||||
</Alert>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user