import './status.css';
import '@justinribeiro/lite-youtube';
import {
ControlledMenu,
Menu,
MenuDivider,
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { decodeBlurHash } from 'fast-blurhash';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla';
import AccountBlock from '../components/account-block';
import EmojiText from '../components/emoji-text';
import Loader from '../components/loader';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import Modal from '../components/modal';
import NameText from '../components/name-text';
import Poll from '../components/poll';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
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';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import localeMatch from '../utils/locale-match';
import niceDateTime from '../utils/nice-date-time';
import openCompose from '../utils/open-compose';
import pmem from '../utils/pmem';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import useTruncated from '../utils/useTruncated';
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';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
const SHOW_COMMENT_COUNT_LIMIT = 280;
const INLINE_TRANSLATE_LIMIT = 140;
const throttle = pThrottle({
limit: 1,
interval: 1000,
});
function fetchAccount(id, masto) {
return masto.v1.accounts.$select(id).fetch();
}
const memFetchAccount = pmem(fetchAccount);
const visibilityText = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Followers only',
direct: 'Private mention',
};
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
function Status({
statusID,
status,
instance: propInstance,
size = 'm',
contentTextWeight,
readOnly,
enableCommentHint,
withinContext,
skeleton,
enableTranslate,
forceTranslate: _forceTranslate,
previewMode,
// allowFilters,
onMediaClick,
quoted,
onStatusLinkClick = () => {},
showFollowedTags,
}) {
if (skeleton) {
return (
);
}
const { masto, instance, authenticated } = api({ instance: propInstance });
const { instance: currentInstance } = api();
const sameInstance = instance === currentInstance;
let sKey = statusKey(statusID, instance);
const snapStates = useSnapshot(states);
if (!status) {
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
sKey = statusKey(status?.id, instance);
}
if (!status) {
return null;
}
const {
account: {
acct,
avatar,
avatarStatic,
id: accountId,
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
repliesCount,
reblogged,
reblogsCount,
favourited,
favouritesCount,
bookmarked,
poll,
muted,
sensitive,
spoilerText,
visibility, // public, unlisted, private, direct
language,
editedAt,
filtered,
card,
createdAt,
inReplyToId,
inReplyToAccountId,
content,
mentions,
mediaAttachments,
reblog,
uri,
url,
emojis,
tags,
// Non-API props
_deleted,
_pinned,
// _filtered,
} = status;
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
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);
const debugHover = (e) => {
if (e.shiftKey) {
console.log({
...status,
});
}
};
if (/*allowFilters && */ size !== 'l' && filterInfo) {
return (
);
}
const createdAtDate = new Date(createdAt);
const editedAtDate = new Date(editedAt);
let inReplyToAccountRef = mentions?.find(
(mention) => mention.id === inReplyToAccountId,
);
if (!inReplyToAccountRef && inReplyToAccountId === id) {
inReplyToAccountRef = { url: accountURL, username, displayName };
}
const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef);
if (!withinContext && !inReplyToAccount && inReplyToAccountId) {
const account = states.accounts[inReplyToAccountId];
if (account) {
setInReplyToAccount(account);
} else {
memFetchAccount(inReplyToAccountId, masto)
.then((account) => {
setInReplyToAccount(account);
states.accounts[account.id] = account;
})
.catch((e) => {});
}
}
const mentionSelf =
inReplyToAccountId === currentAccount ||
mentions?.find((mention) => mention.id === currentAccount);
const readingExpandSpoilers = useMemo(() => {
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
const showSpoiler =
previewMode || readingExpandSpoilers || !!snapStates.spoilers[id] || false;
if (reblog) {
// If has statusID, means useItemID (cached in states)
if (group) {
return (
);
}
return (
);
}
// Check followedTags
if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) {
return (
);
}
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;
}, [
contentTranslation,
contentTranslationAutoInline,
readOnly,
withinContext,
isSizeLarge,
previewMode,
spoilerText,
sensitive,
poll,
card,
mediaAttachments,
content,
]);
const [showEdited, setShowEdited] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const spoilerContentRef = useTruncated();
const contentRef = useTruncated();
const mediaContainerRef = useTruncated();
const readMoreText = 'Read more →';
const statusRef = useRef(null);
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this post from another instance.`;
const textWeight = useCallback(
() =>
Math.max(
Math.round((spoilerText.length + htmlContentLength(content)) / 140) ||
1,
1,
),
[spoilerText, content],
);
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 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);
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 favouriteStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
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);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
};
const bookmarkStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
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);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
};
const differentLanguage =
!!language &&
language !== targetLanguage &&
!localeMatch([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => language === l || localeMatch([language], [l]),
);
const menuInstanceRef = useRef();
const StatusMenuItems = (
<>
{!isSizeLarge && (
<>
{' '}
{visibilityText[visibility]}
{' '}
{repliesCount > 0 && (
{' '}
{shortenNumber(repliesCount)}
)}{' '}
{reblogsCount > 0 && (
{' '}
{shortenNumber(reblogsCount)}
)}{' '}
{favouritesCount > 0 && (
{' '}
{shortenNumber(favouritesCount)}
)}
{createdDateText}
{
onStatusLinkClick(e, status);
}}
>
View post by @{username || acct}
>
)}
{!!editedAt && (
)}
{(!isSizeLarge || !!editedAt) && }
{isSizeLarge && (
)}
{!isSizeLarge && sameInstance && (
<>
>
)}
{enableTranslate ? (
) : (
(!language || differentLanguage) && (
Translate
)
)}
{((!isSizeLarge && sameInstance) || enableTranslate) && }
{(isSelf || mentionSelf) && }
{(isSelf || mentionSelf) && (
)}
{isSelf && (
)}
>
);
const contextMenuRef = useRef();
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuProps, setContextMenuProps] = useState({});
const showContextMenu = !isSizeLarge && !previewMode && !_deleted && !quoted;
// 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
? (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');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuProps({
anchorPoint: {
x: clientX,
y: clientY,
},
direction: 'right',
});
setIsContextMenuOpen(true);
}
: null,
{
threshold: 600,
captureEvent: true,
detect: 'touch',
cancelOnMovement: 2, // true allows movement of up to 25 pixels
},
);
const hotkeysEnabled = !readOnly && !previewMode;
const rRef = useHotkeys('r, shift+r', replyStatus, {
enabled: hotkeysEnabled,
});
const fRef = useHotkeys(
'f, l',
() => {
try {
favouriteStatus();
if (!isSizeLarge) {
showToast(
favourited
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const dRef = useHotkeys(
'd',
() => {
try {
bookmarkStatus();
if (!isSizeLarge) {
showToast(
bookmarked
? `Unbookmarked @${username || acct}'s post`
: `Bookmarked @${username || acct}'s post`,
);
}
} catch (e) {}
},
{
enabled: hotkeysEnabled,
},
);
const bRef = useHotkeys(
'shift+b',
() => {
(async () => {
try {
const done = await confirmBoostStatus();
if (!isSizeLarge && done) {
showToast(
reblogged
? `Unboosted @${username || acct}'s post`
: `Boosted @${username || acct}'s post`,
);
}
} catch (e) {}
})();
},
{
enabled: hotkeysEnabled && canBoost,
},
);
const displayedMediaAttachments = mediaAttachments.slice(
0,
isSizeLarge ? undefined : 4,
);
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 }) => (
i + 1).join(' ')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: media.description,
lang: language,
};
}}
title={media.description}
>
{indices.map((i) => i + 1).join(' ')} {media.description}
));
// return displayedMediaAttachments.map(
// (media, i) =>
// !!media.description && (
// {
// e.preventDefault();
// e.stopPropagation();
// states.showMediaAlt = {
// alt: media.description,
// lang: language,
// };
// }}
// title={media.description}
// >
// {i + 1} {media.description}
//
// ),
// );
}, [showMultipleMediaCaptions, displayedMediaAttachments, language]);
const isThread = useMemo(() => {
return (
(!!inReplyToId && inReplyToAccountId === status.account?.id) ||
!!snapStates.statusThreadNumber[sKey]
);
}, [
inReplyToId,
inReplyToAccountId,
status.account?.id,
snapStates.statusThreadNumber[sKey],
]);
const showCommentHint = useMemo(() => {
return (
enableCommentHint &&
!isThread &&
!withinContext &&
!inReplyToId &&
visibility === 'public' &&
repliesCount > 0
);
}, [
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,
]);
return (
{
statusRef.current = node;
// Use parent node if it's in focus
// Use case:
// When navigating (j/k), the is focused instead of
// 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;
}}
tabindex="-1"
class={`status ${
!withinContext && inReplyToId && inReplyToAccount
? 'status-reply-to'
: ''
} visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
{
s: 'small',
m: 'medium',
l: 'large',
}[size]
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''}`}
onMouseEnter={debugHover}
onContextMenu={(e) => {
// FIXME: this code isn't getting called on Chrome at all?
if (!showContextMenu) return;
if (e.metaKey) return;
// console.log('context menu', e);
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuProps({
anchorPoint: {
x: e.clientX,
y: e.clientY,
},
direction: 'right',
});
setIsContextMenuOpen(true);
}}
{...(showContextMenu ? bindLongPressContext() : {})}
>
{showContextMenu && (
{
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
>
{StatusMenuItems}
)}
{size !== 'l' && (
{reblogged && }
{favourited && }
{bookmarked && }
{_pinned && }
)}
{size !== 's' && (
{
e.preventDefault();
e.stopPropagation();
states.showAccount = {
account: status.account,
instance,
};
}}
>
)}
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
{' '}
>
)} */}
{/* */}{' '}
{size !== 'l' &&
(_deleted ? (
Deleted
) : url && !previewMode && !quoted ? (
{
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 ? (
) : (
)}{' '}
) : (
//
{' '}
))}
{visibility === 'direct' && (
<>
Private mention
{' '}
>
)}
{!withinContext && (
<>
{isThread ? (
Thread
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''}
) : (
!!inReplyToId &&
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
})) && (
{' '}
)
)}
>
)}
{!!spoilerText && (
<>
>
)}
{!!content && (
{
// Remove target="_blank" from links
dom
.querySelectorAll('a.u-url[target="_blank"]')
.forEach((a) => {
if (!/http/i.test(a.innerText.trim())) {
a.removeAttribute('target');
}
});
if (previewMode) return;
// Unfurl Mastodon links
Array.from(
dom.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
),
)
.filter((a) => {
const url = a.href;
const isPostItself =
url === status.url || url === status.uri;
return !isPostItself && isMastodonLinkMaybe(url);
})
.forEach((a, i) => {
unfurlMastodonLink(currentInstance, a.href).then(
(result) => {
if (!result) return;
a.removeAttribute('target');
if (!sKey) return;
if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = [];
}
if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result);
}
},
);
});
},
}),
}}
/>
)}
{!!poll && (
{
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) && (
`- ${option.title}${
option.votesCount >= 0
? ` (${option.votesCount})`
: ''
}`,
)
.join('\n')}`
: '')
}
/>
)}
{!spoilerText && sensitive && !!mediaAttachments.length && (
)}
{!!mediaAttachments.length && (
2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{displayedMediaAttachments.map((media, i) => (
{
onMediaClick(e, i, media, status);
}
: undefined
}
/>
))}
)}
{!!card &&
card?.url !== status.url &&
card?.url !== status.uri &&
/^https/i.test(card?.url) &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length &&
!snapStates.statusQuotes[sKey] && (
)}
{!isSizeLarge && showCommentCount && (
)}
{isSizeLarge && (
<>
{/*
*/}
{reblogged ? 'Unboost?' : 'Boost to everyone?'}
>
}
menuFooter={
mediaNoDesc &&
!reblogged && (
)
}
>
}
>
{StatusMenuItems}
>
)}
{!!showEdited && (
{
if (e.target === e.currentTarget) {
setShowEdited(false);
// statusRef.current?.focus();
}
}}
>
{
return masto.v1.statuses.$select(showEdited).history.list();
}}
onClose={() => {
setShowEdited(false);
statusRef.current?.focus();
}}
/>
)}
{showReactions && (
{
if (e.target === e.currentTarget) {
setShowReactions(false);
}
}}
>
setShowReactions(false)}
/>
)}
);
}
function MultipleMediaFigure(props) {
const { enabled, children, lang, captionChildren } = props;
if (!enabled || !captionChildren) return children;
return (
);
}
function Card({ card, instance }) {
const snapStates = useSnapshot(states);
const {
blurhash,
title,
description,
html,
providerName,
providerUrl,
authorName,
authorUrl,
width,
height,
image,
imageDescription,
url,
type,
embedUrl,
language,
publishedAt,
} = card;
/* type
link = Link OEmbed
photo = Photo OEmbed
video = Video OEmbed
rich = iframe OEmbed. Not currently accepted, so won’t show up in practice.
*/
const hasText = title || providerName || authorName;
const isLandscape = width / height >= 1.2;
const size = isLandscape ? 'large' : '';
const [cardStatusURL, setCardStatusURL] = useState(null);
// const [cardStatusID, setCardStatusID] = useState(null);
useEffect(() => {
if (hasText && image && 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]);
// if (cardStatusID) {
// return (
//
// );
// }
if (snapStates.unfurledLinks[url]) return null;
if (hasText && (image || (type === 'photo' && blurhash))) {
const domain = new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, '');
let blurhashImage;
if (!image) {
const w = 44;
const h = 44;
const blurhashPixels = decodeBlurHash(blurhash, w, h);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(w, h);
imageData.data.set(blurhashPixels);
ctx.putImageData(imageData, 0, 0);
blurhashImage = canvas.toDataURL();
}
return (
{
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
);
} else if (type === 'photo') {
return (
);
} 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) {
return ;
}
}
return (
);
} else if (hasText && !image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
return (
);
}
}
function EditedAtModal({
statusID,
instance,
fetchStatusHistory = () => {},
onClose,
}) {
const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const editHistory = await fetchStatusHistory();
console.log(editHistory);
setEditHistory(editHistory);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
return (
{!!onClose && (
)}
Edit History
{uiState === 'error' && Failed to load history
}
{uiState === 'loading' && (
Loading…
)}
{editHistory.length > 0 && (
{editHistory.map((status) => {
const { createdAt } = status;
const createdAtDate = new Date(createdAt);
return (
-
);
})}
)}
);
}
const REACTIONS_LIMIT = 80;
function ReactionsModal({ statusID, instance, onClose }) {
const { masto } = api({ instance });
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [showMore, setShowMore] = useState(false);
const reblogIterator = useRef();
const favouriteIterator = useRef();
async function fetchAccounts(firstLoad) {
setShowMore(false);
setUIState('loading');
(async () => {
try {
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) {
if (reblogResults.value?.length) {
for (const account of reblogResults.value) {
const theAccount = accounts.find((a) => a.id === account.id);
if (!theAccount) {
accounts.push({
...account,
_types: ['reblog'],
});
} else {
theAccount._types.push('reblog');
}
}
}
if (favouriteResults.value?.length) {
for (const account of favouriteResults.value) {
const theAccount = accounts.find((a) => a.id === account.id);
if (!theAccount) {
accounts.push({
...account,
_types: ['favourite'],
});
} else {
theAccount._types.push('favourite');
}
}
}
setAccounts(accounts);
setShowMore(!reblogResults.done || !favouriteResults.done);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}
useEffect(() => {
fetchAccounts(true);
}, []);
return (
{!!onClose && (
)}
{accounts.length > 0 ? (
<>
{uiState === 'default' ? (
showMore ? (
{
if (inView) {
fetchAccounts();
}
}}
>
) : (
The end.
)
) : (
uiState === 'loading' && (
)
)}
>
) : uiState === 'loading' ? (
) : uiState === 'error' ? (
Unable to load accounts
) : (
No one yet.
)}
);
}
function StatusButton({
checked,
count,
class: className,
title,
alt,
icon,
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 (
);
}
export function formatDuration(time) {
if (!time) return;
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.round(time % 60);
if (hours === 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
}
}
const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {};
function _unfurlMastodonLink(instance, url) {
const snapStates = snapshot(states);
if (denylistDomains.test(url)) {
return;
}
if (failedUnfurls[url]) {
return;
}
const instanceRegex = new RegExp(instance + '/');
if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) {
return Promise.resolve(snapStates.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
let theURL = url;
// https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/elk\.[^\/]+\//i, '');
}
// https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, '');
}
// https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123
if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) {
const urlAfterHash = theURL.split('/#/')[1];
const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/');
theURL = `https://${finalURL}`;
}
let urlObj;
try {
urlObj = new URL(theURL);
} catch (e) {
return;
}
const domain = urlObj.hostname;
const path = urlObj.pathname;
// Regex /:username/:id, where username = @username or @username@domain, id = number
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i;
const statusMatch = statusRegex.exec(path);
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
q: theURL,
type: 'statuses',
resolve: true,
limit: 1,
})
.then((results) => {
if (results.statuses.length > 0) {
const status = results.statuses[0];
return {
status,
instance,
};
} else {
throw new Error('No results');
}
});
function handleFulfill(result) {
const { status, instance } = result;
const { id } = status;
const selfURL = `/${instance}/s/${id}`;
console.debug('🦦 Unfurled URL', url, id, selfURL);
const data = {
id,
instance,
url: selfURL,
};
states.unfurledLinks[url] = data;
saveStatus(status, instance, {
skipThreading: true,
});
return data;
}
function handleCatch(e) {
failedUnfurls[url] = true;
}
if (remoteInstanceFetch) {
return Promise.any([remoteInstanceFetch, mastoSearchFetch])
.then(handleFulfill)
.catch(handleCatch);
} else {
return mastoSearchFetch.then(handleFulfill).catch(handleCatch);
}
}
function nicePostURL(url) {
if (!url) return;
const urlObj = new URL(url);
const { host, pathname } = urlObj;
const path = pathname.replace(/\/$/, '');
// split only first slash
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
return (
<>
{host}
{username ? (
<>
/{username}
/{restPath}
>
) : (
{path}
)}
>
);
}
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
function FilteredStatus({
status,
filterInfo,
instance,
containerProps = {},
showFollowedTags,
}) {
const snapStates = useSnapshot(states);
const {
id: statusID,
account: { avatar, avatarStatic, bot, group },
createdAt,
visibility,
reblog,
} = status;
const isReblog = !!reblog;
const filterTitleStr = filterInfo?.titlesStr || '';
const createdAtDate = new Date(createdAt);
const statusPeekText = statusPeek(status.reblog || status);
const [showPeek, setShowPeek] = useState(false);
const bindLongPressPeek = useLongPress(
() => {
setShowPeek(true);
},
{
threshold: 600,
captureEvent: true,
detect: 'touch',
cancelOnMovement: 2, // true allows movement of up to 25 pixels
},
);
const statusPeekRef = useTruncated();
const sKey = statusKey(status.id, instance);
const ssKey =
statusKey(status.id, instance) +
' ' +
(statusKey(reblog?.id, instance) || '');
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
const isFollowedTags =
showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length;
return (
{
e.preventDefault();
setShowPeek(true);
}}
{...bindLongPressPeek()}
>
{
e.preventDefault();
setShowPeek(true);
}}
>
Filtered
{filterTitleStr}
{' '}
{' '}
{' '}
{isReblog ? (
'boosted'
) : isFollowedTags ? (
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
#{tag}
))}
) : (
)}
{isReblog && (
<>
{' '}
>
)}
{statusPeekText}
{!!showPeek && (
{
if (e.target === e.currentTarget) {
setShowPeek(false);
}
}}
>
Filtered {filterTitleStr}
{
setShowPeek(false);
}}
data-read-more="Read more →"
>
)}
);
}
const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
if (!id || !instance) return;
const snapStates = useSnapshot(states);
const sKey = statusKey(id, instance);
const quotes = snapStates.statusQuotes[sKey];
const uniqueQuotes = quotes?.filter(
(q, i, arr) => arr.findIndex((q2) => q2.url === q.url) === i,
);
if (!uniqueQuotes?.length) return;
if (level > 2) return;
return uniqueQuotes.map((q) => {
return (
);
});
});
export default memo(Status);