phanpy/src/components/status.jsx

1497 lines
43 KiB
React
Raw Normal View History

2022-12-10 09:14:48 +00:00
import './status.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
2022-12-10 09:14:48 +00:00
import { getBlurHashAverageColor } from 'fast-blurhash';
import mem from 'mem';
import { memo } from 'preact/compat';
2022-12-18 14:56:00 +00:00
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
2022-12-28 11:43:02 +00:00
import 'swiped-events';
import useResizeObserver from 'use-resize-observer';
2022-12-10 09:14:48 +00:00
import { useSnapshot } from 'valtio';
import Loader from '../components/loader';
2022-12-10 09:14:48 +00:00
import Modal from '../components/modal';
import NameText from '../components/name-text';
import enhanceContent from '../utils/enhance-content';
import handleAccountLinks from '../utils/handle-account-links';
import htmlContentLength from '../utils/html-content-length';
2022-12-10 09:14:48 +00:00
import shortenNumber from '../utils/shorten-number';
2023-01-09 11:11:34 +00:00
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
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 RelativeTime from './relative-time';
2022-12-10 09:14:48 +00:00
2022-12-18 13:10:05 +00:00
function fetchAccount(id) {
try {
return masto.v1.accounts.fetch(id);
} catch (e) {
return Promise.reject(e);
}
2022-12-18 13:10:05 +00:00
}
const memFetchAccount = mem(fetchAccount);
2022-12-10 09:14:48 +00:00
2022-12-18 13:10:05 +00:00
function Status({
statusID,
status,
withinContext,
size = 'm',
skeleton,
readOnly,
}) {
if (skeleton) {
2022-12-10 09:14:48 +00:00
return (
2022-12-18 13:10:05 +00:00
<div class="status skeleton">
<Avatar size="xxl" />
<div class="container">
<div class="meta"> </div>
<div class="content-container">
<div class="content">
<p> </p>
</div>
</div>
</div>
2022-12-10 09:14:48 +00:00
</div>
);
}
2022-12-18 13:10:05 +00:00
const snapStates = useSnapshot(states);
if (!status) {
status = snapStates.statuses[statusID];
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,
url,
displayName,
username,
emojis: accountEmojis,
},
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,
emojis,
_deleted,
2022-12-18 13:10:05 +00:00
} = status;
2022-12-10 09:14:48 +00:00
console.debug('RENDER Status', id, status?.account.displayName);
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
const isSelf = useMemo(() => {
const currentAccount = store.session.get('currentAccount');
return currentAccount && currentAccount === accountId;
}, [accountId]);
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) {
inReplyToAccountRef = { url, username, displayName };
}
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)
.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
}
const showSpoiler = !!snapStates.spoilers[id] || false;
2022-12-10 09:14:48 +00:00
2022-12-18 13:10:05 +00:00
const debugHover = (e) => {
if (e.shiftKey) {
console.log(status);
}
};
const [showMediaModal, setShowMediaModal] = useState(false);
2022-12-14 13:48:17 +00:00
2022-12-18 13:10:05 +00:00
if (reblog) {
return (
<div class="status-reblog" onMouseEnter={debugHover}>
<div class="status-pre-meta">
<Icon icon="rocket" size="l" />{' '}
<NameText account={status.account} showAvatar /> boosted
</div>
<Status status={reblog} size={size} />
</div>
);
}
2022-12-10 09:14:48 +00:00
2022-12-18 13:10:05 +00:00
const [showEdited, setShowEdited] = useState(false);
const currentYear = new Date().getFullYear();
const spoilerContentRef = useRef(null);
useResizeObserver({
ref: spoilerContentRef,
onResize: () => {
if (spoilerContentRef.current) {
const { scrollHeight, clientHeight } = spoilerContentRef.current;
spoilerContentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
},
});
const contentRef = useRef(null);
useResizeObserver({
ref: contentRef,
onResize: () => {
if (contentRef.current) {
const { scrollHeight, clientHeight } = contentRef.current;
contentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
},
});
const readMoreText = 'Read more →';
2022-12-10 09:14:48 +00:00
2022-12-30 12:37:57 +00:00
const statusRef = useRef(null);
2022-12-10 09:14:48 +00:00
return (
2022-12-29 08:12:09 +00:00
<article
2022-12-30 12:37:57 +00:00
ref={statusRef}
tabindex="-1"
2022-12-18 13:10:05 +00:00
class={`status ${
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
} visibility-${visibility} ${
{
s: 'small',
m: 'medium',
l: 'large',
}[size]
}`}
onMouseEnter={debugHover}
>
2022-12-20 12:17:38 +00:00
{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" />}
</div>
)}
2022-12-18 13:10:05 +00:00
{size !== 's' && (
<a
href={url}
2022-12-29 08:11:58 +00:00
tabindex="-1"
2022-12-18 13:10:05 +00:00
// target="_blank"
title={`@${acct}`}
onClick={(e) => {
2022-12-10 09:14:48 +00:00
e.preventDefault();
2022-12-18 13:10:05 +00:00
e.stopPropagation();
states.showAccount = status.account;
2022-12-10 09:14:48 +00:00
}}
>
2022-12-18 13:10:05 +00:00
<Avatar url={avatarStatic} size="xxl" />
</a>
2022-12-10 09:14:48 +00:00
)}
2022-12-18 13:10:05 +00:00
<div class="container">
<div class="meta">
{/* <span> */}
<NameText
account={status.account}
showAvatar={size === 's'}
showAcct={size === 'l'}
/>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
2022-12-18 13:10:05 +00:00
<>
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} short />
</span>
</>
)} */}
{/* </span> */}{' '}
2022-12-18 13:10:05 +00:00
{size !== 'l' &&
(uri ? (
<Link to={`/s/${id}`} class="time">
2022-12-18 13:10:05 +00:00
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
2022-12-18 13:10:05 +00:00
) : (
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
2022-12-18 13:10:05 +00:00
</span>
))}
</div>
{!withinContext && (
2023-01-10 11:59:02 +00:00
<>
{inReplyToAccountId === status.account?.id ||
!!snapStates.statusThreadNumber[id] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
Thread
{snapStates.statusThreadNumber[id]
? ` ${snapStates.statusThreadNumber[id]}/X`
: ''}
</div>
) : (
!!inReplyToId &&
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText account={inReplyToAccount} short />
</div>
2023-01-10 11:59:02 +00:00
)
)}
</>
)}
2022-12-18 13:10:05 +00:00
<div
class={`content-container ${
sensitive || spoilerText ? 'has-spoiler' : ''
} ${showSpoiler ? 'show-spoiler' : ''}`}
style={
size === 'l' && {
'--content-text-weight':
Math.round(
(spoilerText.length + htmlContentLength(content)) / 140,
) || 1,
}
}
>
{!!spoilerText && sensitive && (
2022-12-14 13:48:17 +00:00
<>
2022-12-18 13:10:05 +00:00
<div
class="content"
lang={language}
ref={spoilerContentRef}
data-read-more={readMoreText}
>
<p>{spoilerText}</p>
</div>
<button
class="light spoiler"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
} else {
states.spoilers[id] = true;
}
2022-12-18 13:10:05 +00:00
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show more'}
</button>
2022-12-10 09:14:48 +00:00
</>
)}
<div
class="content"
lang={language}
ref={contentRef}
data-read-more={readMoreText}
onClick={handleAccountLinks({ mentions })}
2022-12-10 09:14:48 +00:00
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
postEnhanceDOM: (dom) => {
dom
.querySelectorAll('a.u-url[target="_blank"]')
.forEach((a) => {
// Remove target="_blank" from links
if (!/http/i.test(a.innerText.trim())) {
a.removeAttribute('target');
}
});
},
2022-12-10 09:14:48 +00:00
}),
}}
/>
2022-12-21 11:29:37 +00:00
{!!poll && (
<Poll
lang={language}
2022-12-21 11:29:37 +00:00
poll={poll}
readOnly={readOnly}
onUpdate={(newPoll) => {
states.statuses[id].poll = newPoll;
2022-12-21 11:29:37 +00:00
}}
/>
)}
2022-12-10 09:14:48 +00:00
{!spoilerText && sensitive && !!mediaAttachments.length && (
<button
class="plain spoiler"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
} else {
states.spoilers[id] = true;
}
2022-12-10 09:14:48 +00:00
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive
content
</button>
)}
{!!mediaAttachments.length && (
<div
2023-01-23 12:35:15 +00:00
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{mediaAttachments
.slice(0, size === 'l' ? undefined : 4)
.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={size === 'l'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMediaModal(i);
}}
/>
))}
2022-12-10 09:14:48 +00:00
</div>
)}
{!!card &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length && <Card card={card} />}
2022-12-10 09:14:48 +00:00
</div>
{size === 'l' && (
<>
<div class="extra-meta">
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />{' '}
<a href={uri} target="_blank">
<time class="created" datetime={createdAtDate.toISOString()}>
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
createdAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(createdAtDate)}
</time>
</a>
{editedAt && (
2022-12-10 09:14:48 +00:00
<>
{' '}
&bull; <Icon icon="pencil" alt="Edited" />{' '}
<time
class="edited"
datetime={editedAtDate.toISOString()}
onClick={() => {
setShowEdited(id);
}}
>
{Intl.DateTimeFormat('en', {
// Show year if not this year
year:
editedAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(editedAtDate)}
</time>
2022-12-10 09:14:48 +00:00
</>
)}
</div>
<div class="actions">
2022-12-19 05:38:16 +00:00
<div class="action has-count">
<StatusButton
title="Reply"
alt="Comments"
class="reply-button"
icon="comment"
count={repliesCount}
onClick={() => {
states.showCompose = {
replyToStatus: status,
};
}}
/>
</div>
{/* TODO: if visibility = private, only can reblog own statuses */}
{visibility !== 'direct' && (
2022-12-19 05:38:16 +00:00
<div class="action has-count">
<StatusButton
checked={reblogged}
title={['Boost', 'Unboost']}
alt={['Boost', 'Boosted']}
class="reblog-button"
icon="rocket"
count={reblogsCount}
onClick={async () => {
try {
if (!reblogged) {
const yes = confirm(
'Are you sure that you want to boost this post?',
);
if (!yes) {
return;
}
}
2022-12-19 05:38:16 +00:00
// Optimistic
states.statuses[id] = {
2022-12-19 05:38:16 +00:00
...status,
reblogged: !reblogged,
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
2022-12-19 05:38:16 +00:00
if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(
id,
);
2023-01-09 11:11:34 +00:00
saveStatus(newStatus);
2022-12-19 05:38:16 +00:00
} else {
const newStatus = await masto.v1.statuses.reblog(id);
2023-01-09 11:11:34 +00:00
saveStatus(newStatus);
2022-12-19 05:38:16 +00:00
}
} catch (e) {
console.error(e);
2022-12-25 05:22:41 +00:00
// Revert optimistism
states.statuses[id] = status;
2022-12-19 05:38:16 +00:00
}
}}
/>
</div>
)}
<div class="action has-count">
<StatusButton
2022-12-19 05:38:16 +00:00
checked={favourited}
title={['Favourite', 'Unfavourite']}
alt={['Favourite', 'Favourited']}
class="favourite-button"
icon="heart"
count={favouritesCount}
onClick={async () => {
try {
// Optimistic
states.statuses[statusID] = {
...status,
2022-12-19 05:38:16 +00:00
favourited: !favourited,
favouritesCount:
favouritesCount + (favourited ? -1 : 1),
};
2022-12-19 05:38:16 +00:00
if (favourited) {
const newStatus = await masto.v1.statuses.unfavourite(
id,
);
2023-01-09 11:11:34 +00:00
saveStatus(newStatus);
} else {
const newStatus = await masto.v1.statuses.favourite(id);
2023-01-09 11:11:34 +00:00
saveStatus(newStatus);
}
} catch (e) {
console.error(e);
2022-12-25 05:22:41 +00:00
// Revert optimistism
states.statuses[statusID] = status;
}
}}
/>
2022-12-19 05:38:16 +00:00
</div>
<div class="action">
<StatusButton
checked={bookmarked}
title={['Bookmark', 'Unbookmark']}
alt={['Bookmark', 'Bookmarked']}
class="bookmark-button"
icon="bookmark"
onClick={async () => {
try {
// Optimistic
states.statuses[statusID] = {
2022-12-19 05:38:16 +00:00
...status,
bookmarked: !bookmarked,
};
2022-12-19 05:38:16 +00:00
if (bookmarked) {
const newStatus = await masto.v1.statuses.unbookmark(
id,
);
2023-01-09 11:11:34 +00:00
saveStatus(newStatus);
2022-12-19 05:38:16 +00:00
} else {
const newStatus = await masto.v1.statuses.bookmark(id);
2023-01-09 11:11:34 +00:00
saveStatus(newStatus);
2022-12-19 05:38:16 +00:00
}
} catch (e) {
console.error(e);
2022-12-25 05:22:41 +00:00
// Revert optimistism
states.statuses[statusID] = status;
}
2022-12-19 05:38:16 +00:00
}}
/>
</div>
{isSelf && (
<Menu
align="end"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{isSelf && (
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
>
Edit&hellip;
</MenuItem>
)}
</Menu>
)}
</div>
</>
2022-12-10 09:14:48 +00:00
)}
</div>
{showMediaModal !== false && (
<Modal>
2022-12-18 14:56:00 +00:00
<Carousel
mediaAttachments={mediaAttachments}
index={showMediaModal}
onClose={() => {
setShowMediaModal(false);
2022-12-18 13:10:05 +00:00
}}
2022-12-18 14:56:00 +00:00
/>
2022-12-18 13:10:05 +00:00
</Modal>
)}
{!!showEdited && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEdited(false);
2022-12-30 12:37:57 +00:00
statusRef.current?.focus();
2022-12-18 13:10:05 +00:00
}
}}
>
<EditedAtModal
statusID={showEdited}
onClose={() => {
setShowEdited(false);
2022-12-30 12:37:57 +00:00
statusRef.current?.focus();
2022-12-18 13:10:05 +00:00
}}
/>
</Modal>
)}
2022-12-29 08:12:09 +00:00
</article>
2022-12-18 13:10:05 +00:00
);
}
/*
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, autoAnimate, onClick = () => {} }) {
2022-12-18 13:10:05 +00:00
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 (
<div
class={`media media-image`}
onClick={onClick}
style={
showOriginal && {
backgroundImage: `url(${previewUrl})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
aspectRatio: `${width}/${height}`,
width,
height,
maxWidth: '100%',
maxHeight: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}
}
>
<img
src={mediaURL}
alt={description}
width={width}
height={height}
loading={showOriginal ? 'eager' : 'lazy'}
2022-12-18 13:10:05 +00:00
style={
!showOriginal && {
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
}
}
/>
</div>
);
} else if (type === 'gifv' || type === 'video') {
2023-01-07 06:45:04 +00:00
const shortDuration = original.duration < 31;
const isGIF = type === 'gifv' && shortDuration;
// If GIF is too long, treat it as a video
const loopable = original.duration < 61;
const formattedDuration = formatDuration(original.duration);
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
2023-01-07 06:45:04 +00:00
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
2022-12-18 13:10:05 +00:00
return (
<div
class={`media media-${isGIF ? 'gif' : 'video'} ${
2023-01-07 06:45:04 +00:00
autoGIFAnimate ? 'media-contain' : ''
}`}
data-formatted-duration={formattedDuration}
2023-01-07 06:45:04 +00:00
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
2022-12-18 13:10:05 +00:00
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
}}
onClick={(e) => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
2022-12-18 13:10:05 +00:00
}
onClick(e);
}}
onMouseEnter={() => {
if (hoverAnimate) {
2022-12-18 13:10:05 +00:00
try {
videoRef.current.play();
2022-12-18 13:10:05 +00:00
} catch (e) {}
}
}}
onMouseLeave={() => {
if (hoverAnimate) {
2022-12-18 13:10:05 +00:00
try {
videoRef.current.pause();
2022-12-18 13:10:05 +00:00
} catch (e) {}
}
}}
>
2023-01-07 06:45:04 +00:00
{showOriginal || autoGIFAnimate ? (
<div
style={{
width: '100%',
height: '100%',
}}
dangerouslySetInnerHTML={{
__html: `
<video
src="${url}"
poster="${previewUrl}"
width="${width}"
height="${height}"
preload="auto"
autoplay
muted="${isGIF}"
2023-01-07 06:45:04 +00:00
${isGIF ? '' : 'controls'}
playsinline
loop="${loopable}"
2023-01-27 09:51:31 +00:00
${
isGIF
? 'ondblclick="this.paused ? this.play() : this.pause()"'
: ''
}
></video>
`,
}}
/>
2022-12-18 13:10:05 +00:00
) : isGIF ? (
<video
ref={videoRef}
src={url}
poster={previewUrl}
width={width}
height={height}
preload="auto"
// controls
playsinline
loop
muted
/>
) : (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
)}
</div>
);
} else if (type === 'audio') {
2023-01-08 17:17:16 +00:00
const formattedDuration = formatDuration(original.duration);
2022-12-18 13:10:05 +00:00
return (
2023-01-08 17:17:16 +00:00
<div
class="media media-audio"
data-formatted-duration={formattedDuration}
onClick={onClick}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
) : null}
2022-12-18 13:10:05 +00:00
</div>
);
}
}
function Card({ card }) {
2022-12-18 13:10:05 +00:00
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 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
if (hasText && image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
return (
<a
href={url}
target="_blank"
rel="nofollow noopener noreferrer"
class={`card link ${size}`}
2022-12-18 13:10:05 +00:00
>
2023-01-07 12:25:13 +00:00
<div class="card-image">
<img
src={image}
width={width}
height={height}
loading="lazy"
alt=""
onError={(e) => {
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
</div>
2022-12-18 13:10:05 +00:00
<div class="meta-container">
<p class="meta domain">{domain}</p>
<p
class="title"
dangerouslySetInnerHTML={{
__html: title,
}}
/>
<p class="meta">{description || providerName || authorName}</p>
</div>
</a>
);
} else if (type === 'photo') {
return (
<a
href={url}
target="_blank"
rel="nofollow noopener noreferrer"
class="card photo"
>
<img
src={embedUrl}
width={width}
height={height}
alt={title || description}
loading="lazy"
style={{
height: 'auto',
aspectRatio: `${width}/${height}`,
}}
/>
</a>
);
} else if (type === 'video') {
return (
<div
class="card video"
style={{
aspectRatio: `${width}/${height}`,
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
}
function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
2022-12-18 13:10:05 +00:00
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
2022-12-21 11:29:37 +00:00
} = poll;
2022-12-18 13:10:05 +00:00
const expiresAtDate = !!expiresAt && new Date(expiresAt);
2022-12-22 13:52:59 +00:00
// 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 {
2022-12-26 17:17:04 +00:00
const pollResponse = await masto.v1.polls.fetch(id);
2022-12-22 13:52:59 +00:00
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;
}
2022-12-18 13:10:05 +00:00
return (
2022-12-21 11:46:38 +00:00
<div
lang={lang}
2022-12-21 11:46:38 +00:00
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
>
2022-12-18 13:10:05 +00:00
{voted || expired ? (
options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
)
: 0;
2022-12-18 13:10:05 +00:00
// check if current poll choice is the leading one
const isLeading =
optionVotesCount > 0 &&
optionVotesCount === Math.max(...options.map((o) => o.votesCount));
return (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option ${isLeading ? 'poll-option-leading' : ''}`}
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
{title}
{voted && ownVotes.includes(i) && (
<>
{' '}
2022-12-18 15:06:05 +00:00
<Icon icon="check-circle" />
2022-12-18 13:10:05 +00:00
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
</div>
);
})
) : (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const votes = [];
formData.forEach((value, key) => {
if (key === 'poll') {
votes.push(value);
}
});
console.log(votes);
setUIState('loading');
2022-12-26 17:17:04 +00:00
const pollResponse = await masto.v1.polls.vote(id, {
2022-12-18 13:10:05 +00:00
choices: votes,
});
console.log(pollResponse);
2022-12-21 11:29:37 +00:00
onUpdate(pollResponse);
2022-12-18 13:10:05 +00:00
setUIState('default');
}}
>
{options.map((option, i) => {
const { title } = option;
return (
<div class="poll-option">
<label class="poll-label">
<input
type={multiple ? 'checkbox' : 'radio'}
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
{!readOnly && (
<button
2022-12-18 13:10:05 +00:00
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
2022-12-18 13:10:05 +00:00
Vote
</button>
2022-12-10 09:14:48 +00:00
)}
2022-12-18 13:10:05 +00:00
</form>
2022-12-10 09:14:48 +00:00
)}
2022-12-18 13:10:05 +00:00
{!readOnly && (
<p class="poll-meta">
2022-12-21 11:46:38 +00:00
{!expired && (
<>
<button
type="button"
class="textual"
disabled={uiState === 'loading'}
onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
try {
2022-12-26 17:17:04 +00:00
const pollResponse = await masto.v1.polls.fetch(id);
2022-12-21 11:46:38 +00:00
onUpdate(pollResponse);
} catch (e) {
// Silent fail
}
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
2022-12-18 13:10:05 +00:00
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
2022-12-18 13:10:05 +00:00
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
2022-12-18 13:10:05 +00:00
</p>
)}
</div>
);
}
function EditedAtModal({ statusID, onClose = () => {} }) {
const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const editHistory = await masto.v1.statuses.listHistory(statusID);
2022-12-18 13:10:05 +00:00
console.log(editHistory);
setEditHistory(editHistory);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
const currentYear = new Date().getFullYear();
return (
2022-12-30 12:37:57 +00:00
<div id="edit-history" class="sheet">
<header>
{/* <button type="button" class="close-button plain large" onClick={onClose}>
2022-12-18 13:10:05 +00:00
<Icon icon="x" alt="Close" />
</button> */}
<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>
{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)}
</time>
</h3>
<Status status={status} size="s" withinContext readOnly />
</li>
);
})}
</ol>
)}
</main>
2022-12-10 09:14:48 +00:00
</div>
);
}
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 (
<button
type="button"
title={buttonTitle}
class={`plain ${className} ${checked ? 'checked' : ''}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick(e);
}}
{...props}
>
<Icon icon={icon} size="l" alt={iconAlt} />
{!!count && (
<>
{' '}
2022-12-17 16:13:56 +00:00
<small title={count}>{shortenNumber(count)}</small>
</>
)}
</button>
);
}
2022-12-18 14:56:00 +00:00
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
const carouselRef = useRef(null);
const [currentIndex, setCurrentIndex] = useState(index);
const carouselFocusItem = useRef(null);
useLayoutEffect(() => {
carouselFocusItem.current?.scrollIntoView();
2022-12-18 14:56:00 +00:00
}, []);
2022-12-28 12:46:38 +00:00
const [showControls, setShowControls] = useState(true);
2022-12-28 11:31:08 +00:00
2022-12-28 11:43:02 +00:00
useEffect(() => {
let handleSwipe = () => {
onClose();
2022-12-28 11:43:02 +00:00
};
if (carouselRef.current) {
carouselRef.current.addEventListener('swiped-down', handleSwipe);
}
return () => {
if (carouselRef.current) {
carouselRef.current.removeEventListener('swiped-down', handleSwipe);
}
};
}, []);
useHotkeys('esc', onClose, [onClose]);
const [showMediaAlt, setShowMediaAlt] = useState(false);
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);
}
};
}, []);
2022-12-18 14:56:00 +00:00
return (
<>
<div
ref={carouselRef}
2022-12-29 08:11:58 +00:00
tabIndex="-1"
2022-12-28 11:43:02 +00:00
data-swipe-threshold="44"
2022-12-18 14:56:00 +00:00
class="carousel"
onClick={(e) => {
if (
e.target.classList.contains('carousel-item') ||
e.target.classList.contains('media')
) {
onClose();
}
}}
>
{mediaAttachments?.map((media, i) => {
const { blurhash } = media;
const rgbAverageColor = blurhash
? getBlurHashAverageColor(blurhash)
: null;
return (
<div
2022-12-18 14:56:00 +00:00
class="carousel-item"
style={{
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
',',
)}, .5)`,
2022-12-18 14:56:00 +00:00
}}
tabindex="0"
key={media.id}
ref={i === currentIndex ? carouselFocusItem : null}
2022-12-28 11:31:08 +00:00
onClick={(e) => {
if (e.target !== e.currentTarget) {
setShowControls(!showControls);
}
}}
2022-12-18 14:56:00 +00:00
>
{!!media.description && (
<button
type="button"
class="plain2 media-alt"
hidden={!showControls}
onClick={() => {
setShowMediaAlt(media.description);
}}
>
<span class="tag">ALT</span>{' '}
<span class="media-alt-desc">{media.description}</span>
</button>
)}
2022-12-18 14:56:00 +00:00
<Media media={media} showOriginal />
</div>
2022-12-18 14:56:00 +00:00
);
})}
</div>
2022-12-28 11:31:08 +00:00
<div class="carousel-top-controls" hidden={!showControls}>
<span>
<button
type="button"
class="carousel-button plain3"
onClick={() => onClose()}
>
<Icon icon="x" />
</button>
</span>
{mediaAttachments?.length > 1 ? (
<span class="carousel-dots">
{mediaAttachments?.map((media, i) => (
<button
key={media.id}
type="button"
disabled={i === currentIndex}
class={`plain carousel-dot ${
i === currentIndex ? 'active' : ''
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * i,
behavior: 'smooth',
});
}}
>
&bull;
</button>
))}
</span>
) : (
<span />
)}
2023-01-01 04:28:54 +00:00
<span>
<a
href={
mediaAttachments[currentIndex]?.remoteUrl ||
mediaAttachments[currentIndex]?.url
}
target="_blank"
class="button carousel-button plain3"
2023-01-01 04:28:54 +00:00
title="Open original media in new window"
>
<Icon icon="popout" alt="Open original media in new window" />
</a>{' '}
</span>
2022-12-18 14:56:00 +00:00
</div>
{mediaAttachments?.length > 1 && (
2022-12-28 11:31:08 +00:00
<div class="carousel-controls" hidden={!showControls}>
2022-12-18 14:56:00 +00:00
<button
type="button"
class="carousel-button plain3"
2022-12-18 14:56:00 +00:00
hidden={currentIndex === 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex - 1),
behavior: 'smooth',
});
2022-12-18 14:56:00 +00:00
}}
>
<Icon icon="arrow-left" />
</button>
<button
type="button"
class="carousel-button plain3"
2022-12-18 14:56:00 +00:00
hidden={currentIndex === mediaAttachments.length - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
carouselRef.current.scrollTo({
left: carouselRef.current.clientWidth * (currentIndex + 1),
behavior: 'smooth',
});
2022-12-18 14:56:00 +00:00
}}
>
<Icon icon="arrow-right" />
</button>
</div>
)}
{!!showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowMediaAlt(false);
}
}}
>
<div class="sheet">
<header>
<h2>Media description</h2>
</header>
<main>
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{showMediaAlt}
</p>
</main>
</div>
</Modal>
)}
2022-12-18 14:56:00 +00:00
</>
);
}
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')}`;
}
}
export default memo(Status);