mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-12-21 04:12:17 +03:00
761 lines
24 KiB
JavaScript
761 lines
24 KiB
JavaScript
import './compose.css';
|
|
|
|
import '@github/text-expander-element';
|
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
import stringLength from 'string-length';
|
|
|
|
import emojifyText from '../utils/emojify-text';
|
|
import openCompose from '../utils/open-compose';
|
|
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';
|
|
|
|
/* NOTES:
|
|
- Max character limit includes BOTH status text and Content Warning text
|
|
*/
|
|
|
|
export default ({
|
|
onClose,
|
|
replyToStatus,
|
|
editStatus,
|
|
draftStatus,
|
|
standalone,
|
|
}) => {
|
|
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();
|
|
|
|
const [visibility, setVisibility] = useState('public');
|
|
const [sensitive, setSensitive] = useState(false);
|
|
const spoilerTextRef = useRef();
|
|
|
|
useEffect(() => {
|
|
if (replyToStatus) {
|
|
const { spoilerText, visibility, sensitive } = replyToStatus;
|
|
if (spoilerText && spoilerTextRef.current) {
|
|
spoilerTextRef.current.value = spoilerText;
|
|
spoilerTextRef.current.focus();
|
|
} else {
|
|
textareaRef.current.focus();
|
|
if (replyToStatus.account.id !== currentAccount) {
|
|
textareaRef.current.value = `@${replyToStatus.account.acct} `;
|
|
}
|
|
}
|
|
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) {
|
|
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');
|
|
}
|
|
})();
|
|
}
|
|
}, [draftStatus, editStatus, replyToStatus]);
|
|
|
|
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 = () => {
|
|
const { value, dataset } = textareaRef.current;
|
|
|
|
// check for status and media attachments
|
|
const hasMediaAttachments = mediaAttachments.length > 0;
|
|
if (!value && !hasMediaAttachments) {
|
|
console.log('canClose', { value, mediaAttachments });
|
|
return true;
|
|
}
|
|
|
|
// check if all media attachments have IDs
|
|
const hasIDMediaAttachments = mediaAttachments.every((media) => media.id);
|
|
if (hasIDMediaAttachments) {
|
|
console.log('canClose', { hasIDMediaAttachments });
|
|
return true;
|
|
}
|
|
|
|
// check if status contains only "@acct", if replying
|
|
const isSelf = replyToStatus?.account.id === currentAccount;
|
|
const hasOnlyAcct =
|
|
replyToStatus && value.trim() === `@${replyToStatus.account.acct}`;
|
|
if (!isSelf && hasOnlyAcct) {
|
|
console.log('canClose', { isSelf, hasOnlyAcct });
|
|
return true;
|
|
}
|
|
|
|
// check if status is same with source
|
|
const sameWithSource = value === dataset?.source;
|
|
if (sameWithSource) {
|
|
console.log('canClose', { sameWithSource });
|
|
return true;
|
|
}
|
|
|
|
console.log('canClose', {
|
|
value,
|
|
hasMediaAttachments,
|
|
hasIDMediaAttachments,
|
|
isSelf,
|
|
hasOnlyAcct,
|
|
sameWithSource,
|
|
});
|
|
|
|
return false;
|
|
};
|
|
|
|
const confirmClose = () => {
|
|
if (!canClose()) {
|
|
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 (
|
|
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
|
<div class="compose-top">
|
|
{currentAccountInfo?.avatarStatic && (
|
|
<Avatar
|
|
url={currentAccountInfo.avatarStatic}
|
|
size="l"
|
|
alt={currentAccountInfo.username}
|
|
/>
|
|
)}
|
|
{!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,
|
|
);
|
|
|
|
const newWin = openCompose({
|
|
editStatus,
|
|
replyToStatus,
|
|
draftStatus: {
|
|
status: textareaRef.current.value,
|
|
spoilerText: spoilerTextRef.current.value,
|
|
visibility,
|
|
sensitive,
|
|
mediaAttachments: mediaAttachmentsWithIDs,
|
|
},
|
|
});
|
|
|
|
if (!newWin) {
|
|
alert('Looks like your browser is blocking popups.');
|
|
return;
|
|
}
|
|
|
|
onClose();
|
|
}}
|
|
>
|
|
<Icon icon="popout" alt="Pop out" />
|
|
</button>{' '}
|
|
<button
|
|
type="button"
|
|
class="light close-button"
|
|
onClick={() => {
|
|
if (confirmClose()) {
|
|
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,
|
|
);
|
|
|
|
onClose({
|
|
fn: () => {
|
|
window.opener.__STATES__.showCompose = {
|
|
editStatus,
|
|
replyToStatus,
|
|
draftStatus: {
|
|
status: textareaRef.current.value,
|
|
spoilerText: spoilerTextRef.current.value,
|
|
visibility,
|
|
sensitive,
|
|
mediaAttachments: mediaAttachmentsWithIDs,
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<Icon icon="popin" alt="Pop in" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!!replyToStatus && (
|
|
<div class="status-preview">
|
|
<Status status={replyToStatus} size="s" />
|
|
<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>
|
|
</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
|
|
if (stringLength(status) > maxCharacters) {
|
|
alert(`Status is too long! Max characters: ${maxCharacters}`);
|
|
return;
|
|
}
|
|
if (
|
|
sensitive &&
|
|
stringLength(status) + stringLength(spoilerText) > maxCharacters
|
|
) {
|
|
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) => {
|
|
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;
|
|
});
|
|
}
|
|
});
|
|
const results = await Promise.allSettled(mediaPromises);
|
|
|
|
// If any failed, return
|
|
if (
|
|
results.some((result) => {
|
|
return result.status === 'rejected' || !result.value?.id;
|
|
})
|
|
) {
|
|
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,
|
|
sensitive,
|
|
mediaIds: mediaAttachments.map((attachment) => attachment.id),
|
|
};
|
|
if (!editStatus) {
|
|
params.visibility = visibility;
|
|
params.inReplyToId = replyToStatus?.id || undefined;
|
|
}
|
|
console.log('POST', params);
|
|
|
|
let newStatus;
|
|
if (editStatus) {
|
|
newStatus = await masto.statuses.update(editStatus.id, params);
|
|
} else {
|
|
newStatus = await masto.statuses.create(params);
|
|
}
|
|
setUIState('default');
|
|
|
|
// Close
|
|
onClose({
|
|
newStatus,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert(e?.reason || e);
|
|
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"
|
|
checked={sensitive}
|
|
disabled={uiState === 'loading' || !!editStatus}
|
|
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);
|
|
}}
|
|
disabled={uiState === 'loading' || !!editStatus}
|
|
>
|
|
<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={
|
|
replyToStatus
|
|
? 'Post your reply'
|
|
: editStatus
|
|
? 'Edit your status'
|
|
: 'What are you doing?'
|
|
}
|
|
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>
|
|
</text-expander>
|
|
{mediaAttachments.length > 0 && (
|
|
<div class="media-attachments">
|
|
{mediaAttachments.map((attachment, i) => {
|
|
const { id } = attachment;
|
|
return (
|
|
<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);
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</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>
|
|
{uiState === 'loading' && <Loader abrupt />}{' '}
|
|
<button
|
|
type="submit"
|
|
class="large"
|
|
disabled={uiState === 'loading'}
|
|
>
|
|
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function MediaAttachment({
|
|
attachment,
|
|
disabled,
|
|
onDescriptionChange = () => {},
|
|
onRemove = () => {},
|
|
}) {
|
|
const { url, type, id, description } = attachment;
|
|
const suffixType = type.split('/')[0];
|
|
return (
|
|
<div class="media-attachment">
|
|
<div class="media-preview">
|
|
{suffixType === 'image' ? (
|
|
<img src={url} alt="" />
|
|
) : suffixType === 'video' || suffixType === 'gifv' ? (
|
|
<video src={url} playsinline muted />
|
|
) : suffixType === 'audio' ? (
|
|
<audio src={url} controls />
|
|
) : null}
|
|
</div>
|
|
{!!id ? (
|
|
<div class="media-desc">
|
|
<span class="tag">Uploaded</span>
|
|
<p title={description}>{description || <i>No description</i>}</p>
|
|
</div>
|
|
) : (
|
|
<textarea
|
|
value={description || ''}
|
|
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>
|
|
);
|
|
}
|