phanpy/src/components/status.jsx

3367 wiersze
100 KiB
React
Czysty Zwykły widok Historia

2022-12-10 09:14:48 +00:00
import './status.css';
import '@justinribeiro/lite-youtube';
2023-03-02 07:15:49 +00:00
import {
ControlledMenu,
Menu,
MenuDivider,
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
2024-01-06 04:31:25 +00:00
import { shallowEqual } from 'fast-equals';
2024-03-02 10:55:05 +00:00
import prettify from 'html-prettify';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
2023-06-14 03:14:49 +00:00
import {
useCallback,
useContext,
2023-06-14 03:14:49 +00:00
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode';
2023-03-07 16:01:51 +00:00
import { useLongPress } from 'use-long-press';
2022-12-10 09:14:48 +00:00
import { useSnapshot } from 'valtio';
import CustomEmoji from '../components/custom-emoji';
import EmojiText from '../components/emoji-text';
2024-03-26 08:35:02 +00:00
import LazyShazam from '../components/lazy-shazam';
import Loader from '../components/loader';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
2022-12-10 09:14:48 +00:00
import Modal from '../components/modal';
import NameText from '../components/name-text';
2023-04-22 16:55:47 +00:00
import Poll from '../components/poll';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
2022-12-10 09:14:48 +00:00
import enhanceContent from '../utils/enhance-content';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
2023-03-28 17:12:59 +00:00
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
2023-04-22 16:55:47 +00:00
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import localeMatch from '../utils/locale-match';
2023-03-01 12:07:22 +00:00
import niceDateTime from '../utils/nice-date-time';
import openCompose from '../utils/open-compose';
2023-10-14 12:33:40 +00:00
import pmem from '../utils/pmem';
2023-06-13 09:46:37 +00:00
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
2022-12-10 09:14:48 +00:00
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
2023-12-24 13:06:26 +00:00
import { speak, supportsTTS } from '../utils/speech';
2023-03-17 09:14:54 +00:00
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
2023-03-21 16:09:36 +00:00
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import { getCurrentAccountID } from '../utils/store-utils';
2024-04-14 09:20:18 +00:00
import supports from '../utils/supports';
import unfurlMastodonLink from '../utils/unfurl-link';
import useHotkeys from '../utils/useHotkeys';
import useTruncated from '../utils/useTruncated';
2022-12-10 09:14:48 +00:00
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
import { isMediaCaptionLong } from './media';
2023-03-28 07:59:20 +00:00
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
2022-12-10 09:14:48 +00:00
const SHOW_COMMENT_COUNT_LIMIT = 280;
2023-07-21 14:52:53 +00:00
const INLINE_TRANSLATE_LIMIT = 140;
function fetchAccount(id, masto) {
2023-10-14 12:33:40 +00:00
return masto.v1.accounts.$select(id).fetch();
2022-12-18 13:10:05 +00:00
}
2023-10-14 12:33:40 +00:00
const memFetchAccount = pmem(fetchAccount);
2022-12-10 09:14:48 +00:00
const visibilityText = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Followers only',
2023-04-06 10:21:56 +00:00
direct: 'Private mention',
};
2023-11-04 11:05:14 +00:00
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
const rtf = new Intl.RelativeTimeFormat();
2023-12-20 05:55:56 +00:00
const REACTIONS_LIMIT = 80;
2023-12-21 10:17:14 +00:00
function getPollText(poll) {
if (!poll?.options?.length) return '';
return `📊:\n${poll.options
.map(
(option) =>
`- ${option.title}${
option.votesCount >= 0 ? ` (${option.votesCount})` : ''
}`,
)
.join('\n')}`;
}
function getPostText(status) {
const { spoilerText, content, poll } = status;
return (
(spoilerText ? `${spoilerText}\n\n` : '') +
getHTMLText(content) +
getPollText(poll)
);
}
const PostContent = memo(
({ post, instance, previewMode }) => {
const { content, emojis, language, mentions, url } = post;
return (
<div
lang={language}
dir="auto"
class="inner-content"
onClick={handleContentLinks({
mentions,
instance,
previewMode,
statusURL: url,
})}
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
postEnhanceDOM: (dom) => {
// Remove target="_blank" from links
dom.querySelectorAll('a.u-url[target="_blank"]').forEach((a) => {
if (!/http/i.test(a.innerText.trim())) {
a.removeAttribute('target');
}
});
},
}),
}}
/>
);
},
(oldProps, newProps) => {
const { post: oldPost } = oldProps;
const { post: newPost } = newProps;
return oldPost.content === newPost.content;
},
);
2024-04-14 09:20:18 +00:00
const SIZE_CLASS = {
s: 'small',
m: 'medium',
l: 'large',
};
2022-12-18 13:10:05 +00:00
function Status({
statusID,
status,
instance: propInstance,
2022-12-18 13:10:05 +00:00
size = 'm',
contentTextWeight,
2023-12-14 17:58:29 +00:00
readOnly,
enableCommentHint,
withinContext,
skeleton,
enableTranslate,
forceTranslate: _forceTranslate,
2023-03-16 05:02:46 +00:00
previewMode,
// allowFilters,
onMediaClick,
2023-04-22 16:55:47 +00:00
quoted,
onStatusLinkClick = () => {},
2023-12-14 17:58:29 +00:00
showFollowedTags,
allowContextMenu,
2024-01-13 16:32:08 +00:00
showActionsBar,
2024-01-30 06:34:54 +00:00
showReplyParent,
mediaFirst,
2022-12-18 13:10:05 +00:00
}) {
if (skeleton) {
2022-12-10 09:14:48 +00:00
return (
2024-04-14 09:20:18 +00:00
<div
class={`status skeleton ${
mediaFirst ? 'status-media-first small' : ''
}`}
>
{!mediaFirst && <Avatar size="xxl" />}
2022-12-18 13:10:05 +00:00
<div class="container">
<div class="meta">
{(size === 's' || mediaFirst) && <Avatar size="m" />}
</div>
2022-12-18 13:10:05 +00:00
<div class="content-container">
{mediaFirst && <div class="media-first-container" />}
<div class={`content ${mediaFirst ? 'media-first-content' : ''}`}>
2023-02-11 13:09:36 +00:00
<p> </p>
2022-12-18 13:10:05 +00:00
</div>
</div>
</div>
2022-12-10 09:14:48 +00:00
</div>
);
}
2023-02-19 06:49:53 +00:00
const { masto, instance, authenticated } = api({ instance: propInstance });
const { instance: currentInstance } = api();
const sameInstance = instance === currentInstance;
2022-12-10 09:14:48 +00:00
2023-12-29 00:25:41 +00:00
let sKey = statusKey(statusID || status?.id, instance);
2022-12-18 13:10:05 +00:00
const snapStates = useSnapshot(states);
if (!status) {
2023-02-11 10:55:21 +00:00
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
2023-05-19 17:06:16 +00:00
sKey = statusKey(status?.id, instance);
2022-12-18 13:10:05 +00:00
}
if (!status) {
return null;
}
2022-12-10 09:14:48 +00:00
const {
2022-12-18 13:10:05 +00:00
account: {
acct,
avatar,
avatarStatic,
id: accountId,
2023-02-21 06:29:25 +00:00
url: accountURL,
2022-12-18 13:10:05 +00:00
displayName,
username,
emojis: accountEmojis,
2023-04-10 16:26:43 +00:00
bot,
group,
2022-12-18 13:10:05 +00:00
},
id,
repliesCount,
reblogged,
reblogsCount,
favourited,
favouritesCount,
bookmarked,
poll,
muted,
sensitive,
spoilerText,
visibility, // public, unlisted, private, direct
language,
editedAt,
filtered,
card,
createdAt,
inReplyToId,
2022-12-18 13:10:05 +00:00
inReplyToAccountId,
content,
mentions,
mediaAttachments,
reblog,
uri,
2023-02-21 06:29:25 +00:00
url,
2022-12-18 13:10:05 +00:00
emojis,
2023-12-14 17:58:29 +00:00
tags,
2023-02-17 02:12:59 +00:00
// Non-API props
_deleted,
2023-02-17 02:12:59 +00:00
_pinned,
// _filtered,
// Non-Mastodon
emojiReactions,
2022-12-18 13:10:05 +00:00
} = status;
2022-12-10 09:14:48 +00:00
// if (!mediaAttachments?.length) mediaFirst = false;
const hasMediaAttachments = !!mediaAttachments?.length;
if (mediaFirst && hasMediaAttachments) size = 's';
const currentAccount = useMemo(() => {
return getCurrentAccountID();
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
const filterContext = useContext(FilterContext);
const filterInfo =
!isSelf && !readOnly && !previewMode && isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') {
return null;
}
console.debug('RENDER Status', id, status?.account.displayName, quoted);
2023-03-23 13:48:29 +00:00
const debugHover = (e) => {
if (e.shiftKey) {
2023-07-31 12:30:29 +00:00
console.log({
...status,
});
2023-03-23 13:48:29 +00:00
}
};
if (/*allowFilters && */ size !== 'l' && filterInfo) {
2023-03-21 16:09:36 +00:00
return (
<FilteredStatus
status={status}
filterInfo={filterInfo}
2023-03-21 16:09:36 +00:00
instance={instance}
2023-03-23 13:48:29 +00:00
containerProps={{
onMouseEnter: debugHover,
}}
2023-12-14 17:58:29 +00:00
showFollowedTags
2023-03-21 16:09:36 +00:00
/>
);
}
2022-12-18 13:10:05 +00:00
const createdAtDate = new Date(createdAt);
const editedAtDate = new Date(editedAt);
2022-12-10 09:14:48 +00:00
2022-12-18 13:10:05 +00:00
let inReplyToAccountRef = mentions?.find(
(mention) => mention.id === inReplyToAccountId,
);
if (!inReplyToAccountRef && inReplyToAccountId === id) {
2023-02-21 06:29:25 +00:00
inReplyToAccountRef = { url: accountURL, username, displayName };
2022-12-18 13:10:05 +00:00
}
const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef);
if (!withinContext && !inReplyToAccount && inReplyToAccountId) {
const account = states.accounts[inReplyToAccountId];
2022-12-18 13:10:05 +00:00
if (account) {
setInReplyToAccount(account);
} else {
memFetchAccount(inReplyToAccountId, masto)
2022-12-18 13:10:05 +00:00
.then((account) => {
setInReplyToAccount(account);
states.accounts[account.id] = account;
2022-12-18 13:10:05 +00:00
})
.catch((e) => {});
}
2022-12-10 09:14:48 +00:00
}
2023-04-09 16:30:32 +00:00
const mentionSelf =
inReplyToAccountId === currentAccount ||
mentions?.find((mention) => mention.id === currentAccount);
2022-12-10 09:14:48 +00:00
const readingExpandSpoilers = useMemo(() => {
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
const readingExpandMedia = useMemo(() => {
// default | show_all | hide_all
// Ignore hide_all because it means hide *ALL* media including non-sensitive ones
const prefs = store.account.get('preferences') || {};
return prefs['reading:expand:media'] || 'default';
}, []);
// FOR TESTING:
// const readingExpandSpoilers = true;
// const readingExpandMedia = 'show_all';
const showSpoiler =
previewMode || readingExpandSpoilers || !!snapStates.spoilers[id];
const showSpoilerMedia =
previewMode ||
readingExpandMedia === 'show_all' ||
!!snapStates.spoilersMedia[id];
2022-12-10 09:14:48 +00:00
2022-12-18 13:10:05 +00:00
if (reblog) {
2023-03-21 16:09:36 +00:00
// If has statusID, means useItemID (cached in states)
if (group) {
return (
2023-11-05 00:21:43 +00:00
<div
data-state-post-id={sKey}
class="status-group"
onMouseEnter={debugHover}
>
<div class="status-pre-meta">
<Icon icon="group" size="l" alt="Group" />{' '}
<NameText account={status.account} instance={instance} showAvatar />
</div>
<Status
status={statusID ? null : reblog}
statusID={statusID ? reblog.id : null}
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
readOnly={readOnly}
mediaFirst={mediaFirst}
/>
</div>
);
}
2022-12-18 13:10:05 +00:00
return (
2023-11-05 00:21:43 +00:00
<div
data-state-post-id={sKey}
class="status-reblog"
onMouseEnter={debugHover}
>
2022-12-18 13:10:05 +00:00
<div class="status-pre-meta">
<Icon icon="rocket" size="l" />{' '}
<NameText account={status.account} instance={instance} showAvatar />{' '}
2023-04-06 05:21:53 +00:00
<span>boosted</span>
2022-12-18 13:10:05 +00:00
</div>
<Status
2023-03-21 16:09:36 +00:00
status={statusID ? null : reblog}
statusID={statusID ? reblog.id : null}
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
readOnly={readOnly}
2023-11-14 14:45:13 +00:00
enableCommentHint
mediaFirst={mediaFirst}
/>
2022-12-18 13:10:05 +00:00
</div>
);
}
2022-12-10 09:14:48 +00:00
2023-12-14 17:58:29 +00:00
// Check followedTags
const FollowedTagsParent = useCallback(
({ children }) => (
<div
data-state-post-id={sKey}
class="status-followed-tags"
onMouseEnter={debugHover}
>
<div class="status-pre-meta">
<Icon icon="hashtag" size="l" />{' '}
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
<Link
key={tag}
to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`}
class="status-followed-tag-item"
>
{tag}
</Link>
))}
</div>
{children}
2023-12-14 17:58:29 +00:00
</div>
),
[sKey, instance, snapStates.statusFollowedTags[sKey]],
);
const StatusParent =
showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length
? FollowedTagsParent
: Fragment;
2023-12-14 17:58:29 +00:00
const isSizeLarge = size === 'l';
const [forceTranslate, setForceTranslate] = useState(_forceTranslate);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
const { contentTranslation, contentTranslationAutoInline } =
snapStates.settings;
if (!contentTranslation) enableTranslate = false;
const inlineTranslate = useMemo(() => {
if (
!contentTranslation ||
!contentTranslationAutoInline ||
readOnly ||
(withinContext && !isSizeLarge) ||
previewMode ||
spoilerText ||
sensitive ||
poll ||
card ||
mediaAttachments?.length
) {
return false;
}
const contentLength = htmlContentLength(content);
return contentLength > 0 && contentLength <= INLINE_TRANSLATE_LIMIT;
2023-07-21 14:52:53 +00:00
}, [
contentTranslation,
contentTranslationAutoInline,
2023-07-21 14:52:53 +00:00
readOnly,
withinContext,
isSizeLarge,
previewMode,
2023-07-21 14:52:53 +00:00
spoilerText,
sensitive,
2023-07-21 14:52:53 +00:00
poll,
card,
2023-07-21 14:52:53 +00:00
mediaAttachments,
content,
]);
2022-12-18 13:10:05 +00:00
const [showEdited, setShowEdited] = useState(false);
2024-03-02 10:55:05 +00:00
const [showEmbed, setShowEmbed] = useState(false);
2022-12-18 13:10:05 +00:00
const spoilerContentRef = useTruncated();
const contentRef = useTruncated();
2023-09-20 09:27:54 +00:00
const mediaContainerRef = useTruncated();
2022-12-18 13:10:05 +00:00
const readMoreText = 'Read more →';
2022-12-10 09:14:48 +00:00
2022-12-30 12:37:57 +00:00
const statusRef = useRef(null);
2023-04-29 14:22:07 +00:00
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this post from another instance.`;
2023-06-14 03:14:49 +00:00
const textWeight = useCallback(
() =>
Math.max(
Math.round((spoilerText.length + htmlContentLength(content)) / 140) ||
1,
1,
),
[spoilerText, content],
);
2023-03-01 12:07:22 +00:00
const createdDateText = niceDateTime(createdAtDate);
const editedDateText = editedAt && niceDateTime(editedAtDate);
// Can boost if:
// - authenticated AND
// - visibility != direct OR
// - visibility = private AND isSelf
let canBoost =
authenticated && visibility !== 'direct' && visibility !== 'private';
if (visibility === 'private' && isSelf) {
canBoost = true;
}
const replyStatus = (e) => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
// syntheticEvent comes from MenuItem
if (e?.shiftKey || e?.syntheticEvent?.shiftKey) {
const newWin = openCompose({
replyToStatus: status,
});
if (newWin) return;
}
states.showCompose = {
replyToStatus: status,
};
};
// Check if media has no descriptions
const mediaNoDesc = useMemo(() => {
return mediaAttachments.some(
(attachment) => !attachment.description?.trim?.(),
);
}, [mediaAttachments]);
const statusMonthsAgo = useMemo(() => {
return Math.floor(
(new Date() - createdAtDate) / (1000 * 60 * 60 * 24 * 30),
);
}, [createdAtDate]);
const boostStatus = async () => {
if (!sameInstance || !authenticated) {
alert(unauthInteractionErrorMessage);
return false;
}
try {
if (!reblogged) {
let confirmText = 'Boost this post?';
if (mediaNoDesc) {
confirmText += '\n\n⚠ Some media have no descriptions.';
}
const yes = confirm(confirmText);
if (!yes) {
return false;
}
}
// Optimistic
states.statuses[sKey] = {
...status,
reblogged: !reblogged,
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance);
return true;
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
return false;
}
};
const confirmBoostStatus = async () => {
if (!sameInstance || !authenticated) {
alert(unauthInteractionErrorMessage);
return false;
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
reblogged: !reblogged,
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.$select(id).unreblog();
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.$select(id).reblog();
saveStatus(newStatus, instance);
}
return true;
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
return false;
}
};
const favouriteStatus = async () => {
if (!sameInstance || !authenticated) {
alert(unauthInteractionErrorMessage);
return false;
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
favourited: !favourited,
favouritesCount: favouritesCount + (favourited ? -1 : 1),
};
if (favourited) {
const newStatus = await masto.v1.statuses.$select(id).unfavourite();
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.$select(id).favourite();
saveStatus(newStatus, instance);
}
return true;
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
return false;
}
};
const favouriteStatusNotify = async () => {
try {
const done = await favouriteStatus();
if (!isSizeLarge && done) {
showToast(
favourited
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
};
const bookmarkStatus = async () => {
2024-04-14 09:20:18 +00:00
if (!supports('@mastodon/post-bookmark')) return;
if (!sameInstance || !authenticated) {
alert(unauthInteractionErrorMessage);
return false;
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
bookmarked: !bookmarked,
};
if (bookmarked) {
const newStatus = await masto.v1.statuses.$select(id).unbookmark();
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.$select(id).bookmark();
saveStatus(newStatus, instance);
}
return true;
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
return false;
}
};
const bookmarkStatusNotify = async () => {
try {
const done = await bookmarkStatus();
if (!isSizeLarge && done) {
showToast(
bookmarked
? `Unbookmarked @${username || acct}'s post`
: `Bookmarked @${username || acct}'s post`,
);
}
} catch (e) {}
};
const differentLanguage =
!!language &&
language !== targetLanguage &&
!localeMatch([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => language === l || localeMatch([language], [l]),
);
2023-12-20 05:55:56 +00:00
const reblogIterator = useRef();
const favouriteIterator = useRef();
async function fetchBoostedLikedByAccounts(firstLoad) {
if (firstLoad) {
reblogIterator.current = masto.v1.statuses
.$select(statusID)
.rebloggedBy.list({
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses
.$select(statusID)
.favouritedBy.list({
limit: REACTIONS_LIMIT,
});
}
const [{ value: reblogResults }, { value: favouriteResults }] =
await Promise.allSettled([
reblogIterator.current.next(),
favouriteIterator.current.next(),
]);
if (reblogResults.value?.length || favouriteResults.value?.length) {
const accounts = [];
if (reblogResults.value?.length) {
accounts.push(
...reblogResults.value.map((a) => {
a._types = ['reblog'];
return a;
}),
);
}
if (favouriteResults.value?.length) {
accounts.push(
...favouriteResults.value.map((a) => {
a._types = ['favourite'];
return a;
}),
);
}
return {
value: accounts,
done: reblogResults.done && favouriteResults.done,
};
}
return {
value: [],
done: true,
};
}
2024-01-13 16:32:08 +00:00
const actionsRef = useRef();
const isPublic = ['public', 'unlisted'].includes(visibility);
const isPinnable = ['public', 'unlisted', 'private'].includes(visibility);
const StatusMenuItems = (
<>
{!isSizeLarge && sameInstance && (
<>
2024-02-06 09:34:26 +00:00
<div class="menu-control-group-horizontal status-menu">
<MenuItem onClick={replyStatus}>
<Icon icon="comment" />
<span>
{repliesCount > 0 ? shortenNumber(repliesCount) : 'Reply'}
</span>
</MenuItem>
<MenuConfirm
subMenu
confirmLabel={
<>
<Icon icon="rocket" />
<span>{reblogged ? 'Unboost' : 'Boost'}</span>
</>
}
2024-02-06 09:34:26 +00:00
className={`menu-reblog ${reblogged ? 'checked' : ''}`}
menuExtras={
<MenuItem
onClick={() => {
states.showCompose = {
draftStatus: {
status: `\n${url}`,
},
};
}}
>
<Icon icon="quote" />
<span>Quote</span>
</MenuItem>
}
menuFooter={
mediaNoDesc && !reblogged ? (
<div class="footer">
<Icon icon="alert" />
Some media have no descriptions.
</div>
) : (
statusMonthsAgo >= 3 && (
<div class="footer">
<Icon icon="info" />
<span>
Old post (
<strong>{rtf.format(-statusMonthsAgo, 'month')}</strong>
)
</span>
</div>
)
)
}
2023-04-09 17:21:02 +00:00
disabled={!canBoost}
onClick={async () => {
try {
const done = await confirmBoostStatus();
if (!isSizeLarge && done) {
2023-10-19 12:02:31 +00:00
showToast(
reblogged
? `Unboosted @${username || acct}'s post`
: `Boosted @${username || acct}'s post`,
);
}
} catch (e) {}
}}
>
2024-02-06 09:34:26 +00:00
<Icon icon="rocket" />
<span>
{reblogsCount > 0
? shortenNumber(reblogsCount)
: reblogged
? 'Unboost'
: 'Boost…'}
</span>
</MenuConfirm>
2023-04-09 17:21:02 +00:00
<MenuItem
onClick={favouriteStatusNotify}
2024-02-06 09:34:26 +00:00
className={`menu-favourite ${favourited ? 'checked' : ''}`}
2023-04-09 17:21:02 +00:00
>
2024-02-06 09:34:26 +00:00
<Icon icon="heart" />
<span>
{favouritesCount > 0
? shortenNumber(favouritesCount)
: favourited
? 'Unlike'
: 'Like'}
</span>
</MenuItem>
2024-04-14 09:20:18 +00:00
{supports('@mastodon/post-bookmark') && (
<MenuItem
onClick={bookmarkStatusNotify}
className={`menu-bookmark ${bookmarked ? 'checked' : ''}`}
>
<Icon icon="bookmark" />
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
</MenuItem>
)}
2023-04-09 17:21:02 +00:00
</div>
</>
)}
{!isSizeLarge && sameInstance && (isSizeLarge || showActionsBar) && (
<MenuDivider />
)}
{(isSizeLarge || showActionsBar) && (
<>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
heading: 'Boosted/Liked by…',
fetchAccounts: fetchBoostedLikedByAccounts,
instance,
showReactions: true,
postID: sKey,
};
}}
>
<Icon icon="react" />
<span>
Boosted/Liked by<span class="more-insignificant"></span>
</span>
</MenuItem>
</>
)}
{!mediaFirst && (
<>
{(enableTranslate || !language || differentLanguage) && (
<MenuDivider />
2023-12-21 10:17:14 +00:00
)}
{enableTranslate ? (
<div class={supportsTTS ? 'menu-horizontal' : ''}>
2023-12-21 10:17:14 +00:00
<MenuItem
disabled={forceTranslate}
2023-12-21 10:17:14 +00:00
onClick={() => {
setForceTranslate(true);
2023-12-21 10:17:14 +00:00
}}
>
<Icon icon="translate" />
<span>Translate</span>
2023-12-21 10:17:14 +00:00
</MenuItem>
{supportsTTS && (
<MenuItem
onClick={() => {
const postText = getPostText(status);
if (postText) {
speak(postText, language);
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</div>
) : (
(!language || differentLanguage) && (
<div class={supportsTTS ? 'menu-horizontal' : ''}>
<MenuLink
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuLink>
{supportsTTS && (
<MenuItem
onClick={() => {
const postText = getPostText(status);
if (postText) {
speak(postText, language);
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</div>
)
)}
</>
)}
{((!isSizeLarge && sameInstance) ||
enableTranslate ||
!language ||
differentLanguage) && <MenuDivider />}
2024-02-06 09:34:26 +00:00
{!isSizeLarge && (
<>
<MenuLink
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
onStatusLinkClick(e, status);
}}
>
<Icon icon="arrows-right" />
<small>
View post by @{username || acct}
<br />
<span class="more-insignificant">
{visibilityText[visibility]} {createdDateText}
</span>
</small>
</MenuLink>
</>
)}
{!!editedAt && (
<>
<MenuItem
onClick={() => {
setShowEdited(id);
}}
>
<Icon icon="history" />
<small>
Show Edit History
<br />
<span class="more-insignificant">Edited: {editedDateText}</span>
</small>
</MenuItem>
</>
)}
<MenuItem href={url} target="_blank">
<Icon icon="external" />
2023-03-09 13:51:50 +00:00
<small class="menu-double-lines">{nicePostURL(url)}</small>
</MenuItem>
2023-03-09 13:51:50 +00:00
<div class="menu-horizontal">
<MenuItem
onClick={() => {
2023-03-09 13:51:50 +00:00
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
2023-03-09 13:51:50 +00:00
<Icon icon="link" />
<span>Copy</span>
</MenuItem>
{isPublic &&
navigator?.share &&
2023-03-09 13:51:50 +00:00
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
</div>
{isPublic && isSizeLarge && (
2024-03-02 10:55:05 +00:00
<MenuItem
onClick={() => {
setShowEmbed(true);
}}
>
<Icon icon="code" />
2024-03-07 01:05:52 +00:00
<span>Embed post</span>
2024-03-02 10:55:05 +00:00
</MenuItem>
)}
2023-04-09 16:30:32 +00:00
{(isSelf || mentionSelf) && <MenuDivider />}
{(isSelf || mentionSelf) && (
<MenuItem
onClick={async () => {
try {
const newStatus = await masto.v1.statuses
.$select(id)
[muted ? 'unmute' : 'mute']();
2023-04-09 16:30:32 +00:00
saveStatus(newStatus, instance);
showToast(muted ? 'Conversation unmuted' : 'Conversation muted');
} catch (e) {
console.error(e);
showToast(
muted
? 'Unable to unmute conversation'
: 'Unable to mute conversation',
);
}
}}
>
{muted ? (
<>
<Icon icon="unmute" />
<span>Unmute conversation</span>
</>
) : (
<>
<Icon icon="mute" />
<span>Mute conversation</span>
</>
)}
</MenuItem>
)}
{isSelf && isPinnable && (
2024-01-18 11:05:12 +00:00
<MenuItem
onClick={async () => {
try {
const newStatus = await masto.v1.statuses
.$select(id)
[_pinned ? 'unpin' : 'pin']();
// saveStatus(newStatus, instance);
showToast(
_pinned
? 'Post unpinned from profile'
: 'Post pinned to profile',
);
} catch (e) {
console.error(e);
showToast(
_pinned ? 'Unable to unpin post' : 'Unable to pin post',
);
}
}}
>
{_pinned ? (
<>
<Icon icon="unpin" />
<span>Unpin from profile</span>
</>
) : (
<>
<Icon icon="pin" />
<span>Pin to profile</span>
</>
)}
</MenuItem>
)}
{isSelf && (
2023-04-09 17:21:02 +00:00
<div class="menu-horizontal">
2024-04-14 09:20:18 +00:00
{supports('@mastodon/post-edit') && (
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
>
<Icon icon="pencil" />
<span>Edit</span>
</MenuItem>
)}
2023-03-17 09:14:54 +00:00
{isSizeLarge && (
<MenuConfirm
subMenu
confirmLabel={
<>
<Icon icon="trash" />
<span>Delete this post?</span>
</>
}
menuItemClassName="danger"
2023-03-17 09:14:54 +00:00
onClick={() => {
// const yes = confirm('Delete this post?');
// if (yes) {
(async () => {
try {
await masto.v1.statuses.$select(id).remove();
const cachedStatus = getStatus(id, instance);
cachedStatus._deleted = true;
showToast('Deleted');
} catch (e) {
console.error(e);
showToast('Unable to delete');
}
})();
// }
2023-03-17 09:14:54 +00:00
}}
>
<Icon icon="trash" />
<span>Delete</span>
</MenuConfirm>
2023-03-17 09:14:54 +00:00
)}
2023-04-09 17:21:02 +00:00
</div>
)}
{!isSelf && isSizeLarge && (
<>
<MenuDivider />
<MenuItem
className="danger"
onClick={() => {
states.showReportModal = {
account: status.account,
post: status,
};
}}
>
<Icon icon="flag" />
<span>Report post</span>
</MenuItem>
</>
)}
</>
);
2023-03-07 16:01:51 +00:00
const contextMenuRef = useRef();
2023-03-02 07:15:49 +00:00
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuProps, setContextMenuProps] = useState({});
const showContextMenu =
allowContextMenu || (!isSizeLarge && !previewMode && !_deleted && !quoted);
2023-10-01 09:14:32 +00:00
// Only iOS/iPadOS browsers don't support contextmenu
// Some comments report iPadOS might support contextmenu if a mouse is connected
const bindLongPressContext = useLongPress(
isIOS && showContextMenu
2023-10-01 09:14:32 +00:00
? (e) => {
if (e.pointerType === 'mouse') return;
// There's 'pen' too, but not sure if contextmenu event would trigger from a pen
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
2024-03-09 09:01:50 +00:00
if (
link &&
statusRef.current.contains(link) &&
!link.getAttribute('href').startsWith('#')
)
return;
2023-10-01 09:14:32 +00:00
e.preventDefault();
setContextMenuProps({
anchorPoint: {
x: clientX,
y: clientY,
},
direction: 'right',
2023-10-01 09:14:32 +00:00
});
setIsContextMenuOpen(true);
}
: null,
2023-03-07 16:01:51 +00:00
{
2023-04-24 13:36:03 +00:00
threshold: 600,
2023-03-07 16:01:51 +00:00
captureEvent: true,
detect: 'touch',
2023-09-29 16:26:51 +00:00
cancelOnMovement: 2, // true allows movement of up to 25 pixels
2023-03-07 16:01:51 +00:00
},
);
2023-03-02 07:15:49 +00:00
2023-12-29 10:16:08 +00:00
const hotkeysEnabled = !readOnly && !previewMode && !quoted;
const rRef = useHotkeys('r, shift+r', replyStatus, {
2023-09-08 07:32:55 +00:00
enabled: hotkeysEnabled,
});
const fRef = useHotkeys('f, l', favouriteStatusNotify, {
enabled: hotkeysEnabled,
});
const dRef = useHotkeys('d', bookmarkStatusNotify, {
enabled: hotkeysEnabled,
});
2023-09-08 07:32:55 +00:00
const bRef = useHotkeys(
'shift+b',
() => {
(async () => {
try {
const done = await confirmBoostStatus();
if (!isSizeLarge && done) {
2023-10-19 12:02:31 +00:00
showToast(
reblogged
? `Unboosted @${username || acct}'s post`
: `Boosted @${username || acct}'s post`,
);
2023-09-08 07:32:55 +00:00
}
} catch (e) {}
})();
},
{
enabled: hotkeysEnabled && canBoost,
},
);
2023-12-20 08:42:36 +00:00
const xRef = useHotkeys('x', (e) => {
const activeStatus = document.activeElement.closest(
'.status-link, .status-focus',
);
if (activeStatus) {
const spoilerButton = activeStatus.querySelector(
'.spoiler-button:not(.spoiling)',
2023-12-20 08:42:36 +00:00
);
if (spoilerButton) {
e.stopPropagation();
spoilerButton.click();
} else {
const spoilerMediaButton = activeStatus.querySelector(
'.spoiler-media-button:not(.spoiling)',
);
if (spoilerMediaButton) {
e.stopPropagation();
spoilerMediaButton.click();
}
2023-12-20 08:42:36 +00:00
}
}
});
2023-09-08 07:32:55 +00:00
const displayedMediaAttachments = mediaAttachments.slice(
0,
isSizeLarge ? undefined : 4,
);
2023-10-07 01:41:38 +00:00
const showMultipleMediaCaptions =
mediaAttachments.length > 1 &&
displayedMediaAttachments.some(
(media) => !!media.description && !isMediaCaptionLong(media.description),
);
const captionChildren = useMemo(() => {
if (!showMultipleMediaCaptions) return null;
const attachments = [];
displayedMediaAttachments.forEach((media, i) => {
if (!media.description) return;
const index = attachments.findIndex(
(attachment) => attachment.media.description === media.description,
);
if (index === -1) {
attachments.push({
media,
indices: [i],
});
} else {
attachments[index].indices.push(i);
}
});
return attachments.map(({ media, indices }) => (
<div
key={media.id}
data-caption-index={indices.map((i) => i + 1).join(' ')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: media.description,
lang: language,
};
}}
title={media.description}
>
<sup>{indices.map((i) => i + 1).join(' ')}</sup> {media.description}
</div>
));
// return displayedMediaAttachments.map(
// (media, i) =>
// !!media.description && (
// <div
// key={media.id}
// data-caption-index={i + 1}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// states.showMediaAlt = {
// alt: media.description,
// lang: language,
// };
// }}
// title={media.description}
// >
// <sup>{i + 1}</sup> {media.description}
// </div>
// ),
// );
}, [showMultipleMediaCaptions, displayedMediaAttachments, language]);
2023-11-14 08:52:47 +00:00
const isThread = useMemo(() => {
return (
(!!inReplyToId && inReplyToAccountId === status.account?.id) ||
!!snapStates.statusThreadNumber[sKey]
);
2023-11-14 14:45:13 +00:00
}, [
inReplyToId,
inReplyToAccountId,
status.account?.id,
snapStates.statusThreadNumber[sKey],
]);
2023-11-14 08:52:47 +00:00
const showCommentHint = useMemo(() => {
return (
2023-11-14 14:45:13 +00:00
enableCommentHint &&
2023-11-14 08:52:47 +00:00
!isThread &&
!withinContext &&
!inReplyToId &&
visibility === 'public' &&
repliesCount > 0
);
2023-11-14 14:45:13 +00:00
}, [
enableCommentHint,
isThread,
withinContext,
inReplyToId,
repliesCount,
visibility,
]);
const showCommentCount = useMemo(() => {
if (
card ||
poll ||
sensitive ||
spoilerText ||
mediaAttachments?.length ||
isThread ||
withinContext ||
inReplyToId ||
repliesCount <= 0
) {
return false;
}
const questionRegex = /[???︖❓❔⁇⁈⁉¿‽؟]/;
const containsQuestion = questionRegex.test(content);
if (!containsQuestion) return false;
const contentLength = htmlContentLength(content);
if (contentLength > 0 && contentLength <= SHOW_COMMENT_COUNT_LIMIT) {
return true;
}
}, [
card,
poll,
sensitive,
spoilerText,
mediaAttachments,
reblog,
isThread,
withinContext,
inReplyToId,
repliesCount,
content,
]);
2023-11-14 08:52:47 +00:00
2022-12-10 09:14:48 +00:00
return (
<StatusParent>
{showReplyParent && !!(inReplyToId && inReplyToAccountId) && (
2024-01-30 06:34:54 +00:00
<StatusCompact sKey={sKey} />
)}
<article
data-state-post-id={sKey}
ref={(node) => {
statusRef.current = node;
// Use parent node if it's in focus
// Use case: <a><status /></a>
// When navigating (j/k), the <a> is focused instead of <status />
// Hotkey binding doesn't bubble up thus this hack
const nodeRef =
node?.closest?.(
'.timeline-item, .timeline-item-alt, .status-link, .status-focus',
) || node;
rRef.current = nodeRef;
fRef.current = nodeRef;
dRef.current = nodeRef;
bRef.current = nodeRef;
xRef.current = nodeRef;
}}
tabindex="-1"
class={`status ${
!withinContext && inReplyToId && inReplyToAccount
? 'status-reply-to'
: ''
} visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
2024-04-14 09:20:18 +00:00
SIZE_CLASS[size]
2024-01-30 06:34:54 +00:00
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${
isContextMenuOpen ? 'status-menu-open' : ''
} ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`}
2024-01-30 06:34:54 +00:00
onMouseEnter={debugHover}
onContextMenu={(e) => {
if (!showContextMenu) return;
if (e.metaKey) return;
// console.log('context menu', e);
const link = e.target.closest('a');
2024-03-09 09:01:50 +00:00
if (
link &&
statusRef.current.contains(link) &&
!link.getAttribute('href').startsWith('#')
)
return;
2024-01-30 06:34:54 +00:00
// If there's selected text, don't show custom context menu
const selection = window.getSelection?.();
if (selection.toString().length > 0) {
const { anchorNode } = selection;
if (statusRef.current?.contains(anchorNode)) {
return;
2023-08-06 08:54:13 +00:00
}
2024-01-30 06:34:54 +00:00
}
e.preventDefault();
setContextMenuProps({
anchorPoint: {
x: e.clientX,
y: e.clientY,
2023-03-07 16:01:51 +00:00
},
2024-01-30 06:34:54 +00:00
direction: 'right',
});
setIsContextMenuOpen(true);
}}
{...(showContextMenu ? bindLongPressContext() : {})}
>
{showContextMenu && (
<ControlledMenu
ref={contextMenuRef}
state={isContextMenuOpen ? 'open' : undefined}
{...contextMenuProps}
onClose={(e) => {
setIsContextMenuOpen(false);
// statusRef.current?.focus?.();
if (e?.reason === 'click') {
statusRef.current?.closest('[tabindex]')?.focus?.();
}
}}
portal={{
target: document.body,
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
onClick: () => {
contextMenuRef.current?.closeMenu?.();
},
}}
overflow="auto"
boundingBoxPadding={safeBoundingBoxPadding()}
unmountOnClose
2024-01-13 16:32:08 +00:00
>
2024-01-30 06:34:54 +00:00
{StatusMenuItems}
</ControlledMenu>
2024-01-15 14:05:18 +00:00
)}
2024-01-30 06:34:54 +00:00
{showActionsBar &&
size !== 'l' &&
!previewMode &&
!readOnly &&
!_deleted &&
!quoted && (
<div
class={`status-actions ${
isContextMenuOpen === 'actions-bar' ? 'open' : ''
}`}
ref={actionsRef}
>
<StatusButton
size="s"
title="Reply"
alt="Reply"
class="reply-button"
icon="comment"
iconSize="m"
onClick={replyStatus}
/>
<StatusButton
size="s"
checked={favourited}
title={['Like', 'Unlike']}
alt={['Like', 'Liked']}
class="favourite-button"
icon="heart"
iconSize="m"
count={favouritesCount}
onClick={favouriteStatusNotify}
2024-01-30 06:34:54 +00:00
/>
<button
type="button"
title="More"
class="plain more-button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setContextMenuProps({
anchorRef: {
current: e.currentTarget,
},
align: 'start',
direction: 'left',
gap: 0,
shift: -8,
});
2024-01-30 06:34:54 +00:00
setIsContextMenuOpen('actions-bar');
}}
>
2024-01-30 06:34:54 +00:00
<Icon icon="more2" size="m" alt="More" />
</button>
</div>
)}
{size !== 'l' && (
<div class="status-badge">
{reblogged && <Icon class="reblog" icon="rocket" size="s" />}
{favourited && <Icon class="favourite" icon="heart" size="s" />}
{bookmarked && <Icon class="bookmark" icon="bookmark" size="s" />}
{_pinned && <Icon class="pin" icon="pin" size="s" />}
</div>
)}
{size !== 's' && (
<a
href={accountURL}
tabindex="-1"
// target="_blank"
title={`@${acct}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showAccount = {
account: status.account,
instance,
};
}}
>
<Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} />
</a>
)}
<div class="container">
<div class="meta">
<span class="meta-name">
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
</span>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} instance={instance} short />
</span>
</>
)} */}
{/* </span> */}{' '}
{size !== 'l' &&
(_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : url && !previewMode && !readOnly && !quoted ? (
2024-01-30 06:34:54 +00:00
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
if (
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
e.which === 2
) {
return;
}
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.(e, status);
setContextMenuProps({
anchorRef: {
current: e.currentTarget,
},
align: 'end',
direction: 'bottom',
gap: 4,
});
setIsContextMenuOpen(true);
}}
class={`time ${
isContextMenuOpen && contextMenuProps?.anchorRef
? 'is-open'
: ''
}`}
>
{showCommentHint && !showCommentCount ? (
<Icon
icon="comment2"
size="s"
alt={`${repliesCount} ${
repliesCount === 1 ? 'reply' : 'replies'
}`}
/>
) : (
2024-03-27 11:09:01 +00:00
visibility !== 'public' &&
visibility !== 'direct' && (
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>
)
2024-01-30 06:34:54 +00:00
)}{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
{!previewMode && !readOnly && (
<Icon icon="more2" class="more" />
)}
2024-01-30 06:34:54 +00:00
</Link>
) : (
// <Menu
// instanceRef={menuInstanceRef}
// portal={{
// target: document.body,
// }}
// containerProps={{
// style: {
// // Higher than the backdrop
// zIndex: 1001,
// },
// onClick: (e) => {
// if (e.target === e.currentTarget)
// menuInstanceRef.current?.closeMenu?.();
// },
// }}
// align="end"
// gap={4}
// overflow="auto"
// viewScroll="close"
// boundingBoxPadding="8 8 8 8"
// unmountOnClose
// menuButton={({ open }) => (
// <Link
// to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// onStatusLinkClick?.(e, status);
// }}
// class={`time ${open ? 'is-open' : ''}`}
// >
// <Icon
// icon={visibilityIconsMap[visibility]}
// alt={visibilityText[visibility]}
// size="s"
// />{' '}
// <RelativeTime datetime={createdAtDate} format="micro" />
// </Link>
// )}
// >
// {StatusMenuItems}
// </Menu>
<span class="time">
2024-03-27 11:09:01 +00:00
{visibility !== 'public' && visibility !== 'direct' && (
<>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
</>
)}
2024-01-30 06:34:54 +00:00
<RelativeTime datetime={createdAtDate} format="micro" />
</span>
))}
</div>
{visibility === 'direct' && (
<>
<div class="status-direct-badge">Private mention</div>{' '}
</>
)}
{!withinContext && (
<>
{isThread ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
Thread
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''}
</div>
2024-01-30 06:34:54 +00:00
) : (
!!inReplyToId &&
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText
account={inReplyToAccount}
instance={instance}
short
/>
</div>
)
)}
</>
)}
<div
class={`content-container ${
spoilerText || sensitive ? 'has-spoiler' : ''
} ${showSpoiler ? 'show-spoiler' : ''} ${
showSpoilerMedia ? 'show-media' : ''
}`}
data-content-text-weight={contentTextWeight ? textWeight() : null}
style={
(isSizeLarge || contentTextWeight) && {
'--content-text-weight': textWeight(),
}
2022-12-18 13:10:05 +00:00
}
2024-01-30 06:34:54 +00:00
>
{mediaFirst && hasMediaAttachments ? (
2024-01-30 06:34:54 +00:00
<>
{(!!spoilerText || !!sensitive) && !readingExpandSpoilers && (
<>
{!!spoilerText && (
<span
class="spoiler-content media-first-spoiler-content"
lang={language}
dir="auto"
ref={spoilerContentRef}
data-read-more={readMoreText}
>
<EmojiText text={spoilerText} emojis={emojis} />{' '}
</span>
)}
<button
class={`light spoiler-button media-first-spoiler-button ${
showSpoiler ? 'spoiling' : ''
}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
if (!readingExpandSpoilers) {
delete states.spoilersMedia[id];
}
} else {
states.spoilers[id] = true;
if (!readingExpandSpoilers) {
states.spoilersMedia[id] = true;
}
2024-01-30 06:34:54 +00:00
}
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show content'}
</button>
</>
2024-01-30 06:34:54 +00:00
)}
<MediaFirstContainer
mediaAttachments={mediaAttachments}
language={language}
postID={id}
instance={instance}
2024-01-30 06:34:54 +00:00
/>
{!!content && (
<div class="media-first-content content" ref={contentRef}>
<PostContent
post={status}
instance={instance}
previewMode={previewMode}
/>
</div>
)}
</>
) : (
<>
{!!spoilerText && (
<>
<div
class="content spoiler-content"
2024-01-30 06:34:54 +00:00
lang={language}
dir="auto"
ref={spoilerContentRef}
data-read-more={readMoreText}
>
<p>
<EmojiText text={spoilerText} emojis={emojis} />
</p>
</div>
{readingExpandSpoilers || previewMode ? (
<div class="spoiler-divider">
<Icon icon="eye-open" /> Content warning
</div>
) : (
<button
class={`light spoiler-button ${
showSpoiler ? 'spoiling' : ''
}`}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
if (!readingExpandSpoilers) {
delete states.spoilersMedia[id];
}
} else {
states.spoilers[id] = true;
if (!readingExpandSpoilers) {
states.spoilersMedia[id] = true;
2024-01-30 06:34:54 +00:00
}
}
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show content'}
</button>
)}
</>
)}
{!!content && (
<div
class="content"
ref={contentRef}
data-read-more={readMoreText}
>
<PostContent
post={status}
instance={instance}
previewMode={previewMode}
/>
<QuoteStatuses id={id} instance={instance} level={quoted} />
</div>
)}
{!!poll && (
<Poll
lang={language}
poll={poll}
readOnly={readOnly || !sameInstance || !authenticated}
onUpdate={(newPoll) => {
states.statuses[sKey].poll = newPoll;
}}
refresh={() => {
return masto.v1.polls
.$select(poll.id)
.fetch()
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
votePoll={(choices) => {
return masto.v1.polls
.$select(poll.id)
.votes.create({
choices,
})
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
/>
)}
{(((enableTranslate || inlineTranslate) &&
!!content.trim() &&
!!getHTMLText(emojifyText(content, emojis)) &&
differentLanguage) ||
forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate || inlineTranslate}
mini={!isSizeLarge && !withinContext}
sourceLanguage={language}
text={getPostText(status)}
/>
)}
{!previewMode &&
sensitive &&
!!mediaAttachments.length &&
readingExpandMedia !== 'show_all' && (
<button
class={`plain spoiler-media-button ${
showSpoilerMedia ? 'spoiling' : ''
}`}
type="button"
hidden={!readingExpandSpoilers && !!spoilerText}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoilerMedia) {
delete states.spoilersMedia[id];
} else {
states.spoilersMedia[id] = true;
}
}}
>
<Icon
icon={showSpoilerMedia ? 'eye-open' : 'eye-close'}
/>{' '}
{showSpoilerMedia ? 'Show less' : 'Show media'}
</button>
)}
{!!mediaAttachments.length && (
<MultipleMediaFigure
lang={language}
enabled={showMultipleMediaCaptions}
captionChildren={captionChildren}
>
<div
ref={mediaContainerRef}
class={`media-container media-eq${
mediaAttachments.length
} ${mediaAttachments.length > 2 ? 'media-gt2' : ''} ${
mediaAttachments.length > 4 ? 'media-gt4' : ''
}`}
>
{displayedMediaAttachments.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
allowLongerCaption={
!content && mediaAttachments.length === 1
}
lang={language}
altIndex={
showMultipleMediaCaptions &&
!!media.description &&
i + 1
}
to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only'
}=${i + 1}`}
onClick={
onMediaClick
? (e) => {
onMediaClick(e, i, media, status);
}
: undefined
}
/>
))}
</div>
</MultipleMediaFigure>
)}
{!!card &&
/^https/i.test(card?.url) &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length &&
!snapStates.statusQuotes[sKey] && (
<Card
card={card}
selfReferential={
card?.url === status.url || card?.url === status.uri
2024-01-30 06:34:54 +00:00
}
instance={currentInstance}
2024-01-30 06:34:54 +00:00
/>
)}
</>
)}
</div>
2024-01-30 06:34:54 +00:00
{!isSizeLarge && showCommentCount && (
<div class="content-comment-hint insignificant">
<Icon icon="comment2" alt="Replies" /> {repliesCount}
</div>
)}
{isSizeLarge && (
<>
<div class="extra-meta">
{_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : (
<>
{/* <Icon
2024-01-30 06:34:54 +00:00
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
/> */}
<span>{visibilityText[visibility]}</span> &bull;{' '}
2024-01-30 06:34:54 +00:00
<a href={url} target="_blank" rel="noopener noreferrer">
2023-03-17 09:14:54 +00:00
<time
2024-01-30 06:34:54 +00:00
class="created"
datetime={createdAtDate.toISOString()}
title={createdAtDate.toLocaleString()}
2023-03-17 09:14:54 +00:00
>
2024-01-30 06:34:54 +00:00
{createdDateText}
2023-03-17 09:14:54 +00:00
</time>
2024-01-30 06:34:54 +00:00
</a>
{editedAt && (
<>
{' '}
&bull; <Icon icon="pencil" alt="Edited" />{' '}
<time
tabIndex="0"
class="edited"
datetime={editedAtDate.toISOString()}
onClick={() => {
setShowEdited(id);
}}
>
{editedDateText}
</time>
</>
)}
</>
)}
2022-12-19 05:38:16 +00:00
</div>
{!!emojiReactions?.length && (
<div class="emoji-reactions">
{emojiReactions.map((emojiReaction) => {
const { name, count, me, url, staticUrl } = emojiReaction;
if (url) {
// Some servers return url and staticUrl
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
<CustomEmoji
alt={name}
url={url}
staticUrl={staticUrl}
/>{' '}
{count}
</span>
);
}
const isShortCode = /^:.+?:$/.test(name);
if (isShortCode) {
const emoji = emojis.find(
(e) =>
e.shortcode ===
name.replace(/^:/, '').replace(/:$/, ''),
);
if (emoji) {
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
<CustomEmoji
alt={name}
url={emoji.url}
staticUrl={emoji.staticUrl}
/>{' '}
{count}
</span>
);
}
}
return (
<span
class={`emoji-reaction tag ${
me ? '' : 'insignificant'
}`}
>
{name} {count}
</span>
);
})}
</div>
)}
2024-01-30 06:34:54 +00:00
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
<div class="action has-count">
<StatusButton
title="Reply"
alt="Comments"
class="reply-button"
icon="comment"
count={repliesCount}
onClick={replyStatus}
/>
</div>
{/* <div class="action has-count">
<StatusButton
checked={reblogged}
title={['Boost', 'Unboost']}
alt={['Boost', 'Boosted']}
class="reblog-button"
icon="rocket"
count={reblogsCount}
onClick={boostStatus}
disabled={!canBoost}
/>
</div> */}
2024-01-30 06:34:54 +00:00
<MenuConfirm
disabled={!canBoost}
onClick={confirmBoostStatus}
confirmLabel={
<>
<Icon icon="rocket" />
<span>{reblogged ? 'Unboost' : 'Boost'}</span>
2024-01-30 06:34:54 +00:00
</>
}
menuExtras={
<MenuItem
onClick={() => {
states.showCompose = {
draftStatus: {
status: `\n${url}`,
},
};
}}
>
<Icon icon="quote" />
<span>Quote</span>
</MenuItem>
}
2024-01-30 06:34:54 +00:00
menuFooter={
mediaNoDesc &&
!reblogged && (
<div class="footer">
<Icon icon="alert" />
Some media have no descriptions.
</div>
)
}
>
<div class="action has-count">
<StatusButton
checked={reblogged}
title={['Boost', 'Unboost']}
alt={['Boost', 'Boosted']}
class="reblog-button"
icon="rocket"
count={reblogsCount}
// onClick={boostStatus}
disabled={!canBoost}
/>
</div>
</MenuConfirm>
2023-07-18 10:45:38 +00:00
<div class="action has-count">
<StatusButton
2024-01-30 06:34:54 +00:00
checked={favourited}
title={['Like', 'Unlike']}
alt={['Like', 'Liked']}
class="favourite-button"
icon="heart"
count={favouritesCount}
onClick={favouriteStatus}
2023-07-18 10:45:38 +00:00
/>
</div>
2024-04-14 09:20:18 +00:00
{supports('@mastodon/post-bookmark') && (
<div class="action">
<StatusButton
checked={bookmarked}
title={['Bookmark', 'Unbookmark']}
alt={['Bookmark', 'Bookmarked']}
class="bookmark-button"
icon="bookmark"
onClick={bookmarkStatus}
/>
</div>
)}
2024-01-30 06:34:54 +00:00
<Menu2
portal={{
target:
document.querySelector('.status-deck') || document.body,
}}
align="end"
gap={4}
overflow="auto"
viewScroll="close"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{StatusMenuItems}
</Menu2>
2022-12-19 05:38:16 +00:00
</div>
2024-01-30 06:34:54 +00:00
</>
)}
</div>
{!!showEdited && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEdited(false);
// statusRef.current?.focus();
}
2022-12-18 13:10:05 +00:00
}}
2024-01-30 06:34:54 +00:00
>
<EditedAtModal
statusID={showEdited}
instance={instance}
fetchStatusHistory={() => {
return masto.v1.statuses.$select(showEdited).history.list();
}}
onClose={() => {
setShowEdited(false);
statusRef.current?.focus();
}}
/>
</Modal>
)}
2024-03-02 10:55:05 +00:00
{!!showEmbed && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEmbed(false);
}
}}
>
<EmbedModal
post={status}
instance={instance}
onClose={() => {
setShowEmbed(false);
}}
/>
</Modal>
)}
2024-01-30 06:34:54 +00:00
</article>
</StatusParent>
2022-12-18 13:10:05 +00:00
);
}
function MultipleMediaFigure(props) {
const { enabled, children, lang, captionChildren } = props;
if (!enabled || !captionChildren) return children;
return (
<figure class="media-figure-multiple">
{children}
<figcaption lang={lang} dir="auto">
{captionChildren}
</figcaption>
</figure>
);
}
function MediaFirstContainer(props) {
const { mediaAttachments, language, postID, instance } = props;
const moreThanOne = mediaAttachments.length > 1;
const carouselRef = useRef();
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
let handleScroll = () => {
const { clientWidth, scrollLeft } = carouselRef.current;
const index = Math.round(scrollLeft / clientWidth);
setCurrentIndex(index);
};
if (carouselRef.current) {
carouselRef.current.addEventListener('scroll', handleScroll, {
passive: true,
});
}
return () => {
if (carouselRef.current) {
carouselRef.current.removeEventListener('scroll', handleScroll);
}
};
}, []);
return (
<>
<div class="media-first-container" ref={carouselRef}>
{mediaAttachments.map((media, i) => (
<div class="media-first-item" key={media.id}>
<Media
media={media}
lang={language}
to={`/${instance}/s/${postID}?media=${i + 1}`}
/>
</div>
))}
{moreThanOne && (
<div class="media-carousel-controls">
<div class="carousel-indexer">
{currentIndex + 1}/{mediaAttachments.length}
</div>
<label class="media-carousel-button">
<button
type="button"
class="carousel-button"
hidden={currentIndex === 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex - 1),
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-left" />
</button>
</label>
<label class="media-carousel-button">
<button
type="button"
class="carousel-button"
hidden={currentIndex === mediaAttachments.length - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.focus();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex + 1),
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-right" />
</button>
</label>
</div>
)}
</div>
{moreThanOne && (
<div
class="media-carousel-dots"
style={{
'--dots-count': mediaAttachments.length,
}}
>
{mediaAttachments.map((media, i) => (
<span
key={media.id}
class={`carousel-dot ${i === currentIndex ? 'active' : ''}`}
/>
))}
</div>
)}
</>
);
}
function Card({ card, selfReferential, instance }) {
2023-04-22 16:55:47 +00:00
const snapStates = useSnapshot(states);
2022-12-18 13:10:05 +00:00
const {
blurhash,
title,
description,
html,
providerName,
providerUrl,
2022-12-18 13:10:05 +00:00
authorName,
authorUrl,
2022-12-18 13:10:05 +00:00
width,
height,
image,
imageDescription,
2022-12-18 13:10:05 +00:00
url,
type,
embedUrl,
language,
publishedAt,
2022-12-18 13:10:05 +00:00
} = card;
/* type
link = Link OEmbed
photo = Photo OEmbed
video = Video OEmbed
rich = iframe OEmbed. Not currently accepted, so wont show up in practice.
*/
const hasText = title || providerName || authorName;
const isLandscape = width / height >= 1.2;
const size = isLandscape ? 'large' : '';
2022-12-18 13:10:05 +00:00
const [cardStatusURL, setCardStatusURL] = useState(null);
// const [cardStatusID, setCardStatusID] = useState(null);
useEffect(() => {
if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) {
unfurlMastodonLink(instance, url).then((result) => {
if (!result) return;
const { id, url } = result;
setCardStatusURL('#' + url);
// NOTE: This is for quote post
// (async () => {
// const { masto } = api({ instance });
// const status = await masto.v1.statuses.$select(id).fetch();
// saveStatus(status, instance);
// setCardStatusID(id);
// })();
});
}
}, [hasText, image, selfReferential]);
// if (cardStatusID) {
// return (
// <Status statusID={cardStatusID} instance={instance} size="s" readOnly />
// );
// }
2023-04-22 16:55:47 +00:00
if (snapStates.unfurledLinks[url]) return null;
2024-01-06 08:46:45 +00:00
const hasIframeHTML = /<iframe/i.test(html);
const handleClick = useCallback(
(e) => {
if (hasIframeHTML) {
e.preventDefault();
states.showEmbedModal = {
html,
url: url || embedUrl,
2024-01-19 12:31:05 +00:00
width,
height,
2024-01-06 08:46:45 +00:00
};
}
},
[hasIframeHTML],
);
2023-08-04 16:16:18 +00:00
if (hasText && (image || (type === 'photo' && blurhash))) {
const domain = punycode.toUnicode(
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
);
let blurhashImage;
const rgbAverageColor =
image && blurhash ? getBlurHashAverageColor(blurhash) : null;
if (!image) {
const w = 44;
const h = 44;
const blurhashPixels = decodeBlurHash(blurhash, w, h);
2024-03-10 15:25:07 +00:00
const canvas = window.OffscreenCanvas
? new OffscreenCanvas(1, 1)
: document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
2024-03-10 15:25:07 +00:00
ctx.imageSmoothingEnabled = false;
const imageData = ctx.createImageData(w, h);
imageData.data.set(blurhashPixels);
ctx.putImageData(imageData, 0, 0);
blurhashImage = canvas.toDataURL();
}
2022-12-18 13:10:05 +00:00
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
2022-12-18 13:10:05 +00:00
rel="nofollow noopener noreferrer"
class={`card link ${blurhashImage ? '' : size}`}
lang={language}
2023-09-23 04:58:12 +00:00
dir="auto"
style={{
'--average-color':
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
}}
2024-01-06 08:46:45 +00:00
onClick={handleClick}
2022-12-18 13:10:05 +00:00
>
2023-01-07 12:25:13 +00:00
<div class="card-image">
<img
src={image || blurhashImage}
2023-01-07 12:25:13 +00:00
width={width}
height={height}
loading="lazy"
alt={imageDescription || ''}
2023-01-07 12:25:13 +00:00
onError={(e) => {
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
</div>
2022-12-18 13:10:05 +00:00
<div class="meta-container">
2024-03-23 04:26:50 +00:00
<p class="meta domain">
<span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime datetime={publishedAt} format="micro" />
</>
)}
2023-09-23 04:58:12 +00:00
</p>
<p class="title" dir="auto" title={title}>
2023-09-23 04:58:12 +00:00
{title}
</p>
<p class="meta" dir="auto" title={description}>
{description ||
(!!publishedAt && (
<RelativeTime datetime={publishedAt} format="micro" />
))}
2023-09-23 04:58:12 +00:00
</p>
2022-12-18 13:10:05 +00:00
</div>
</a>
);
} else if (type === 'photo') {
return (
<a
href={url}
target="_blank"
rel="nofollow noopener noreferrer"
class="card photo"
2024-01-06 08:46:45 +00:00
onClick={handleClick}
2022-12-18 13:10:05 +00:00
>
<img
src={embedUrl}
width={width}
height={height}
alt={title || description}
loading="lazy"
style={{
height: 'auto',
aspectRatio: `${width}/${height}`,
}}
/>
</a>
);
2024-01-06 08:46:45 +00:00
} else {
if (type === 'video') {
if (/youtube/i.test(providerName)) {
// Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID]
const videoID = url.match(/watch\?v=([^&]+)/)?.[1];
if (videoID) {
2024-03-02 10:53:35 +00:00
return (
<a class="card video" onClick={handleClick}>
<lite-youtube videoid={videoID} nocookie></lite-youtube>
</a>
);
2024-01-06 08:46:45 +00:00
}
}
2024-01-06 08:46:45 +00:00
// return (
// <div
// class="card video"
// style={{
// aspectRatio: `${width}/${height}`,
// }}
// dangerouslySetInnerHTML={{ __html: html }}
// />
// );
}
if (hasText && !image) {
const domain = punycode.toUnicode(
new URL(url).hostname.replace(/^www\./, ''),
);
2024-01-06 08:46:45 +00:00
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link no-image`}
lang={language}
onClick={handleClick}
>
<div class="meta-container">
<p class="meta domain">
2024-03-23 04:26:50 +00:00
<span class="domain">
<Icon icon="link" size="s" /> <span>{domain}</span>
</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime datetime={publishedAt} format="micro" />
</>
)}
2024-01-06 08:46:45 +00:00
</p>
2024-03-20 03:03:15 +00:00
<p class="title" title={title}>
{title}
</p>
<p class="meta" title={description || providerName || authorName}>
{description || providerName || authorName}
</p>
2024-01-06 08:46:45 +00:00
</div>
</a>
);
}
2022-12-18 13:10:05 +00:00
}
}
function EditedAtModal({
statusID,
instance,
fetchStatusHistory = () => {},
2023-04-20 08:10:57 +00:00
onClose,
}) {
2022-12-18 13:10:05 +00:00
const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const editHistory = await fetchStatusHistory();
2022-12-18 13:10:05 +00:00
console.log(editHistory);
setEditHistory(editHistory);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
return (
2022-12-30 12:37:57 +00:00
<div id="edit-history" class="sheet">
2023-04-20 08:10:57 +00:00
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>Edit History</h2>
{uiState === 'error' && <p>Failed to load history</p>}
{uiState === 'loading' && (
<p>
<Loader abrupt /> Loading&hellip;
</p>
)}
</header>
2022-12-30 12:37:57 +00:00
<main tabIndex="-1">
{editHistory.length > 0 && (
<ol>
{editHistory.map((status) => {
const { createdAt } = status;
const createdAtDate = new Date(createdAt);
return (
<li key={createdAt} class="history-item">
<h3>
<time>
{niceDateTime(createdAtDate, {
formatOpts: {
weekday: 'short',
second: 'numeric',
},
})}
</time>
</h3>
<Status
status={status}
instance={instance}
size="s"
withinContext
readOnly
previewMode
/>
</li>
);
})}
</ol>
)}
</main>
2022-12-10 09:14:48 +00:00
</div>
);
2023-04-06 14:51:48 +00:00
}
2024-03-02 10:55:05 +00:00
function generateHTMLCode(post, instance, level = 0) {
const {
account: {
url: accountURL,
displayName,
2024-03-03 04:12:40 +00:00
acct,
2024-03-02 10:55:05 +00:00
username,
emojis: accountEmojis,
bot,
group,
},
id,
poll,
spoilerText,
language,
editedAt,
createdAt,
content,
mediaAttachments,
url,
emojis,
} = post;
const sKey = statusKey(id, instance);
const quotes = states.statusQuotes[sKey] || [];
const uniqueQuotes = quotes.filter(
(q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i,
);
const quoteStatusesHTML =
uniqueQuotes.length && level <= 2
? uniqueQuotes
.map((quote) => {
const { id, instance } = quote;
const sKey = statusKey(id, instance);
const s = states.statuses[sKey];
if (s) {
return generateHTMLCode(s, instance, ++level);
}
})
.join('')
: '';
const createdAtDate = new Date(createdAt);
// const editedAtDate = editedAt && new Date(editedAt);
const contentHTML =
emojifyText(content, emojis) +
'\n' +
quoteStatusesHTML +
'\n' +
(poll?.options?.length
? `
<p>📊:</p>
<ul>
${poll.options
.map(
(option) => `
<li>
${option.title}
${option.votesCount >= 0 ? ` (${option.votesCount})` : ''}
</li>
`,
)
.join('')}
</ul>`
: '') +
(mediaAttachments.length > 0
? '\n' +
mediaAttachments
.map((media) => {
const {
description,
meta,
previewRemoteUrl,
previewUrl,
remoteUrl,
url,
type,
} = media;
const { original = {}, small } = meta || {};
const width = small?.width || original?.width;
const height = small?.height || original?.height;
// Prefer remote over original
const sourceMediaURL = remoteUrl || url;
const previewMediaURL = previewRemoteUrl || previewUrl;
const mediaURL = previewMediaURL || sourceMediaURL;
const sourceMediaURLObj = sourceMediaURL
? new URL(sourceMediaURL)
: null;
const isVideoMaybe =
type === 'unknown' &&
sourceMediaURLObj &&
/\.(mp4|m4r|m4v|mov|webm)$/i.test(sourceMediaURLObj.pathname);
const isAudioMaybe =
type === 'unknown' &&
sourceMediaURLObj &&
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(sourceMediaURLObj.pathname);
const isImage =
type === 'image' ||
(type === 'unknown' &&
previewMediaURL &&
!isVideoMaybe &&
!isAudioMaybe);
const isVideo = type === 'gifv' || type === 'video' || isVideoMaybe;
const isAudio = type === 'audio' || isAudioMaybe;
let mediaHTML = '';
if (isImage) {
mediaHTML = `<img src="${mediaURL}" width="${width}" height="${height}" alt="${description}" loading="lazy" />`;
} else if (isVideo) {
mediaHTML = `
<video src="${sourceMediaURL}" width="${width}" height="${height}" controls preload="auto" poster="${previewMediaURL}" loading="lazy"></video>
${description ? `<figcaption>${description}</figcaption>` : ''}
`;
} else if (isAudio) {
mediaHTML = `
<audio src="${sourceMediaURL}" controls preload="auto"></audio>
${description ? `<figcaption>${description}</figcaption>` : ''}
`;
} else {
mediaHTML = `
<a href="${sourceMediaURL}">📄 ${
description || sourceMediaURL
}</a>
`;
}
return `<figure>${mediaHTML}</figure>`;
})
.join('\n')
: '');
const htmlCode = `
<blockquote lang="${language}" cite="${url}">
${
spoilerText
? `
<details>
<summary>${spoilerText}</summary>
${contentHTML}
</details>
`
: contentHTML
}
<footer>
${emojifyText(
displayName,
accountEmojis,
2024-03-03 04:12:40 +00:00
)} (@${acct}) <a href="${url}"><time datetime="${createdAtDate.toISOString()}">${createdAtDate.toLocaleString()}</time></a>
2024-03-02 10:55:05 +00:00
</footer>
</blockquote>
`;
return prettify(htmlCode);
}
function EmbedModal({ post, instance, onClose }) {
const {
account: {
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
poll,
spoilerText,
language,
editedAt,
createdAt,
content,
mediaAttachments,
url,
emojis,
} = post;
const htmlCode = generateHTMLCode(post, instance);
return (
<div id="embed-post" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>Embed post</h2>
</header>
<main tabIndex="-1">
<h3>HTML Code</h3>
<textarea
class="embed-code"
readonly
onClick={(e) => {
e.target.select();
}}
>
{htmlCode}
</textarea>
<button
type="button"
onClick={() => {
try {
navigator.clipboard.writeText(htmlCode);
showToast('HTML code copied');
} catch (e) {
console.error(e);
showToast('Unable to copy HTML code');
}
}}
>
<Icon icon="clipboard" /> <span>Copy</span>
</button>
{!!mediaAttachments?.length && (
<section>
<p>Media attachments:</p>
<ol class="links-list">
{mediaAttachments.map((media) => {
return (
<li key={media.id}>
<a
href={media.remoteUrl || media.url}
target="_blank"
download
>
{media.remoteUrl || media.url}
</a>
</li>
);
})}
</ol>
</section>
)}
{!!accountEmojis?.length && (
<section>
<p>Account Emojis:</p>
2024-03-06 10:58:12 +00:00
<ul>
2024-03-02 10:55:05 +00:00
{accountEmojis.map((emoji) => {
return (
<li key={emoji.shortcode}>
<picture>
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
></source>
<img
class="shortcode-emoji emoji"
src={emoji.url}
alt={`:${emoji.shortcode}:`}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>{' '}
<code>:{emoji.shortcode}:</code> (
<a href={emoji.url} target="_blank" download>
url
</a>
)
{emoji.staticUrl ? (
<>
{' '}
(
<a href={emoji.staticUrl} target="_blank" download>
static
</a>
)
</>
) : null}
</li>
);
})}
</ul>
</section>
)}
{!!emojis?.length && (
<section>
<p>Emojis:</p>
2024-03-06 10:58:12 +00:00
<ul>
2024-03-02 10:55:05 +00:00
{emojis.map((emoji) => {
return (
<li key={emoji.shortcode}>
<picture>
<source
srcset={emoji.staticUrl}
media="(prefers-reduced-motion: reduce)"
></source>
<img
class="shortcode-emoji emoji"
src={emoji.url}
alt={`:${emoji.shortcode}:`}
width="16"
height="16"
loading="lazy"
decoding="async"
/>
</picture>{' '}
<code>:{emoji.shortcode}:</code> (
<a href={emoji.url} target="_blank" download>
url
</a>
)
{emoji.staticUrl ? (
<>
{' '}
(
<a href={emoji.staticUrl} target="_blank" download>
static
</a>
)
</>
) : null}
</li>
);
})}
</ul>
</section>
)}
<section>
<small>
<p>Notes:</p>
<ul>
<li>
This is static, unstyled and scriptless. You may need to apply
your own styles and edit as needed.
</li>
<li>
Polls are not interactive, becomes a list with vote counts.
</li>
<li>
Media attachments can be images, videos, audios or any file
types.
</li>
<li>Post could be edited or deleted later.</li>
</ul>
</small>
</section>
<h3>Preview</h3>
<output
class="embed-preview"
dangerouslySetInnerHTML={{ __html: htmlCode }}
/>
<p>
<small>Note: This preview is lightly styled.</small>
</p>
</main>
</div>
);
}
function StatusButton({
checked,
count,
class: className,
title,
alt,
2024-01-13 16:32:08 +00:00
size,
icon,
2024-01-13 16:32:08 +00:00
iconSize = 'l',
onClick,
...props
}) {
if (typeof title === 'string') {
title = [title, title];
}
if (typeof alt === 'string') {
alt = [alt, alt];
}
const [buttonTitle, setButtonTitle] = useState(title[0] || '');
const [iconAlt, setIconAlt] = useState(alt[0] || '');
useEffect(() => {
if (checked) {
setButtonTitle(title[1] || '');
setIconAlt(alt[1] || '');
} else {
setButtonTitle(title[0] || '');
setIconAlt(alt[0] || '');
}
}, [checked, title, alt]);
return (
<button
type="button"
title={buttonTitle}
2024-01-13 16:32:08 +00:00
class={`plain ${size ? 'small' : ''} ${className} ${
checked ? 'checked' : ''
}`}
onClick={(e) => {
if (!onClick) return;
e.preventDefault();
e.stopPropagation();
onClick(e);
}}
{...props}
>
2024-01-13 16:32:08 +00:00
<Icon icon={icon} size={iconSize} alt={iconAlt} />
{!!count && (
<>
{' '}
2022-12-17 16:13:56 +00:00
<small title={count}>{shortenNumber(count)}</small>
</>
)}
</button>
);
}
2023-03-09 13:51:50 +00:00
function nicePostURL(url) {
if (!url) return;
2023-03-09 13:51:50 +00:00
const urlObj = new URL(url);
const { host, pathname } = urlObj;
const path = pathname.replace(/\/$/, '');
// split only first slash
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
return (
<>
{punycode.toUnicode(host)}
2023-03-09 13:51:50 +00:00
{username ? (
<>
/{username}
2023-03-10 11:34:04 +00:00
<wbr />
2023-03-09 13:51:50 +00:00
<span class="more-insignificant">/{restPath}</span>
</>
) : (
<span class="more-insignificant">{path}</span>
)}
</>
);
}
2024-01-30 06:34:54 +00:00
function StatusCompact({ sKey }) {
const snapStates = useSnapshot(states);
const statusReply = snapStates.statusReply[sKey];
if (!statusReply) return null;
const { id, instance } = statusReply;
const status = getStatus(id, instance);
if (!status) return null;
const {
sensitive,
spoilerText,
account: { avatar, avatarStatic, bot },
visibility,
content,
language,
2024-02-28 07:04:01 +00:00
filtered,
2024-01-30 06:34:54 +00:00
} = status;
if (sensitive || spoilerText) return null;
if (!content) return null;
const srKey = statusKey(id, instance);
2024-01-30 06:34:54 +00:00
const statusPeekText = statusPeek(status);
2024-02-28 07:04:01 +00:00
const filterContext = useContext(FilterContext);
const filterInfo = isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') return null;
const filterTitleStr = filterInfo?.titlesStr || '';
2024-01-30 06:34:54 +00:00
return (
<article
class={`status compact-reply ${
visibility === 'direct' ? 'visibility-direct' : ''
}`}
tabindex="-1"
data-state-post-id={srKey}
2024-01-30 06:34:54 +00:00
>
<Avatar url={avatarStatic || avatar} squircle={bot} />
<div
class="content-compact"
title={statusPeekText}
lang={language}
dir="auto"
>
2024-02-28 07:04:01 +00:00
{filterInfo ? (
<b class="status-filtered-badge badge-meta" title={filterTitleStr}>
<span>Filtered</span>
<span>{filterTitleStr}</span>
</b>
) : (
2024-02-28 07:34:11 +00:00
<span>{statusPeekText}</span>
2024-02-28 07:04:01 +00:00
)}
2024-01-30 06:34:54 +00:00
</div>
</article>
);
}
2023-12-14 17:58:29 +00:00
function FilteredStatus({
status,
filterInfo,
instance,
containerProps = {},
showFollowedTags,
}) {
const snapStates = useSnapshot(states);
2023-03-21 16:09:36 +00:00
const {
id: statusID,
account: { avatar, avatarStatic, bot, group },
2023-03-21 16:09:36 +00:00
createdAt,
visibility,
reblog,
2023-03-21 16:09:36 +00:00
} = status;
const isReblog = !!reblog;
2023-03-21 16:09:36 +00:00
const filterTitleStr = filterInfo?.titlesStr || '';
const createdAtDate = new Date(createdAt);
const statusPeekText = statusPeek(status.reblog || status);
2023-03-21 16:09:36 +00:00
const [showPeek, setShowPeek] = useState(false);
const bindLongPressPeek = useLongPress(
2023-03-21 16:09:36 +00:00
() => {
setShowPeek(true);
},
{
2023-04-24 13:36:03 +00:00
threshold: 600,
2023-03-21 16:09:36 +00:00
captureEvent: true,
detect: 'touch',
2023-09-29 16:26:51 +00:00
cancelOnMovement: 2, // true allows movement of up to 25 pixels
2023-03-21 16:09:36 +00:00
},
);
const statusPeekRef = useTruncated();
2023-12-14 17:58:29 +00:00
const sKey = statusKey(status.id, instance);
const ssKey =
2023-11-05 00:21:43 +00:00
statusKey(status.id, instance) +
' ' +
(statusKey(reblog?.id, instance) || '');
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
2023-12-14 17:58:29 +00:00
const isFollowedTags =
showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length;
2023-03-21 16:09:36 +00:00
return (
<div
2023-12-14 17:58:29 +00:00
class={
isReblog
? group
? 'status-group'
: 'status-reblog'
: isFollowedTags
? 'status-followed-tags'
: ''
}
2023-03-23 13:48:29 +00:00
{...containerProps}
// title={statusPeekText}
2023-03-21 16:09:36 +00:00
onContextMenu={(e) => {
e.preventDefault();
setShowPeek(true);
}}
{...bindLongPressPeek()}
2023-03-21 16:09:36 +00:00
>
2023-12-14 17:58:29 +00:00
<article data-state-post-id={ssKey} class="status filtered" tabindex="-1">
2023-03-21 16:09:36 +00:00
<b
2023-03-22 04:26:28 +00:00
class="status-filtered-badge clickable badge-meta"
2023-03-21 16:09:36 +00:00
title={filterTitleStr}
onClick={(e) => {
e.preventDefault();
setShowPeek(true);
}}
>
2023-03-22 04:26:28 +00:00
<span>Filtered</span>
<span>{filterTitleStr}</span>
2023-03-21 16:09:36 +00:00
</b>{' '}
2023-04-10 16:26:43 +00:00
<Avatar url={avatarStatic || avatar} squircle={bot} />
2023-03-21 16:09:36 +00:00
<span class="status-filtered-info">
<span class="status-filtered-info-1">
<NameText account={status.account} instance={instance} />{' '}
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
{isReblog ? (
'boosted'
2023-12-14 17:58:29 +00:00
) : isFollowedTags ? (
<span>
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
<span key={tag} class="status-followed-tag-item">
#{tag}
</span>
))}
</span>
) : (
<RelativeTime datetime={createdAtDate} format="micro" />
)}
</span>
<span class="status-filtered-info-2">
{isReblog && (
<>
<Avatar
url={reblog.account.avatarStatic || reblog.account.avatar}
2023-04-10 16:26:43 +00:00
squircle={bot}
/>{' '}
</>
)}
{statusPeekText}
2023-03-21 16:09:36 +00:00
</span>
</span>
</article>
{!!showPeek && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowPeek(false);
}
}}
>
<div id="filtered-status-peek" class="sheet">
2023-04-20 08:10:57 +00:00
<button
type="button"
class="sheet-close"
onClick={() => setShowPeek(false)}
>
<Icon icon="x" />
</button>
<header>
<b class="status-filtered-badge">Filtered</b> {filterTitleStr}
</header>
2023-03-21 16:09:36 +00:00
<main tabIndex="-1">
<Link
ref={statusPeekRef}
2023-03-21 16:09:36 +00:00
class="status-link"
to={url}
2023-03-21 16:09:36 +00:00
onClick={() => {
setShowPeek(false);
}}
data-read-more="Read more →"
2023-03-21 16:09:36 +00:00
>
<Status status={status} instance={instance} size="s" readOnly />
</Link>
</main>
</div>
</Modal>
)}
</div>
);
}
const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
2023-05-19 17:06:16 +00:00
if (!id || !instance) return;
2023-04-22 16:55:47 +00:00
const snapStates = useSnapshot(states);
const sKey = statusKey(id, instance);
const quotes = snapStates.statusQuotes[sKey];
2023-05-17 08:13:49 +00:00
const uniqueQuotes = quotes?.filter(
(q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i,
);
2023-04-22 16:55:47 +00:00
2023-05-17 08:13:49 +00:00
if (!uniqueQuotes?.length) return;
if (level > 2) return;
2023-04-22 16:55:47 +00:00
2023-05-17 08:13:49 +00:00
return uniqueQuotes.map((q) => {
2023-04-22 16:55:47 +00:00
return (
2024-03-26 08:35:02 +00:00
<LazyShazam>
<Link
key={q.instance + q.id}
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link"
data-read-more="Read more →"
>
<Status
statusID={q.id}
instance={q.instance}
size="s"
quoted={level + 1}
enableCommentHint
/>
</Link>
</LazyShazam>
2023-04-22 16:55:47 +00:00
);
});
});
2024-01-06 04:31:25 +00:00
export default memo(Status, (oldProps, newProps) => {
// Shallow equal all props except 'status'
// This will be pure static until status ID changes
const { status, ...restOldProps } = oldProps;
const { status: newStatus, ...restNewProps } = newProps;
return (
status?.id === newStatus?.id && shallowEqual(restOldProps, restNewProps)
);
});