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-13 15:42:09 +03:00
export default ( {
onClose ,
replyToStatus ,
editStatus ,
draftStatus ,
standalone ,
} ) => {
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-13 15:42:09 +03:00
const [ visibility , setVisibility ] = useState ( 'public' ) ;
const [ sensitive , setSensitive ] = useState ( false ) ;
2022-12-10 12:14:48 +03:00
const spoilerTextRef = useRef ( ) ;
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-13 15:42:09 +03:00
textareaRef . current . focus ( ) ;
if ( replyToStatus . account . id !== currentAccount ) {
textareaRef . current . value = ` @ ${ replyToStatus . account . acct } ` ;
}
2022-12-10 12:14:48 +03:00
}
2022-12-13 15:42:09 +03:00
setVisibility ( visibility ) ;
setSensitive ( sensitive ) ;
}
if ( draftStatus ) {
const { status , spoilerText , visibility , sensitive , mediaAttachments } =
draftStatus ;
textareaRef . current . value = status ;
spoilerTextRef . current . value = spoilerText ;
setVisibility ( visibility ) ;
setSensitive ( sensitive ) ;
setMediaAttachments ( mediaAttachments ) ;
} else if ( editStatus ) {
2022-12-12 16:54:31 +03:00
const { visibility , sensitive , mediaAttachments } = editStatus ;
setUIState ( 'loading' ) ;
( async ( ) => {
try {
const statusSource = await masto . statuses . fetchSource ( editStatus . id ) ;
console . log ( { statusSource } ) ;
const { text , spoilerText } = statusSource ;
textareaRef . current . value = text ;
textareaRef . current . dataset . source = text ;
spoilerTextRef . current . value = spoilerText ;
setVisibility ( visibility ) ;
setSensitive ( sensitive ) ;
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 ) => {
console . log ( 'text-expander-change' , e ) ;
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 [ mediaAttachments , setMediaAttachments ] = useState ( [ ] ) ;
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 16:54:16 +03:00
// check for status and media attachments with IDs
const hasIDMediaAttachments =
2022-12-12 16:54:31 +03:00
mediaAttachments . length > 0 &&
2022-12-13 16:54:16 +03:00
mediaAttachments . every ( ( media ) => media . id ) ;
if ( ! value && hasIDMediaAttachments ) {
console . log ( 'canClose' , { value , mediaAttachments } ) ;
return true ;
}
2022-12-12 16:54:31 +03:00
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-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 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 ,
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 >
) : (
< 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 ;
}
}
if ( ! window . opener ) {
alert ( 'Looks like you closed the parent window.' ) ;
return ;
}
const mediaAttachmentsWithIDs = mediaAttachments . filter (
( media ) => media . id ,
) ;
2022-12-13 16:54:16 +03:00
onClose ( {
fn : ( ) => {
window . opener . _ _STATES _ _ . showCompose = {
editStatus ,
replyToStatus ,
draftStatus : {
status : textareaRef . current . value ,
spoilerText : spoilerTextRef . current . value ,
visibility ,
sensitive ,
mediaAttachments : mediaAttachmentsWithIDs ,
} ,
} ;
} ,
2022-12-13 15:42:09 +03:00
} ) ;
} }
>
< Icon icon = "popin" alt = "Pop in" / >
< / button >
) }
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 }
< / 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 ;
}
// 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-12 11:27:44 +03:00
const { file , description , sourceDescription , id } =
attachment ;
console . log ( 'UPLOADING' , attachment ) ;
if ( id ) {
// If already uploaded
return attachment ;
} else {
const params = {
file ,
description ,
} ;
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-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 >
) }
< div class = "toolbar" >
< div >
< label class = "toolbar-button" >
< input
type = "file"
accept = { supportedMimeTypes . join ( ',' ) }
multiple = { mediaAttachments . length < maxMediaAttachments - 1 }
disabled = {
uiState === 'loading' ||
mediaAttachments . length >= maxMediaAttachments
}
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 ) ;
} ) ;
}
} }
/ >
< Icon icon = "attachment" / >
< / label >
< / div >
< div >
2022-12-12 16:54:31 +03:00
{ uiState === 'loading' && < Loader abrupt / > } { ' ' }
2022-12-10 12:14:48 +03:00
< button
type = "submit"
class = "large"
disabled = { uiState === 'loading' }
>
2022-12-12 20:14:52 +03:00
{ replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post' }
2022-12-10 12:14:48 +03:00
< / button >
< / div >
< / div >
< / form >
< / div >
) ;
} ;
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 >
) ;
}