import './status.css';
import { getBlurHashAverageColor } from 'fast-blurhash';
import mem from 'mem';
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NameText from '../components/name-text';
import enhanceContent from '../utils/enhance-content';
import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import store from '../utils/store';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
function fetchAccount(id) {
return masto.accounts.fetch(id);
}
const memFetchAccount = mem(fetchAccount);
function Status({
statusID,
status,
withinContext,
size = 'm',
skeleton,
readOnly,
}) {
if (skeleton) {
return (
{size !== 'l' && (
{reblogged && }
{favourited && }
{bookmarked && }
)}
{size !== 's' && (
{
e.preventDefault();
e.stopPropagation();
states.showAccount = status.account;
}}
>
)}
{inReplyToAccountId && !withinContext && size !== 's' && (
<>
{inReplyToAccountId === status.account.id ? (
Thread
) : (
!!inReplyToAccount &&
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
}) && (
{' '}
)
)}
>
)}
{!!spoilerText && sensitive && (
<>
{
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
states.spoilers.delete(id);
} else {
states.spoilers.set(id, true);
}
}}
>
{' '}
{showSpoiler ? 'Show less' : 'Show more'}
>
)}
{
let { target } = e;
if (target.parentNode.tagName.toLowerCase() === 'a') {
target = target.parentNode;
}
if (
target.tagName.toLowerCase() === 'a' &&
target.classList.contains('u-url')
) {
e.preventDefault();
e.stopPropagation();
const username = (
target.querySelector('span') || target
).innerText
.trim()
.replace(/^@/, '');
const url = target.getAttribute('href');
const mention = mentions.find(
(mention) =>
mention.username === username ||
mention.acct === username ||
mention.url === url,
);
if (mention) {
states.showAccount = mention.acct;
} else {
const href = target.getAttribute('href');
states.showAccount = href;
}
}
}}
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
postEnhanceDOM: (dom) => {
dom
.querySelectorAll('a.u-url[target="_blank"]')
.forEach((a) => {
// Remove target="_blank" from links
a.removeAttribute('target');
});
},
}),
}}
/>
{!!poll && (
{
states.statuses.get(id).poll = newPoll;
}}
/>
)}
{!spoilerText && sensitive && !!mediaAttachments.length && (
{
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
states.spoilers.delete(id);
} else {
states.spoilers.set(id, true);
}
}}
>
Sensitive
content
)}
{!!mediaAttachments.length && (
2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{mediaAttachments.map((media, i) => (
{
e.preventDefault();
e.stopPropagation();
setShowMediaModal(i);
}}
/>
))}
)}
{!!card &&
(size === 'l' ||
(size === 'm' && !poll && !mediaAttachments.length)) && (
)}
{size === 'l' && (
<>
{
states.showCompose = {
replyToStatus: status,
};
}}
/>
{/* TODO: if visibility = private, only can reblog own statuses */}
{visibility !== 'direct' && (
{
try {
if (!reblogged) {
const yes = confirm(
'Are you sure that you want to boost this post?',
);
if (!yes) {
return;
}
}
// Optimistic
states.statuses.set(id, {
...status,
reblogged: !reblogged,
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
});
if (reblogged) {
const newStatus = await masto.statuses.unreblog(id);
states.statuses.set(newStatus.id, newStatus);
} else {
const newStatus = await masto.statuses.reblog(id);
states.statuses.set(newStatus.id, newStatus);
states.statuses.set(
newStatus.reblog.id,
newStatus.reblog,
);
}
} catch (e) {
console.error(e);
}
}}
/>
)}
{
try {
// Optimistic
states.statuses.set(statusID, {
...status,
favourited: !favourited,
favouritesCount:
favouritesCount + (favourited ? -1 : 1),
});
if (favourited) {
const newStatus = await masto.statuses.unfavourite(id);
states.statuses.set(newStatus.id, newStatus);
} else {
const newStatus = await masto.statuses.favourite(id);
states.statuses.set(newStatus.id, newStatus);
}
} catch (e) {
console.error(e);
}
}}
/>
{
try {
// Optimistic
states.statuses.set(statusID, {
...status,
bookmarked: !bookmarked,
});
if (bookmarked) {
const newStatus = await masto.statuses.unbookmark(id);
states.statuses.set(newStatus.id, newStatus);
} else {
const newStatus = await masto.statuses.bookmark(id);
states.statuses.set(newStatus.id, newStatus);
}
} catch (e) {
console.error(e);
}
}}
/>
{isSelf && (
)}
>
)}
{showMediaModal !== false && (
{
setShowMediaModal(false);
}}
/>
)}
{!!showEdited && (
{
if (e.target === e.currentTarget) {
setShowEdited(false);
}
}}
>
{
setShowEdited(false);
}}
/>
)}
);
}
/*
Media type
===
unknown = unsupported or unrecognized file type
image = Static image
gifv = Looping, soundless animation
video = Video clip
audio = Audio track
*/
function Media({ media, showOriginal, onClick = () => {} }) {
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
media;
const { original, small, focus } = meta || {};
const width = showOriginal ? original?.width : small?.width;
const height = showOriginal ? original?.height : small?.height;
const mediaURL = showOriginal ? url : previewUrl;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
const videoRef = useRef();
let focalBackgroundPosition;
if (focus) {
// Convert focal point to CSS background position
// Formula from jquery-focuspoint
// x = -1, y = 1 => 0% 0%
// x = 0, y = 0 => 50% 50%
// x = 1, y = -1 => 100% 100%
const x = ((focus.x + 1) / 2) * 100;
const y = ((1 - focus.y) / 2) * 100;
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
}
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
// Note: type: unknown might not have width/height
return (
);
} else if (type === 'gifv' || type === 'video') {
// 20 seconds, treat as a gif
const shortDuration = original.duration <= 20;
const isGIF = type === 'gifv' || shortDuration;
const loopable = original.duration <= 60;
return (
);
} else if (type === 'audio') {
return (
);
}
}
function Card({ card }) {
const {
blurhash,
title,
description,
html,
providerName,
authorName,
width,
height,
image,
url,
type,
embedUrl,
} = 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;
if (hasText && image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
return (
{
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
);
} else if (type === 'photo') {
return (
);
} else if (type === 'video') {
return (
);
}
}
function Poll({ poll, readOnly, onUpdate = () => {} }) {
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
} = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt);
// Update poll at point of expiry
useEffect(() => {
let timeout;
if (!expired && expiresAtDate) {
const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer
if (ms > 0) {
timeout = setTimeout(() => {
setUIState('loading');
(async () => {
try {
const pollResponse = await masto.poll.fetch(id);
onUpdate(pollResponse);
} catch (e) {
// Silent fail
}
setUIState('default');
})();
}, ms);
}
}
return () => {
clearTimeout(timeout);
};
}, [expired, expiresAtDate]);
const pollVotesCount = votersCount || votesCount;
let roundPrecision = 0;
if (pollVotesCount <= 1000) {
roundPrecision = 0;
} else if (pollVotesCount <= 10000) {
roundPrecision = 1;
} else if (pollVotesCount <= 100000) {
roundPrecision = 2;
}
return (
{voted || expired ? (
options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage =
((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
) || 0;
// check if current poll choice is the leading one
const isLeading =
optionVotesCount > 0 &&
optionVotesCount === Math.max(...options.map((o) => o.votesCount));
return (
{title}
{voted && ownVotes.includes(i) && (
<>
{' '}
>
)}
{percentage}%
);
})
) : (
)}
{!readOnly && (
{!expired && (
<>
{
e.preventDefault();
setUIState('loading');
(async () => {
try {
const pollResponse = await masto.poll.fetch(id);
onUpdate(pollResponse);
} catch (e) {
// Silent fail
}
setUIState('default');
})();
}}
>
Refresh
{' '}
•{' '}
>
)}
{shortenNumber(votersCount)} {' '}
{votersCount === 1 ? 'voter' : 'voters'}
{votersCount !== votesCount && (
<>
{' '}
•
{shortenNumber(votesCount)}
{' '}
vote
{votesCount === 1 ? '' : 's'}
>
)}{' '}
• {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && (
)}
)}
);
}
function EditedAtModal({ statusID, onClose = () => {} }) {
const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const editHistory = await masto.statuses.fetchHistory(statusID);
console.log(editHistory);
setEditHistory(editHistory);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
const currentYear = new Date().getFullYear();
return (
{/*
*/}
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 (
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
createdAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
}).format(createdAtDate)}
);
})}
)}
);
}
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 (
{
e.preventDefault();
e.stopPropagation();
onClick(e);
}}
{...props}
>
{!!count && (
<>
{' '}
{shortenNumber(count)}
>
)}
);
}
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
const carouselRef = useRef(null);
const [currentIndex, setCurrentIndex] = useState(index);
const carouselFocusItem = useRef(null);
useLayoutEffect(() => {
carouselFocusItem.current?.node?.scrollIntoView();
}, []);
useLayoutEffect(() => {
carouselFocusItem.current?.node?.scrollIntoView({
behavior: 'smooth',
});
}, [currentIndex]);
const onSnap = useDebouncedCallback((inView, i) => {
if (inView) {
setCurrentIndex(i);
}
}, 100);
return (
<>
{
if (
e.target.classList.contains('carousel-item') ||
e.target.classList.contains('media')
) {
onClose();
}
}}
tabindex="0"
>
{mediaAttachments?.map((media, i) => {
const { blurhash } = media;
const rgbAverageColor = blurhash
? getBlurHashAverageColor(blurhash)
: null;
return (
onSnap(inView, i)}
>
);
})}
onClose()}
>
{mediaAttachments?.length > 1 && (
{
e.preventDefault();
e.stopPropagation();
setCurrentIndex(
(currentIndex - 1 + mediaAttachments.length) %
mediaAttachments.length,
);
}}
>
{mediaAttachments?.map((media, i) => (
{
e.preventDefault();
e.stopPropagation();
setCurrentIndex(i);
}}
>
•
))}
{
e.preventDefault();
e.stopPropagation();
setCurrentIndex((currentIndex + 1) % mediaAttachments.length);
}}
>
)}
>
);
}
export default Status;