mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-12-19 04:12:13 +03:00
3400 lines
109 KiB
JavaScript
3400 lines
109 KiB
JavaScript
import './compose.css';
|
||
|
||
import '@github/text-expander-element';
|
||
import { MenuItem } from '@szhsin/react-menu';
|
||
import { deepEqual } from 'fast-equals';
|
||
import Fuse from 'fuse.js';
|
||
import { memo } from 'preact/compat';
|
||
import { forwardRef } from 'preact/compat';
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'preact/hooks';
|
||
import { useHotkeys } from 'react-hotkeys-hook';
|
||
import stringLength from 'string-length';
|
||
import { detectAll } from 'tinyld/light';
|
||
import { uid } from 'uid/single';
|
||
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
|
||
import { useSnapshot } from 'valtio';
|
||
|
||
import poweredByGiphyURL from '../assets/powered-by-giphy.svg';
|
||
|
||
import Menu2 from '../components/menu2';
|
||
import supportedLanguages from '../data/status-supported-languages';
|
||
import urlRegex from '../data/url-regex';
|
||
import { api } from '../utils/api';
|
||
import db from '../utils/db';
|
||
import emojifyText from '../utils/emojify-text';
|
||
import localeMatch from '../utils/locale-match';
|
||
import localeCode2Text from '../utils/localeCode2Text';
|
||
import openCompose from '../utils/open-compose';
|
||
import pmem from '../utils/pmem';
|
||
import { fetchRelationships } from '../utils/relationships';
|
||
import shortenNumber from '../utils/shorten-number';
|
||
import showToast from '../utils/show-toast';
|
||
import states, { saveStatus } from '../utils/states';
|
||
import store from '../utils/store';
|
||
import {
|
||
getCurrentAccount,
|
||
getCurrentAccountNS,
|
||
getCurrentInstance,
|
||
getCurrentInstanceConfiguration,
|
||
} from '../utils/store-utils';
|
||
import supports from '../utils/supports';
|
||
import useCloseWatcher from '../utils/useCloseWatcher';
|
||
import useInterval from '../utils/useInterval';
|
||
import visibilityIconsMap from '../utils/visibility-icons-map';
|
||
|
||
import AccountBlock from './account-block';
|
||
// import Avatar from './avatar';
|
||
import Icon from './icon';
|
||
import Loader from './loader';
|
||
import Modal from './modal';
|
||
import Status from './status';
|
||
|
||
const {
|
||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||
} = import.meta.env;
|
||
|
||
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
|
||
const [code, common, native] = l;
|
||
acc[code] = {
|
||
common,
|
||
native,
|
||
};
|
||
return acc;
|
||
}, {});
|
||
|
||
/* 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,
|
||
'12 hours': 12 * 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;
|
||
};
|
||
|
||
const menu = document.createElement('ul');
|
||
menu.role = 'listbox';
|
||
menu.className = 'text-expander-menu';
|
||
|
||
// Set IntersectionObserver on menu, reposition it because text-expander doesn't handle it
|
||
const windowMargin = 16;
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
const { left, width } = entry.boundingClientRect;
|
||
const { innerWidth } = window;
|
||
if (left + width > innerWidth) {
|
||
menu.style.left = innerWidth - width - windowMargin + 'px';
|
||
}
|
||
}
|
||
});
|
||
});
|
||
observer.observe(menu);
|
||
|
||
const DEFAULT_LANG = localeMatch(
|
||
[new Intl.DateTimeFormat().resolvedOptions().locale, ...navigator.languages],
|
||
supportedLanguages.map((l) => l[0]),
|
||
'en',
|
||
);
|
||
|
||
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
|
||
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
|
||
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
|
||
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
|
||
function countableText(inputText) {
|
||
return inputText
|
||
.replace(urlRegexObj, urlPlaceholder)
|
||
.replace(usernameRegex, '$1@$3');
|
||
}
|
||
|
||
// https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69
|
||
const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i;
|
||
const MENTION_RE = new RegExp(
|
||
`(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`,
|
||
'uig',
|
||
);
|
||
|
||
// AI-generated, all other regexes are too complicated
|
||
const HASHTAG_RE = new RegExp(
|
||
`(^|[^=\\/\\w])(#[a-z0-9_]+([a-z0-9_.]+[a-z0-9_]+)?)(?![\\/\\w])`,
|
||
'ig',
|
||
);
|
||
|
||
// https://github.com/mastodon/mastodon/blob/23e32a4b3031d1da8b911e0145d61b4dd47c4f96/app/models/custom_emoji.rb#L31
|
||
const SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}';
|
||
const SCAN_RE = new RegExp(
|
||
`(^|[^=\\/\\w])(:${SHORTCODE_RE_FRAGMENT}:)(?=[^A-Za-z0-9_:]|$)`,
|
||
'g',
|
||
);
|
||
|
||
const segmenter = new Intl.Segmenter();
|
||
function highlightText(text, { maxCharacters = Infinity }) {
|
||
// Accept text string, return formatted HTML string
|
||
// Escape all HTML special characters
|
||
let html = text
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
// Exceeded characters limit
|
||
const { composerCharacterCount } = states;
|
||
if (composerCharacterCount > maxCharacters) {
|
||
// Highlight exceeded characters
|
||
let withinLimitHTML = '',
|
||
exceedLimitHTML = '';
|
||
const htmlSegments = segmenter.segment(html);
|
||
for (const { segment, index } of htmlSegments) {
|
||
if (index < maxCharacters) {
|
||
withinLimitHTML += segment;
|
||
} else {
|
||
exceedLimitHTML += segment;
|
||
}
|
||
}
|
||
if (exceedLimitHTML) {
|
||
exceedLimitHTML =
|
||
'<mark class="compose-highlight-exceeded">' +
|
||
exceedLimitHTML +
|
||
'</mark>';
|
||
}
|
||
return withinLimitHTML + exceedLimitHTML;
|
||
}
|
||
|
||
return html
|
||
.replace(urlRegexObj, '$2<mark class="compose-highlight-url">$3</mark>') // URLs
|
||
.replace(MENTION_RE, '$1<mark class="compose-highlight-mention">$2</mark>') // Mentions
|
||
.replace(HASHTAG_RE, '$1<mark class="compose-highlight-hashtag">$2</mark>') // Hashtags
|
||
.replace(
|
||
SCAN_RE,
|
||
'$1<mark class="compose-highlight-emoji-shortcode">$2</mark>',
|
||
); // Emoji shortcodes
|
||
}
|
||
|
||
const rtf = new Intl.RelativeTimeFormat();
|
||
|
||
const CUSTOM_EMOJIS_COUNT = 100;
|
||
|
||
function Compose({
|
||
onClose,
|
||
replyToStatus,
|
||
editStatus,
|
||
draftStatus,
|
||
standalone,
|
||
hasOpener,
|
||
}) {
|
||
console.warn('RENDER COMPOSER');
|
||
const { masto, instance } = api();
|
||
const [uiState, setUIState] = useState('default');
|
||
const UID = useRef(draftStatus?.uid || uid());
|
||
console.log('Compose UID', UID.current);
|
||
|
||
const currentAccount = getCurrentAccount();
|
||
const currentAccountInfo = currentAccount.info;
|
||
|
||
const configuration = getCurrentInstanceConfiguration();
|
||
console.log('⚙️ Configuration', configuration);
|
||
|
||
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 [language, setLanguage] = useState(
|
||
store.session.get('currentLanguage') || DEFAULT_LANG,
|
||
);
|
||
const prevLanguage = useRef(language);
|
||
const [mediaAttachments, setMediaAttachments] = useState([]);
|
||
const [poll, setPoll] = useState(null);
|
||
|
||
const prefs = store.account.get('preferences') || {};
|
||
|
||
const oninputTextarea = () => {
|
||
if (!textareaRef.current) return;
|
||
textareaRef.current.dispatchEvent(new Event('input'));
|
||
};
|
||
const focusTextarea = () => {
|
||
setTimeout(() => {
|
||
if (!textareaRef.current) return;
|
||
// status starts with newline, focus on first position
|
||
if (draftStatus?.status?.startsWith?.('\n')) {
|
||
textareaRef.current.selectionStart = 0;
|
||
textareaRef.current.selectionEnd = 0;
|
||
}
|
||
console.debug('FOCUS textarea');
|
||
textareaRef.current?.focus();
|
||
}, 300);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (replyToStatus) {
|
||
const { spoilerText, visibility, language, sensitive } = replyToStatus;
|
||
if (spoilerText && spoilerTextRef.current) {
|
||
spoilerTextRef.current.value = spoilerText;
|
||
}
|
||
const mentions = new Set([
|
||
replyToStatus.account.acct,
|
||
...replyToStatus.mentions.map((m) => m.acct),
|
||
]);
|
||
const allMentions = [...mentions].filter(
|
||
(m) => m !== currentAccountInfo.acct,
|
||
);
|
||
if (allMentions.length > 0) {
|
||
textareaRef.current.value = `${allMentions
|
||
.map((m) => `@${m}`)
|
||
.join(' ')} `;
|
||
oninputTextarea();
|
||
}
|
||
focusTextarea();
|
||
setVisibility(
|
||
visibility === 'public' && prefs['posting:default:visibility']
|
||
? prefs['posting:default:visibility']
|
||
: visibility,
|
||
);
|
||
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
|
||
setSensitive(sensitive && !!spoilerText);
|
||
} else if (editStatus) {
|
||
const { visibility, language, 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.v1.statuses
|
||
.$select(editStatus.id)
|
||
.source.fetch();
|
||
console.log({ statusSource });
|
||
const { text, spoilerText } = statusSource;
|
||
textareaRef.current.value = text;
|
||
textareaRef.current.dataset.source = text;
|
||
oninputTextarea();
|
||
focusTextarea();
|
||
spoilerTextRef.current.value = spoilerText;
|
||
setVisibility(visibility);
|
||
setLanguage(language || presf.postingDefaultLanguage || DEFAULT_LANG);
|
||
setSensitive(sensitive);
|
||
if (composablePoll) setPoll(composablePoll);
|
||
setMediaAttachments(mediaAttachments);
|
||
setUIState('default');
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert(e?.reason || e);
|
||
setUIState('error');
|
||
}
|
||
})();
|
||
} else {
|
||
focusTextarea();
|
||
console.log('Apply prefs', prefs);
|
||
if (prefs['posting:default:visibility']) {
|
||
setVisibility(prefs['posting:default:visibility']);
|
||
}
|
||
if (prefs['posting:default:language']) {
|
||
setLanguage(prefs['posting:default:language']);
|
||
}
|
||
if (prefs['posting:default:sensitive']) {
|
||
setSensitive(prefs['posting:default:sensitive']);
|
||
}
|
||
}
|
||
if (draftStatus) {
|
||
const {
|
||
status,
|
||
spoilerText,
|
||
visibility,
|
||
language,
|
||
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;
|
||
oninputTextarea();
|
||
focusTextarea();
|
||
if (spoilerText) spoilerTextRef.current.value = spoilerText;
|
||
if (visibility) setVisibility(visibility);
|
||
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
|
||
if (sensitive !== null) setSensitive(sensitive);
|
||
if (composablePoll) setPoll(composablePoll);
|
||
if (mediaAttachments) setMediaAttachments(mediaAttachments);
|
||
}
|
||
}, [draftStatus, editStatus, replyToStatus]);
|
||
|
||
const formRef = useRef();
|
||
|
||
const beforeUnloadCopy = 'You have unsaved changes. Discard this post?';
|
||
const canClose = () => {
|
||
const { value, dataset } = textareaRef.current;
|
||
|
||
// check if loading
|
||
if (uiState === 'loading') {
|
||
console.log('canClose', { uiState });
|
||
return false;
|
||
}
|
||
|
||
// check for status and media attachments
|
||
const hasValue = (value || '')
|
||
.trim()
|
||
.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, '');
|
||
const hasMediaAttachments = mediaAttachments.length > 0;
|
||
if (!hasValue && !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 === currentAccountInfo.id;
|
||
const hasOnlyAcct =
|
||
replyToStatus && value.trim() === `@${replyToStatus.account.acct}`;
|
||
// TODO: check for mentions, or maybe just generic "@username<space>", including multiple mentions like "@username1<space>@username2<space>"
|
||
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,
|
||
uiState,
|
||
});
|
||
|
||
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,
|
||
});
|
||
}, []);
|
||
|
||
const getCharCount = () => {
|
||
const { value } = textareaRef.current;
|
||
const { value: spoilerText } = spoilerTextRef.current;
|
||
return stringLength(countableText(value)) + stringLength(spoilerText);
|
||
};
|
||
const updateCharCount = () => {
|
||
const count = getCharCount();
|
||
states.composerCharacterCount = count;
|
||
};
|
||
useEffect(updateCharCount, []);
|
||
|
||
const supportsCloseWatcher = window.CloseWatcher;
|
||
const escDownRef = useRef(false);
|
||
useHotkeys(
|
||
'esc',
|
||
() => {
|
||
escDownRef.current = true;
|
||
// This won't be true if this event is already handled and not propagated 🤞
|
||
},
|
||
{
|
||
enabled: !supportsCloseWatcher,
|
||
enableOnFormTags: true,
|
||
},
|
||
);
|
||
useHotkeys(
|
||
'esc',
|
||
() => {
|
||
if (!standalone && escDownRef.current && confirmClose()) {
|
||
onClose();
|
||
}
|
||
escDownRef.current = false;
|
||
},
|
||
{
|
||
enabled: !supportsCloseWatcher,
|
||
enableOnFormTags: true,
|
||
// Use keyup because Esc keydown will close the confirm dialog on Safari
|
||
keyup: true,
|
||
ignoreEventWhen: (e) => {
|
||
const modals = document.querySelectorAll('#modal-container > *');
|
||
const hasModal = !!modals;
|
||
const hasOnlyComposer =
|
||
modals.length === 1 && modals[0].querySelector('#compose-container');
|
||
return hasModal && !hasOnlyComposer;
|
||
},
|
||
},
|
||
);
|
||
useCloseWatcher(() => {
|
||
if (!standalone && confirmClose()) {
|
||
onClose();
|
||
}
|
||
}, [standalone, confirmClose, onClose]);
|
||
|
||
const prevBackgroundDraft = useRef({});
|
||
const draftKey = () => {
|
||
const ns = getCurrentAccountNS();
|
||
return `${ns}#${UID.current}`;
|
||
};
|
||
const saveUnsavedDraft = () => {
|
||
// Not enabling this for editing status
|
||
// I don't think this warrant a draft mode for a status that's already posted
|
||
// Maybe it could be a big edit change but it should be rare
|
||
if (editStatus) return;
|
||
if (states.composerState.minimized) return;
|
||
const key = draftKey();
|
||
const backgroundDraft = {
|
||
key,
|
||
replyTo: replyToStatus
|
||
? {
|
||
/* Smaller payload of replyToStatus. Reasons:
|
||
- No point storing whole thing
|
||
- Could have media attachments
|
||
- Could be deleted/edited later
|
||
*/
|
||
id: replyToStatus.id,
|
||
account: {
|
||
id: replyToStatus.account.id,
|
||
username: replyToStatus.account.username,
|
||
acct: replyToStatus.account.acct,
|
||
},
|
||
}
|
||
: null,
|
||
draftStatus: {
|
||
uid: UID.current,
|
||
status: textareaRef.current.value,
|
||
spoilerText: spoilerTextRef.current.value,
|
||
visibility,
|
||
language,
|
||
sensitive,
|
||
poll,
|
||
mediaAttachments,
|
||
},
|
||
};
|
||
if (
|
||
!deepEqual(backgroundDraft, prevBackgroundDraft.current) &&
|
||
!canClose()
|
||
) {
|
||
console.debug('not equal', backgroundDraft, prevBackgroundDraft.current);
|
||
db.drafts
|
||
.set(key, {
|
||
...backgroundDraft,
|
||
state: 'unsaved',
|
||
updatedAt: Date.now(),
|
||
})
|
||
.then(() => {
|
||
console.debug('DRAFT saved', key, backgroundDraft);
|
||
})
|
||
.catch((e) => {
|
||
console.error('DRAFT failed', key, e);
|
||
});
|
||
prevBackgroundDraft.current = structuredClone(backgroundDraft);
|
||
}
|
||
};
|
||
useInterval(saveUnsavedDraft, 5000); // background save every 5s
|
||
useEffect(() => {
|
||
saveUnsavedDraft();
|
||
// If unmounted, means user discarded the draft
|
||
// Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft
|
||
return () => {
|
||
db.drafts.del(draftKey());
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handleItems = (e) => {
|
||
const { items } = e.clipboardData || e.dataTransfer;
|
||
const files = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
if (item.kind === 'file') {
|
||
const file = item.getAsFile();
|
||
if (file && supportedMimeTypes.includes(file.type)) {
|
||
files.push(file);
|
||
}
|
||
}
|
||
}
|
||
if (files.length > 0 && mediaAttachments.length >= maxMediaAttachments) {
|
||
alert(`You can only attach up to ${maxMediaAttachments} files.`);
|
||
return;
|
||
}
|
||
console.log({ files });
|
||
if (files.length > 0) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// Auto-cut-off files to avoid exceeding maxMediaAttachments
|
||
const max = maxMediaAttachments - mediaAttachments.length;
|
||
const allowedFiles = files.slice(0, max);
|
||
if (allowedFiles.length <= 0) {
|
||
alert(`You can only attach up to ${maxMediaAttachments} files.`);
|
||
return;
|
||
}
|
||
const mediaFiles = allowedFiles.map((file) => ({
|
||
file,
|
||
type: file.type,
|
||
size: file.size,
|
||
url: URL.createObjectURL(file),
|
||
id: null,
|
||
description: null,
|
||
}));
|
||
setMediaAttachments([...mediaAttachments, ...mediaFiles]);
|
||
}
|
||
};
|
||
window.addEventListener('paste', handleItems);
|
||
const handleDragover = (e) => {
|
||
// Prevent default if there's files
|
||
if (e.dataTransfer.items.length > 0) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
};
|
||
window.addEventListener('dragover', handleDragover);
|
||
window.addEventListener('drop', handleItems);
|
||
return () => {
|
||
window.removeEventListener('paste', handleItems);
|
||
window.removeEventListener('dragover', handleDragover);
|
||
window.removeEventListener('drop', handleItems);
|
||
};
|
||
}, [mediaAttachments]);
|
||
|
||
const [showMentionPicker, setShowMentionPicker] = useState(false);
|
||
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
||
const [showGIFPicker, setShowGIFPicker] = useState(false);
|
||
|
||
const [autoDetectedLanguages, setAutoDetectedLanguages] = useState(null);
|
||
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
|
||
const topLanguages = [];
|
||
const restLanguages = [];
|
||
const { contentTranslationHideLanguages = [] } = states.settings;
|
||
supportedLanguages.forEach((l) => {
|
||
const [code] = l;
|
||
if (
|
||
code === language ||
|
||
code === prevLanguage.current ||
|
||
code === DEFAULT_LANG ||
|
||
contentTranslationHideLanguages.includes(code) ||
|
||
(autoDetectedLanguages?.length && autoDetectedLanguages.includes(code))
|
||
) {
|
||
topLanguages.push(l);
|
||
} else {
|
||
restLanguages.push(l);
|
||
}
|
||
});
|
||
topLanguages.sort(([codeA, commonA], [codeB, commonB]) => {
|
||
if (codeA === language) return -1;
|
||
if (codeB === language) return 1;
|
||
return commonA.localeCompare(commonB);
|
||
});
|
||
restLanguages.sort(([codeA, commonA], [codeB, commonB]) =>
|
||
commonA.localeCompare(commonB),
|
||
);
|
||
return [topLanguages, restLanguages];
|
||
}, [language, autoDetectedLanguages]);
|
||
|
||
const replyToStatusMonthsAgo = useMemo(
|
||
() =>
|
||
!!replyToStatus?.createdAt &&
|
||
Math.floor(
|
||
(Date.now() - new Date(replyToStatus.createdAt)) /
|
||
(1000 * 60 * 60 * 24 * 30),
|
||
),
|
||
[replyToStatus],
|
||
);
|
||
|
||
const onMinimize = () => {
|
||
saveUnsavedDraft();
|
||
states.composerState.minimized = true;
|
||
};
|
||
|
||
return (
|
||
<div id="compose-container-outer">
|
||
<div id="compose-container" class={standalone ? 'standalone' : ''}>
|
||
<div class="compose-top">
|
||
{currentAccountInfo?.avatarStatic && (
|
||
// <Avatar
|
||
// url={currentAccountInfo.avatarStatic}
|
||
// size="xl"
|
||
// alt={currentAccountInfo.username}
|
||
// squircle={currentAccountInfo?.bot}
|
||
// />
|
||
<AccountBlock
|
||
account={currentAccountInfo}
|
||
accountInstance={currentAccount.instanceURL}
|
||
hideDisplayName
|
||
useAvatarStatic
|
||
/>
|
||
)}
|
||
{!standalone ? (
|
||
<span class="compose-controls">
|
||
<button
|
||
type="button"
|
||
class="plain4 pop-button"
|
||
disabled={uiState === 'loading'}
|
||
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: {
|
||
uid: UID.current,
|
||
status: textareaRef.current.value,
|
||
spoilerText: spoilerTextRef.current.value,
|
||
visibility,
|
||
language,
|
||
sensitive,
|
||
poll,
|
||
mediaAttachments,
|
||
},
|
||
});
|
||
|
||
if (!newWin) {
|
||
return;
|
||
}
|
||
|
||
onClose();
|
||
}}
|
||
>
|
||
<Icon icon="popout" alt="Pop out" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="plain4 min-button"
|
||
onClick={onMinimize}
|
||
>
|
||
<Icon icon="minimize" alt="Minimize" />
|
||
</button>{' '}
|
||
<button
|
||
type="button"
|
||
class="light close-button"
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
if (confirmClose()) {
|
||
onClose();
|
||
}
|
||
}}
|
||
>
|
||
<Icon icon="x" />
|
||
</button>
|
||
</span>
|
||
) : (
|
||
hasOpener && (
|
||
<button
|
||
type="button"
|
||
class="light pop-button"
|
||
disabled={uiState === 'loading'}
|
||
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;
|
||
}
|
||
|
||
if (window.opener.__STATES__.showCompose) {
|
||
if (window.opener.__STATES__.composerState?.publishing) {
|
||
alert(
|
||
'Looks like you already have a compose field open in the parent window and currently publishing. Please wait for it to be done and try again later.',
|
||
);
|
||
return;
|
||
}
|
||
|
||
let confirmText =
|
||
'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?';
|
||
const yes = confirm(confirmText);
|
||
if (!yes) return;
|
||
}
|
||
|
||
// const mediaAttachmentsWithIDs = mediaAttachments.filter(
|
||
// (media) => media.id,
|
||
// );
|
||
|
||
onClose({
|
||
fn: () => {
|
||
const passData = {
|
||
editStatus,
|
||
replyToStatus,
|
||
draftStatus: {
|
||
uid: UID.current,
|
||
status: textareaRef.current.value,
|
||
spoilerText: spoilerTextRef.current.value,
|
||
visibility,
|
||
language,
|
||
sensitive,
|
||
poll,
|
||
mediaAttachments,
|
||
},
|
||
};
|
||
window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again
|
||
if (window.opener.__STATES__.showCompose) {
|
||
window.opener.__STATES__.showCompose = false;
|
||
setTimeout(() => {
|
||
window.opener.__STATES__.showCompose = true;
|
||
}, 10);
|
||
} else {
|
||
window.opener.__STATES__.showCompose = true;
|
||
}
|
||
if (window.opener.__STATES__.composerState.minimized) {
|
||
// Maximize it
|
||
window.opener.__STATES__.composerState.minimized = false;
|
||
}
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<Icon icon="popin" alt="Pop in" />
|
||
</button>
|
||
)
|
||
)}
|
||
</div>
|
||
{!!replyToStatus && (
|
||
<div class="status-preview">
|
||
<Status status={replyToStatus} size="s" previewMode />
|
||
<div class="status-preview-legend reply-to">
|
||
Replying to @
|
||
{replyToStatus.account.acct || replyToStatus.account.username}
|
||
’s post
|
||
{replyToStatusMonthsAgo >= 3 && (
|
||
<>
|
||
{' '}
|
||
(
|
||
<strong>
|
||
{rtf.format(-replyToStatusMonthsAgo, 'month')}
|
||
</strong>
|
||
)
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!!editStatus && (
|
||
<div class="status-preview">
|
||
<Status status={editStatus} size="s" previewMode />
|
||
<div class="status-preview-legend">Editing source post</div>
|
||
</div>
|
||
)}
|
||
<form
|
||
ref={formRef}
|
||
class={`form-visibility-${visibility}`}
|
||
style={{
|
||
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
|
||
opacity: uiState === 'loading' ? 0.5 : 1,
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||
formRef.current.dispatchEvent(
|
||
new Event('submit', { cancelable: true }),
|
||
);
|
||
}
|
||
}}
|
||
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
|
||
/* Let the backend validate this
|
||
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;
|
||
}
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
|
||
|
||
if (mediaAttachments.length > 0) {
|
||
// If there are media attachments, check if they have no descriptions
|
||
const hasNoDescriptions = mediaAttachments.some(
|
||
(media) => !media.description?.trim?.(),
|
||
);
|
||
if (hasNoDescriptions) {
|
||
const yes = confirm(
|
||
'Some media have no descriptions. Continue?',
|
||
);
|
||
if (!yes) return;
|
||
}
|
||
}
|
||
|
||
// Post-cleanup
|
||
spoilerText = (sensitive && spoilerText) || undefined;
|
||
status = status === '' ? undefined : status;
|
||
|
||
// states.composerState.minimized = true;
|
||
states.composerState.publishing = true;
|
||
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, id } = attachment;
|
||
console.log('UPLOADING', attachment);
|
||
if (id) {
|
||
// If already uploaded
|
||
return attachment;
|
||
} else {
|
||
const params = removeNullUndefined({
|
||
file,
|
||
description,
|
||
});
|
||
return masto.v2.media.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;
|
||
})
|
||
) {
|
||
states.composerState.publishing = false;
|
||
states.composerState.publishingError = true;
|
||
setUIState('error');
|
||
// Alert all the reasons
|
||
results.forEach((result) => {
|
||
if (result.status === 'rejected') {
|
||
console.error(result);
|
||
alert(result.reason || `Attachment #${i} failed`);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
console.log({ results, mediaAttachments });
|
||
}
|
||
|
||
/* NOTE:
|
||
Using snakecase here because masto.js's `isObject` returns false for `params`, ONLY happens when opening in pop-out window. This is maybe due to `window.masto` variable being passed from the parent window. The check that failed is `x.constructor === Object`, so maybe the `Object` in new window is different than parent window's?
|
||
Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2
|
||
|
||
// TODO: Note above is no longer true in Masto.js v6. Revisit this.
|
||
*/
|
||
let params = {
|
||
status,
|
||
// spoilerText,
|
||
spoiler_text: spoilerText,
|
||
language,
|
||
sensitive,
|
||
poll,
|
||
// mediaIds: mediaAttachments.map((attachment) => attachment.id),
|
||
media_ids: mediaAttachments.map(
|
||
(attachment) => attachment.id,
|
||
),
|
||
};
|
||
if (editStatus && supports('@mastodon/edit-media-attributes')) {
|
||
params.media_attributes = mediaAttachments.map(
|
||
(attachment) => {
|
||
return {
|
||
id: attachment.id,
|
||
description: attachment.description,
|
||
// focus
|
||
// thumbnail
|
||
};
|
||
},
|
||
);
|
||
} else if (!editStatus) {
|
||
params.visibility = visibility;
|
||
// params.inReplyToId = replyToStatus?.id || undefined;
|
||
params.in_reply_to_id = replyToStatus?.id || undefined;
|
||
}
|
||
params = removeNullUndefined(params);
|
||
console.log('POST', params);
|
||
|
||
let newStatus;
|
||
if (editStatus) {
|
||
newStatus = await masto.v1.statuses
|
||
.$select(editStatus.id)
|
||
.update(params);
|
||
saveStatus(newStatus, instance, {
|
||
skipThreading: true,
|
||
});
|
||
} else {
|
||
try {
|
||
newStatus = await masto.v1.statuses.create(params, {
|
||
requestInit: {
|
||
headers: {
|
||
'Idempotency-Key': UID.current,
|
||
},
|
||
},
|
||
});
|
||
} catch (_) {
|
||
// If idempotency key fails, try again without it
|
||
newStatus = await masto.v1.statuses.create(params);
|
||
}
|
||
}
|
||
states.composerState.minimized = false;
|
||
states.composerState.publishing = false;
|
||
setUIState('default');
|
||
|
||
// Close
|
||
onClose({
|
||
// type: post, reply, edit
|
||
type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
|
||
newStatus,
|
||
instance,
|
||
});
|
||
} catch (e) {
|
||
states.composerState.publishing = false;
|
||
states.composerState.publishingError = true;
|
||
console.error(e);
|
||
alert(e?.reason || e);
|
||
setUIState('error');
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
<div class="toolbar stretch">
|
||
<input
|
||
ref={spoilerTextRef}
|
||
type="text"
|
||
name="spoilerText"
|
||
placeholder="Content warning"
|
||
disabled={uiState === 'loading'}
|
||
class="spoiler-text-field"
|
||
lang={language}
|
||
spellCheck="true"
|
||
dir="auto"
|
||
style={{
|
||
opacity: sensitive ? 1 : 0,
|
||
pointerEvents: sensitive ? 'auto' : 'none',
|
||
}}
|
||
onInput={() => {
|
||
updateCharCount();
|
||
}}
|
||
/>
|
||
<label
|
||
class={`toolbar-button ${sensitive ? 'highlight' : ''}`}
|
||
title="Content warning or sensitive media"
|
||
>
|
||
<input
|
||
name="sensitive"
|
||
type="checkbox"
|
||
checked={sensitive}
|
||
disabled={uiState === 'loading'}
|
||
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' : ''
|
||
} ${visibility !== 'public' ? 'highlight' : ''}`}
|
||
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">Private mention</option>
|
||
</select>
|
||
</label>{' '}
|
||
</div>
|
||
<Textarea
|
||
ref={textareaRef}
|
||
placeholder={
|
||
replyToStatus
|
||
? 'Post your reply'
|
||
: editStatus
|
||
? 'Edit your post'
|
||
: 'What are you doing?'
|
||
}
|
||
required={mediaAttachments?.length === 0}
|
||
disabled={uiState === 'loading'}
|
||
lang={language}
|
||
onInput={() => {
|
||
updateCharCount();
|
||
}}
|
||
maxCharacters={maxCharacters}
|
||
performSearch={(params) => {
|
||
const { type, q, limit } = params;
|
||
if (type === 'accounts') {
|
||
return masto.v1.accounts.search.list({
|
||
q,
|
||
limit,
|
||
resolve: false,
|
||
});
|
||
}
|
||
return masto.v2.search.fetch(params);
|
||
}}
|
||
onTrigger={(action) => {
|
||
if (action?.name === 'custom-emojis') {
|
||
setShowEmoji2Picker({
|
||
defaultSearchTerm: action?.defaultSearchTerm || null,
|
||
});
|
||
} else if (action?.name === 'mention') {
|
||
setShowMentionPicker({
|
||
defaultSearchTerm: action?.defaultSearchTerm || null,
|
||
});
|
||
} else if (
|
||
action?.name === 'auto-detect-language' &&
|
||
action?.languages
|
||
) {
|
||
setAutoDetectedLanguages(action.languages);
|
||
}
|
||
}}
|
||
/>
|
||
{mediaAttachments?.length > 0 && (
|
||
<div class="media-attachments">
|
||
{mediaAttachments.map((attachment, i) => {
|
||
const { id, file } = attachment;
|
||
const fileID = file?.size + file?.type + file?.name;
|
||
return (
|
||
<MediaAttachment
|
||
key={id || fileID || i}
|
||
attachment={attachment}
|
||
disabled={uiState === 'loading'}
|
||
lang={language}
|
||
onDescriptionChange={(value) => {
|
||
setMediaAttachments((attachments) => {
|
||
const newAttachments = [...attachments];
|
||
newAttachments[i].description = value;
|
||
return newAttachments;
|
||
});
|
||
}}
|
||
onRemove={() => {
|
||
setMediaAttachments((attachments) => {
|
||
return attachments.filter((_, j) => j !== i);
|
||
});
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
<label class="media-sensitive">
|
||
<input
|
||
name="sensitive"
|
||
type="checkbox"
|
||
checked={sensitive}
|
||
disabled={uiState === 'loading'}
|
||
onChange={(e) => {
|
||
const sensitive = e.target.checked;
|
||
setSensitive(sensitive);
|
||
}}
|
||
/>{' '}
|
||
<span>Mark media as sensitive</span>{' '}
|
||
<Icon icon={`eye-${sensitive ? 'close' : 'open'}`} />
|
||
</label>
|
||
</div>
|
||
)}
|
||
{!!poll && (
|
||
<Poll
|
||
lang={language}
|
||
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);
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
<div
|
||
class="toolbar wrap"
|
||
style={{
|
||
justifyContent: 'flex-end',
|
||
}}
|
||
>
|
||
<span>
|
||
<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);
|
||
});
|
||
}
|
||
// Reset
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
<Icon icon="attachment" />
|
||
</label>
|
||
{/* If maxOptions is not defined or defined and is greater than 1, show poll button */}
|
||
{maxOptions == null ||
|
||
(maxOptions > 1 && (
|
||
<>
|
||
<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>
|
||
</>
|
||
))}
|
||
{/* <button
|
||
type="button"
|
||
class="toolbar-button"
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
setShowMentionPicker(true);
|
||
}}
|
||
>
|
||
<Icon icon="at" />
|
||
</button> */}
|
||
<button
|
||
type="button"
|
||
class="toolbar-button"
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
setShowEmoji2Picker(true);
|
||
}}
|
||
>
|
||
<Icon icon="emoji2" />
|
||
</button>
|
||
{!!states.settings.composerGIFPicker && (
|
||
<button
|
||
type="button"
|
||
class="toolbar-button gif-picker-button"
|
||
disabled={
|
||
uiState === 'loading' ||
|
||
mediaAttachments.length >= maxMediaAttachments ||
|
||
!!poll
|
||
}
|
||
onClick={() => {
|
||
setShowGIFPicker(true);
|
||
}}
|
||
>
|
||
<span>GIF</span>
|
||
</button>
|
||
)}
|
||
</span>
|
||
<div class="spacer" />
|
||
{uiState === 'loading' ? (
|
||
<Loader abrupt />
|
||
) : (
|
||
<CharCountMeter
|
||
maxCharacters={maxCharacters}
|
||
hidden={uiState === 'loading'}
|
||
/>
|
||
)}
|
||
<label
|
||
class={`toolbar-button ${
|
||
language !== prevLanguage.current ||
|
||
(autoDetectedLanguages?.length &&
|
||
!autoDetectedLanguages.includes(language))
|
||
? 'highlight'
|
||
: ''
|
||
}`}
|
||
>
|
||
<span class="icon-text">
|
||
{supportedLanguagesMap[language]?.native}
|
||
</span>
|
||
<select
|
||
name="language"
|
||
value={language}
|
||
onChange={(e) => {
|
||
const { value } = e.target;
|
||
setLanguage(value || DEFAULT_LANG);
|
||
store.session.set('currentLanguage', value || DEFAULT_LANG);
|
||
}}
|
||
disabled={uiState === 'loading'}
|
||
>
|
||
{topSupportedLanguages.map(([code, common, native]) => (
|
||
<option value={code} key={code}>
|
||
{common} ({native})
|
||
</option>
|
||
))}
|
||
<hr />
|
||
{restSupportedLanguages.map(([code, common, native]) => (
|
||
<option value={code} key={code}>
|
||
{common} ({native})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>{' '}
|
||
<button
|
||
type="submit"
|
||
class="large"
|
||
disabled={uiState === 'loading'}
|
||
>
|
||
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
{showMentionPicker && (
|
||
<Modal
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
setShowMentionPicker(false);
|
||
}
|
||
}}
|
||
>
|
||
<MentionModal
|
||
masto={masto}
|
||
instance={instance}
|
||
onClose={() => {
|
||
setShowMentionPicker(false);
|
||
}}
|
||
defaultSearchTerm={showMentionPicker?.defaultSearchTerm}
|
||
onSelect={(socialAddress) => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
const { selectionStart, selectionEnd } = textarea;
|
||
const text = textarea.value;
|
||
const textBeforeMention = text.slice(0, selectionStart);
|
||
const spaceBeforeMention = textBeforeMention
|
||
? /[\s\t\n\r]$/.test(textBeforeMention)
|
||
? ''
|
||
: ' '
|
||
: '';
|
||
const textAfterMention = text.slice(selectionEnd);
|
||
const spaceAfterMention = /^[\s\t\n\r]/.test(textAfterMention)
|
||
? ''
|
||
: ' ';
|
||
const newText =
|
||
textBeforeMention +
|
||
spaceBeforeMention +
|
||
'@' +
|
||
socialAddress +
|
||
spaceAfterMention +
|
||
textAfterMention;
|
||
textarea.value = newText;
|
||
textarea.selectionStart = textarea.selectionEnd =
|
||
selectionEnd +
|
||
1 +
|
||
socialAddress.length +
|
||
spaceAfterMention.length;
|
||
textarea.focus();
|
||
textarea.dispatchEvent(new Event('input'));
|
||
}}
|
||
/>
|
||
</Modal>
|
||
)}
|
||
{showEmoji2Picker && (
|
||
<Modal
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
setShowEmoji2Picker(false);
|
||
}
|
||
}}
|
||
>
|
||
<CustomEmojisModal
|
||
masto={masto}
|
||
instance={instance}
|
||
onClose={() => {
|
||
setShowEmoji2Picker(false);
|
||
}}
|
||
defaultSearchTerm={showEmoji2Picker?.defaultSearchTerm}
|
||
onSelect={(emojiShortcode) => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
const { selectionStart, selectionEnd } = textarea;
|
||
const text = textarea.value;
|
||
const textBeforeEmoji = text.slice(0, selectionStart);
|
||
const spaceBeforeEmoji = textBeforeEmoji
|
||
? /[\s\t\n\r]$/.test(textBeforeEmoji)
|
||
? ''
|
||
: ' '
|
||
: '';
|
||
const textAfterEmoji = text.slice(selectionEnd);
|
||
const spaceAfterEmoji = /^[\s\t\n\r]/.test(textAfterEmoji)
|
||
? ''
|
||
: ' ';
|
||
const newText =
|
||
textBeforeEmoji +
|
||
spaceBeforeEmoji +
|
||
emojiShortcode +
|
||
spaceAfterEmoji +
|
||
textAfterEmoji;
|
||
textarea.value = newText;
|
||
textarea.selectionStart = textarea.selectionEnd =
|
||
selectionEnd + emojiShortcode.length + spaceAfterEmoji.length;
|
||
textarea.focus();
|
||
textarea.dispatchEvent(new Event('input'));
|
||
}}
|
||
/>
|
||
</Modal>
|
||
)}
|
||
{showGIFPicker && (
|
||
<Modal
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
setShowGIFPicker(false);
|
||
}
|
||
}}
|
||
>
|
||
<GIFPickerModal
|
||
onClose={() => setShowGIFPicker(false)}
|
||
onSelect={({ url, type, alt_text }) => {
|
||
console.log('GIF URL', url);
|
||
if (mediaAttachments.length >= maxMediaAttachments) {
|
||
alert(
|
||
`You can only attach up to ${maxMediaAttachments} files.`,
|
||
);
|
||
return;
|
||
}
|
||
// Download the GIF and insert it as media attachment
|
||
(async () => {
|
||
let theToast;
|
||
try {
|
||
theToast = showToast({
|
||
text: 'Downloading GIF…',
|
||
duration: -1,
|
||
});
|
||
const blob = await fetch(url, {
|
||
referrerPolicy: 'no-referrer',
|
||
}).then((res) => res.blob());
|
||
const file = new File(
|
||
[blob],
|
||
type === 'video/mp4' ? 'video.mp4' : 'image.gif',
|
||
{
|
||
type,
|
||
},
|
||
);
|
||
const newMediaAttachments = [
|
||
...mediaAttachments,
|
||
{
|
||
file,
|
||
type,
|
||
size: file.size,
|
||
id: null,
|
||
description: alt_text || '',
|
||
},
|
||
];
|
||
setMediaAttachments(newMediaAttachments);
|
||
theToast?.hideToast?.();
|
||
} catch (err) {
|
||
console.error(err);
|
||
theToast?.hideToast?.();
|
||
showToast('Failed to download GIF');
|
||
}
|
||
})();
|
||
}}
|
||
/>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function autoResizeTextarea(textarea) {
|
||
if (!textarea) return;
|
||
const { value, offsetHeight, scrollHeight, clientHeight } = textarea;
|
||
if (offsetHeight < window.innerHeight) {
|
||
// NOTE: This check is needed because the offsetHeight return 50000 (really large number) on first render
|
||
// No idea why it does that, will re-investigate in far future
|
||
const offset = offsetHeight - clientHeight;
|
||
const height = value ? scrollHeight + offset + 'px' : null;
|
||
textarea.style.height = height;
|
||
}
|
||
}
|
||
|
||
async function _getCustomEmojis(instance, masto) {
|
||
const emojis = await masto.v1.customEmojis.list();
|
||
const visibleEmojis = emojis.filter((e) => e.visibleInPicker);
|
||
const searcher = new Fuse(visibleEmojis, {
|
||
keys: ['shortcode'],
|
||
findAllMatches: true,
|
||
});
|
||
return [visibleEmojis, searcher];
|
||
}
|
||
const getCustomEmojis = pmem(_getCustomEmojis, {
|
||
// Limit by time to reduce memory usage
|
||
// Cached by instance
|
||
matchesArg: (cacheKeyArg, keyArg) => cacheKeyArg.instance === keyArg.instance,
|
||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||
});
|
||
|
||
const detectLangs = (text) => {
|
||
const langs = detectAll(text);
|
||
if (langs?.length) {
|
||
// return max 2
|
||
return langs.slice(0, 2).map((lang) => lang.lang);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const Textarea = forwardRef((props, ref) => {
|
||
const { masto, instance } = api();
|
||
const [text, setText] = useState(ref.current?.value || '');
|
||
const {
|
||
maxCharacters,
|
||
performSearch = () => {},
|
||
onTrigger = () => {},
|
||
...textareaProps
|
||
} = props;
|
||
// const snapStates = useSnapshot(states);
|
||
// const charCount = snapStates.composerCharacterCount;
|
||
|
||
// const customEmojis = useRef();
|
||
const searcherRef = useRef();
|
||
useEffect(() => {
|
||
getCustomEmojis(instance, masto)
|
||
.then((r) => {
|
||
const [emojis, searcher] = r;
|
||
searcherRef.current = searcher;
|
||
})
|
||
.catch((e) => {
|
||
console.error(e);
|
||
});
|
||
}, []);
|
||
|
||
const textExpanderRef = useRef();
|
||
const textExpanderTextRef = useRef('');
|
||
useEffect(() => {
|
||
let handleChange, handleValue, handleCommited;
|
||
if (textExpanderRef.current) {
|
||
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;
|
||
}
|
||
|
||
if (key === ':') {
|
||
// const emojis = customEmojis.current.filter((emoji) =>
|
||
// emoji.shortcode.startsWith(text),
|
||
// );
|
||
// const emojis = filterShortcodes(customEmojis.current, text);
|
||
const results = searcherRef.current?.search(text, {
|
||
limit: 5,
|
||
});
|
||
let html = '';
|
||
results.forEach(({ item: emoji }) => {
|
||
const { shortcode, url } = emoji;
|
||
html += `
|
||
<li role="option" data-value="${encodeHTML(shortcode)}">
|
||
<img src="${encodeHTML(
|
||
url,
|
||
)}" width="16" height="16" alt="" loading="lazy" />
|
||
${encodeHTML(shortcode)}
|
||
</li>`;
|
||
});
|
||
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
|
||
// console.log({ emojis, html });
|
||
menu.innerHTML = html;
|
||
provide(
|
||
Promise.resolve({
|
||
matched: results.length > 0,
|
||
fragment: menu,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const type = {
|
||
'@': 'accounts',
|
||
'#': 'hashtags',
|
||
}[key];
|
||
provide(
|
||
new Promise((resolve) => {
|
||
const searchResults = performSearch({
|
||
type,
|
||
q: text,
|
||
limit: 5,
|
||
});
|
||
searchResults.then((value) => {
|
||
if (text !== textExpanderTextRef.current) {
|
||
return;
|
||
}
|
||
console.log({ value, type, v: value[type] });
|
||
const results = value[type] || value;
|
||
console.log('RESULTS', value, results);
|
||
let html = '';
|
||
results.forEach((result) => {
|
||
const {
|
||
name,
|
||
avatarStatic,
|
||
displayName,
|
||
username,
|
||
acct,
|
||
emojis,
|
||
history,
|
||
} = result;
|
||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||
// const item = menuItem.cloneNode();
|
||
if (acct) {
|
||
html += `
|
||
<li role="option" data-value="${encodeHTML(acct)}">
|
||
<span class="avatar">
|
||
<img src="${encodeHTML(
|
||
avatarStatic,
|
||
)}" width="16" height="16" alt="" loading="lazy" />
|
||
</span>
|
||
<span>
|
||
<b>${displayNameWithEmoji || username}</b>
|
||
<br>@${encodeHTML(acct)}
|
||
</span>
|
||
</li>
|
||
`;
|
||
} else {
|
||
const total = history?.reduce?.(
|
||
(acc, cur) => acc + +cur.uses,
|
||
0,
|
||
);
|
||
html += `
|
||
<li role="option" data-value="${encodeHTML(name)}">
|
||
<span class="grow">#<b>${encodeHTML(name)}</b></span>
|
||
${
|
||
total
|
||
? `<span class="count">${shortenNumber(total)}</span>`
|
||
: ''
|
||
}
|
||
</li>
|
||
`;
|
||
}
|
||
});
|
||
if (type === 'accounts') {
|
||
html += `<li role="option" data-value="" data-more="${text}">More…</li>`;
|
||
}
|
||
menu.innerHTML = html;
|
||
console.log('MENU', results, menu);
|
||
resolve({
|
||
matched: results.length > 0,
|
||
fragment: menu,
|
||
});
|
||
});
|
||
}),
|
||
);
|
||
};
|
||
|
||
textExpanderRef.current.addEventListener(
|
||
'text-expander-change',
|
||
handleChange,
|
||
);
|
||
|
||
handleValue = (e) => {
|
||
const { key, item } = e.detail;
|
||
const { value, more } = item.dataset;
|
||
if (key === ':') {
|
||
e.detail.value = value ? `:${value}:` : ''; // zero-width space
|
||
if (more) {
|
||
// Prevent adding space after the above value
|
||
e.detail.continue = true;
|
||
|
||
setTimeout(() => {
|
||
onTrigger?.({
|
||
name: 'custom-emojis',
|
||
defaultSearchTerm: more,
|
||
});
|
||
}, 300);
|
||
}
|
||
} else if (key === '@') {
|
||
e.detail.value = value ? `@${value} ` : ''; // zero-width space
|
||
if (more) {
|
||
e.detail.continue = true;
|
||
setTimeout(() => {
|
||
onTrigger?.({
|
||
name: 'mention',
|
||
defaultSearchTerm: more,
|
||
});
|
||
}, 300);
|
||
}
|
||
} else {
|
||
e.detail.value = `${key}${value}`;
|
||
}
|
||
};
|
||
|
||
textExpanderRef.current.addEventListener(
|
||
'text-expander-value',
|
||
handleValue,
|
||
);
|
||
|
||
handleCommited = (e) => {
|
||
const { input } = e.detail;
|
||
setText(input.value);
|
||
// fire input event
|
||
if (ref.current) {
|
||
const event = new Event('input', { bubbles: true });
|
||
ref.current.dispatchEvent(event);
|
||
}
|
||
};
|
||
|
||
textExpanderRef.current.addEventListener(
|
||
'text-expander-committed',
|
||
handleCommited,
|
||
);
|
||
}
|
||
|
||
return () => {
|
||
if (textExpanderRef.current) {
|
||
textExpanderRef.current.removeEventListener(
|
||
'text-expander-change',
|
||
handleChange,
|
||
);
|
||
textExpanderRef.current.removeEventListener(
|
||
'text-expander-value',
|
||
handleValue,
|
||
);
|
||
textExpanderRef.current.removeEventListener(
|
||
'text-expander-committed',
|
||
handleCommited,
|
||
);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Resize observer for textarea
|
||
const textarea = ref.current;
|
||
if (!textarea) return;
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
// Get height of textarea, set height to textExpander
|
||
if (textExpanderRef.current) {
|
||
const { height } = textarea.getBoundingClientRect();
|
||
textExpanderRef.current.style.height = height + 'px';
|
||
}
|
||
});
|
||
resizeObserver.observe(textarea);
|
||
}, []);
|
||
|
||
const slowHighlightPerf = useRef(0); // increment if slow
|
||
const composeHighlightRef = useRef();
|
||
const throttleHighlightText = useThrottledCallback((text) => {
|
||
if (!composeHighlightRef.current) return;
|
||
if (slowHighlightPerf.current > 3) {
|
||
// After 3 times of lag, disable highlighting
|
||
composeHighlightRef.current.innerHTML = '';
|
||
composeHighlightRef.current = null; // Destroy the whole thing
|
||
throttleHighlightText?.cancel?.();
|
||
return;
|
||
}
|
||
let start;
|
||
let end;
|
||
if (slowHighlightPerf.current <= 3) start = Date.now();
|
||
composeHighlightRef.current.innerHTML =
|
||
highlightText(text, {
|
||
maxCharacters,
|
||
}) + '\n';
|
||
if (slowHighlightPerf.current <= 3) end = Date.now();
|
||
console.debug('HIGHLIGHT PERF', { start, end, diff: end - start });
|
||
if (start && end && end - start > 50) {
|
||
// if slow, increment
|
||
slowHighlightPerf.current++;
|
||
}
|
||
// Newline to prevent multiple line breaks at the end from being collapsed, no idea why
|
||
}, 500);
|
||
|
||
const debouncedAutoDetectLanguage = useDebouncedCallback(() => {
|
||
// Make use of the highlightRef to get the DOM
|
||
// Clone the dom
|
||
const dom = composeHighlightRef.current?.cloneNode(true);
|
||
if (!dom) return;
|
||
// Remove mark
|
||
dom.querySelectorAll('mark').forEach((mark) => {
|
||
mark.remove();
|
||
});
|
||
const text = dom.innerText?.trim();
|
||
if (!text) return;
|
||
const langs = detectLangs(text);
|
||
if (langs?.length) {
|
||
onTrigger?.({
|
||
name: 'auto-detect-language',
|
||
languages: langs,
|
||
});
|
||
}
|
||
}, 2000);
|
||
|
||
return (
|
||
<text-expander
|
||
ref={textExpanderRef}
|
||
keys="@ # :"
|
||
class="compose-field-container"
|
||
>
|
||
<textarea
|
||
class="compose-field"
|
||
autoCapitalize="sentences"
|
||
autoComplete="on"
|
||
autoCorrect="on"
|
||
spellCheck="true"
|
||
dir="auto"
|
||
rows="6"
|
||
cols="50"
|
||
{...textareaProps}
|
||
ref={ref}
|
||
name="status"
|
||
value={text}
|
||
onKeyDown={(e) => {
|
||
// Get line before cursor position after pressing 'Enter'
|
||
const { key, target } = e;
|
||
if (key === 'Enter' && !(e.ctrlKey || e.metaKey)) {
|
||
try {
|
||
const { value, selectionStart } = target;
|
||
const textBeforeCursor = value.slice(0, selectionStart);
|
||
const lastLine = textBeforeCursor.split('\n').slice(-1)[0];
|
||
if (lastLine) {
|
||
// If line starts with "- " or "12. "
|
||
if (/^\s*(-|\d+\.)\s/.test(lastLine)) {
|
||
// insert "- " at cursor position
|
||
const [_, preSpaces, bullet, postSpaces, anything] =
|
||
lastLine.match(/^(\s*)(-|\d+\.)(\s+)(.+)?/) || [];
|
||
if (anything) {
|
||
e.preventDefault();
|
||
const [number] = bullet.match(/\d+/) || [];
|
||
const newBullet = number ? `${+number + 1}.` : '-';
|
||
const text = `\n${preSpaces}${newBullet}${postSpaces}`;
|
||
target.setRangeText(text, selectionStart, selectionStart);
|
||
const pos = selectionStart + text.length;
|
||
target.setSelectionRange(pos, pos);
|
||
} else {
|
||
// trim the line before the cursor, then insert new line
|
||
const pos = selectionStart - lastLine.length;
|
||
target.setRangeText('', pos, selectionStart);
|
||
}
|
||
autoResizeTextarea(target);
|
||
target.dispatchEvent(new Event('input'));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// silent fail
|
||
console.error(e);
|
||
}
|
||
}
|
||
if (composeHighlightRef.current) {
|
||
composeHighlightRef.current.scrollTop = target.scrollTop;
|
||
}
|
||
}}
|
||
onInput={(e) => {
|
||
const { target } = e;
|
||
// Replace zero-width space
|
||
const text = target.value.replace(/\u200b/g, '');
|
||
setText(text);
|
||
autoResizeTextarea(target);
|
||
props.onInput?.(e);
|
||
throttleHighlightText(text);
|
||
debouncedAutoDetectLanguage();
|
||
}}
|
||
style={{
|
||
width: '100%',
|
||
height: '4em',
|
||
// '--text-weight': (1 + charCount / 140).toFixed(1) || 1,
|
||
}}
|
||
onScroll={(e) => {
|
||
if (composeHighlightRef.current) {
|
||
const { scrollTop } = e.target;
|
||
composeHighlightRef.current.scrollTop = scrollTop;
|
||
}
|
||
}}
|
||
/>
|
||
<div
|
||
ref={composeHighlightRef}
|
||
class="compose-highlight"
|
||
aria-hidden="true"
|
||
/>
|
||
</text-expander>
|
||
);
|
||
});
|
||
|
||
function CharCountMeter({ maxCharacters = 500, hidden }) {
|
||
const snapStates = useSnapshot(states);
|
||
const charCount = snapStates.composerCharacterCount;
|
||
const leftChars = maxCharacters - charCount;
|
||
if (hidden) {
|
||
return <span class="char-counter" hidden />;
|
||
}
|
||
return (
|
||
<span
|
||
class="char-counter"
|
||
title={`${leftChars}/${maxCharacters}`}
|
||
style={{
|
||
'--percentage': (charCount / maxCharacters) * 100,
|
||
}}
|
||
>
|
||
<meter
|
||
class={`${
|
||
leftChars <= -10
|
||
? 'explode'
|
||
: leftChars <= 0
|
||
? 'danger'
|
||
: leftChars <= 20
|
||
? 'warning'
|
||
: ''
|
||
}`}
|
||
value={charCount}
|
||
max={maxCharacters}
|
||
/>
|
||
<span class="counter">{leftChars}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function prettyBytes(bytes) {
|
||
const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||
let unitIndex = 0;
|
||
while (bytes >= 1024) {
|
||
bytes /= 1024;
|
||
unitIndex++;
|
||
}
|
||
return `${bytes.toFixed(0).toLocaleString()} ${units[unitIndex]}`;
|
||
}
|
||
|
||
function scaleDimension(matrix, matrixLimit, width, height) {
|
||
// matrix = number of pixels
|
||
// matrixLimit = max number of pixels
|
||
// Calculate new width and height, downsize to within the limit, preserve aspect ratio, no decimals
|
||
const scalingFactor = Math.sqrt(matrixLimit / matrix);
|
||
const newWidth = Math.floor(width * scalingFactor);
|
||
const newHeight = Math.floor(height * scalingFactor);
|
||
return { newWidth, newHeight };
|
||
}
|
||
|
||
function MediaAttachment({
|
||
attachment,
|
||
disabled,
|
||
lang,
|
||
onDescriptionChange = () => {},
|
||
onRemove = () => {},
|
||
}) {
|
||
const [uiState, setUIState] = useState('default');
|
||
const supportsEdit = supports('@mastodon/edit-media-attributes');
|
||
const { type, id, file } = attachment;
|
||
const url = useMemo(
|
||
() => (file ? URL.createObjectURL(file) : attachment.url),
|
||
[file, attachment.url],
|
||
);
|
||
console.log({ attachment });
|
||
|
||
const checkMaxError = !!file?.size;
|
||
const configuration = checkMaxError ? getCurrentInstanceConfiguration() : {};
|
||
const {
|
||
mediaAttachments: {
|
||
imageSizeLimit,
|
||
imageMatrixLimit,
|
||
videoSizeLimit,
|
||
videoMatrixLimit,
|
||
videoFrameRateLimit,
|
||
} = {},
|
||
} = configuration || {};
|
||
|
||
const [maxError, setMaxError] = useState(() => {
|
||
if (!checkMaxError) return null;
|
||
if (
|
||
type.startsWith('image') &&
|
||
imageSizeLimit &&
|
||
file.size > imageSizeLimit
|
||
) {
|
||
return {
|
||
type: 'imageSizeLimit',
|
||
details: {
|
||
imageSize: file.size,
|
||
imageSizeLimit,
|
||
},
|
||
};
|
||
} else if (
|
||
type.startsWith('video') &&
|
||
videoSizeLimit &&
|
||
file.size > videoSizeLimit
|
||
) {
|
||
return {
|
||
type: 'videoSizeLimit',
|
||
details: {
|
||
videoSize: file.size,
|
||
videoSizeLimit,
|
||
},
|
||
};
|
||
}
|
||
return null;
|
||
});
|
||
|
||
const [imageMatrix, setImageMatrix] = useState({});
|
||
useEffect(() => {
|
||
if (!checkMaxError || !imageMatrixLimit) return;
|
||
if (imageMatrix?.matrix > imageMatrixLimit) {
|
||
setMaxError({
|
||
type: 'imageMatrixLimit',
|
||
details: {
|
||
imageMatrix: imageMatrix?.matrix,
|
||
imageMatrixLimit,
|
||
width: imageMatrix?.width,
|
||
height: imageMatrix?.height,
|
||
},
|
||
});
|
||
}
|
||
}, [imageMatrix, imageMatrixLimit, checkMaxError]);
|
||
|
||
const [videoMatrix, setVideoMatrix] = useState({});
|
||
useEffect(() => {
|
||
if (!checkMaxError || !videoMatrixLimit) return;
|
||
if (videoMatrix?.matrix > videoMatrixLimit) {
|
||
setMaxError({
|
||
type: 'videoMatrixLimit',
|
||
details: {
|
||
videoMatrix: videoMatrix?.matrix,
|
||
videoMatrixLimit,
|
||
width: videoMatrix?.width,
|
||
height: videoMatrix?.height,
|
||
},
|
||
});
|
||
}
|
||
}, [videoMatrix, videoMatrixLimit, checkMaxError]);
|
||
|
||
const [description, setDescription] = useState(attachment.description);
|
||
const [suffixType, subtype] = type.split('/');
|
||
const debouncedOnDescriptionChange = useDebouncedCallback(
|
||
onDescriptionChange,
|
||
250,
|
||
);
|
||
useEffect(() => {
|
||
debouncedOnDescriptionChange(description);
|
||
}, [description, debouncedOnDescriptionChange]);
|
||
|
||
const [showModal, setShowModal] = useState(false);
|
||
const textareaRef = useRef(null);
|
||
useEffect(() => {
|
||
let timer;
|
||
if (showModal && textareaRef.current) {
|
||
timer = setTimeout(() => {
|
||
textareaRef.current.focus();
|
||
}, 100);
|
||
}
|
||
return () => {
|
||
clearTimeout(timer);
|
||
};
|
||
}, [showModal]);
|
||
|
||
const descTextarea = (
|
||
<>
|
||
{!!id && !supportsEdit ? (
|
||
<div class="media-desc">
|
||
<span class="tag">Uploaded</span>
|
||
<p title={description}>
|
||
{attachment.description || <i>No description</i>}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={description || ''}
|
||
lang={lang}
|
||
placeholder={
|
||
{
|
||
image: 'Image description',
|
||
video: 'Video description',
|
||
audio: 'Audio description',
|
||
}[suffixType]
|
||
}
|
||
autoCapitalize="sentences"
|
||
autoComplete="on"
|
||
autoCorrect="on"
|
||
spellCheck="true"
|
||
dir="auto"
|
||
disabled={disabled || uiState === 'loading'}
|
||
class={uiState === 'loading' ? 'loading' : ''}
|
||
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;
|
||
setDescription(value);
|
||
// debouncedOnDescriptionChange(value);
|
||
}}
|
||
></textarea>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
const toastRef = useRef(null);
|
||
useEffect(() => {
|
||
return () => {
|
||
toastRef.current?.hideToast?.();
|
||
};
|
||
}, []);
|
||
|
||
const maxErrorToast = useRef(null);
|
||
|
||
const maxErrorText = (err) => {
|
||
const { type, details } = err;
|
||
switch (type) {
|
||
case 'imageSizeLimit': {
|
||
const { imageSize, imageSizeLimit } = details;
|
||
return `File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
|
||
imageSize,
|
||
)} to ${prettyBytes(imageSizeLimit)} or lower.`;
|
||
}
|
||
case 'imageMatrixLimit': {
|
||
const { imageMatrix, imageMatrixLimit, width, height } = details;
|
||
const { newWidth, newHeight } = scaleDimension(
|
||
imageMatrix,
|
||
imageMatrixLimit,
|
||
width,
|
||
height,
|
||
);
|
||
return `Dimension too large. Uploading might encounter issues. Try reduce dimension from ${width.toLocaleString()}×${height.toLocaleString()}px to ${newWidth.toLocaleString()}×${newHeight.toLocaleString()}px.`;
|
||
}
|
||
case 'videoSizeLimit': {
|
||
const { videoSize, videoSizeLimit } = details;
|
||
return `File size too large. Uploading might encounter issues. Try reduce the file size from ${prettyBytes(
|
||
videoSize,
|
||
)} to ${prettyBytes(videoSizeLimit)} or lower.`;
|
||
}
|
||
case 'videoMatrixLimit': {
|
||
const { videoMatrix, videoMatrixLimit, width, height } = details;
|
||
const { newWidth, newHeight } = scaleDimension(
|
||
videoMatrix,
|
||
videoMatrixLimit,
|
||
width,
|
||
height,
|
||
);
|
||
return `Dimension too large. Uploading might encounter issues. Try reduce dimension from ${width.toLocaleString()}×${height.toLocaleString()}px to ${newWidth.toLocaleString()}×${newHeight.toLocaleString()}px.`;
|
||
}
|
||
case 'videoFrameRateLimit': {
|
||
// Not possible to detect this on client-side for now
|
||
return 'Frame rate too high. Uploading might encounter issues.';
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div class="media-attachment">
|
||
<div
|
||
class="media-preview"
|
||
tabIndex="0"
|
||
onClick={() => {
|
||
setShowModal(true);
|
||
}}
|
||
>
|
||
{suffixType === 'image' ? (
|
||
<img
|
||
src={url}
|
||
alt=""
|
||
onLoad={(e) => {
|
||
if (!checkMaxError) return;
|
||
const { naturalWidth, naturalHeight } = e.target;
|
||
setImageMatrix({
|
||
matrix: naturalWidth * naturalHeight,
|
||
width: naturalWidth,
|
||
height: naturalHeight,
|
||
});
|
||
}}
|
||
/>
|
||
) : suffixType === 'video' || suffixType === 'gifv' ? (
|
||
<video
|
||
src={url + '#t=0.1'} // Make Safari show 1st-frame preview
|
||
playsinline
|
||
muted
|
||
disablePictureInPicture
|
||
preload="metadata"
|
||
onLoadedMetadata={(e) => {
|
||
if (!checkMaxError) return;
|
||
const { videoWidth, videoHeight } = e.target;
|
||
if (videoWidth && videoHeight) {
|
||
setVideoMatrix({
|
||
matrix: videoWidth * videoHeight,
|
||
width: videoWidth,
|
||
height: videoHeight,
|
||
});
|
||
}
|
||
}}
|
||
/>
|
||
) : suffixType === 'audio' ? (
|
||
<audio src={url} controls />
|
||
) : null}
|
||
</div>
|
||
{descTextarea}
|
||
<div class="media-aside">
|
||
<button
|
||
type="button"
|
||
class="plain close-button"
|
||
disabled={disabled}
|
||
onClick={onRemove}
|
||
>
|
||
<Icon icon="x" />
|
||
</button>
|
||
{!!maxError && (
|
||
<button
|
||
type="button"
|
||
class="media-error"
|
||
title={maxErrorText(maxError)}
|
||
onClick={() => {
|
||
if (maxErrorToast.current) {
|
||
maxErrorToast.current.hideToast();
|
||
}
|
||
maxErrorToast.current = showToast({
|
||
text: maxErrorText(maxError),
|
||
duration: 10_000,
|
||
});
|
||
}}
|
||
>
|
||
<Icon icon="alert" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{showModal && (
|
||
<Modal
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
setShowModal(false);
|
||
}
|
||
}}
|
||
>
|
||
<div id="media-sheet" class="sheet sheet-max">
|
||
<button
|
||
type="button"
|
||
class="sheet-close"
|
||
onClick={() => {
|
||
setShowModal(false);
|
||
}}
|
||
>
|
||
<Icon icon="x" />
|
||
</button>
|
||
<header>
|
||
<h2>
|
||
{
|
||
{
|
||
image: 'Edit image description',
|
||
video: 'Edit video description',
|
||
audio: 'Edit audio description',
|
||
}[suffixType]
|
||
}
|
||
</h2>
|
||
</header>
|
||
<main tabIndex="-1">
|
||
<div class="media-preview">
|
||
{suffixType === 'image' ? (
|
||
<img src={url} alt="" />
|
||
) : suffixType === 'video' || suffixType === 'gifv' ? (
|
||
<video src={url} playsinline controls />
|
||
) : suffixType === 'audio' ? (
|
||
<audio src={url} controls />
|
||
) : null}
|
||
</div>
|
||
<div class="media-form">
|
||
{descTextarea}
|
||
<footer>
|
||
{suffixType === 'image' &&
|
||
/^(png|jpe?g|gif|webp)$/i.test(subtype) &&
|
||
!!states.settings.mediaAltGenerator &&
|
||
!!IMG_ALT_API_URL && (
|
||
<Menu2
|
||
portal={{
|
||
target: document.body,
|
||
}}
|
||
containerProps={{
|
||
style: {
|
||
zIndex: 1001,
|
||
},
|
||
}}
|
||
align="center"
|
||
position="anchor"
|
||
overflow="auto"
|
||
menuButton={
|
||
<button type="button" title="More" class="plain">
|
||
<Icon icon="more" size="l" alt="More" />
|
||
</button>
|
||
}
|
||
>
|
||
<MenuItem
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
setUIState('loading');
|
||
toastRef.current = showToast({
|
||
text: 'Generating description. Please wait...',
|
||
duration: -1,
|
||
});
|
||
// POST with multipart
|
||
(async function () {
|
||
try {
|
||
const body = new FormData();
|
||
body.append('image', file);
|
||
const response = await fetch(IMG_ALT_API_URL, {
|
||
method: 'POST',
|
||
body,
|
||
}).then((r) => r.json());
|
||
if (response.error) {
|
||
throw new Error(response.error);
|
||
}
|
||
setDescription(response.description);
|
||
} catch (e) {
|
||
console.error(e);
|
||
showToast(
|
||
`Failed to generate description${
|
||
e?.message ? `: ${e.message}` : ''
|
||
}`,
|
||
);
|
||
} finally {
|
||
setUIState('default');
|
||
toastRef.current?.hideToast?.();
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
<Icon icon="sparkles2" />
|
||
{lang && lang !== 'en' ? (
|
||
<small>
|
||
Generate description…
|
||
<br />
|
||
(English)
|
||
</small>
|
||
) : (
|
||
<span>Generate description…</span>
|
||
)}
|
||
</MenuItem>
|
||
{!!lang && lang !== 'en' && (
|
||
<MenuItem
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
setUIState('loading');
|
||
toastRef.current = showToast({
|
||
text: 'Generating description. Please wait...',
|
||
duration: -1,
|
||
});
|
||
// POST with multipart
|
||
(async function () {
|
||
try {
|
||
const body = new FormData();
|
||
body.append('image', file);
|
||
const params = `?lang=${lang}`;
|
||
const response = await fetch(
|
||
IMG_ALT_API_URL + params,
|
||
{
|
||
method: 'POST',
|
||
body,
|
||
},
|
||
).then((r) => r.json());
|
||
if (response.error) {
|
||
throw new Error(response.error);
|
||
}
|
||
setDescription(response.description);
|
||
} catch (e) {
|
||
console.error(e);
|
||
showToast(
|
||
`Failed to generate description${
|
||
e?.message ? `: ${e.message}` : ''
|
||
}`,
|
||
);
|
||
} finally {
|
||
setUIState('default');
|
||
toastRef.current?.hideToast?.();
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
<Icon icon="sparkles2" />
|
||
<small>
|
||
Generate description…
|
||
<br />({localeCode2Text(lang)}){' '}
|
||
<span class="more-insignificant">
|
||
— experimental
|
||
</span>
|
||
</small>
|
||
</MenuItem>
|
||
)}
|
||
</Menu2>
|
||
)}
|
||
<button
|
||
type="button"
|
||
class="light block"
|
||
onClick={() => {
|
||
setShowModal(false);
|
||
}}
|
||
disabled={uiState === 'loading'}
|
||
>
|
||
Done
|
||
</button>
|
||
</footer>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Poll({
|
||
lang,
|
||
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}`}
|
||
lang={lang}
|
||
spellCheck="true"
|
||
dir="auto"
|
||
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>
|
||
);
|
||
}
|
||
|
||
function filterShortcodes(emojis, searchTerm) {
|
||
searchTerm = searchTerm.toLowerCase();
|
||
|
||
// Return an array of shortcodes that start with or contain the search term, sorted by relevance and limited to the first 5
|
||
return emojis
|
||
.sort((a, b) => {
|
||
let aLower = a.shortcode.toLowerCase();
|
||
let bLower = b.shortcode.toLowerCase();
|
||
|
||
let aStartsWith = aLower.startsWith(searchTerm);
|
||
let bStartsWith = bLower.startsWith(searchTerm);
|
||
let aContains = aLower.includes(searchTerm);
|
||
let bContains = bLower.includes(searchTerm);
|
||
let bothStartWith = aStartsWith && bStartsWith;
|
||
let bothContain = aContains && bContains;
|
||
|
||
return bothStartWith
|
||
? a.length - b.length
|
||
: aStartsWith
|
||
? -1
|
||
: bStartsWith
|
||
? 1
|
||
: bothContain
|
||
? a.length - b.length
|
||
: aContains
|
||
? -1
|
||
: bContains
|
||
? 1
|
||
: 0;
|
||
})
|
||
.slice(0, 5);
|
||
}
|
||
|
||
function encodeHTML(str) {
|
||
return str.replace(/[&<>"']/g, function (char) {
|
||
return '&#' + char.charCodeAt(0) + ';';
|
||
});
|
||
}
|
||
|
||
function removeNullUndefined(obj) {
|
||
for (let key in obj) {
|
||
if (obj[key] === null || obj[key] === undefined) {
|
||
delete obj[key];
|
||
}
|
||
}
|
||
return obj;
|
||
}
|
||
|
||
function MentionModal({
|
||
onClose = () => {},
|
||
onSelect = () => {},
|
||
defaultSearchTerm,
|
||
}) {
|
||
const { masto } = api();
|
||
const [uiState, setUIState] = useState('default');
|
||
const [accounts, setAccounts] = useState([]);
|
||
const [relationshipsMap, setRelationshipsMap] = useState({});
|
||
|
||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||
|
||
const loadRelationships = async (accounts) => {
|
||
if (!accounts?.length) return;
|
||
const relationships = await fetchRelationships(accounts, relationshipsMap);
|
||
if (relationships) {
|
||
setRelationshipsMap({
|
||
...relationshipsMap,
|
||
...relationships,
|
||
});
|
||
}
|
||
};
|
||
|
||
const loadAccounts = (term) => {
|
||
if (!term) return;
|
||
setUIState('loading');
|
||
(async () => {
|
||
try {
|
||
const accounts = await masto.v1.accounts.search.list({
|
||
q: term,
|
||
limit: 40,
|
||
resolve: false,
|
||
});
|
||
setAccounts(accounts);
|
||
loadRelationships(accounts);
|
||
setUIState('default');
|
||
} catch (e) {
|
||
setUIState('error');
|
||
console.error(e);
|
||
}
|
||
})();
|
||
};
|
||
|
||
const debouncedLoadAccounts = useDebouncedCallback(loadAccounts, 1000);
|
||
|
||
useEffect(() => {
|
||
loadAccounts();
|
||
}, [loadAccounts]);
|
||
|
||
const inputRef = useRef();
|
||
useEffect(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus();
|
||
// Put cursor at the end
|
||
if (inputRef.current.value) {
|
||
inputRef.current.selectionStart = inputRef.current.value.length;
|
||
inputRef.current.selectionEnd = inputRef.current.value.length;
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (defaultSearchTerm) {
|
||
loadAccounts(defaultSearchTerm);
|
||
}
|
||
}, [defaultSearchTerm]);
|
||
|
||
const selectAccount = (account) => {
|
||
const socialAddress = account.acct;
|
||
onSelect(socialAddress);
|
||
onClose();
|
||
};
|
||
|
||
useHotkeys(
|
||
'enter',
|
||
() => {
|
||
const selectedAccount = accounts[selectedIndex];
|
||
if (selectedAccount) {
|
||
selectAccount(selectedAccount);
|
||
}
|
||
},
|
||
{
|
||
preventDefault: true,
|
||
enableOnFormTags: ['input'],
|
||
},
|
||
);
|
||
|
||
const listRef = useRef();
|
||
useHotkeys(
|
||
'down',
|
||
() => {
|
||
if (selectedIndex < accounts.length - 1) {
|
||
setSelectedIndex(selectedIndex + 1);
|
||
} else {
|
||
setSelectedIndex(0);
|
||
}
|
||
setTimeout(() => {
|
||
const selectedItem = listRef.current.querySelector('.selected');
|
||
if (selectedItem) {
|
||
selectedItem.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'center',
|
||
});
|
||
}
|
||
}, 1);
|
||
},
|
||
{
|
||
preventDefault: true,
|
||
enableOnFormTags: ['input'],
|
||
},
|
||
);
|
||
|
||
useHotkeys(
|
||
'up',
|
||
() => {
|
||
if (selectedIndex > 0) {
|
||
setSelectedIndex(selectedIndex - 1);
|
||
} else {
|
||
setSelectedIndex(accounts.length - 1);
|
||
}
|
||
setTimeout(() => {
|
||
const selectedItem = listRef.current.querySelector('.selected');
|
||
if (selectedItem) {
|
||
selectedItem.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center',
|
||
inline: 'center',
|
||
});
|
||
}
|
||
}, 1);
|
||
},
|
||
{
|
||
preventDefault: true,
|
||
enableOnFormTags: ['input'],
|
||
},
|
||
);
|
||
|
||
return (
|
||
<div id="mention-sheet" class="sheet">
|
||
{!!onClose && (
|
||
<button type="button" class="sheet-close" onClick={onClose}>
|
||
<Icon icon="x" />
|
||
</button>
|
||
)}
|
||
<header>
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
debouncedLoadAccounts.flush?.();
|
||
// const searchTerm = inputRef.current.value;
|
||
// debouncedLoadAccounts(searchTerm);
|
||
}}
|
||
>
|
||
<input
|
||
ref={inputRef}
|
||
required
|
||
type="search"
|
||
class="block"
|
||
placeholder="Search accounts"
|
||
onInput={(e) => {
|
||
const { value } = e.target;
|
||
debouncedLoadAccounts(value);
|
||
}}
|
||
autocomplete="off"
|
||
autocorrect="off"
|
||
autocapitalize="off"
|
||
spellCheck="false"
|
||
dir="auto"
|
||
defaultValue={defaultSearchTerm || ''}
|
||
/>
|
||
</form>
|
||
</header>
|
||
<main>
|
||
{accounts?.length > 0 ? (
|
||
<ul
|
||
ref={listRef}
|
||
class={`accounts-list ${uiState === 'loading' ? 'loading' : ''}`}
|
||
>
|
||
{accounts.map((account, i) => {
|
||
const relationship = relationshipsMap[account.id];
|
||
return (
|
||
<li
|
||
key={account.id}
|
||
class={i === selectedIndex ? 'selected' : ''}
|
||
>
|
||
<AccountBlock
|
||
avatarSize="xxl"
|
||
account={account}
|
||
relationship={relationship}
|
||
showStats
|
||
showActivity
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="plain2"
|
||
onClick={() => {
|
||
selectAccount(account);
|
||
}}
|
||
>
|
||
<Icon icon="plus" size="xl" />
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
) : uiState === 'loading' ? (
|
||
<div class="ui-state">
|
||
<Loader abrupt />
|
||
</div>
|
||
) : uiState === 'error' ? (
|
||
<div class="ui-state">
|
||
<p>Error loading accounts</p>
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CustomEmojisModal({
|
||
masto,
|
||
instance,
|
||
onClose = () => {},
|
||
onSelect = () => {},
|
||
defaultSearchTerm,
|
||
}) {
|
||
const [uiState, setUIState] = useState('default');
|
||
const customEmojisList = useRef([]);
|
||
const [customEmojis, setCustomEmojis] = useState([]);
|
||
const recentlyUsedCustomEmojis = useMemo(
|
||
() => store.account.get('recentlyUsedCustomEmojis') || [],
|
||
);
|
||
const searcherRef = useRef();
|
||
useEffect(() => {
|
||
setUIState('loading');
|
||
(async () => {
|
||
try {
|
||
const [emojis, searcher] = await getCustomEmojis(instance, masto);
|
||
console.log('emojis', emojis);
|
||
searcherRef.current = searcher;
|
||
setCustomEmojis(emojis);
|
||
setUIState('default');
|
||
} catch (e) {
|
||
setUIState('error');
|
||
console.error(e);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
const customEmojisCatList = useMemo(() => {
|
||
// Group emojis by category
|
||
const emojisCat = {
|
||
'--recent--': recentlyUsedCustomEmojis.filter((emoji) =>
|
||
customEmojis.find((e) => e.shortcode === emoji.shortcode),
|
||
),
|
||
};
|
||
const othersCat = [];
|
||
customEmojis.forEach((emoji) => {
|
||
customEmojisList.current?.push?.(emoji);
|
||
if (!emoji.category) {
|
||
othersCat.push(emoji);
|
||
return;
|
||
}
|
||
if (!emojisCat[emoji.category]) {
|
||
emojisCat[emoji.category] = [];
|
||
}
|
||
emojisCat[emoji.category].push(emoji);
|
||
});
|
||
if (othersCat.length) {
|
||
emojisCat['--others--'] = othersCat;
|
||
}
|
||
return emojisCat;
|
||
}, [customEmojis]);
|
||
|
||
const scrollableRef = useRef();
|
||
const [matches, setMatches] = useState(null);
|
||
const onFind = useCallback(
|
||
(e) => {
|
||
const { value } = e.target;
|
||
if (value) {
|
||
const results = searcherRef.current?.search(value, {
|
||
limit: CUSTOM_EMOJIS_COUNT,
|
||
});
|
||
setMatches(results.map((r) => r.item));
|
||
scrollableRef.current?.scrollTo?.(0, 0);
|
||
} else {
|
||
setMatches(null);
|
||
}
|
||
},
|
||
[customEmojis],
|
||
);
|
||
useEffect(() => {
|
||
if (defaultSearchTerm && customEmojis?.length) {
|
||
onFind({ target: { value: defaultSearchTerm } });
|
||
}
|
||
}, [defaultSearchTerm, onFind, customEmojis]);
|
||
|
||
const onSelectEmoji = useCallback(
|
||
(emoji) => {
|
||
onSelect?.(emoji);
|
||
onClose?.();
|
||
|
||
queueMicrotask(() => {
|
||
let recentlyUsedCustomEmojis =
|
||
store.account.get('recentlyUsedCustomEmojis') || [];
|
||
const recentlyUsedEmojiIndex = recentlyUsedCustomEmojis.findIndex(
|
||
(e) => e.shortcode === emoji.shortcode,
|
||
);
|
||
if (recentlyUsedEmojiIndex !== -1) {
|
||
// Move emoji to index 0
|
||
recentlyUsedCustomEmojis.splice(recentlyUsedEmojiIndex, 1);
|
||
recentlyUsedCustomEmojis.unshift(emoji);
|
||
} else {
|
||
recentlyUsedCustomEmojis.unshift(emoji);
|
||
// Remove unavailable ones
|
||
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.filter((e) =>
|
||
customEmojisList.current?.find?.(
|
||
(emoji) => emoji.shortcode === e.shortcode,
|
||
),
|
||
);
|
||
// Limit to 10
|
||
recentlyUsedCustomEmojis = recentlyUsedCustomEmojis.slice(0, 10);
|
||
}
|
||
|
||
// Store back
|
||
store.account.set('recentlyUsedCustomEmojis', recentlyUsedCustomEmojis);
|
||
});
|
||
},
|
||
[onSelect],
|
||
);
|
||
|
||
const inputRef = useRef();
|
||
useEffect(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus();
|
||
// Put cursor at the end
|
||
if (inputRef.current.value) {
|
||
inputRef.current.selectionStart = inputRef.current.value.length;
|
||
inputRef.current.selectionEnd = inputRef.current.value.length;
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div id="custom-emojis-sheet" class="sheet">
|
||
{!!onClose && (
|
||
<button type="button" class="sheet-close" onClick={onClose}>
|
||
<Icon icon="x" />
|
||
</button>
|
||
)}
|
||
<header>
|
||
<div>
|
||
<b>Custom emojis</b>{' '}
|
||
{uiState === 'loading' ? (
|
||
<Loader />
|
||
) : (
|
||
<small class="insignificant"> • {instance}</small>
|
||
)}
|
||
</div>
|
||
<form
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
const emoji = matches[0];
|
||
if (emoji) {
|
||
onSelectEmoji(`:${emoji.shortcode}:`);
|
||
}
|
||
}}
|
||
>
|
||
<input
|
||
ref={inputRef}
|
||
type="search"
|
||
placeholder="Search emoji"
|
||
onInput={onFind}
|
||
autocomplete="off"
|
||
autocorrect="off"
|
||
autocapitalize="off"
|
||
spellCheck="false"
|
||
dir="auto"
|
||
defaultValue={defaultSearchTerm || ''}
|
||
/>
|
||
</form>
|
||
</header>
|
||
<main ref={scrollableRef}>
|
||
{matches !== null ? (
|
||
<ul class="custom-emojis-matches custom-emojis-list">
|
||
{matches.map((emoji) => (
|
||
<li key={emoji.shortcode} class="custom-emojis-match">
|
||
<CustomEmojiButton
|
||
emoji={emoji}
|
||
onClick={() => {
|
||
onSelectEmoji(`:${emoji.shortcode}:`);
|
||
}}
|
||
showCode
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<div class="custom-emojis-list">
|
||
{uiState === 'error' && (
|
||
<div class="ui-state">
|
||
<p>Error loading custom emojis</p>
|
||
</div>
|
||
)}
|
||
{uiState === 'default' &&
|
||
Object.entries(customEmojisCatList).map(
|
||
([category, emojis]) =>
|
||
!!emojis?.length && (
|
||
<>
|
||
<div class="section-header">
|
||
{{
|
||
'--recent--': 'Recently used',
|
||
'--others--': 'Others',
|
||
}[category] || category}
|
||
</div>
|
||
<CustomEmojisList
|
||
emojis={emojis}
|
||
onSelect={onSelectEmoji}
|
||
/>
|
||
</>
|
||
),
|
||
)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CustomEmojisList = memo(({ emojis, onSelect }) => {
|
||
const [max, setMax] = useState(CUSTOM_EMOJIS_COUNT);
|
||
const showMore = emojis.length > max;
|
||
return (
|
||
<section>
|
||
{emojis.slice(0, max).map((emoji) => (
|
||
<CustomEmojiButton
|
||
key={emoji.shortcode}
|
||
emoji={emoji}
|
||
onClick={() => {
|
||
onSelect(`:${emoji.shortcode}:`);
|
||
}}
|
||
/>
|
||
))}
|
||
{showMore && (
|
||
<button
|
||
type="button"
|
||
class="plain small"
|
||
onClick={() => setMax(max + CUSTOM_EMOJIS_COUNT)}
|
||
>
|
||
{(emojis.length - max).toLocaleString()} more…
|
||
</button>
|
||
)}
|
||
</section>
|
||
);
|
||
});
|
||
|
||
const CustomEmojiButton = memo(({ emoji, onClick, showCode }) => {
|
||
const addEdges = (e) => {
|
||
// Add edge-left or edge-right class based on self position relative to scrollable parent
|
||
// If near left edge, add edge-left, if near right edge, add edge-right
|
||
const buffer = 88;
|
||
const parent = e.currentTarget.closest('main');
|
||
if (parent) {
|
||
const rect = parent.getBoundingClientRect();
|
||
const selfRect = e.currentTarget.getBoundingClientRect();
|
||
const targetClassList = e.currentTarget.classList;
|
||
if (selfRect.left < rect.left + buffer) {
|
||
targetClassList.add('edge-left');
|
||
targetClassList.remove('edge-right');
|
||
} else if (selfRect.right > rect.right - buffer) {
|
||
targetClassList.add('edge-right');
|
||
targetClassList.remove('edge-left');
|
||
} else {
|
||
targetClassList.remove('edge-left', 'edge-right');
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="plain4"
|
||
onClick={onClick}
|
||
data-title={showCode ? undefined : emoji.shortcode}
|
||
onPointerEnter={addEdges}
|
||
onFocus={addEdges}
|
||
>
|
||
<picture>
|
||
{!!emoji.staticUrl && (
|
||
<source
|
||
srcSet={emoji.staticUrl}
|
||
media="(prefers-reduced-motion: reduce)"
|
||
/>
|
||
)}
|
||
<img
|
||
className="shortcode-emoji"
|
||
src={emoji.url || emoji.staticUrl}
|
||
alt={emoji.shortcode}
|
||
width="24"
|
||
height="24"
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
</picture>
|
||
{showCode && (
|
||
<>
|
||
{' '}
|
||
<code>{emoji.shortcode}</code>
|
||
</>
|
||
)}
|
||
</button>
|
||
);
|
||
});
|
||
|
||
const GIFS_PER_PAGE = 20;
|
||
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
||
const [uiState, setUIState] = useState('default');
|
||
const [results, setResults] = useState([]);
|
||
const formRef = useRef(null);
|
||
const qRef = useRef(null);
|
||
const currentOffset = useRef(0);
|
||
const scrollableRef = useRef(null);
|
||
|
||
function fetchGIFs({ offset }) {
|
||
console.log('fetchGIFs', { offset });
|
||
if (!qRef.current?.value) return;
|
||
setUIState('loading');
|
||
scrollableRef.current?.scrollTo?.({
|
||
top: 0,
|
||
left: 0,
|
||
behavior: 'smooth',
|
||
});
|
||
(async () => {
|
||
try {
|
||
const query = {
|
||
api_key: GIPHY_API_KEY,
|
||
q: qRef.current.value,
|
||
rating: 'g',
|
||
limit: GIFS_PER_PAGE,
|
||
bundle: 'messaging_non_clips',
|
||
offset,
|
||
};
|
||
const response = await fetch(
|
||
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
|
||
{
|
||
referrerPolicy: 'no-referrer',
|
||
},
|
||
).then((r) => r.json());
|
||
currentOffset.current = response.pagination?.offset || 0;
|
||
setResults(response);
|
||
setUIState('results');
|
||
} catch (e) {
|
||
setUIState('error');
|
||
console.error(e);
|
||
}
|
||
})();
|
||
}
|
||
|
||
useEffect(() => {
|
||
qRef.current?.focus();
|
||
}, []);
|
||
|
||
const debouncedOnInput = useDebouncedCallback(() => {
|
||
fetchGIFs({ offset: 0 });
|
||
}, 1000);
|
||
|
||
return (
|
||
<div id="gif-picker-sheet" class="sheet">
|
||
{!!onClose && (
|
||
<button type="button" class="sheet-close" onClick={onClose}>
|
||
<Icon icon="x" />
|
||
</button>
|
||
)}
|
||
<header>
|
||
<form
|
||
ref={formRef}
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
fetchGIFs({ offset: 0 });
|
||
}}
|
||
>
|
||
<input
|
||
ref={qRef}
|
||
type="search"
|
||
name="q"
|
||
placeholder="Search GIFs"
|
||
required
|
||
autocomplete="off"
|
||
autocorrect="off"
|
||
autocapitalize="off"
|
||
spellCheck="false"
|
||
dir="auto"
|
||
onInput={debouncedOnInput}
|
||
/>
|
||
<input
|
||
type="image"
|
||
class="powered-button"
|
||
src={poweredByGiphyURL}
|
||
width="86"
|
||
height="30"
|
||
/>
|
||
</form>
|
||
</header>
|
||
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
|
||
{uiState === 'default' && (
|
||
<div class="ui-state">
|
||
<p class="insignificant">Type to search GIFs</p>
|
||
</div>
|
||
)}
|
||
{uiState === 'loading' && !results?.data?.length && (
|
||
<div class="ui-state">
|
||
<Loader abrupt />
|
||
</div>
|
||
)}
|
||
{results?.data?.length > 0 ? (
|
||
<>
|
||
<ul>
|
||
{results.data.map((gif) => {
|
||
const { id, images, title, alt_text } = gif;
|
||
const {
|
||
fixed_height_small,
|
||
fixed_height_downsampled,
|
||
fixed_height,
|
||
original,
|
||
} = images;
|
||
const theImage = fixed_height_small?.url
|
||
? fixed_height_small
|
||
: fixed_height_downsampled?.url
|
||
? fixed_height_downsampled
|
||
: fixed_height;
|
||
let { url, webp, width, height } = theImage;
|
||
if (+height > 100) {
|
||
width = (width / height) * 100;
|
||
height = 100;
|
||
}
|
||
const urlObj = URL.parse(url);
|
||
const strippedURL = urlObj.origin + urlObj.pathname;
|
||
let strippedWebP;
|
||
if (webp) {
|
||
const webpObj = URL.parse(webp);
|
||
strippedWebP = webpObj.origin + webpObj.pathname;
|
||
}
|
||
return (
|
||
<li key={id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const { mp4, url } = original;
|
||
const theURL = mp4 || url;
|
||
const urlObj = URL.parse(theURL);
|
||
const strippedURL = urlObj.origin + urlObj.pathname;
|
||
onClose();
|
||
onSelect({
|
||
url: strippedURL,
|
||
type: mp4 ? 'video/mp4' : 'image/gif',
|
||
alt_text: alt_text || title,
|
||
});
|
||
}}
|
||
>
|
||
<figure
|
||
style={{
|
||
'--figure-width': width + 'px',
|
||
// width: width + 'px'
|
||
}}
|
||
>
|
||
<picture>
|
||
{strippedWebP && (
|
||
<source srcset={strippedWebP} type="image/webp" />
|
||
)}
|
||
<img
|
||
src={strippedURL}
|
||
width={width}
|
||
height={height}
|
||
loading="lazy"
|
||
decoding="async"
|
||
alt={alt_text}
|
||
referrerpolicy="no-referrer"
|
||
onLoad={(e) => {
|
||
e.target.style.backgroundColor = 'transparent';
|
||
}}
|
||
/>
|
||
</picture>
|
||
<figcaption>{alt_text || title}</figcaption>
|
||
</figure>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
<p class="pagination">
|
||
{results.pagination?.offset > 0 && (
|
||
<button
|
||
type="button"
|
||
class="light small"
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
fetchGIFs({
|
||
offset: results.pagination?.offset - GIFS_PER_PAGE,
|
||
});
|
||
}}
|
||
>
|
||
<Icon icon="chevron-left" />
|
||
<span>Previous</span>
|
||
</button>
|
||
)}
|
||
<span />
|
||
{results.pagination?.offset + results.pagination?.count <
|
||
results.pagination?.total_count && (
|
||
<button
|
||
type="button"
|
||
class="light small"
|
||
disabled={uiState === 'loading'}
|
||
onClick={() => {
|
||
fetchGIFs({
|
||
offset: results.pagination?.offset + GIFS_PER_PAGE,
|
||
});
|
||
}}
|
||
>
|
||
<span>Next</span> <Icon icon="chevron-right" />
|
||
</button>
|
||
)}
|
||
</p>
|
||
</>
|
||
) : (
|
||
uiState === 'results' && (
|
||
<div class="ui-state">
|
||
<p>No results</p>
|
||
</div>
|
||
)
|
||
)}
|
||
{uiState === 'error' && (
|
||
<div class="ui-state">
|
||
<p>Error loading GIFs</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Compose;
|