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
*/
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;
};
function Compose({
onClose,
replyToStatus,
editStatus,
draftStatus,
standalone,
hasOpener,
}) {
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 spoilerTextRef = useRef();
const [visibility, setVisibility] = useState('public');
const [sensitive, setSensitive] = useState(false);
const [mediaAttachments, setMediaAttachments] = useState([]);
const [poll, setPoll] = useState(null);
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,
poll,
mediaAttachments,
} = draftStatus;
const composablePoll = !!poll?.options && {
...poll,
options: poll.options.map((o) => o?.title || o),
expiresIn: poll?.expiresIn || expiresInFromExpiresAt(poll.expiresAt),
};
textareaRef.current.value = status;
textareaRef.current.dispatchEvent(new Event('input'));
spoilerTextRef.current.value = spoilerText;
setVisibility(visibility);
setSensitive(sensitive);
setPoll(composablePoll);
setMediaAttachments(mediaAttachments);
} else if (editStatus) {
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),
};
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.dispatchEvent(new Event('input'));
textareaRef.current.dataset.source = text;
spoilerTextRef.current.value = spoilerText;
setVisibility(visibility);
setSensitive(sensitive);
setPoll(composablePoll);
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
@${acct}
`;
} else {
item.dataset.value = name;
item.innerHTML = `
#${name}
`;
}
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 = () => {
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.length > 0 &&
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,
poll,
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 (