this repo has no description
1import './status.css';
2
3import { msg, plural } from '@lingui/core/macro';
4import { Trans, useLingui } from '@lingui/react/macro';
5import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
6import { shallowEqual } from 'fast-equals';
7import pThrottle from 'p-throttle';
8import { Fragment } from 'preact';
9import { memo } from 'preact/compat';
10import {
11 useCallback,
12 useContext,
13 useEffect,
14 useMemo,
15 useReducer,
16 useRef,
17 useState,
18} from 'preact/hooks';
19import punycode from 'punycode/';
20import { useHotkeys } from 'react-hotkeys-hook';
21import { useLongPress } from 'use-long-press';
22import { useSnapshot } from 'valtio';
23
24import { api, getPreferences } from '../utils/api';
25import { langDetector } from '../utils/browser-translator';
26import FilterContext from '../utils/filter-context';
27import { isFiltered } from '../utils/filters';
28import getTranslateTargetLanguage from '../utils/get-translate-target-language';
29import getHTMLText from '../utils/getHTMLText';
30import htmlContentLength from '../utils/html-content-length';
31import localeMatch from '../utils/locale-match';
32import niceDateTime from '../utils/nice-date-time';
33import openCompose from '../utils/open-compose';
34import pmem from '../utils/pmem';
35import RTF from '../utils/relative-time-format';
36import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
37import shortenNumber from '../utils/shorten-number';
38import showCompose from '../utils/show-compose';
39import showToast from '../utils/show-toast';
40import { speak, supportsTTS } from '../utils/speech';
41import states, { getStatus, saveStatus, statusKey } from '../utils/states';
42import statusPeek from '../utils/status-peek';
43import { getCurrentAccID, getCurrentAccountID } from '../utils/store-utils';
44import supports from '../utils/supports';
45import useTruncated from '../utils/useTruncated';
46import visibilityIconsMap from '../utils/visibility-icons-map';
47import visibilityText from '../utils/visibility-text';
48
49import Avatar from './avatar';
50import CustomEmoji from './custom-emoji';
51import EmojiText from './emoji-text';
52import Icon from './icon';
53import LazyShazam from './lazy-shazam';
54import Link from './link';
55import Loader from './loader';
56import MathBlock from './math-block';
57import Media, { isMediaCaptionLong } from './media';
58import MediaFirstContainer from './media-first-container';
59import MenuConfirm from './menu-confirm';
60import MenuLink from './menu-link';
61import Menu2 from './menu2';
62import Modal from './modal';
63import MultipleMediaFigure from './multiple-media-figure';
64import NameText from './name-text';
65import Poll from './poll';
66import PostContent from './post-content';
67import PostEmbedModal from './post-embed-modal';
68import RelativeTime from './relative-time';
69import StatusButton from './status-button';
70import StatusCard from './status-card';
71import StatusCompact from './status-compact';
72import ThreadBadge from './thread-badge';
73import TranslationBlock from './translation-block';
74
75const SHOW_COMMENT_COUNT_LIMIT = 280;
76const INLINE_TRANSLATE_LIMIT = 140;
77
78const throttle = pThrottle({
79 limit: 1,
80 interval: 1000,
81});
82function fetchAccount(id, masto) {
83 return masto.v1.accounts.$select(id).fetch();
84}
85const memFetchAccount = pmem(throttle(fetchAccount));
86
87const isIOS =
88 window.ontouchstart !== undefined &&
89 /iPad|iPhone|iPod/.test(navigator.userAgent);
90
91const REACTIONS_LIMIT = 80;
92
93function getPollText(poll) {
94 if (!poll?.options?.length) return '';
95 return `📊:\n${poll.options
96 .map(
97 (option) =>
98 `- ${option.title}${
99 option.votesCount >= 0 ? ` (${option.votesCount})` : ''
100 }`,
101 )
102 .join('\n')}`;
103}
104function getPostText(status, opts) {
105 const { maskCustomEmojis, maskURLs } = opts || {};
106 const { spoilerText, poll, emojis } = status;
107 let { content } = status;
108 if (maskCustomEmojis && emojis?.length) {
109 const emojisRegex = new RegExp(
110 `:(${emojis.map((e) => e.shortcode).join('|')}):`,
111 'g',
112 );
113 content = content.replace(emojisRegex, '⬚');
114 }
115 return (
116 (spoilerText ? `${spoilerText}\n\n` : '') +
117 getHTMLText(content, {
118 preProcess:
119 maskURLs &&
120 ((dom) => {
121 // Remove links that contains text that starts with https?://
122 for (const a of dom.querySelectorAll('a')) {
123 const text = a.innerText.trim();
124 if (/^https?:\/\//i.test(text)) {
125 a.replaceWith('«🔗»');
126 }
127 }
128 }),
129 }) +
130 getPollText(poll)
131 );
132}
133
134function forgivingQSA(selectors = [], dom = document) {
135 // Run QSA for list of selectors
136 // If a selector return invalid selector error, try the next one
137 for (const selector of selectors) {
138 try {
139 return dom.querySelectorAll(selector);
140 } catch (e) {}
141 }
142 return [];
143}
144
145function isTranslateble(content, emojis) {
146 if (!content) return false;
147 // Remove custom emojis
148 if (emojis?.length) {
149 const emojisRegex = new RegExp(
150 `:(${emojis.map((e) => e.shortcode).join('|')}):`,
151 'g',
152 );
153 content = content.replace(emojisRegex, '');
154 }
155 content = content.trim();
156 if (!content) return false;
157 const text = getHTMLText(content, {
158 preProcess: (dom) => {
159 // Remove .mention, pre, code, a:has(.invisible)
160 for (const a of forgivingQSA(
161 ['.mention, pre, code, a:has(.invisible)', '.mention, pre, code'],
162 dom,
163 )) {
164 a.remove();
165 }
166 },
167 });
168 return !!text;
169}
170
171function getHTMLTextForDetectLang(content, emojis) {
172 if (emojis?.length) {
173 const emojisRegex = new RegExp(
174 `:(${emojis.map((e) => e.shortcode).join('|')}):`,
175 'g',
176 );
177 content = content.replace(emojisRegex, '');
178 }
179
180 return getHTMLText(content, {
181 preProcess: (dom) => {
182 // Remove anything that can skew the language detection
183
184 // Remove .mention, .hashtag, pre, code, a:has(.invisible)
185 for (const a of forgivingQSA(
186 [
187 '.mention, .hashtag, pre, code, a:has(.invisible)',
188 '.mention, .hashtag, pre, code',
189 ],
190 dom,
191 )) {
192 a.remove();
193 }
194
195 // Remove links that contains text that starts with https?://
196 for (const a of dom.querySelectorAll('a')) {
197 const text = a.innerText.trim();
198 if (text.startsWith('https://') || text.startsWith('http://')) {
199 a.remove();
200 }
201 }
202 },
203 });
204}
205
206const SIZE_CLASS = {
207 s: 'small',
208 m: 'medium',
209 l: 'large',
210};
211
212const detectLang = pmem(async (text) => {
213 text = text?.trim();
214
215 // Ref: https://github.com/komodojp/tinyld/blob/develop/docs/benchmark.md
216 // 500 should be enough for now, also the default max chars for Mastodon
217 if (text?.length > 500) {
218 return null;
219 }
220
221 if (langDetector) {
222 const langs = await langDetector.detect(text);
223 console.groupCollapsed(
224 '💬 DETECTLANG BROWSER',
225 langs.slice(0, 3).map((l) => l.detectedLanguage),
226 );
227 console.log(text, langs.slice(0, 3));
228 console.groupEnd();
229 const lang = langs[0];
230 if (lang?.detectedLanguage && lang?.confidence > 0.5) {
231 return lang.detectedLanguage;
232 }
233 }
234
235 const { detectAll } = await import('tinyld/light');
236 const langs = detectAll(text);
237 console.groupCollapsed(
238 '💬 DETECTLANG TINYLD',
239 langs.slice(0, 3).map((l) => l.lang),
240 );
241 console.log(text, langs.slice(0, 3));
242 console.groupEnd();
243 const lang = langs[0];
244 if (lang?.lang && lang?.accuracy > 0.5) {
245 // If > 50% accurate, use it
246 // It can be accurate if < 50% but better be safe
247 // Though > 50% also can be inaccurate 🤷♂️
248 return lang.lang;
249 }
250 return null;
251});
252
253const readMoreText = msg`Read more →`;
254
255// All this work just to make sure this only lazy-run once
256// Because first run is slow due to intl-localematcher
257const DIFFERENT_LANG_CHECK = {};
258const diffLangCheckCacheKey = (l, hls) => `${l}:${hls.join('|')}`;
259const checkDifferentLanguage = (
260 language,
261 contentTranslationHideLanguages = [],
262) => {
263 if (!language) return false;
264 const cacheKey = diffLangCheckCacheKey(
265 language,
266 contentTranslationHideLanguages,
267 );
268 const targetLanguage = getTranslateTargetLanguage(true);
269 const different =
270 language !== targetLanguage &&
271 !localeMatch([language], [targetLanguage]) &&
272 !contentTranslationHideLanguages.find(
273 (l) => language === l || localeMatch([language], [l]),
274 );
275 if (different) {
276 DIFFERENT_LANG_CHECK[cacheKey] = true;
277 }
278 return different;
279};
280
281function Status({
282 statusID,
283 status,
284 instance: propInstance,
285 size = 'm',
286 contentTextWeight,
287 readOnly,
288 enableCommentHint,
289 withinContext,
290 skeleton,
291 enableTranslate,
292 forceTranslate: _forceTranslate,
293 previewMode,
294 allowFilters,
295 onMediaClick,
296 quoted,
297 onStatusLinkClick = () => {},
298 showFollowedTags,
299 allowContextMenu,
300 showActionsBar,
301 showReplyParent,
302 mediaFirst,
303}) {
304 const { _, t, i18n } = useLingui();
305 const rtf = RTF(i18n.locale);
306
307 if (skeleton) {
308 return (
309 <div
310 class={`status skeleton ${
311 mediaFirst ? 'status-media-first small' : ''
312 }`}
313 >
314 {!mediaFirst && <Avatar size="xxl" />}
315 <div class="container">
316 <div class="meta">
317 {(size === 's' || mediaFirst) && <Avatar size="m" />} ███ ████████
318 </div>
319 <div class="content-container">
320 {mediaFirst && <div class="media-first-container" />}
321 <div class={`content ${mediaFirst ? 'media-first-content' : ''}`}>
322 <p>████ ████████</p>
323 </div>
324 </div>
325 </div>
326 </div>
327 );
328 }
329 const { masto, instance, authenticated } = api({ instance: propInstance });
330 const { instance: currentInstance } = api();
331 const sameInstance = instance === currentInstance;
332
333 let sKey = statusKey(statusID || status?.id, instance);
334 const snapStates = useSnapshot(states);
335 if (!status) {
336 status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
337 sKey = statusKey(status?.id, instance);
338 }
339 if (!status) {
340 return null;
341 }
342
343 const {
344 account: {
345 acct,
346 avatar,
347 avatarStatic,
348 id: accountId,
349 url: accountURL,
350 displayName,
351 username,
352 emojis: accountEmojis,
353 bot,
354 group,
355 } = {},
356 id,
357 repliesCount,
358 reblogged,
359 reblogsCount,
360 favourited,
361 favouritesCount,
362 bookmarked,
363 poll,
364 muted,
365 sensitive,
366 spoilerText,
367 visibility, // public, unlisted, private, direct
368 language: _language,
369 editedAt,
370 filtered,
371 card,
372 createdAt,
373 inReplyToId,
374 inReplyToAccountId,
375 content,
376 mentions,
377 mediaAttachments = [],
378 reblog,
379 uri,
380 url,
381 emojis,
382 tags,
383 pinned,
384 // Non-API props
385 _deleted,
386 _pinned,
387 // _filtered,
388 // Non-Mastodon
389 emojiReactions,
390 } = status;
391
392 const [languageAutoDetected, setLanguageAutoDetected] = useState(null);
393 useEffect(() => {
394 if (!content) return;
395 if (_language) return;
396 if (languageAutoDetected) return;
397 let timer;
398 timer = setTimeout(async () => {
399 let detected = await detectLang(
400 getHTMLTextForDetectLang(content, emojis),
401 );
402 setLanguageAutoDetected(detected);
403 }, 1000);
404 return () => clearTimeout(timer);
405 }, [content, _language]);
406 const language = _language || languageAutoDetected;
407
408 // if (!mediaAttachments?.length) mediaFirst = false;
409 const hasMediaAttachments = !!mediaAttachments?.length;
410 if (mediaFirst && hasMediaAttachments) size = 's';
411
412 const currentAccount = getCurrentAccID();
413 const isSelf = useMemo(() => {
414 return currentAccount && currentAccount === accountId;
415 }, [accountId, currentAccount]);
416
417 const filterContext = useContext(FilterContext);
418 const filterInfo =
419 !isSelf &&
420 ((!readOnly && !previewMode) || allowFilters) &&
421 isFiltered(filtered, filterContext);
422
423 if (filterInfo?.action === 'hide') {
424 return null;
425 }
426
427 console.debug('RENDER Status', id, status?.account?.displayName, quoted);
428
429 const debugHover = (e) => {
430 if (e.shiftKey) {
431 console.log({
432 ...status,
433 });
434 }
435 };
436
437 if (
438 (allowFilters || size !== 'l') &&
439 filterInfo &&
440 filterInfo.action !== 'blur'
441 ) {
442 return (
443 <FilteredStatus
444 status={status}
445 filterInfo={filterInfo}
446 instance={instance}
447 containerProps={{
448 onMouseEnter: debugHover,
449 }}
450 showFollowedTags
451 quoted={quoted}
452 />
453 );
454 }
455
456 const createdAtDate = new Date(createdAt);
457 const editedAtDate = new Date(editedAt);
458
459 let inReplyToAccountRef = mentions?.find(
460 (mention) => mention.id === inReplyToAccountId,
461 );
462 if (!inReplyToAccountRef && inReplyToAccountId === id) {
463 inReplyToAccountRef = { url: accountURL, username, displayName };
464 }
465 const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef);
466 if (!withinContext && !inReplyToAccount && inReplyToAccountId) {
467 const account = states.accounts[inReplyToAccountId];
468 if (account) {
469 setInReplyToAccount(account);
470 } else {
471 memFetchAccount(inReplyToAccountId, masto)
472 .then((account) => {
473 setInReplyToAccount(account);
474 states.accounts[account.id] = account;
475 })
476 .catch((e) => {});
477 }
478 }
479 const mentionSelf =
480 inReplyToAccountId === currentAccount ||
481 mentions?.find((mention) => mention.id === currentAccount);
482
483 const prefs = getPreferences();
484 const readingExpandSpoilers = !!prefs['reading:expand:spoilers'];
485
486 // default | show_all | hide_all
487 // Ignore hide_all because it means hide *ALL* media including non-sensitive ones
488 const readingExpandMedia =
489 prefs['reading:expand:media']?.toLowerCase() || 'default';
490
491 // FOR TESTING:
492 // const readingExpandSpoilers = true;
493 // const readingExpandMedia = 'show_all';
494 const showSpoiler =
495 previewMode || readingExpandSpoilers || !!snapStates.spoilers[id];
496 const showSpoilerMedia =
497 previewMode ||
498 (readingExpandMedia === 'show_all' && filterInfo?.action !== 'blur') ||
499 !!snapStates.spoilersMedia[id];
500
501 if (reblog) {
502 // If has statusID, means useItemID (cached in states)
503
504 if (group) {
505 return (
506 <div
507 data-state-post-id={sKey}
508 class="status-group"
509 onMouseEnter={debugHover}
510 >
511 <div class="status-pre-meta">
512 <Icon icon="group" size="l" alt={t`Group`} />{' '}
513 <NameText account={status.account} instance={instance} showAvatar />
514 </div>
515 <Status
516 status={statusID ? null : reblog}
517 statusID={statusID ? reblog.id : null}
518 instance={instance}
519 size={size}
520 contentTextWeight={contentTextWeight}
521 readOnly={readOnly}
522 mediaFirst={mediaFirst}
523 />
524 </div>
525 );
526 }
527
528 return (
529 <div
530 data-state-post-id={sKey}
531 class="status-reblog"
532 onMouseEnter={debugHover}
533 >
534 <div class="status-pre-meta">
535 <Icon icon="rocket" size="l" />{' '}
536 <Trans>
537 <NameText account={status.account} instance={instance} showAvatar />{' '}
538 <span>boosted</span>
539 </Trans>
540 </div>
541 <Status
542 status={statusID ? null : reblog}
543 statusID={statusID ? reblog.id : null}
544 instance={instance}
545 size={size}
546 contentTextWeight={contentTextWeight}
547 readOnly={readOnly}
548 enableCommentHint
549 mediaFirst={mediaFirst}
550 />
551 </div>
552 );
553 }
554
555 // Check followedTags
556 const FollowedTagsParent = useCallback(
557 ({ children }) => (
558 <div
559 data-state-post-id={sKey}
560 class="status-followed-tags"
561 onMouseEnter={debugHover}
562 >
563 <div class="status-pre-meta">
564 <Icon icon="hashtag" size="l" />{' '}
565 {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
566 <Link
567 key={tag}
568 to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`}
569 class="status-followed-tag-item"
570 >
571 {tag}
572 </Link>
573 ))}
574 </div>
575 {children}
576 </div>
577 ),
578 [sKey, instance, snapStates.statusFollowedTags[sKey]],
579 );
580 const StatusParent =
581 showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length
582 ? FollowedTagsParent
583 : Fragment;
584
585 const isSizeLarge = size === 'l';
586
587 const [forceTranslate, setForceTranslate] = useState(_forceTranslate);
588 // const targetLanguage = getTranslateTargetLanguage(true);
589 // const contentTranslationHideLanguages =
590 // snapStates.settings.contentTranslationHideLanguages || [];
591 const { contentTranslation, contentTranslationAutoInline } =
592 snapStates.settings;
593 if (!contentTranslation) enableTranslate = false;
594 const inlineTranslate = useMemo(() => {
595 if (
596 !contentTranslation ||
597 !contentTranslationAutoInline ||
598 readOnly ||
599 (withinContext && !isSizeLarge) ||
600 previewMode ||
601 spoilerText ||
602 sensitive ||
603 poll ||
604 card /*||
605 mediaAttachments?.length*/
606 ) {
607 return false;
608 }
609 const contentLength = htmlContentLength(content);
610 return contentLength > 0 && contentLength <= INLINE_TRANSLATE_LIMIT;
611 }, [
612 contentTranslation,
613 contentTranslationAutoInline,
614 readOnly,
615 withinContext,
616 isSizeLarge,
617 previewMode,
618 spoilerText,
619 sensitive,
620 poll,
621 card,
622 mediaAttachments,
623 content,
624 ]);
625
626 const [showEdited, setShowEdited] = useState(false);
627 const [showEmbed, setShowEmbed] = useState(false);
628
629 const spoilerContentRef = useTruncated();
630 const contentRef = useTruncated();
631 const mediaContainerRef = useTruncated();
632
633 const statusRef = useRef(null);
634 const [reloadPostContentCount, reloadPostContent] = useReducer(
635 (c) => c + 1,
636 0,
637 );
638
639 const unauthInteractionErrorMessage = t`Sorry, your current logged-in instance can't interact with this post from another instance.`;
640
641 const textWeight = useCallback(
642 () =>
643 Math.max(
644 Math.round(
645 ((spoilerText?.length || 0) + htmlContentLength(content)) / 140,
646 ) || 1,
647 1,
648 ),
649 [spoilerText, content],
650 );
651
652 const createdDateText = createdAt && niceDateTime(createdAtDate);
653 const editedDateText = editedAt && niceDateTime(editedAtDate);
654
655 // Can boost if:
656 // - authenticated AND
657 // - visibility != direct OR
658 // - visibility = private AND isSelf
659 let canBoost =
660 authenticated && visibility !== 'direct' && visibility !== 'private';
661 if (visibility === 'private' && isSelf) {
662 canBoost = true;
663 }
664
665 const replyStatus = (e) => {
666 if (!sameInstance || !authenticated) {
667 return alert(unauthInteractionErrorMessage);
668 }
669 // syntheticEvent comes from MenuItem
670 if (e?.shiftKey || e?.syntheticEvent?.shiftKey) {
671 const newWin = openCompose({
672 replyToStatus: status,
673 });
674 if (newWin) return;
675 }
676 showCompose({
677 replyToStatus: status,
678 });
679 };
680
681 // Check if media has no descriptions
682 const mediaNoDesc = useMemo(() => {
683 return mediaAttachments.some(
684 (attachment) => !attachment.description?.trim?.(),
685 );
686 }, [mediaAttachments]);
687
688 const statusMonthsAgo = useMemo(() => {
689 return Math.floor(
690 (new Date() - createdAtDate) / (1000 * 60 * 60 * 24 * 30),
691 );
692 }, [createdAtDate]);
693
694 // const boostStatus = async () => {
695 // if (!sameInstance || !authenticated) {
696 // alert(unauthInteractionErrorMessage);
697 // return false;
698 // }
699 // try {
700 // if (!reblogged) {
701 // let confirmText = 'Boost this post?';
702 // if (mediaNoDesc) {
703 // confirmText += '\n\n⚠️ Some media have no descriptions.';
704 // }
705 // const yes = confirm(confirmText);
706 // if (!yes) {
707 // return false;
708 // }
709 // }
710 // // Optimistic
711 // states.statuses[sKey] = {
712 // ...status,
713 // reblogged: !reblogged,
714 // reblogsCount: reblogsCount + (reblogged ? -1 : 1),
715 // };
716 // if (reblogged) {
717 // const newStatus = await masto.v1.statuses.$select(id).unreblog();
718 // saveStatus(newStatus, instance);
719 // return true;
720 // } else {
721 // const newStatus = await masto.v1.statuses.$select(id).reblog();
722 // saveStatus(newStatus, instance);
723 // return true;
724 // }
725 // } catch (e) {
726 // console.error(e);
727 // // Revert optimistism
728 // states.statuses[sKey] = status;
729 // return false;
730 // }
731 // };
732 const confirmBoostStatus = async () => {
733 if (!sameInstance || !authenticated) {
734 alert(unauthInteractionErrorMessage);
735 return false;
736 }
737 try {
738 // Optimistic
739 states.statuses[sKey] = {
740 ...status,
741 reblogged: !reblogged,
742 reblogsCount: reblogsCount + (reblogged ? -1 : 1),
743 };
744 if (reblogged) {
745 const newStatus = await masto.v1.statuses.$select(id).unreblog();
746 saveStatus(newStatus, instance);
747 } else {
748 const newStatus = await masto.v1.statuses.$select(id).reblog();
749 saveStatus(newStatus, instance);
750 }
751 return true;
752 } catch (e) {
753 console.error(e);
754 // Revert optimistism
755 states.statuses[sKey] = status;
756 return false;
757 }
758 };
759
760 const favouriteStatus = async () => {
761 if (!sameInstance || !authenticated) {
762 alert(unauthInteractionErrorMessage);
763 return false;
764 }
765 try {
766 // Optimistic
767 states.statuses[sKey] = {
768 ...status,
769 favourited: !favourited,
770 favouritesCount: favouritesCount + (favourited ? -1 : 1),
771 };
772 if (favourited) {
773 const newStatus = await masto.v1.statuses.$select(id).unfavourite();
774 saveStatus(newStatus, instance);
775 } else {
776 const newStatus = await masto.v1.statuses.$select(id).favourite();
777 saveStatus(newStatus, instance);
778 }
779 return true;
780 } catch (e) {
781 console.error(e);
782 // Revert optimistism
783 states.statuses[sKey] = status;
784 return false;
785 }
786 };
787 const favouriteStatusNotify = async () => {
788 try {
789 const done = await favouriteStatus();
790 if (!isSizeLarge && done) {
791 showToast(
792 favourited
793 ? t`Unliked @${username || acct}'s post`
794 : t`Liked @${username || acct}'s post`,
795 );
796 }
797 } catch (e) {}
798 };
799
800 const bookmarkStatus = async () => {
801 if (!supports('@mastodon/post-bookmark')) return;
802 if (!sameInstance || !authenticated) {
803 alert(unauthInteractionErrorMessage);
804 return false;
805 }
806 try {
807 // Optimistic
808 states.statuses[sKey] = {
809 ...status,
810 bookmarked: !bookmarked,
811 };
812 if (bookmarked) {
813 const newStatus = await masto.v1.statuses.$select(id).unbookmark();
814 saveStatus(newStatus, instance);
815 } else {
816 const newStatus = await masto.v1.statuses.$select(id).bookmark();
817 saveStatus(newStatus, instance);
818 }
819 return true;
820 } catch (e) {
821 console.error(e);
822 // Revert optimistism
823 states.statuses[sKey] = status;
824 return false;
825 }
826 };
827 const bookmarkStatusNotify = async () => {
828 try {
829 const done = await bookmarkStatus();
830 if (!isSizeLarge && done) {
831 showToast(
832 bookmarked
833 ? t`Unbookmarked @${username || acct}'s post`
834 : t`Bookmarked @${username || acct}'s post`,
835 );
836 }
837 } catch (e) {}
838 };
839
840 // const differentLanguage =
841 // !!language &&
842 // language !== targetLanguage &&
843 // !localeMatch([language], [targetLanguage]) &&
844 // !contentTranslationHideLanguages.find(
845 // (l) => language === l || localeMatch([language], [l]),
846 // );
847 const contentTranslationHideLanguages =
848 snapStates.settings.contentTranslationHideLanguages || [];
849 const [differentLanguage, setDifferentLanguage] = useState(
850 DIFFERENT_LANG_CHECK[
851 diffLangCheckCacheKey(language, contentTranslationHideLanguages)
852 ],
853 );
854 useEffect(() => {
855 if (!language || differentLanguage) {
856 return;
857 }
858 if (
859 !differentLanguage &&
860 DIFFERENT_LANG_CHECK[
861 diffLangCheckCacheKey(language, contentTranslationHideLanguages)
862 ]
863 ) {
864 setDifferentLanguage(true);
865 return;
866 }
867 let timeout = setTimeout(() => {
868 const different = checkDifferentLanguage(
869 language,
870 contentTranslationHideLanguages,
871 );
872 if (different) setDifferentLanguage(different);
873 }, 100);
874 return () => clearTimeout(timeout);
875 }, [language, differentLanguage]);
876
877 const reblogIterator = useRef();
878 const favouriteIterator = useRef();
879 async function fetchBoostedLikedByAccounts(firstLoad) {
880 if (firstLoad) {
881 reblogIterator.current = masto.v1.statuses
882 .$select(statusID)
883 .rebloggedBy.list({
884 limit: REACTIONS_LIMIT,
885 })
886 .values();
887 favouriteIterator.current = masto.v1.statuses
888 .$select(statusID)
889 .favouritedBy.list({
890 limit: REACTIONS_LIMIT,
891 })
892 .values();
893 }
894 const [{ value: reblogResults }, { value: favouriteResults }] =
895 await Promise.allSettled([
896 reblogIterator.current.next(),
897 favouriteIterator.current.next(),
898 ]);
899 if (reblogResults.value?.length || favouriteResults.value?.length) {
900 const accounts = [];
901 if (reblogResults.value?.length) {
902 accounts.push(
903 ...reblogResults.value.map((a) => {
904 a._types = ['reblog'];
905 return a;
906 }),
907 );
908 }
909 if (favouriteResults.value?.length) {
910 accounts.push(
911 ...favouriteResults.value.map((a) => {
912 a._types = ['favourite'];
913 return a;
914 }),
915 );
916 }
917 return {
918 value: accounts,
919 done: reblogResults.done && favouriteResults.done,
920 };
921 }
922 return {
923 value: [],
924 done: true,
925 };
926 }
927
928 const actionsRef = useRef();
929 const isPublic = ['public', 'unlisted'].includes(visibility);
930 const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
931 const menuFooter =
932 mediaNoDesc && !reblogged ? (
933 <div class="footer">
934 <Icon icon="alert" />
935 <Trans>Some media have no descriptions.</Trans>
936 </div>
937 ) : (
938 statusMonthsAgo >= 3 && (
939 <div class="footer">
940 <Icon icon="info" />
941 <span>
942 <Trans>
943 Old post (<strong>{rtf.format(-statusMonthsAgo, 'month')}</strong>
944 )
945 </Trans>
946 </span>
947 </div>
948 )
949 );
950 const StatusMenuItems = (
951 <>
952 {!isSizeLarge && sameInstance && (
953 <>
954 <div class="menu-control-group-horizontal status-menu">
955 <MenuItem onClick={replyStatus}>
956 <Icon icon="comment" />
957 <span>
958 {repliesCount > 0 ? shortenNumber(repliesCount) : t`Reply`}
959 </span>
960 </MenuItem>
961 <MenuConfirm
962 subMenu
963 confirmLabel={
964 <>
965 <Icon icon="rocket" />
966 <span>{reblogged ? t`Unboost` : t`Boost`}</span>
967 </>
968 }
969 className={`menu-reblog ${reblogged ? 'checked' : ''}`}
970 menuExtras={
971 <MenuItem
972 onClick={() => {
973 showCompose({
974 draftStatus: {
975 status: `\n${url}`,
976 },
977 });
978 }}
979 >
980 <Icon icon="quote" />
981 <span>
982 <Trans>Quote</Trans>
983 </span>
984 </MenuItem>
985 }
986 menuFooter={menuFooter}
987 disabled={!canBoost}
988 onClick={async () => {
989 try {
990 const done = await confirmBoostStatus();
991 if (!isSizeLarge && done) {
992 showToast(
993 reblogged
994 ? t`Unboosted @${username || acct}'s post`
995 : t`Boosted @${username || acct}'s post`,
996 );
997 }
998 } catch (e) {}
999 }}
1000 >
1001 <Icon icon="rocket" />
1002 <span>
1003 {reblogsCount > 0
1004 ? shortenNumber(reblogsCount)
1005 : reblogged
1006 ? t`Unboost`
1007 : t`Boost…`}
1008 </span>
1009 </MenuConfirm>
1010 <MenuItem
1011 onClick={favouriteStatusNotify}
1012 className={`menu-favourite ${favourited ? 'checked' : ''}`}
1013 >
1014 <Icon icon="heart" />
1015 <span>
1016 {favouritesCount > 0
1017 ? shortenNumber(favouritesCount)
1018 : favourited
1019 ? t`Unlike`
1020 : t`Like`}
1021 </span>
1022 </MenuItem>
1023 {supports('@mastodon/post-bookmark') && (
1024 <MenuItem
1025 onClick={bookmarkStatusNotify}
1026 className={`menu-bookmark ${bookmarked ? 'checked' : ''}`}
1027 >
1028 <Icon icon="bookmark" />
1029 <span>{bookmarked ? t`Unbookmark` : t`Bookmark`}</span>
1030 </MenuItem>
1031 )}
1032 </div>
1033 </>
1034 )}
1035 {!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && (
1036 <MenuDivider />
1037 )}
1038 {(isSizeLarge || showActionsBar) && (
1039 <>
1040 <MenuItem
1041 onClick={() => {
1042 states.showGenericAccounts = {
1043 heading: t`Boosted/Liked by…`,
1044 fetchAccounts: fetchBoostedLikedByAccounts,
1045 instance,
1046 showReactions: true,
1047 postID: sKey,
1048 };
1049 }}
1050 >
1051 <Icon icon="react" />
1052 <span>
1053 <Trans>Boosted/Liked by…</Trans>
1054 </span>
1055 </MenuItem>
1056 </>
1057 )}
1058 {(isSizeLarge ||
1059 (!mediaFirst &&
1060 (enableTranslate || !language || differentLanguage))) && (
1061 <MenuDivider />
1062 )}
1063 {!mediaFirst && (enableTranslate || !language || differentLanguage) && (
1064 <div class={supportsTTS ? 'menu-horizontal' : ''}>
1065 {enableTranslate ? (
1066 <MenuItem
1067 disabled={forceTranslate}
1068 onClick={() => setForceTranslate(true)}
1069 >
1070 <Icon icon="translate" />
1071 <span>
1072 <Trans>Translate</Trans>
1073 </span>
1074 </MenuItem>
1075 ) : (
1076 <MenuLink
1077 to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
1078 >
1079 <Icon icon="translate" />
1080 <span>
1081 <Trans>Translate</Trans>
1082 </span>
1083 </MenuLink>
1084 )}
1085 {supportsTTS && (
1086 <MenuItem
1087 onClick={() => {
1088 try {
1089 const postText = getPostText(status);
1090 if (postText) {
1091 speak(postText, language);
1092 }
1093 } catch (error) {
1094 console.error('Failed to speak text:', error);
1095 }
1096 }}
1097 >
1098 <Icon icon="speak" />
1099 <span>
1100 <Trans>Speak</Trans>
1101 </span>
1102 </MenuItem>
1103 )}
1104 </div>
1105 )}
1106 {isSizeLarge && (
1107 <MenuItem
1108 onClick={() => {
1109 try {
1110 const postText = getPostText(status);
1111 navigator.clipboard.writeText(postText);
1112 showToast(t`Post text copied`);
1113 } catch (e) {
1114 console.error(e);
1115 showToast(t`Unable to copy post text`);
1116 }
1117 }}
1118 >
1119 <Icon icon="clipboard" />
1120 <span>
1121 <Trans>Copy post text</Trans>
1122 </span>
1123 </MenuItem>
1124 )}
1125 {((!isSizeLarge && sameInstance) ||
1126 enableTranslate ||
1127 !language ||
1128 differentLanguage) && <MenuDivider />}
1129 {!isSizeLarge && (
1130 <>
1131 <MenuLink
1132 to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
1133 onClick={(e) => {
1134 onStatusLinkClick(e, status);
1135 }}
1136 >
1137 <Icon icon="arrows-right" />
1138 <small>
1139 <Trans>
1140 View post by{' '}
1141 <span class="bidi-isolate">@{username || acct}</span>
1142 </Trans>
1143 <br />
1144 <span class="more-insignificant">
1145 {_(visibilityText[visibility])} • {createdDateText}
1146 </span>
1147 </small>
1148 </MenuLink>
1149 </>
1150 )}
1151 {!!editedAt && (
1152 <>
1153 <MenuItem
1154 onClick={() => {
1155 setShowEdited(id);
1156 }}
1157 >
1158 <Icon icon="history" />
1159 <small>
1160 <Trans>Show Edit History</Trans>
1161 <br />
1162 <span class="more-insignificant">
1163 <Trans>Edited: {editedDateText}</Trans>
1164 </span>
1165 </small>
1166 </MenuItem>
1167 </>
1168 )}
1169 <MenuItem href={url} target="_blank">
1170 <Icon icon="external" />
1171 <small
1172 class="menu-double-lines"
1173 style={{
1174 maxWidth: '16em',
1175 }}
1176 >
1177 {nicePostURL(url)}
1178 </small>
1179 </MenuItem>
1180 <div class="menu-horizontal">
1181 <MenuItem
1182 onClick={() => {
1183 // Copy url to clipboard
1184 try {
1185 navigator.clipboard.writeText(url);
1186 showToast(t`Link copied`);
1187 } catch (e) {
1188 console.error(e);
1189 showToast(t`Unable to copy link`);
1190 }
1191 }}
1192 >
1193 <Icon icon="link" />
1194 <span>
1195 <Trans>Copy</Trans>
1196 </span>
1197 </MenuItem>
1198 {isPublic &&
1199 navigator?.share &&
1200 navigator?.canShare?.({
1201 url,
1202 }) && (
1203 <MenuItem
1204 onClick={() => {
1205 try {
1206 navigator.share({
1207 url,
1208 });
1209 } catch (e) {
1210 console.error(e);
1211 alert(t`Sharing doesn't seem to work.`);
1212 }
1213 }}
1214 >
1215 <Icon icon="share" />
1216 <span>
1217 <Trans>Share…</Trans>
1218 </span>
1219 </MenuItem>
1220 )}
1221 </div>
1222 {isPublic && isSizeLarge && (
1223 <MenuItem
1224 onClick={() => {
1225 setShowEmbed(true);
1226 }}
1227 >
1228 <Icon icon="code" />
1229 <span>
1230 <Trans>Embed post</Trans>
1231 </span>
1232 </MenuItem>
1233 )}
1234 {(isSelf || mentionSelf) && <MenuDivider />}
1235 {(isSelf || mentionSelf) && (
1236 <MenuItem
1237 onClick={async () => {
1238 try {
1239 const newStatus = await masto.v1.statuses
1240 .$select(id)
1241 [muted ? 'unmute' : 'mute']();
1242 saveStatus(newStatus, instance);
1243 showToast(
1244 muted ? t`Conversation unmuted` : t`Conversation muted`,
1245 );
1246 } catch (e) {
1247 console.error(e);
1248 showToast(
1249 muted
1250 ? t`Unable to unmute conversation`
1251 : t`Unable to mute conversation`,
1252 );
1253 }
1254 }}
1255 >
1256 {muted ? (
1257 <>
1258 <Icon icon="unmute" />
1259 <span>
1260 <Trans>Unmute conversation</Trans>
1261 </span>
1262 </>
1263 ) : (
1264 <>
1265 <Icon icon="mute" />
1266 <span>
1267 <Trans>Mute conversation</Trans>
1268 </span>
1269 </>
1270 )}
1271 </MenuItem>
1272 )}
1273 {isSelf && isPinnable && (
1274 <MenuItem
1275 onClick={async () => {
1276 try {
1277 const newStatus = await masto.v1.statuses
1278 .$select(id)
1279 [pinned ? 'unpin' : 'pin']();
1280 saveStatus(newStatus, instance);
1281 showToast(
1282 pinned
1283 ? t`Post unpinned from profile`
1284 : t`Post pinned to profile`,
1285 );
1286 } catch (e) {
1287 console.error(e);
1288 showToast(
1289 pinned ? t`Unable to unpin post` : t`Unable to pin post`,
1290 );
1291 }
1292 }}
1293 >
1294 {pinned ? (
1295 <>
1296 <Icon icon="unpin" />
1297 <span>
1298 <Trans>Unpin from profile</Trans>
1299 </span>
1300 </>
1301 ) : (
1302 <>
1303 <Icon icon="pin" />
1304 <span>
1305 <Trans>Pin to profile</Trans>
1306 </span>
1307 </>
1308 )}
1309 </MenuItem>
1310 )}
1311 {isSelf && (
1312 <div class="menu-horizontal">
1313 {supports('@mastodon/post-edit') && (
1314 <MenuItem
1315 onClick={() => {
1316 showCompose({
1317 editStatus: status,
1318 });
1319 }}
1320 >
1321 <Icon icon="pencil" />
1322 <span>
1323 <Trans>Edit</Trans>
1324 </span>
1325 </MenuItem>
1326 )}
1327 {isSizeLarge && (
1328 <MenuConfirm
1329 subMenu
1330 confirmLabel={
1331 <>
1332 <Icon icon="trash" />
1333 <span>
1334 <Trans>Delete this post?</Trans>
1335 </span>
1336 </>
1337 }
1338 itemProps={{
1339 className: 'danger',
1340 }}
1341 menuItemClassName="danger"
1342 onClick={() => {
1343 // const yes = confirm('Delete this post?');
1344 // if (yes) {
1345 (async () => {
1346 try {
1347 await masto.v1.statuses.$select(id).remove();
1348 const cachedStatus = getStatus(id, instance);
1349 cachedStatus._deleted = true;
1350 showToast(t`Post deleted`);
1351 } catch (e) {
1352 console.error(e);
1353 showToast(t`Unable to delete post`);
1354 }
1355 })();
1356 // }
1357 }}
1358 >
1359 <Icon icon="trash" />
1360 <span>
1361 <Trans>Delete…</Trans>
1362 </span>
1363 </MenuConfirm>
1364 )}
1365 </div>
1366 )}
1367 {!isSelf && isSizeLarge && (
1368 <>
1369 <MenuDivider />
1370 <MenuItem
1371 className="danger"
1372 onClick={() => {
1373 states.showReportModal = {
1374 account: status.account,
1375 post: status,
1376 };
1377 }}
1378 >
1379 <Icon icon="flag" />
1380 <span>
1381 <Trans>Report post…</Trans>
1382 </span>
1383 </MenuItem>
1384 </>
1385 )}
1386 </>
1387 );
1388
1389 const contextMenuRef = useRef();
1390 const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
1391 const [contextMenuProps, setContextMenuProps] = useState({});
1392
1393 const showContextMenu =
1394 allowContextMenu || (!isSizeLarge && !previewMode && !_deleted && !quoted);
1395
1396 // Only iOS/iPadOS browsers don't support contextmenu
1397 // Some comments report iPadOS might support contextmenu if a mouse is connected
1398 const bindLongPressContext = useLongPress(
1399 isIOS && showContextMenu
1400 ? (e) => {
1401 if (e.pointerType === 'mouse') return;
1402 // There's 'pen' too, but not sure if contextmenu event would trigger from a pen
1403
1404 const { clientX, clientY } = e.touches?.[0] || e;
1405 // link detection copied from onContextMenu because here it works
1406 const link = e.target.closest('a');
1407 if (
1408 link &&
1409 statusRef.current.contains(link) &&
1410 !link.getAttribute('href').startsWith('#')
1411 )
1412 return;
1413 e.preventDefault();
1414 setContextMenuProps({
1415 anchorPoint: {
1416 x: clientX,
1417 y: clientY,
1418 },
1419 direction: 'right',
1420 });
1421 setIsContextMenuOpen(true);
1422 }
1423 : null,
1424 {
1425 threshold: 600,
1426 captureEvent: true,
1427 detect: 'touch',
1428 cancelOnMovement: 2, // true allows movement of up to 25 pixels
1429 },
1430 );
1431
1432 const hotkeysEnabled = !readOnly && !previewMode && !quoted;
1433 const rRef = useHotkeys('r, shift+r', replyStatus, {
1434 enabled: hotkeysEnabled,
1435 useKey: true,
1436 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey,
1437 });
1438 const fRef = useHotkeys('f, l', favouriteStatusNotify, {
1439 enabled: hotkeysEnabled,
1440 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey,
1441 useKey: true,
1442 });
1443 const dRef = useHotkeys('d', bookmarkStatusNotify, {
1444 enabled: hotkeysEnabled,
1445 useKey: true,
1446 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey,
1447 });
1448 const bRef = useHotkeys(
1449 'shift+b',
1450 (e) => {
1451 // Need shiftKey check due to useKey: true
1452 if (!e.shiftKey) return;
1453
1454 (async () => {
1455 try {
1456 const done = await confirmBoostStatus();
1457 if (!isSizeLarge && done) {
1458 showToast(
1459 reblogged
1460 ? t`Unboosted @${username || acct}'s post`
1461 : t`Boosted @${username || acct}'s post`,
1462 );
1463 }
1464 } catch (e) {}
1465 })();
1466 },
1467 {
1468 enabled: hotkeysEnabled && canBoost,
1469 useKey: true,
1470 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey,
1471 },
1472 );
1473 const xRef = useHotkeys(
1474 'x',
1475 (e) => {
1476 const activeStatus = document.activeElement.closest(
1477 '.status-link, .status-focus',
1478 );
1479 if (activeStatus) {
1480 const spoilerButton = activeStatus.querySelector(
1481 '.spoiler-button:not(.spoiling)',
1482 );
1483 if (spoilerButton) {
1484 e.stopPropagation();
1485 spoilerButton.click();
1486 } else {
1487 const spoilerMediaButton = activeStatus.querySelector(
1488 '.spoiler-media-button:not(.spoiling)',
1489 );
1490 if (spoilerMediaButton) {
1491 e.stopPropagation();
1492 spoilerMediaButton.click();
1493 }
1494 }
1495 }
1496 },
1497 {
1498 useKey: true,
1499 ignoreEventWhen: (e) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey,
1500 },
1501 );
1502
1503 const displayedMediaAttachments = mediaAttachments.slice(
1504 0,
1505 isSizeLarge ? undefined : 4,
1506 );
1507 const showMultipleMediaCaptions =
1508 mediaAttachments.length > 1 &&
1509 displayedMediaAttachments.some(
1510 (media) => !!media.description && !isMediaCaptionLong(media.description),
1511 );
1512 const captionChildren = useMemo(() => {
1513 if (!showMultipleMediaCaptions) return null;
1514 const attachments = [];
1515 displayedMediaAttachments.forEach((media, i) => {
1516 if (!media.description) return;
1517 const index = attachments.findIndex(
1518 (attachment) => attachment.media.description === media.description,
1519 );
1520 if (index === -1) {
1521 attachments.push({
1522 media,
1523 indices: [i],
1524 });
1525 } else {
1526 attachments[index].indices.push(i);
1527 }
1528 });
1529 return attachments.map(({ media, indices }) => (
1530 <div
1531 key={media.id}
1532 data-caption-index={indices.map((i) => i + 1).join(' ')}
1533 onClick={(e) => {
1534 e.preventDefault();
1535 e.stopPropagation();
1536 states.showMediaAlt = {
1537 alt: media.description,
1538 lang: language,
1539 };
1540 }}
1541 title={media.description}
1542 >
1543 <sup>{indices.map((i) => i + 1).join(' ')}</sup> {media.description}
1544 </div>
1545 ));
1546
1547 // return displayedMediaAttachments.map(
1548 // (media, i) =>
1549 // !!media.description && (
1550 // <div
1551 // key={media.id}
1552 // data-caption-index={i + 1}
1553 // onClick={(e) => {
1554 // e.preventDefault();
1555 // e.stopPropagation();
1556 // states.showMediaAlt = {
1557 // alt: media.description,
1558 // lang: language,
1559 // };
1560 // }}
1561 // title={media.description}
1562 // >
1563 // <sup>{i + 1}</sup> {media.description}
1564 // </div>
1565 // ),
1566 // );
1567 }, [showMultipleMediaCaptions, displayedMediaAttachments, language]);
1568
1569 const isThread = useMemo(() => {
1570 return (
1571 (!!inReplyToId && inReplyToAccountId === status.account?.id) ||
1572 !!snapStates.statusThreadNumber[sKey]
1573 );
1574 }, [
1575 inReplyToId,
1576 inReplyToAccountId,
1577 status.account?.id,
1578 snapStates.statusThreadNumber[sKey],
1579 ]);
1580
1581 const showCommentHint = useMemo(() => {
1582 return (
1583 enableCommentHint &&
1584 !isThread &&
1585 !withinContext &&
1586 !inReplyToId &&
1587 visibility === 'public' &&
1588 repliesCount > 0
1589 );
1590 }, [
1591 enableCommentHint,
1592 isThread,
1593 withinContext,
1594 inReplyToId,
1595 repliesCount,
1596 visibility,
1597 ]);
1598 const showCommentCount = useMemo(() => {
1599 if (
1600 card ||
1601 poll ||
1602 sensitive ||
1603 spoilerText ||
1604 mediaAttachments?.length ||
1605 isThread ||
1606 withinContext ||
1607 inReplyToId ||
1608 repliesCount <= 0
1609 ) {
1610 return false;
1611 }
1612 const questionRegex = /[???︖❓❔⁇⁈⁉¿‽؟]/;
1613 const containsQuestion = questionRegex.test(content);
1614 if (!containsQuestion) return false;
1615 const contentLength = htmlContentLength(content);
1616 if (contentLength > 0 && contentLength <= SHOW_COMMENT_COUNT_LIMIT) {
1617 return true;
1618 }
1619 }, [
1620 card,
1621 poll,
1622 sensitive,
1623 spoilerText,
1624 mediaAttachments,
1625 reblog,
1626 isThread,
1627 withinContext,
1628 inReplyToId,
1629 repliesCount,
1630 content,
1631 ]);
1632
1633 return (
1634 <StatusParent>
1635 {showReplyParent && !!(inReplyToId && inReplyToAccountId) && (
1636 <StatusCompact sKey={sKey} />
1637 )}
1638 <article
1639 data-state-post-id={sKey}
1640 ref={(node) => {
1641 statusRef.current = node;
1642 // Use parent node if it's in focus
1643 // Use case: <a><status /></a>
1644 // When navigating (j/k), the <a> is focused instead of <status />
1645 // Hotkey binding doesn't bubble up thus this hack
1646 const nodeRef =
1647 node?.closest?.(
1648 '.timeline-item, .timeline-item-alt, .status-link, .status-focus',
1649 ) || node;
1650 rRef.current = nodeRef;
1651 fRef.current = nodeRef;
1652 dRef.current = nodeRef;
1653 bRef.current = nodeRef;
1654 xRef.current = nodeRef;
1655 }}
1656 tabindex="-1"
1657 class={`status ${
1658 !withinContext && inReplyToId && inReplyToAccount
1659 ? 'status-reply-to'
1660 : ''
1661 } visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
1662 SIZE_CLASS[size]
1663 } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
1664 isContextMenuOpen ? 'status-menu-open' : ''
1665 } ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`}
1666 onMouseEnter={debugHover}
1667 onContextMenu={(e) => {
1668 if (!showContextMenu) return;
1669 if (e.metaKey) return;
1670 // console.log('context menu', e);
1671 const link = e.target.closest('a');
1672 if (
1673 link &&
1674 statusRef.current.contains(link) &&
1675 !link.getAttribute('href').startsWith('#')
1676 )
1677 return;
1678
1679 // If there's selected text, don't show custom context menu
1680 const selection = window.getSelection?.();
1681 if (selection.toString().length > 0) {
1682 const { anchorNode } = selection;
1683 if (statusRef.current?.contains(anchorNode)) {
1684 return;
1685 }
1686 }
1687 e.preventDefault();
1688 setContextMenuProps({
1689 anchorPoint: {
1690 x: e.clientX,
1691 y: e.clientY,
1692 },
1693 direction: 'right',
1694 });
1695 setIsContextMenuOpen(true);
1696 }}
1697 {...(showContextMenu ? bindLongPressContext() : {})}
1698 >
1699 {showContextMenu && (
1700 <ControlledMenu
1701 ref={contextMenuRef}
1702 state={isContextMenuOpen ? 'open' : undefined}
1703 {...contextMenuProps}
1704 onClose={(e) => {
1705 setIsContextMenuOpen(false);
1706 // statusRef.current?.focus?.();
1707 if (e?.reason === 'click') {
1708 statusRef.current?.closest('[tabindex]')?.focus?.();
1709 }
1710 }}
1711 portal={{
1712 target: document.body,
1713 }}
1714 containerProps={{
1715 style: {
1716 // Higher than the backdrop
1717 zIndex: 1001,
1718 },
1719 onClick: () => {
1720 contextMenuRef.current?.closeMenu?.();
1721 },
1722 }}
1723 overflow="auto"
1724 boundingBoxPadding={safeBoundingBoxPadding()}
1725 unmountOnClose
1726 >
1727 {StatusMenuItems}
1728 </ControlledMenu>
1729 )}
1730 {showActionsBar &&
1731 size !== 'l' &&
1732 !previewMode &&
1733 !readOnly &&
1734 !_deleted &&
1735 !quoted && (
1736 <div
1737 class={`status-actions ${
1738 isContextMenuOpen === 'actions-bar' ? 'open' : ''
1739 }`}
1740 ref={actionsRef}
1741 >
1742 <StatusButton
1743 size="s"
1744 title={t`Reply`}
1745 alt={t`Reply`}
1746 class="reply-button"
1747 icon="comment"
1748 iconSize="m"
1749 onClick={replyStatus}
1750 />
1751 <StatusButton
1752 size="s"
1753 checked={favourited}
1754 title={[t`Like`, t`Unlike`]}
1755 alt={[t`Like`, t`Liked`]}
1756 class="favourite-button"
1757 icon="heart"
1758 iconSize="m"
1759 count={favouritesCount}
1760 onClick={favouriteStatusNotify}
1761 />
1762 <button
1763 type="button"
1764 title={t`More`}
1765 class="plain more-button"
1766 onClick={(e) => {
1767 e.preventDefault();
1768 e.stopPropagation();
1769 setContextMenuProps({
1770 anchorRef: {
1771 current: e.currentTarget,
1772 },
1773 align: 'start',
1774 direction: 'left',
1775 gap: 0,
1776 shift: -8,
1777 });
1778 setIsContextMenuOpen('actions-bar');
1779 }}
1780 >
1781 <Icon icon="more2" size="m" alt={t`More`} />
1782 </button>
1783 </div>
1784 )}
1785 {size !== 'l' && (
1786 <div class="status-badge">
1787 {reblogged && (
1788 <Icon class="reblog" icon="rocket" size="s" alt={t`Boosted`} />
1789 )}
1790 {favourited && (
1791 <Icon class="favourite" icon="heart" size="s" alt={t`Liked`} />
1792 )}
1793 {bookmarked && (
1794 <Icon
1795 class="bookmark"
1796 icon="bookmark"
1797 size="s"
1798 alt={t`Bookmarked`}
1799 />
1800 )}
1801 {_pinned && (
1802 <Icon class="pin" icon="pin" size="s" alt={t`Pinned`} />
1803 )}
1804 </div>
1805 )}
1806 {size !== 's' && (
1807 <a
1808 href={accountURL}
1809 tabindex="-1"
1810 // target="_blank"
1811 title={`@${acct}`}
1812 onClick={(e) => {
1813 e.preventDefault();
1814 e.stopPropagation();
1815 states.showAccount = {
1816 account: status.account,
1817 instance,
1818 };
1819 }}
1820 >
1821 <Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} />
1822 </a>
1823 )}
1824 <div class="container">
1825 {!!(status.account || createdAt) && (
1826 <div class="meta">
1827 <span class="meta-name">
1828 <NameText
1829 account={status.account}
1830 instance={instance}
1831 showAvatar={size === 's'}
1832 showAcct={isSizeLarge}
1833 />
1834 </span>
1835 {withinContext && isThread && (
1836 <ThreadBadge
1837 showIcon={isSizeLarge}
1838 index={snapStates.statusThreadNumber[sKey]}
1839 />
1840 )}
1841 {/* {inReplyToAccount && !withinContext && size !== 's' && (
1842 <>
1843 {' '}
1844 <span class="ib">
1845 <Icon icon="arrow-right" class="arrow" />{' '}
1846 <NameText account={inReplyToAccount} instance={instance} short />
1847 </span>
1848 </>
1849 )} */}
1850 {/* </span> */}{' '}
1851 {size !== 'l' &&
1852 (_deleted ? (
1853 <span class="status-deleted-tag">
1854 <Trans>Deleted</Trans>
1855 </span>
1856 ) : url && !previewMode && !readOnly && !quoted ? (
1857 <Link
1858 to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
1859 onClick={(e) => {
1860 if (
1861 e.metaKey ||
1862 e.ctrlKey ||
1863 e.shiftKey ||
1864 e.altKey ||
1865 e.which === 2
1866 ) {
1867 return;
1868 }
1869 e.preventDefault();
1870 e.stopPropagation();
1871 onStatusLinkClick?.(e, status);
1872 setContextMenuProps({
1873 anchorRef: {
1874 current: e.currentTarget,
1875 },
1876 align: 'end',
1877 direction: 'bottom',
1878 gap: 4,
1879 });
1880 setIsContextMenuOpen(true);
1881 }}
1882 class={`time ${
1883 isContextMenuOpen && contextMenuProps?.anchorRef
1884 ? 'is-open'
1885 : ''
1886 }`}
1887 >
1888 {showCommentHint && !showCommentCount ? (
1889 <Icon
1890 icon="comment2"
1891 size="s"
1892 // alt={`${repliesCount} ${
1893 // repliesCount === 1 ? 'reply' : 'replies'
1894 // }`}
1895 alt={plural(repliesCount, {
1896 one: '# reply',
1897 other: '# replies',
1898 })}
1899 />
1900 ) : (
1901 visibility !== 'public' &&
1902 visibility !== 'direct' && (
1903 <Icon
1904 icon={visibilityIconsMap[visibility]}
1905 alt={_(visibilityText[visibility])}
1906 size="s"
1907 />
1908 )
1909 )}{' '}
1910 <RelativeTime datetime={createdAtDate} format="micro" />
1911 {!previewMode && !readOnly && (
1912 <Icon icon="more2" class="more" alt={t`More`} />
1913 )}
1914 </Link>
1915 ) : (
1916 // <Menu
1917 // instanceRef={menuInstanceRef}
1918 // portal={{
1919 // target: document.body,
1920 // }}
1921 // containerProps={{
1922 // style: {
1923 // // Higher than the backdrop
1924 // zIndex: 1001,
1925 // },
1926 // onClick: (e) => {
1927 // if (e.target === e.currentTarget)
1928 // menuInstanceRef.current?.closeMenu?.();
1929 // },
1930 // }}
1931 // align="end"
1932 // gap={4}
1933 // overflow="auto"
1934 // viewScroll="close"
1935 // boundingBoxPadding="8 8 8 8"
1936 // unmountOnClose
1937 // menuButton={({ open }) => (
1938 // <Link
1939 // to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
1940 // onClick={(e) => {
1941 // e.preventDefault();
1942 // e.stopPropagation();
1943 // onStatusLinkClick?.(e, status);
1944 // }}
1945 // class={`time ${open ? 'is-open' : ''}`}
1946 // >
1947 // <Icon
1948 // icon={visibilityIconsMap[visibility]}
1949 // alt={visibilityText[visibility]}
1950 // size="s"
1951 // />{' '}
1952 // <RelativeTime datetime={createdAtDate} format="micro" />
1953 // </Link>
1954 // )}
1955 // >
1956 // {StatusMenuItems}
1957 // </Menu>
1958 <span class="time">
1959 {visibility !== 'public' && visibility !== 'direct' && (
1960 <>
1961 <Icon
1962 icon={visibilityIconsMap[visibility]}
1963 alt={_(visibilityText[visibility])}
1964 size="s"
1965 />{' '}
1966 </>
1967 )}
1968 <RelativeTime datetime={createdAtDate} format="micro" />
1969 </span>
1970 ))}
1971 </div>
1972 )}
1973 {visibility === 'direct' && (
1974 <>
1975 <div class="status-direct-badge">
1976 <Trans>Private mention</Trans>
1977 </div>{' '}
1978 </>
1979 )}
1980 {!withinContext && (
1981 <>
1982 {isThread ? (
1983 <ThreadBadge
1984 showIcon
1985 showText
1986 index={snapStates.statusThreadNumber[sKey]}
1987 />
1988 ) : (
1989 !!inReplyToId &&
1990 !!inReplyToAccount &&
1991 (!!spoilerText ||
1992 !mentions.find((mention) => {
1993 return mention.id === inReplyToAccountId;
1994 })) && (
1995 <div class="status-reply-badge">
1996 <Icon icon="reply" />{' '}
1997 <NameText
1998 account={inReplyToAccount}
1999 instance={instance}
2000 short
2001 />
2002 </div>
2003 )
2004 )}
2005 </>
2006 )}
2007 <div
2008 class={`content-container ${
2009 spoilerText || sensitive || filterInfo?.action === 'blur'
2010 ? 'has-spoiler'
2011 : ''
2012 } ${showSpoiler ? 'show-spoiler' : ''} ${
2013 showSpoilerMedia ? 'show-media' : ''
2014 }`}
2015 data-content-text-weight={contentTextWeight ? textWeight() : null}
2016 style={
2017 (isSizeLarge || contentTextWeight) && {
2018 '--content-text-weight': textWeight(),
2019 }
2020 }
2021 >
2022 {mediaFirst && hasMediaAttachments ? (
2023 <>
2024 {(!!spoilerText || !!sensitive) && !readingExpandSpoilers && (
2025 <>
2026 {!!spoilerText && (
2027 <span
2028 class="spoiler-content media-first-spoiler-content"
2029 lang={language}
2030 dir="auto"
2031 ref={spoilerContentRef}
2032 data-read-more={_(readMoreText)}
2033 >
2034 <EmojiText text={spoilerText} emojis={emojis} />{' '}
2035 </span>
2036 )}
2037 <button
2038 class={`light spoiler-button media-first-spoiler-button ${
2039 showSpoiler ? 'spoiling' : ''
2040 }`}
2041 type="button"
2042 onClick={(e) => {
2043 e.preventDefault();
2044 e.stopPropagation();
2045 if (showSpoiler) {
2046 delete states.spoilers[id];
2047 if (!readingExpandSpoilers) {
2048 delete states.spoilersMedia[id];
2049 }
2050 } else {
2051 states.spoilers[id] = true;
2052 if (!readingExpandSpoilers) {
2053 states.spoilersMedia[id] = true;
2054 }
2055 }
2056 }}
2057 >
2058 <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
2059 {showSpoiler ? t`Show less` : t`Show content`}
2060 </button>
2061 </>
2062 )}
2063 <MediaFirstContainer
2064 mediaAttachments={mediaAttachments}
2065 language={language}
2066 postID={id}
2067 instance={instance}
2068 />
2069 {!!content && (
2070 <div class="media-first-content content" ref={contentRef}>
2071 <PostContent
2072 post={status}
2073 instance={instance}
2074 previewMode={previewMode}
2075 />
2076 </div>
2077 )}
2078 </>
2079 ) : (
2080 <>
2081 {!!spoilerText && (
2082 <>
2083 <div
2084 class="content spoiler-content"
2085 lang={language}
2086 dir="auto"
2087 ref={spoilerContentRef}
2088 data-read-more={_(readMoreText)}
2089 >
2090 <p>
2091 <EmojiText text={spoilerText} emojis={emojis} />
2092 </p>
2093 </div>
2094 {readingExpandSpoilers || previewMode ? (
2095 <div class="spoiler-divider">
2096 <Icon icon="eye-open" /> <Trans>Content warning</Trans>
2097 </div>
2098 ) : (
2099 <button
2100 class={`light spoiler-button ${
2101 showSpoiler ? 'spoiling' : ''
2102 }`}
2103 type="button"
2104 onClick={(e) => {
2105 e.preventDefault();
2106 e.stopPropagation();
2107 if (showSpoiler) {
2108 delete states.spoilers[id];
2109 if (!readingExpandSpoilers) {
2110 delete states.spoilersMedia[id];
2111 }
2112 } else {
2113 states.spoilers[id] = true;
2114 if (!readingExpandSpoilers) {
2115 states.spoilersMedia[id] = true;
2116 }
2117 }
2118 }}
2119 >
2120 <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
2121 {showSpoiler ? t`Show less` : t`Show content`}
2122 </button>
2123 )}
2124 </>
2125 )}
2126 {!!content && (
2127 <div
2128 class="content"
2129 ref={contentRef}
2130 data-read-more={_(readMoreText)}
2131 inert={!!spoilerText && !showSpoiler ? true : undefined}
2132 >
2133 <PostContent
2134 key={reloadPostContentCount}
2135 post={status}
2136 instance={instance}
2137 previewMode={previewMode}
2138 />
2139 <QuoteStatuses id={id} instance={instance} level={quoted} />
2140 </div>
2141 )}
2142 {!!content && (
2143 <MathBlock
2144 content={content}
2145 contentRef={contentRef}
2146 onRevert={reloadPostContent}
2147 />
2148 )}
2149 {!!poll && (
2150 <Poll
2151 lang={language}
2152 poll={poll}
2153 readOnly={readOnly || !sameInstance || !authenticated}
2154 onUpdate={(newPoll) => {
2155 states.statuses[sKey].poll = newPoll;
2156 }}
2157 refresh={() => {
2158 return masto.v1.polls
2159 .$select(poll.id)
2160 .fetch()
2161 .then((pollResponse) => {
2162 states.statuses[sKey].poll = pollResponse;
2163 })
2164 .catch((e) => {}); // Silently fail
2165 }}
2166 votePoll={(choices) => {
2167 return masto.v1.polls
2168 .$select(poll.id)
2169 .votes.create({
2170 choices,
2171 })
2172 .then((pollResponse) => {
2173 states.statuses[sKey].poll = pollResponse;
2174 })
2175 .catch((e) => {}); // Silently fail
2176 }}
2177 />
2178 )}
2179 {(((enableTranslate || inlineTranslate) &&
2180 isTranslateble(content, emojis) &&
2181 differentLanguage) ||
2182 forceTranslate) && (
2183 <TranslationBlock
2184 forceTranslate={forceTranslate || inlineTranslate}
2185 mini={!isSizeLarge && !withinContext}
2186 sourceLanguage={language}
2187 autoDetected={languageAutoDetected}
2188 text={getPostText(status, {
2189 maskCustomEmojis: true,
2190 maskURLs: true,
2191 })}
2192 />
2193 )}
2194 {!previewMode &&
2195 (sensitive || filterInfo?.action === 'blur') &&
2196 !!mediaAttachments.length &&
2197 (readingExpandMedia !== 'show_all' ||
2198 filterInfo?.action === 'blur') && (
2199 <button
2200 class={`plain spoiler-media-button ${
2201 showSpoilerMedia ? 'spoiling' : ''
2202 }`}
2203 type="button"
2204 hidden={!readingExpandSpoilers && !!spoilerText}
2205 onClick={(e) => {
2206 e.preventDefault();
2207 e.stopPropagation();
2208 if (showSpoilerMedia) {
2209 delete states.spoilersMedia[id];
2210 } else {
2211 states.spoilersMedia[id] = true;
2212 }
2213 }}
2214 >
2215 <Icon
2216 icon={showSpoilerMedia ? 'eye-open' : 'eye-close'}
2217 />{' '}
2218 <span>
2219 {filterInfo?.action === 'blur' && (
2220 <small>
2221 <Trans>Filtered: {filterInfo?.titlesStr}</Trans>
2222 <br />
2223 </small>
2224 )}
2225 {showSpoilerMedia ? t`Show less` : t`Show media`}
2226 </span>
2227 </button>
2228 )}
2229 {!!mediaAttachments.length &&
2230 (mediaAttachments.length > 1 &&
2231 (isSizeLarge || (withinContext && size === 'm')) ? (
2232 <div class="media-large-container">
2233 {mediaAttachments.map((media, i) => (
2234 <div key={media.id} class={`media-container media-eq1`}>
2235 <Media
2236 media={media}
2237 autoAnimate
2238 showCaption
2239 allowLongerCaption={!content || isSizeLarge}
2240 lang={language}
2241 to={`/${instance}/s/${id}?${
2242 withinContext ? 'media' : 'media-only'
2243 }=${i + 1}`}
2244 onClick={
2245 onMediaClick
2246 ? (e) => onMediaClick(e, i, media, status)
2247 : undefined
2248 }
2249 />
2250 </div>
2251 ))}
2252 </div>
2253 ) : (
2254 <MultipleMediaFigure
2255 lang={language}
2256 enabled={showMultipleMediaCaptions}
2257 captionChildren={captionChildren}
2258 >
2259 <div
2260 ref={mediaContainerRef}
2261 class={`media-container media-eq${
2262 mediaAttachments.length
2263 } ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${
2264 mediaAttachments.length > 4 ? 'media-gt4' : ''
2265 }`}
2266 >
2267 {displayedMediaAttachments.map((media, i) => (
2268 <Media
2269 key={media.id}
2270 media={media}
2271 autoAnimate={isSizeLarge}
2272 showCaption={mediaAttachments.length === 1}
2273 allowLongerCaption={
2274 !content && mediaAttachments.length === 1
2275 }
2276 lang={language}
2277 altIndex={
2278 showMultipleMediaCaptions &&
2279 !!media.description &&
2280 i + 1
2281 }
2282 to={`/${instance}/s/${id}?${
2283 withinContext ? 'media' : 'media-only'
2284 }=${i + 1}`}
2285 onClick={
2286 onMediaClick
2287 ? (e) => {
2288 onMediaClick(e, i, media, status);
2289 }
2290 : undefined
2291 }
2292 checkAspectRatio={mediaAttachments.length === 1}
2293 />
2294 ))}
2295 </div>
2296 </MultipleMediaFigure>
2297 ))}
2298 {!!card &&
2299 /^https/i.test(card?.url) &&
2300 !sensitive &&
2301 !spoilerText &&
2302 !poll &&
2303 !mediaAttachments.length &&
2304 !snapStates.statusQuotes[sKey] && (
2305 <StatusCard
2306 card={card}
2307 selfReferential={
2308 card?.url === status.url || card?.url === status.uri
2309 }
2310 selfAuthor={card?.authors?.some(
2311 (a) => a.account?.url === accountURL,
2312 )}
2313 instance={currentInstance}
2314 />
2315 )}
2316 </>
2317 )}
2318 </div>
2319 {!isSizeLarge && showCommentCount && (
2320 <div class="content-comment-hint insignificant">
2321 <Icon icon="comment2" alt={t`Replies`} /> {repliesCount}
2322 </div>
2323 )}
2324 {isSizeLarge && (
2325 <>
2326 <div class="extra-meta">
2327 {_deleted ? (
2328 <span class="status-deleted-tag">
2329 <Trans>Deleted</Trans>
2330 </span>
2331 ) : (
2332 <>
2333 {/* <Icon
2334 icon={visibilityIconsMap[visibility]}
2335 alt={visibilityText[visibility]}
2336 /> */}
2337 <span>{_(visibilityText[visibility])}</span> •{' '}
2338 <a href={url} target="_blank" rel="noopener">
2339 {
2340 // within a day
2341 Date.now() - createdAtDate.getTime() < 86400000 && (
2342 <>
2343 <RelativeTime
2344 datetime={createdAtDate}
2345 format="micro"
2346 />{' '}
2347 ‒{' '}
2348 </>
2349 )
2350 }
2351 {!!createdAt && (
2352 <time
2353 class="created"
2354 datetime={createdAtDate.toISOString()}
2355 title={createdAtDate.toLocaleString()}
2356 >
2357 {createdDateText}
2358 </time>
2359 )}
2360 </a>
2361 {editedAt && (
2362 <>
2363 {' '}
2364 • <Icon icon="pencil" alt={t`Edited`} />{' '}
2365 <time
2366 tabIndex="0"
2367 class="edited"
2368 datetime={editedAtDate.toISOString()}
2369 onClick={() => {
2370 setShowEdited(id);
2371 }}
2372 >
2373 {editedDateText}
2374 </time>
2375 </>
2376 )}
2377 </>
2378 )}
2379 </div>
2380 {!!emojiReactions?.length && (
2381 <div class="emoji-reactions">
2382 {emojiReactions.map((emojiReaction) => {
2383 const { name, count, me, url, staticUrl } = emojiReaction;
2384 if (url) {
2385 // Some servers return url and staticUrl
2386 return (
2387 <span
2388 class={`emoji-reaction tag ${
2389 me ? '' : 'insignificant'
2390 }`}
2391 >
2392 <CustomEmoji
2393 alt={name}
2394 url={url}
2395 staticUrl={staticUrl}
2396 />{' '}
2397 {count}
2398 </span>
2399 );
2400 }
2401 const isShortCode = /^:.+?:$/.test(name);
2402 if (isShortCode) {
2403 const emoji = emojis.find(
2404 (e) =>
2405 e.shortcode ===
2406 name.replace(/^:/, '').replace(/:$/, ''),
2407 );
2408 if (emoji) {
2409 return (
2410 <span
2411 class={`emoji-reaction tag ${
2412 me ? '' : 'insignificant'
2413 }`}
2414 >
2415 <CustomEmoji
2416 alt={name}
2417 url={emoji.url}
2418 staticUrl={emoji.staticUrl}
2419 />{' '}
2420 {count}
2421 </span>
2422 );
2423 }
2424 }
2425 return (
2426 <span
2427 class={`emoji-reaction tag ${
2428 me ? '' : 'insignificant'
2429 }`}
2430 >
2431 {name} {count}
2432 </span>
2433 );
2434 })}
2435 </div>
2436 )}
2437 <div class={`actions ${_deleted ? 'disabled' : ''}`}>
2438 <div class="action has-count">
2439 <StatusButton
2440 title={t`Reply`}
2441 alt={t`Comments`}
2442 class="reply-button"
2443 icon="comment"
2444 count={repliesCount}
2445 onClick={replyStatus}
2446 />
2447 </div>
2448 {/* <div class="action has-count">
2449 <StatusButton
2450 checked={reblogged}
2451 title={['Boost', 'Unboost']}
2452 alt={['Boost', 'Boosted']}
2453 class="reblog-button"
2454 icon="rocket"
2455 count={reblogsCount}
2456 onClick={boostStatus}
2457 disabled={!canBoost}
2458 />
2459 </div> */}
2460 <div class="action has-count">
2461 <MenuConfirm
2462 disabled={!canBoost}
2463 onClick={confirmBoostStatus}
2464 confirmLabel={
2465 <>
2466 <Icon icon="rocket" />
2467 <span>{reblogged ? t`Unboost` : t`Boost`}</span>
2468 </>
2469 }
2470 menuExtras={
2471 <MenuItem
2472 onClick={() => {
2473 showCompose({
2474 draftStatus: {
2475 status: `\n${url}`,
2476 },
2477 });
2478 }}
2479 >
2480 <Icon icon="quote" />
2481 <span>
2482 <Trans>Quote</Trans>
2483 </span>
2484 </MenuItem>
2485 }
2486 menuFooter={menuFooter}
2487 >
2488 <StatusButton
2489 checked={reblogged}
2490 title={[t`Boost`, t`Unboost`]}
2491 alt={[t`Boost`, t`Boosted`]}
2492 class="reblog-button"
2493 icon="rocket"
2494 count={reblogsCount}
2495 // onClick={boostStatus}
2496 disabled={!canBoost}
2497 />
2498 </MenuConfirm>
2499 </div>
2500 <div class="action has-count">
2501 <StatusButton
2502 checked={favourited}
2503 title={[t`Like`, t`Unlike`]}
2504 alt={[t`Like`, t`Liked`]}
2505 class="favourite-button"
2506 icon="heart"
2507 count={favouritesCount}
2508 onClick={favouriteStatus}
2509 />
2510 </div>
2511 {supports('@mastodon/post-bookmark') && (
2512 <div class="action">
2513 <StatusButton
2514 checked={bookmarked}
2515 title={[t`Bookmark`, t`Unbookmark`]}
2516 alt={[t`Bookmark`, t`Bookmarked`]}
2517 class="bookmark-button"
2518 icon="bookmark"
2519 onClick={bookmarkStatus}
2520 />
2521 </div>
2522 )}
2523 <Menu2
2524 portal={{
2525 target:
2526 document.querySelector('.status-deck') || document.body,
2527 }}
2528 align="end"
2529 gap={4}
2530 overflow="auto"
2531 viewScroll="close"
2532 menuButton={
2533 <div class="action">
2534 <button
2535 type="button"
2536 title={t`More`}
2537 class="plain more-button"
2538 >
2539 <Icon icon="more" size="l" alt={t`More`} />
2540 </button>
2541 </div>
2542 }
2543 >
2544 {StatusMenuItems}{' '}
2545 </Menu2>
2546 </div>
2547 </>
2548 )}
2549 </div>
2550 {!!showEdited && (
2551 <Modal
2552 onClick={(e) => {
2553 if (e.target === e.currentTarget) {
2554 setShowEdited(false);
2555 // statusRef.current?.focus();
2556 }
2557 }}
2558 >
2559 <EditedAtModal
2560 statusID={showEdited}
2561 instance={instance}
2562 fetchStatusHistory={() => {
2563 return masto.v1.statuses.$select(showEdited).history.list();
2564 }}
2565 onClose={() => {
2566 setShowEdited(false);
2567 statusRef.current?.focus();
2568 }}
2569 />
2570 </Modal>
2571 )}
2572 {!!showEmbed && (
2573 <Modal
2574 onClick={(e) => {
2575 if (e.target === e.currentTarget) {
2576 setShowEmbed(false);
2577 }
2578 }}
2579 >
2580 <PostEmbedModal
2581 post={status}
2582 instance={instance}
2583 onClose={() => {
2584 setShowEmbed(false);
2585 }}
2586 />
2587 </Modal>
2588 )}
2589 </article>
2590 </StatusParent>
2591 );
2592}
2593
2594function nicePostURL(url) {
2595 if (!url) return;
2596 const urlObj = URL.parse(url);
2597 if (!urlObj) return;
2598 const { host, pathname } = urlObj;
2599 const path = pathname.replace(/\/$/, '');
2600 // split only first slash
2601 const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
2602 return (
2603 <>
2604 {punycode.toUnicode(host)}
2605 {username ? (
2606 <>
2607 /{username}
2608 <wbr />
2609 <span class="more-insignificant">/{restPath}</span>
2610 </>
2611 ) : (
2612 <span class="more-insignificant">{path}</span>
2613 )}
2614 </>
2615 );
2616}
2617
2618const handledUnfulfilledStates = [
2619 'deleted',
2620 'unauthorized',
2621 'pending',
2622 'rejected',
2623 'revoked',
2624];
2625const unfulfilledText = {
2626 filterHidden: msg`Post hidden by your filters`,
2627 pending: msg`Post pending`,
2628 deleted: msg`Post unavailable`,
2629 unauthorized: msg`Post unavailable`,
2630 rejected: msg`Post unavailable`,
2631 revoked: msg`Post unavailable`,
2632};
2633
2634const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
2635 if (!id || !instance) return;
2636 const { _ } = useLingui();
2637 const snapStates = useSnapshot(states);
2638 const sKey = statusKey(id, instance);
2639 const quotes = snapStates.statusQuotes[sKey];
2640 const uniqueQuotes = quotes?.filter(
2641 (q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i,
2642 );
2643
2644 if (!uniqueQuotes?.length) return;
2645 if (level > 2) return;
2646
2647 const filterContext = useContext(FilterContext);
2648 const currentAccount = getCurrentAccountID();
2649
2650 return uniqueQuotes.map((q) => {
2651 let unfulfilledState;
2652
2653 const quoteStatus = snapStates.statuses[statusKey(q.id, q.instance)];
2654 if (quoteStatus) {
2655 const isSelf =
2656 currentAccount && currentAccount === quoteStatus.account?.id;
2657 const filterInfo =
2658 !isSelf && isFiltered(quoteStatus.filtered, filterContext);
2659
2660 if (filterInfo?.action === 'hide') {
2661 unfulfilledState = 'filterHidden';
2662 }
2663 }
2664
2665 if (!unfulfilledState) {
2666 unfulfilledState = handledUnfulfilledStates.find(
2667 (state) => q.state === state,
2668 );
2669 }
2670
2671 if (unfulfilledState) {
2672 return (
2673 <div
2674 class={`status-card-unfulfilled ${
2675 unfulfilledState === 'filterHidden' ? 'status-card-ghost' : ''
2676 }`}
2677 >
2678 <Icon icon="quote" />
2679 <i>{_(unfulfilledText[unfulfilledState])}</i>
2680 </div>
2681 );
2682 }
2683
2684 const Parent = q.native ? Fragment : LazyShazam;
2685 return (
2686 <Parent id={q.instance + q.id} key={q.instance + q.id}>
2687 <Link
2688 key={q.instance + q.id}
2689 to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
2690 class={`status-card-link ${q.native ? 'quote-post-native' : ''}`}
2691 data-read-more={_(readMoreText)}
2692 >
2693 <Status
2694 statusID={q.id}
2695 instance={q.instance}
2696 size="s"
2697 quoted={level + 1}
2698 enableCommentHint
2699 />
2700 </Link>
2701 </Parent>
2702 );
2703 });
2704});
2705
2706function EditedAtModal({
2707 statusID,
2708 instance,
2709 fetchStatusHistory = () => {},
2710 onClose,
2711}) {
2712 const { t } = useLingui();
2713 const [uiState, setUIState] = useState('default');
2714 const [editHistory, setEditHistory] = useState([]);
2715
2716 useEffect(() => {
2717 setUIState('loading');
2718 (async () => {
2719 try {
2720 const editHistory = await fetchStatusHistory();
2721 console.log(editHistory);
2722 setEditHistory(editHistory);
2723 setUIState('default');
2724 } catch (e) {
2725 console.error(e);
2726 setUIState('error');
2727 }
2728 })();
2729 }, []);
2730
2731 return (
2732 <div id="edit-history" class="sheet">
2733 {!!onClose && (
2734 <button type="button" class="sheet-close" onClick={onClose}>
2735 <Icon icon="x" alt={t`Close`} />
2736 </button>
2737 )}
2738 <header>
2739 <h2>
2740 <Trans>Edit History</Trans>
2741 </h2>
2742 {uiState === 'error' && (
2743 <p>
2744 <Trans>Failed to load history</Trans>
2745 </p>
2746 )}
2747 {uiState === 'loading' && (
2748 <p>
2749 <Loader abrupt /> <Trans>Loading…</Trans>
2750 </p>
2751 )}
2752 </header>
2753 <main tabIndex="-1">
2754 {editHistory.length > 0 && (
2755 <ol>
2756 {editHistory.map((status) => {
2757 const { createdAt } = status;
2758 const createdAtDate = new Date(createdAt);
2759 return (
2760 <li key={createdAt} class="history-item">
2761 <h3>
2762 <time>
2763 {niceDateTime(createdAtDate, {
2764 formatOpts: {
2765 weekday: 'short',
2766 second: 'numeric',
2767 },
2768 })}
2769 </time>
2770 </h3>
2771 <Status
2772 status={status}
2773 instance={instance}
2774 size="s"
2775 withinContext
2776 readOnly
2777 previewMode
2778 />
2779 </li>
2780 );
2781 })}
2782 </ol>
2783 )}
2784 </main>
2785 </div>
2786 );
2787}
2788
2789function FilteredStatus({
2790 status,
2791 filterInfo,
2792 instance,
2793 containerProps = {},
2794 showFollowedTags,
2795 quoted,
2796}) {
2797 const { _, t } = useLingui();
2798 const snapStates = useSnapshot(states);
2799 const {
2800 id: statusID,
2801 account: { avatar, avatarStatic, bot, group },
2802 createdAt,
2803 visibility,
2804 reblog,
2805 } = status;
2806 const isReblog = !!reblog;
2807 const filterTitleStr = filterInfo?.titlesStr || '';
2808 const createdAtDate = new Date(createdAt);
2809 const statusPeekText = statusPeek(status.reblog || status);
2810
2811 const [showPeek, setShowPeek] = useState(false);
2812 const bindLongPressPeek = useLongPress(
2813 () => {
2814 setShowPeek(true);
2815 },
2816 {
2817 threshold: 600,
2818 captureEvent: true,
2819 detect: 'touch',
2820 cancelOnMovement: 2, // true allows movement of up to 25 pixels
2821 },
2822 );
2823
2824 const statusPeekRef = useTruncated();
2825 const sKey = statusKey(status.id, instance);
2826 const ssKey =
2827 statusKey(status.id, instance) +
2828 ' ' +
2829 (statusKey(reblog?.id, instance) || '');
2830
2831 const actualStatusID = reblog?.id || statusID;
2832 const url = instance
2833 ? `/${instance}/s/${actualStatusID}`
2834 : `/s/${actualStatusID}`;
2835 const isFollowedTags =
2836 showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length;
2837
2838 return (
2839 <div
2840 class={`${
2841 quoted
2842 ? ''
2843 : isReblog
2844 ? group
2845 ? 'status-group'
2846 : 'status-reblog'
2847 : isFollowedTags
2848 ? 'status-followed-tags'
2849 : ''
2850 } visibility-${visibility}`}
2851 {...containerProps}
2852 // title={statusPeekText}
2853 onContextMenu={(e) => {
2854 e.preventDefault();
2855 setShowPeek(true);
2856 }}
2857 {...bindLongPressPeek()}
2858 >
2859 <article
2860 data-state-post-id={ssKey}
2861 class={`status filtered ${quoted ? 'status-card' : ''}`}
2862 tabindex="-1"
2863 >
2864 <b
2865 class="status-filtered-badge clickable badge-meta"
2866 title={filterTitleStr}
2867 onClick={(e) => {
2868 e.preventDefault();
2869 setShowPeek(true);
2870 }}
2871 >
2872 <span>
2873 <Trans>Filtered</Trans>
2874 </span>
2875 <span>{filterTitleStr}</span>
2876 </b>{' '}
2877 <Avatar url={avatarStatic || avatar} squircle={bot} />
2878 <span class="status-filtered-info">
2879 <span class="status-filtered-info-1">
2880 {isReblog ? (
2881 <Trans comment="[Name] [Visibility icon] boosted">
2882 <NameText account={status.account} instance={instance} />{' '}
2883 <Icon
2884 icon={visibilityIconsMap[visibility]}
2885 alt={_(visibilityText[visibility])}
2886 size="s"
2887 />{' '}
2888 boosted
2889 </Trans>
2890 ) : isFollowedTags ? (
2891 <>
2892 <NameText account={status.account} instance={instance} />{' '}
2893 <Icon
2894 icon={visibilityIconsMap[visibility]}
2895 alt={_(visibilityText[visibility])}
2896 size="s"
2897 />{' '}
2898 <span>
2899 {snapStates.statusFollowedTags[sKey]
2900 .slice(0, 3)
2901 .map((tag) => (
2902 <span key={tag} class="status-followed-tag-item">
2903 #{tag}
2904 </span>
2905 ))}
2906 </span>
2907 </>
2908 ) : (
2909 <>
2910 <NameText account={status.account} instance={instance} />{' '}
2911 <Icon
2912 icon={visibilityIconsMap[visibility]}
2913 alt={_(visibilityText[visibility])}
2914 size="s"
2915 />{' '}
2916 <RelativeTime datetime={createdAtDate} format="micro" />
2917 </>
2918 )}
2919 </span>
2920 <span class="status-filtered-info-2">
2921 {isReblog && (
2922 <>
2923 <Avatar
2924 url={reblog.account.avatarStatic || reblog.account.avatar}
2925 squircle={bot}
2926 />{' '}
2927 </>
2928 )}
2929 {statusPeekText}
2930 </span>
2931 </span>
2932 </article>
2933 {!!showPeek && (
2934 <Modal
2935 onClick={(e) => {
2936 if (e.target === e.currentTarget) {
2937 setShowPeek(false);
2938 }
2939 }}
2940 >
2941 <div id="filtered-status-peek" class="sheet">
2942 <button
2943 type="button"
2944 class="sheet-close"
2945 onClick={() => setShowPeek(false)}
2946 >
2947 <Icon icon="x" alt={t`Close`} />
2948 </button>
2949 <header>
2950 <b class="status-filtered-badge">
2951 <Trans>Filtered</Trans>
2952 </b>{' '}
2953 {filterTitleStr}
2954 </header>
2955 <main tabIndex="-1">
2956 <Link
2957 ref={statusPeekRef}
2958 class="status-link"
2959 to={url}
2960 onClick={() => {
2961 setShowPeek(false);
2962 }}
2963 data-read-more={_(readMoreText)}
2964 >
2965 <Status status={status} instance={instance} size="s" readOnly />
2966 </Link>
2967 </main>
2968 </div>
2969 </Modal>
2970 )}
2971 </div>
2972 );
2973}
2974
2975export default memo(Status, (oldProps, newProps) => {
2976 // Shallow equal all props except 'status'
2977 // This will be pure static until status ID changes
2978 const { status, ...restOldProps } = oldProps;
2979 const { status: newStatus, ...restNewProps } = newProps;
2980 return (
2981 status?.id === newStatus?.id && shallowEqual(restOldProps, restNewProps)
2982 );
2983});