2022-12-10 12:14:48 +03:00
import './compose.css' ;
import '@github/text-expander-element' ;
import { useEffect , useMemo , useRef , useState } from 'preact/hooks' ;
2022-12-10 15:46:56 +03:00
import stringLength from 'string-length' ;
2022-12-10 12:14:48 +03:00
import emojifyText from '../utils/emojify-text' ;
2022-12-13 16:54:16 +03:00
import openCompose from '../utils/open-compose' ;
2022-12-10 12:14:48 +03:00
import store from '../utils/store' ;
import visibilityIconsMap from '../utils/visibility-icons-map' ;
import Avatar from './avatar' ;
import Icon from './icon' ;
import Loader from './loader' ;
import Status from './status' ;
/ * N O T E S :
- Max character limit includes BOTH status text and Content Warning text
* /
2022-12-14 16:48:17 +03:00
const expiryOptions = {
'5 minutes' : 5 * 60 ,
'30 minutes' : 30 * 60 ,
'1 hour' : 60 * 60 ,
'6 hours' : 6 * 60 * 60 ,
'1 day' : 24 * 60 * 60 ,
'3 days' : 3 * 24 * 60 * 60 ,
'7 days' : 7 * 24 * 60 * 60 ,
} ;
const expirySeconds = Object . values ( expiryOptions ) ;
const oneDay = 24 * 60 * 60 ;
const expiresInFromExpiresAt = ( expiresAt ) => {
if ( ! expiresAt ) return oneDay ;
const delta = ( new Date ( expiresAt ) . getTime ( ) - Date . now ( ) ) / 1000 ;
return expirySeconds . find ( ( s ) => s >= delta ) || oneDay ;
} ;
2022-12-13 19:39:35 +03:00
function Compose ( {
2022-12-13 15:42:09 +03:00
onClose ,
replyToStatus ,
editStatus ,
draftStatus ,
standalone ,
2022-12-14 16:48:17 +03:00
hasOpener ,
2022-12-13 19:39:35 +03:00
} ) {
2022-12-10 12:14:48 +03:00
const [ uiState , setUIState ] = useState ( 'default' ) ;
const accounts = store . local . getJSON ( 'accounts' ) ;
const currentAccount = store . session . get ( 'currentAccount' ) ;
const currentAccountInfo = accounts . find (
( a ) => a . info . id === currentAccount ,
) . info ;
const configuration = useMemo ( ( ) => {
const instances = store . local . getJSON ( 'instances' ) ;
const currentInstance = accounts . find (
( a ) => a . info . id === currentAccount ,
) . instanceURL ;
const config = instances [ currentInstance ] . configuration ;
console . log ( config ) ;
return config ;
} , [ ] ) ;
const {
statuses : { maxCharacters , maxMediaAttachments , charactersReservedPerUrl } ,
mediaAttachments : {
supportedMimeTypes ,
imageSizeLimit ,
imageMatrixLimit ,
videoSizeLimit ,
videoMatrixLimit ,
videoFrameRateLimit ,
} ,
polls : { maxOptions , maxCharactersPerOption , maxExpiration , minExpiration } ,
} = configuration ;
const textareaRef = useRef ( ) ;
2022-12-14 16:48:17 +03:00
const spoilerTextRef = useRef ( ) ;
2022-12-13 15:42:09 +03:00
const [ visibility , setVisibility ] = useState ( 'public' ) ;
const [ sensitive , setSensitive ] = useState ( false ) ;
2022-12-14 16:48:17 +03:00
const [ mediaAttachments , setMediaAttachments ] = useState ( [ ] ) ;
const [ poll , setPoll ] = useState ( null ) ;
2022-12-10 12:14:48 +03:00
useEffect ( ( ) => {
2022-12-13 15:42:09 +03:00
if ( replyToStatus ) {
const { spoilerText , visibility , sensitive } = replyToStatus ;
2022-12-10 12:14:48 +03:00
if ( spoilerText && spoilerTextRef . current ) {
spoilerTextRef . current . value = spoilerText ;
spoilerTextRef . current . focus ( ) ;
} else {
2022-12-15 19:50:38 +03:00
const mentions = new Set ( [
replyToStatus . account . acct ,
... replyToStatus . mentions . map ( ( m ) => m . acct ) ,
] ) ;
textareaRef . current . value = ` ${ [ ... mentions ]
. filter ( ( m ) => m !== currentAccountInfo . acct ) // Excluding self
. map ( ( m ) => ` @ ${ m } ` )
. join ( ' ' ) } ` ;
2022-12-13 15:42:09 +03:00
textareaRef . current . focus ( ) ;
2022-12-10 12:14:48 +03:00
}
2022-12-13 15:42:09 +03:00
setVisibility ( visibility ) ;
setSensitive ( sensitive ) ;
}
if ( draftStatus ) {
2022-12-14 16:48:17 +03:00
const {
status ,
spoilerText ,
visibility ,
sensitive ,
poll ,
mediaAttachments ,
} = draftStatus ;
const composablePoll = ! ! poll ? . options && {
... poll ,
options : poll . options . map ( ( o ) => o ? . title || o ) ,
expiresIn : poll ? . expiresIn || expiresInFromExpiresAt ( poll . expiresAt ) ,
} ;
2022-12-13 15:42:09 +03:00
textareaRef . current . value = status ;
2022-12-14 17:54:12 +03:00
textareaRef . current . dispatchEvent ( new Event ( 'input' ) ) ;
2022-12-13 15:42:09 +03:00
spoilerTextRef . current . value = spoilerText ;
setVisibility ( visibility ) ;
setSensitive ( sensitive ) ;
2022-12-14 16:48:17 +03:00
setPoll ( composablePoll ) ;
2022-12-13 15:42:09 +03:00
setMediaAttachments ( mediaAttachments ) ;
} else if ( editStatus ) {
2022-12-14 16:48:17 +03:00
const { visibility , sensitive , poll , mediaAttachments } = editStatus ;
const composablePoll = ! ! poll ? . options && {
... poll ,
options : poll . options . map ( ( o ) => o ? . title || o ) ,
expiresIn : poll ? . expiresIn || expiresInFromExpiresAt ( poll . expiresAt ) ,
} ;
2022-12-12 16:54:31 +03:00
setUIState ( 'loading' ) ;
( async ( ) => {
try {
const statusSource = await masto . statuses . fetchSource ( editStatus . id ) ;
console . log ( { statusSource } ) ;
const { text , spoilerText } = statusSource ;
textareaRef . current . value = text ;
2022-12-14 17:54:12 +03:00
textareaRef . current . dispatchEvent ( new Event ( 'input' ) ) ;
2022-12-12 16:54:31 +03:00
textareaRef . current . dataset . source = text ;
spoilerTextRef . current . value = spoilerText ;
setVisibility ( visibility ) ;
setSensitive ( sensitive ) ;
2022-12-14 16:48:17 +03:00
setPoll ( composablePoll ) ;
2022-12-12 16:54:31 +03:00
setMediaAttachments ( mediaAttachments ) ;
setUIState ( 'default' ) ;
} catch ( e ) {
console . error ( e ) ;
alert ( e ? . reason || e ) ;
setUIState ( 'error' ) ;
}
} ) ( ) ;
}
2022-12-13 15:42:09 +03:00
} , [ draftStatus , editStatus , replyToStatus ] ) ;
2022-12-12 16:54:31 +03:00
2022-12-10 12:14:48 +03:00
const textExpanderRef = useRef ( ) ;
const textExpanderTextRef = useRef ( '' ) ;
useEffect ( ( ) => {
if ( textExpanderRef . current ) {
const handleChange = ( e ) => {
2022-12-13 19:19:49 +03:00
// console.log('text-expander-change', e);
2022-12-10 12:14:48 +03:00
const { key , provide , text } = e . detail ;
textExpanderTextRef . current = text ;
if ( text === '' ) {
provide (
Promise . resolve ( {
matched : false ,
} ) ,
) ;
return ;
}
const type = {
'@' : 'accounts' ,
'#' : 'hashtags' ,
} [ key ] ;
provide (
new Promise ( ( resolve ) => {
const resultsIterator = masto . search ( {
type ,
q : text ,
limit : 5 ,
} ) ;
resultsIterator . next ( ) . then ( ( { value } ) => {
if ( text !== textExpanderTextRef . current ) {
return ;
}
const results = value [ type ] ;
console . log ( 'RESULTS' , value , results ) ;
const menu = document . createElement ( 'ul' ) ;
menu . role = 'listbox' ;
menu . className = 'text-expander-menu' ;
results . forEach ( ( result ) => {
const {
name ,
avatarStatic ,
displayName ,
username ,
acct ,
emojis ,
} = result ;
const displayNameWithEmoji = emojifyText ( displayName , emojis ) ;
const item = document . createElement ( 'li' ) ;
item . setAttribute ( 'role' , 'option' ) ;
if ( acct ) {
item . dataset . value = acct ;
// Want to use <Avatar /> here, but will need to render to string 😅
item . innerHTML = `
< span class = "avatar" >
< img src = "${avatarStatic}" width = "16" height = "16" alt = "" loading = "lazy" / >
< / span >
< span >
< b > $ { displayNameWithEmoji || username } < / b >
< br > @ $ { acct }
< / span >
` ;
} else {
item . dataset . value = name ;
item . innerHTML = `
< span > # < b > $ { name } < / b > < / span >
` ;
}
menu . appendChild ( item ) ;
} ) ;
console . log ( 'MENU' , results , menu ) ;
resolve ( {
matched : results . length > 0 ,
fragment : menu ,
} ) ;
} ) ;
} ) ,
) ;
} ;
textExpanderRef . current . addEventListener (
'text-expander-change' ,
handleChange ,
) ;
textExpanderRef . current . addEventListener ( 'text-expander-value' , ( e ) => {
const { key , item } = e . detail ;
e . detail . value = key + item . dataset . value ;
} ) ;
}
} , [ ] ) ;
const formRef = useRef ( ) ;
const beforeUnloadCopy =
'You have unsaved changes. Are you sure you want to discard this post?' ;
const canClose = ( ) => {
2022-12-12 16:54:31 +03:00
const { value , dataset } = textareaRef . current ;
2022-12-13 15:42:09 +03:00
2022-12-13 19:20:24 +03:00
// check for status and media attachments
const hasMediaAttachments = mediaAttachments . length > 0 ;
if ( ! value && ! hasMediaAttachments ) {
2022-12-13 16:54:16 +03:00
console . log ( 'canClose' , { value , mediaAttachments } ) ;
return true ;
}
2022-12-12 16:54:31 +03:00
2022-12-13 19:20:24 +03:00
// check if all media attachments have IDs
2022-12-14 16:48:17 +03:00
const hasIDMediaAttachments =
mediaAttachments . length > 0 &&
mediaAttachments . every ( ( media ) => media . id ) ;
2022-12-13 19:20:24 +03:00
if ( hasIDMediaAttachments ) {
console . log ( 'canClose' , { hasIDMediaAttachments } ) ;
return true ;
}
2022-12-13 15:42:09 +03:00
// check if status contains only "@acct", if replying
2022-12-13 16:54:16 +03:00
const isSelf = replyToStatus ? . account . id === currentAccount ;
const hasOnlyAcct =
2022-12-13 15:42:09 +03:00
replyToStatus && value . trim ( ) === ` @ ${ replyToStatus . account . acct } ` ;
2022-12-15 19:53:04 +03:00
// TODO: check for mentions, or maybe just generic "@username<space>", including multiple mentions like "@username1<space>@username2<space>"
2022-12-13 16:54:16 +03:00
if ( ! isSelf && hasOnlyAcct ) {
console . log ( 'canClose' , { isSelf , hasOnlyAcct } ) ;
return true ;
}
2022-12-13 15:42:09 +03:00
2022-12-13 16:54:16 +03:00
// check if status is same with source
const sameWithSource = value === dataset ? . source ;
if ( sameWithSource ) {
console . log ( 'canClose' , { sameWithSource } ) ;
return true ;
}
2022-12-13 15:42:09 +03:00
2022-12-13 19:20:24 +03:00
console . log ( 'canClose' , {
value ,
hasMediaAttachments ,
hasIDMediaAttachments ,
2022-12-14 16:48:17 +03:00
poll ,
2022-12-13 19:20:24 +03:00
isSelf ,
hasOnlyAcct ,
sameWithSource ,
} ) ;
2022-12-13 16:54:16 +03:00
return false ;
} ;
2022-12-13 15:42:09 +03:00
2022-12-13 16:54:16 +03:00
const confirmClose = ( ) => {
2022-12-13 17:26:29 +03:00
if ( ! canClose ( ) ) {
2022-12-10 12:14:48 +03:00
const yes = confirm ( beforeUnloadCopy ) ;
return yes ;
}
return true ;
} ;
useEffect ( ( ) => {
// Show warning if user tries to close window with unsaved changes
const handleBeforeUnload = ( e ) => {
if ( ! canClose ( ) ) {
e . preventDefault ( ) ;
e . returnValue = beforeUnloadCopy ;
}
} ;
window . addEventListener ( 'beforeunload' , handleBeforeUnload , {
capture : true ,
} ) ;
return ( ) =>
window . removeEventListener ( 'beforeunload' , handleBeforeUnload , {
capture : true ,
} ) ;
} , [ ] ) ;
return (
2022-12-13 15:42:09 +03:00
< div id = "compose-container" class = { standalone ? 'standalone' : '' } >
2022-12-10 12:14:48 +03:00
< div class = "compose-top" >
{ currentAccountInfo ? . avatarStatic && (
< Avatar
url = { currentAccountInfo . avatarStatic }
size = "l"
alt = { currentAccountInfo . username }
/ >
) }
2022-12-13 15:42:09 +03:00
{ ! standalone ? (
< span >
< button
type = "button"
class = "light"
onClick = { ( ) => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments =
mediaAttachments . length > 0 &&
mediaAttachments . some ( ( media ) => ! media . id ) ;
if ( containNonIDMediaAttachments ) {
const yes = confirm (
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?' ,
) ;
if ( ! yes ) {
return ;
}
}
const mediaAttachmentsWithIDs = mediaAttachments . filter (
( media ) => media . id ,
) ;
2022-12-13 16:54:16 +03:00
const newWin = openCompose ( {
2022-12-13 15:42:09 +03:00
editStatus ,
replyToStatus ,
draftStatus : {
status : textareaRef . current . value ,
spoilerText : spoilerTextRef . current . value ,
visibility ,
sensitive ,
2022-12-14 16:48:17 +03:00
poll ,
2022-12-13 15:42:09 +03:00
mediaAttachments : mediaAttachmentsWithIDs ,
} ,
} ) ;
2022-12-13 16:54:16 +03:00
if ( ! newWin ) {
alert ( 'Looks like your browser is blocking popups.' ) ;
return ;
}
onClose ( ) ;
2022-12-13 15:42:09 +03:00
} }
>
< Icon icon = "popout" alt = "Pop out" / >
< / button > { ' ' }
< button
type = "button"
class = "light close-button"
onClick = { ( ) => {
2022-12-13 16:54:16 +03:00
if ( confirmClose ( ) ) {
2022-12-13 15:42:09 +03:00
onClose ( ) ;
}
} }
>
< Icon icon = "x" / >
< / button >
< / span >
) : (
2022-12-14 16:48:17 +03:00
hasOpener && (
< button
type = "button"
class = "light"
onClick = { ( ) => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments =
mediaAttachments . length > 0 &&
mediaAttachments . some ( ( media ) => ! media . id ) ;
if ( containNonIDMediaAttachments ) {
const yes = confirm (
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?' ,
) ;
if ( ! yes ) {
return ;
}
2022-12-13 15:42:09 +03:00
}
2022-12-14 16:48:17 +03:00
if ( ! window . opener ) {
alert ( 'Looks like you closed the parent window.' ) ;
return ;
}
2022-12-13 15:42:09 +03:00
2022-12-15 08:03:20 +03:00
if ( window . opener . _ _STATES _ _ . showCompose ) {
const yes = confirm (
'Looks like you already have a compose field open in the parent window. Popping in this window will discard the changes you made in the parent window. Continue?' ,
) ;
if ( ! yes ) return ;
}
2022-12-14 16:48:17 +03:00
const mediaAttachmentsWithIDs = mediaAttachments . filter (
( media ) => media . id ,
) ;
2022-12-13 15:42:09 +03:00
2022-12-14 16:48:17 +03:00
onClose ( {
fn : ( ) => {
window . opener . _ _STATES _ _ . showCompose = {
editStatus ,
replyToStatus ,
draftStatus : {
status : textareaRef . current . value ,
spoilerText : spoilerTextRef . current . value ,
visibility ,
sensitive ,
poll ,
mediaAttachments : mediaAttachmentsWithIDs ,
} ,
} ;
} ,
} ) ;
} }
>
< Icon icon = "popin" alt = "Pop in" / >
< / button >
)
2022-12-13 15:42:09 +03:00
) }
2022-12-10 12:14:48 +03:00
< / div >
{ ! ! replyToStatus && (
2022-12-13 15:42:09 +03:00
< div class = "status-preview" >
2022-12-10 12:14:48 +03:00
< Status status = { replyToStatus } size = "s" / >
2022-12-13 15:42:09 +03:00
< div class = "status-preview-legend reply-to" >
Replying to @
{ replyToStatus . account . acct || replyToStatus . account . username }
2022-12-15 19:51:08 +03:00
& rsquo ; s status
2022-12-13 15:42:09 +03:00
< / div >
< / div >
) }
{ ! ! editStatus && (
< div class = "status-preview" >
< Status status = { editStatus } size = "s" / >
< div class = "status-preview-legend" > Editing source status < / div >
2022-12-10 12:14:48 +03:00
< / div >
) }
< form
ref = { formRef }
style = { {
pointerEvents : uiState === 'loading' ? 'none' : 'auto' ,
opacity : uiState === 'loading' ? 0.5 : 1 ,
} }
onSubmit = { ( e ) => {
e . preventDefault ( ) ;
const formData = new FormData ( e . target ) ;
const entries = Object . fromEntries ( formData . entries ( ) ) ;
console . log ( 'ENTRIES' , entries ) ;
let { status , visibility , sensitive , spoilerText } = entries ;
// Pre-cleanup
sensitive = sensitive === 'on' ; // checkboxes return "on" if checked
// Validation
2022-12-10 15:46:56 +03:00
if ( stringLength ( status ) > maxCharacters ) {
2022-12-10 12:14:48 +03:00
alert ( ` Status is too long! Max characters: ${ maxCharacters } ` ) ;
return ;
}
2022-12-10 15:46:56 +03:00
if (
sensitive &&
stringLength ( status ) + stringLength ( spoilerText ) > maxCharacters
) {
2022-12-10 12:14:48 +03:00
alert (
` Status and content warning is too long! Max characters: ${ maxCharacters } ` ,
) ;
return ;
}
2022-12-14 16:48:17 +03:00
if ( poll ) {
if ( poll . options . length < 2 ) {
alert ( 'Poll must have at least 2 options' ) ;
return ;
}
if ( poll . options . some ( ( option ) => option === '' ) ) {
alert ( 'Some poll choices are empty' ) ;
return ;
}
}
2022-12-10 12:14:48 +03:00
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
// Post-cleanup
spoilerText = ( sensitive && spoilerText ) || undefined ;
status = status === '' ? undefined : status ;
setUIState ( 'loading' ) ;
( async ( ) => {
try {
console . log ( 'MEDIA ATTACHMENTS' , mediaAttachments ) ;
if ( mediaAttachments . length > 0 ) {
// Upload media attachments first
const mediaPromises = mediaAttachments . map ( ( attachment ) => {
2022-12-14 16:48:17 +03:00
const { file , description , id } = attachment ;
2022-12-12 11:27:44 +03:00
console . log ( 'UPLOADING' , attachment ) ;
if ( id ) {
// If already uploaded
return attachment ;
} else {
const params = {
file ,
description ,
2022-12-15 16:34:21 +03:00
skipPolling : true ,
2022-12-12 11:27:44 +03:00
} ;
return masto . mediaAttachments . create ( params ) . then ( ( res ) => {
if ( res . id ) {
attachment . id = res . id ;
}
return res ;
} ) ;
}
2022-12-10 12:14:48 +03:00
} ) ;
const results = await Promise . allSettled ( mediaPromises ) ;
// If any failed, return
if (
2022-12-12 11:27:44 +03:00
results . some ( ( result ) => {
return result . status === 'rejected' || ! result . value ? . id ;
} )
2022-12-10 12:14:48 +03:00
) {
setUIState ( 'error' ) ;
// Alert all the reasons
results . forEach ( ( result ) => {
if ( result . status === 'rejected' ) {
alert ( result . reason || ` Attachment # ${ i } failed ` ) ;
}
} ) ;
return ;
}
console . log ( { results , mediaAttachments } ) ;
}
const params = {
status ,
spoilerText ,
2022-12-12 16:54:31 +03:00
sensitive ,
2022-12-14 16:48:17 +03:00
poll ,
2022-12-10 12:14:48 +03:00
mediaIds : mediaAttachments . map ( ( attachment ) => attachment . id ) ,
} ;
2022-12-12 16:54:31 +03:00
if ( ! editStatus ) {
params . visibility = visibility ;
params . inReplyToId = replyToStatus ? . id || undefined ;
}
2022-12-10 12:14:48 +03:00
console . log ( 'POST' , params ) ;
2022-12-12 16:54:31 +03:00
let newStatus ;
if ( editStatus ) {
newStatus = await masto . statuses . update ( editStatus . id , params ) ;
} else {
newStatus = await masto . statuses . create ( params ) ;
}
2022-12-10 12:14:48 +03:00
setUIState ( 'default' ) ;
// Close
onClose ( {
newStatus ,
} ) ;
} catch ( e ) {
2022-12-12 11:27:44 +03:00
console . error ( e ) ;
alert ( e ? . reason || e ) ;
2022-12-10 12:14:48 +03:00
setUIState ( 'error' ) ;
}
} ) ( ) ;
} }
>
< div class = "toolbar stretch" >
< input
ref = { spoilerTextRef }
type = "text"
name = "spoilerText"
placeholder = "Spoiler text"
disabled = { uiState === 'loading' }
class = "spoiler-text-field"
style = { {
opacity : sensitive ? 1 : 0 ,
pointerEvents : sensitive ? 'auto' : 'none' ,
} }
/ >
< label
class = "toolbar-button"
title = "Content warning or sensitive media"
>
< input
name = "sensitive"
type = "checkbox"
2022-12-13 15:42:09 +03:00
checked = { sensitive }
2022-12-12 16:54:31 +03:00
disabled = { uiState === 'loading' || ! ! editStatus }
2022-12-10 12:14:48 +03:00
onChange = { ( e ) => {
const sensitive = e . target . checked ;
setSensitive ( sensitive ) ;
if ( sensitive ) {
spoilerTextRef . current ? . focus ( ) ;
} else {
textareaRef . current ? . focus ( ) ;
}
} }
/ >
< Icon icon = { ` eye- ${ sensitive ? 'close' : 'open' } ` } / >
< / label > { ' ' }
< label
class = { ` toolbar-button ${
visibility !== 'public' && ! sensitive ? 'show-field' : ''
} ` }
title = { ` Visibility: ${ visibility } ` }
>
< Icon icon = { visibilityIconsMap [ visibility ] } alt = { visibility } / >
< select
name = "visibility"
value = { visibility }
onChange = { ( e ) => {
setVisibility ( e . target . value ) ;
} }
2022-12-12 16:54:31 +03:00
disabled = { uiState === 'loading' || ! ! editStatus }
2022-12-10 12:14:48 +03:00
>
< option value = "public" >
Public < Icon icon = "earth" / >
< / option >
< option value = "unlisted" > Unlisted < / option >
< option value = "private" > Followers only < / option >
< option value = "direct" > Direct < / option >
< / select >
< / label > { ' ' }
< / div >
< text - expander ref = { textExpanderRef } keys = "@ #" >
< textarea
class = "large"
ref = { textareaRef }
placeholder = {
2022-12-12 20:14:52 +03:00
replyToStatus
? 'Post your reply'
: editStatus
? 'Edit your status'
: 'What are you doing?'
2022-12-10 12:14:48 +03:00
}
required = { mediaAttachments . length === 0 }
autoCapitalize = "sentences"
autoComplete = "on"
autoCorrect = "on"
spellCheck = "true"
dir = "auto"
rows = "6"
cols = "50"
name = "status"
disabled = { uiState === 'loading' }
onInput = { ( e ) => {
const { scrollHeight , offsetHeight , clientHeight , value } =
e . target ;
const offset = offsetHeight - clientHeight ;
e . target . style . height = value
? scrollHeight + offset + 'px'
: null ;
} }
style = { {
maxHeight : ` ${ maxCharacters / 50 } em ` ,
} }
> < / textarea >
< / t e x t - e x p a n d e r >
{ mediaAttachments . length > 0 && (
< div class = "media-attachments" >
{ mediaAttachments . map ( ( attachment , i ) => {
2022-12-12 11:27:44 +03:00
const { id } = attachment ;
2022-12-10 12:14:48 +03:00
return (
2022-12-12 11:27:44 +03:00
< MediaAttachment
key = { i + id }
attachment = { attachment }
disabled = { uiState === 'loading' }
onDescriptionChange = { ( value ) => {
setMediaAttachments ( ( attachments ) => {
const newAttachments = [ ... attachments ] ;
newAttachments [ i ] . description = value ;
return newAttachments ;
} ) ;
} }
onRemove = { ( ) => {
setMediaAttachments ( ( attachments ) => {
return attachments . filter ( ( _ , j ) => j !== i ) ;
} ) ;
} }
/ >
2022-12-10 12:14:48 +03:00
) ;
} ) }
< / div >
) }
2022-12-14 16:48:17 +03:00
{ ! ! poll && (
< Poll
maxOptions = { maxOptions }
maxExpiration = { maxExpiration }
minExpiration = { minExpiration }
maxCharactersPerOption = { maxCharactersPerOption }
poll = { poll }
disabled = { uiState === 'loading' }
onInput = { ( poll ) => {
if ( poll ) {
const newPoll = { ... poll } ;
setPoll ( newPoll ) ;
} else {
setPoll ( null ) ;
}
} }
/ >
) }
2022-12-10 12:14:48 +03:00
< div class = "toolbar" >
2022-12-14 16:48:17 +03:00
< label class = "toolbar-button" >
< input
type = "file"
accept = { supportedMimeTypes . join ( ',' ) }
multiple = { mediaAttachments . length < maxMediaAttachments - 1 }
disabled = {
uiState === 'loading' ||
mediaAttachments . length >= maxMediaAttachments ||
! ! poll
}
onChange = { ( e ) => {
const files = e . target . files ;
if ( ! files ) return ;
const mediaFiles = Array . from ( files ) . map ( ( file ) => ( {
file ,
type : file . type ,
size : file . size ,
url : URL . createObjectURL ( file ) ,
id : null , // indicate uploaded state
description : null ,
} ) ) ;
console . log ( 'MEDIA ATTACHMENTS' , files , mediaFiles ) ;
// Validate max media attachments
if (
mediaAttachments . length + mediaFiles . length >
maxMediaAttachments
) {
alert (
` You can only attach up to ${ maxMediaAttachments } files. ` ,
) ;
} else {
setMediaAttachments ( ( attachments ) => {
return attachments . concat ( mediaFiles ) ;
} ) ;
2022-12-10 12:14:48 +03:00
}
2022-12-14 16:48:17 +03:00
} }
/ >
< Icon icon = "attachment" / >
< / label > { ' ' }
< button
type = "button"
class = "toolbar-button"
disabled = {
uiState === 'loading' || ! ! poll || ! ! mediaAttachments . length
}
onClick = { ( ) => {
setPoll ( {
options : [ '' , '' ] ,
expiresIn : 24 * 60 * 60 , // 1 day
multiple : false ,
} ) ;
} }
>
< Icon icon = "poll" alt = "Add poll" / >
< / button > { ' ' }
< div class = "spacer" / >
{ uiState === 'loading' && < Loader abrupt / > } { ' ' }
< button type = "submit" class = "large" disabled = { uiState === 'loading' } >
{ replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post' }
< / button >
2022-12-10 12:14:48 +03:00
< / div >
< / form >
< / div >
) ;
2022-12-13 19:39:35 +03:00
}
2022-12-12 11:27:44 +03:00
function MediaAttachment ( {
attachment ,
disabled ,
onDescriptionChange = ( ) => { } ,
onRemove = ( ) => { } ,
} ) {
2022-12-12 16:54:31 +03:00
const { url , type , id , description } = attachment ;
2022-12-12 11:27:44 +03:00
const suffixType = type . split ( '/' ) [ 0 ] ;
return (
< div class = "media-attachment" >
< div class = "media-preview" >
{ suffixType === 'image' ? (
< img src = { url } alt = "" / >
2022-12-12 19:23:37 +03:00
) : suffixType === 'video' || suffixType === 'gifv' ? (
2022-12-12 11:27:44 +03:00
< video src = { url } playsinline muted / >
) : suffixType === 'audio' ? (
< audio src = { url } controls / >
) : null }
< / div >
{ ! ! id ? (
< div class = "media-desc" >
< span class = "tag" > Uploaded < / span >
2022-12-12 16:54:31 +03:00
< p title = { description } > { description || < i > No description < / i > } < / p >
2022-12-12 11:27:44 +03:00
< / div >
) : (
< textarea
2022-12-12 16:54:31 +03:00
value = { description || '' }
2022-12-12 11:27:44 +03:00
placeholder = {
{
image : 'Image description' ,
video : 'Video description' ,
audio : 'Audio description' ,
} [ suffixType ]
}
autoCapitalize = "sentences"
autoComplete = "on"
autoCorrect = "on"
spellCheck = "true"
dir = "auto"
disabled = { disabled }
maxlength = "1500" // Not unicode-aware :(
// TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39
onInput = { ( e ) => {
const { value } = e . target ;
onDescriptionChange ( value ) ;
} }
> < / textarea >
) }
< div class = "media-aside" >
< button
type = "button"
class = "plain close-button"
disabled = { disabled }
onClick = { onRemove }
>
< Icon icon = "x" / >
< / button >
< / div >
< / div >
) ;
}
2022-12-13 19:39:35 +03:00
2022-12-14 16:48:17 +03:00
function Poll ( {
poll ,
disabled ,
onInput = ( ) => { } ,
maxOptions ,
maxExpiration ,
minExpiration ,
maxCharactersPerOption ,
} ) {
const { options , expiresIn , multiple } = poll ;
return (
< div class = { ` poll ${ multiple ? 'multiple' : '' } ` } >
< div class = "poll-choices" >
{ options . map ( ( option , i ) => (
< div class = "poll-choice" key = { i } >
< input
required
type = "text"
value = { option }
disabled = { disabled }
maxlength = { maxCharactersPerOption }
placeholder = { ` Choice ${ i + 1 } ` }
onInput = { ( e ) => {
const { value } = e . target ;
options [ i ] = value ;
onInput ( poll ) ;
} }
/ >
< button
type = "button"
class = "plain2 poll-button"
disabled = { disabled || options . length <= 1 }
onClick = { ( ) => {
options . splice ( i , 1 ) ;
onInput ( poll ) ;
} }
>
< Icon icon = "x" size = "s" / >
< / button >
< / div >
) ) }
< / div >
< div class = "poll-toolbar" >
< button
type = "button"
class = "plain2 poll-button"
disabled = { disabled || options . length >= maxOptions }
onClick = { ( ) => {
options . push ( '' ) ;
onInput ( poll ) ;
} }
>
+
< / button > { ' ' }
< label class = "multiple-choices" >
< input
type = "checkbox"
checked = { multiple }
disabled = { disabled }
onChange = { ( e ) => {
const { checked } = e . target ;
poll . multiple = checked ;
onInput ( poll ) ;
} }
/ > { ' ' }
Multiple choices
< / label >
< label class = "expires-in" >
Duration { ' ' }
< select
value = { expiresIn }
disabled = { disabled }
onChange = { ( e ) => {
const { value } = e . target ;
poll . expiresIn = value ;
onInput ( poll ) ;
} }
>
{ Object . entries ( expiryOptions )
. filter ( ( [ label , value ] ) => {
return value >= minExpiration && value <= maxExpiration ;
} )
. map ( ( [ label , value ] ) => (
< option value = { value } key = { value } >
{ label }
< / option >
) ) }
< / select >
< / label >
< / div >
< div class = "poll-toolbar" >
< button
type = "button"
class = "plain remove-poll-button"
disabled = { disabled }
onClick = { ( ) => {
onInput ( null ) ;
} }
>
Remove poll
< / button >
< / div >
< / div >
) ;
}
2022-12-13 19:39:35 +03:00
export default Compose ;