2022-12-10 12:14:48 +03:00
|
|
|
|
import './status.css';
|
|
|
|
|
|
|
|
|
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
|
|
|
|
import mem from 'mem';
|
2022-12-18 17:56:00 +03:00
|
|
|
|
import {
|
|
|
|
|
useEffect,
|
|
|
|
|
useLayoutEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from 'preact/hooks';
|
2022-12-11 09:35:57 +03:00
|
|
|
|
import { InView } from 'react-intersection-observer';
|
2022-12-17 16:06:51 +03:00
|
|
|
|
import useResizeObserver from 'use-resize-observer';
|
2022-12-10 12:14:48 +03:00
|
|
|
|
import { useSnapshot } from 'valtio';
|
|
|
|
|
|
2022-12-11 16:22:22 +03:00
|
|
|
|
import Loader from '../components/loader';
|
2022-12-10 12:14:48 +03:00
|
|
|
|
import Modal from '../components/modal';
|
|
|
|
|
import NameText from '../components/name-text';
|
|
|
|
|
import enhanceContent from '../utils/enhance-content';
|
2022-12-14 19:41:48 +03:00
|
|
|
|
import htmlContentLength from '../utils/html-content-length';
|
2022-12-10 12:14:48 +03:00
|
|
|
|
import shortenNumber from '../utils/shorten-number';
|
|
|
|
|
import states from '../utils/states';
|
2022-12-12 16:54:31 +03:00
|
|
|
|
import store from '../utils/store';
|
2022-12-18 17:56:00 +03:00
|
|
|
|
import useDebouncedCallback from '../utils/useDebouncedCallback';
|
2022-12-10 12:14:48 +03:00
|
|
|
|
import visibilityIconsMap from '../utils/visibility-icons-map';
|
|
|
|
|
|
|
|
|
|
import Avatar from './avatar';
|
|
|
|
|
import Icon from './icon';
|
|
|
|
|
|
2022-12-18 16:10:05 +03:00
|
|
|
|
function fetchAccount(id) {
|
|
|
|
|
return masto.accounts.fetch(id);
|
|
|
|
|
}
|
|
|
|
|
const memFetchAccount = mem(fetchAccount);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03:00
|
|
|
|
function Status({
|
|
|
|
|
statusID,
|
|
|
|
|
status,
|
|
|
|
|
withinContext,
|
|
|
|
|
size = 'm',
|
|
|
|
|
skeleton,
|
|
|
|
|
readOnly,
|
|
|
|
|
}) {
|
|
|
|
|
if (skeleton) {
|
2022-12-10 12:14:48 +03:00
|
|
|
|
return (
|
2022-12-18 16:10:05 +03: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 12:14:48 +03:00
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-18 16:10:05 +03:00
|
|
|
|
const snapStates = useSnapshot(states);
|
|
|
|
|
if (!status) {
|
|
|
|
|
status = snapStates.statuses.get(statusID);
|
|
|
|
|
}
|
|
|
|
|
if (!status) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-10 12:14:48 +03:00
|
|
|
|
const {
|
2022-12-18 16:10:05 +03: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,
|
|
|
|
|
inReplyToAccountId,
|
|
|
|
|
content,
|
|
|
|
|
mentions,
|
|
|
|
|
mediaAttachments,
|
|
|
|
|
reblog,
|
|
|
|
|
uri,
|
|
|
|
|
emojis,
|
2022-12-22 17:43:04 +03:00
|
|
|
|
_deleted,
|
2022-12-18 16:10:05 +03:00
|
|
|
|
} = status;
|
2022-12-10 12:14:48 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03:00
|
|
|
|
const createdAtDate = new Date(createdAt);
|
|
|
|
|
const editedAtDate = new Date(editedAt);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03:00
|
|
|
|
const isSelf = useMemo(() => {
|
|
|
|
|
const currentAccount = store.session.get('currentAccount');
|
|
|
|
|
return currentAccount && currentAccount === accountId;
|
|
|
|
|
}, [accountId]);
|
2022-12-10 12:14:48 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03: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.get(inReplyToAccountId);
|
|
|
|
|
if (account) {
|
|
|
|
|
setInReplyToAccount(account);
|
|
|
|
|
} else {
|
|
|
|
|
memFetchAccount(inReplyToAccountId)
|
|
|
|
|
.then((account) => {
|
|
|
|
|
setInReplyToAccount(account);
|
|
|
|
|
states.accounts.set(account.id, account);
|
|
|
|
|
})
|
|
|
|
|
.catch((e) => {});
|
|
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
}
|
|
|
|
|
|
2022-12-20 14:14:50 +03:00
|
|
|
|
const showSpoiler = snapStates.spoilers.has(id) || false;
|
2022-12-10 12:14:48 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03:00
|
|
|
|
const debugHover = (e) => {
|
|
|
|
|
if (e.shiftKey) {
|
|
|
|
|
console.log(status);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [showMediaModal, setShowMediaModal] = useState(false);
|
2022-12-14 16:48:17 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03: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 12:14:48 +03:00
|
|
|
|
|
2022-12-18 16:10:05 +03: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 12:14:48 +03:00
|
|
|
|
|
|
|
|
|
return (
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<div
|
|
|
|
|
class={`status ${
|
|
|
|
|
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
|
|
|
|
|
} visibility-${visibility} ${
|
|
|
|
|
{
|
|
|
|
|
s: 'small',
|
|
|
|
|
m: 'medium',
|
|
|
|
|
l: 'large',
|
|
|
|
|
}[size]
|
|
|
|
|
}`}
|
|
|
|
|
onMouseEnter={debugHover}
|
|
|
|
|
>
|
2022-12-20 15:17:38 +03: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 16:10:05 +03:00
|
|
|
|
{size !== 's' && (
|
|
|
|
|
<a
|
|
|
|
|
href={url}
|
|
|
|
|
// target="_blank"
|
|
|
|
|
title={`@${acct}`}
|
|
|
|
|
onClick={(e) => {
|
2022-12-10 12:14:48 +03:00
|
|
|
|
e.preventDefault();
|
2022-12-18 16:10:05 +03:00
|
|
|
|
e.stopPropagation();
|
|
|
|
|
states.showAccount = status.account;
|
2022-12-10 12:14:48 +03:00
|
|
|
|
}}
|
|
|
|
|
>
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<Avatar url={avatarStatic} size="xxl" />
|
|
|
|
|
</a>
|
2022-12-10 12:14:48 +03:00
|
|
|
|
)}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<div class="container">
|
|
|
|
|
<div class="meta">
|
2022-12-22 05:35:39 +03:00
|
|
|
|
{/* <span> */}
|
|
|
|
|
<NameText
|
|
|
|
|
account={status.account}
|
|
|
|
|
showAvatar={size === 's'}
|
|
|
|
|
showAcct={size === 'l'}
|
|
|
|
|
/>
|
|
|
|
|
{/* {inReplyToAccount && !withinContext && size !== 's' && (
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<>
|
|
|
|
|
{' '}
|
|
|
|
|
<span class="ib">
|
|
|
|
|
<Icon icon="arrow-right" class="arrow" />{' '}
|
|
|
|
|
<NameText account={inReplyToAccount} short />
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
2022-12-22 05:35:39 +03:00
|
|
|
|
)} */}
|
|
|
|
|
{/* </span> */}{' '}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
{size !== 'l' &&
|
|
|
|
|
(uri ? (
|
|
|
|
|
<a href={uri} target="_blank" class="time">
|
|
|
|
|
<Icon
|
|
|
|
|
icon={visibilityIconsMap[visibility]}
|
|
|
|
|
alt={visibility}
|
|
|
|
|
size="s"
|
|
|
|
|
/>{' '}
|
|
|
|
|
<relative-time
|
|
|
|
|
datetime={createdAtDate.toISOString()}
|
|
|
|
|
format="micro"
|
|
|
|
|
threshold="P1D"
|
|
|
|
|
prefix=""
|
|
|
|
|
>
|
|
|
|
|
{createdAtDate.toLocaleString()}
|
|
|
|
|
</relative-time>
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
<span class="time">
|
|
|
|
|
<Icon
|
|
|
|
|
icon={visibilityIconsMap[visibility]}
|
|
|
|
|
alt={visibility}
|
|
|
|
|
size="s"
|
|
|
|
|
/>{' '}
|
|
|
|
|
<relative-time
|
|
|
|
|
datetime={createdAtDate.toISOString()}
|
|
|
|
|
format="micro"
|
|
|
|
|
threshold="P1D"
|
|
|
|
|
prefix=""
|
|
|
|
|
>
|
|
|
|
|
{createdAtDate.toLocaleString()}
|
|
|
|
|
</relative-time>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2022-12-22 07:14:01 +03:00
|
|
|
|
{inReplyToAccountId && !withinContext && size !== 's' && (
|
2022-12-23 20:22:25 +03:00
|
|
|
|
<>
|
2022-12-22 05:35:39 +03:00
|
|
|
|
{inReplyToAccountId === status.account.id ? (
|
2022-12-23 20:22:25 +03:00
|
|
|
|
<div class="status-thread-badge">
|
2022-12-22 05:35:39 +03:00
|
|
|
|
<Icon icon="thread" size="s" />
|
|
|
|
|
Thread
|
2022-12-23 20:22:25 +03:00
|
|
|
|
</div>
|
2022-12-22 05:35:39 +03:00
|
|
|
|
) : (
|
2022-12-23 20:22:25 +03:00
|
|
|
|
!!inReplyToAccount &&
|
|
|
|
|
!mentions.find((mention) => {
|
|
|
|
|
return mention.id === inReplyToAccountId;
|
|
|
|
|
}) && (
|
|
|
|
|
<div class="status-reply-badge">
|
2022-12-22 07:14:01 +03:00
|
|
|
|
<Icon icon="reply" />{' '}
|
|
|
|
|
<NameText account={inReplyToAccount} short />
|
2022-12-23 20:22:25 +03:00
|
|
|
|
</div>
|
2022-12-22 07:14:01 +03:00
|
|
|
|
)
|
2022-12-22 05:35:39 +03:00
|
|
|
|
)}
|
2022-12-23 20:22:25 +03:00
|
|
|
|
</>
|
2022-12-22 05:35:39 +03:00
|
|
|
|
)}
|
2022-12-18 16:10:05 +03: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 16:48:17 +03:00
|
|
|
|
<>
|
2022-12-18 16:10:05 +03: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();
|
2022-12-20 14:14:50 +03:00
|
|
|
|
if (showSpoiler) {
|
|
|
|
|
states.spoilers.delete(id);
|
|
|
|
|
} else {
|
|
|
|
|
states.spoilers.set(id, true);
|
|
|
|
|
}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
|
|
|
|
|
{showSpoiler ? 'Show less' : 'Show more'}
|
|
|
|
|
</button>
|
2022-12-10 12:14:48 +03:00
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<div
|
|
|
|
|
class="content"
|
2022-12-17 16:06:51 +03:00
|
|
|
|
lang={language}
|
|
|
|
|
ref={contentRef}
|
|
|
|
|
data-read-more={readMoreText}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
let { target } = e;
|
|
|
|
|
if (target.parentNode.tagName.toLowerCase() === 'a') {
|
|
|
|
|
target = target.parentNode;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
target.tagName.toLowerCase() === 'a' &&
|
2022-12-10 14:16:57 +03:00
|
|
|
|
target.classList.contains('u-url')
|
2022-12-10 12:14:48 +03:00
|
|
|
|
) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
2022-12-14 10:39:20 +03:00
|
|
|
|
const username = (
|
|
|
|
|
target.querySelector('span') || target
|
|
|
|
|
).innerText
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/^@/, '');
|
2022-12-22 13:25:18 +03:00
|
|
|
|
const url = target.getAttribute('href');
|
2022-12-10 12:14:48 +03:00
|
|
|
|
const mention = mentions.find(
|
2022-12-14 10:39:20 +03:00
|
|
|
|
(mention) =>
|
2022-12-22 13:25:18 +03:00
|
|
|
|
mention.username === username ||
|
|
|
|
|
mention.acct === username ||
|
|
|
|
|
mention.url === url,
|
2022-12-10 12:14:48 +03:00
|
|
|
|
);
|
|
|
|
|
if (mention) {
|
|
|
|
|
states.showAccount = mention.acct;
|
|
|
|
|
} else {
|
|
|
|
|
const href = target.getAttribute('href');
|
|
|
|
|
states.showAccount = href;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
|
__html: enhanceContent(content, {
|
|
|
|
|
emojis,
|
2022-12-11 04:28:02 +03:00
|
|
|
|
postEnhanceDOM: (dom) => {
|
|
|
|
|
dom
|
|
|
|
|
.querySelectorAll('a.u-url[target="_blank"]')
|
|
|
|
|
.forEach((a) => {
|
|
|
|
|
// Remove target="_blank" from links
|
|
|
|
|
a.removeAttribute('target');
|
|
|
|
|
});
|
|
|
|
|
},
|
2022-12-10 12:14:48 +03:00
|
|
|
|
}),
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2022-12-21 14:29:37 +03:00
|
|
|
|
{!!poll && (
|
|
|
|
|
<Poll
|
|
|
|
|
poll={poll}
|
|
|
|
|
readOnly={readOnly}
|
|
|
|
|
onUpdate={(newPoll) => {
|
|
|
|
|
states.statuses.get(id).poll = newPoll;
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
|
|
|
|
<button
|
|
|
|
|
class="plain spoiler"
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
2022-12-20 14:14:50 +03:00
|
|
|
|
if (showSpoiler) {
|
|
|
|
|
states.spoilers.delete(id);
|
|
|
|
|
} else {
|
2022-12-22 04:02:22 +03:00
|
|
|
|
states.spoilers.set(id, true);
|
2022-12-20 14:14:50 +03:00
|
|
|
|
}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive
|
|
|
|
|
content
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2022-12-12 11:25:55 +03:00
|
|
|
|
{!!mediaAttachments.length && (
|
2022-12-22 05:47:45 +03:00
|
|
|
|
<div
|
|
|
|
|
class={`media-container ${
|
|
|
|
|
mediaAttachments.length > 2 ? 'media-gt2' : ''
|
|
|
|
|
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
|
|
|
|
|
>
|
2022-12-10 12:14:48 +03:00
|
|
|
|
{mediaAttachments.map((media, i) => (
|
|
|
|
|
<Media
|
|
|
|
|
media={media}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setShowMediaModal(i);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{!!card &&
|
|
|
|
|
(size === 'l' ||
|
|
|
|
|
(size === 'm' && !poll && !mediaAttachments.length)) && (
|
|
|
|
|
<Card card={card} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{size === 'l' && (
|
2022-12-11 16:22:22 +03:00
|
|
|
|
<>
|
|
|
|
|
<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 12:14:48 +03:00
|
|
|
|
<>
|
|
|
|
|
{' '}
|
2022-12-11 16:22:22 +03:00
|
|
|
|
• <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 12:14:48 +03:00
|
|
|
|
</>
|
|
|
|
|
)}
|
2022-12-11 16:22:22 +03:00
|
|
|
|
</div>
|
|
|
|
|
<div class="actions">
|
2022-12-19 08:38:16 +03:00
|
|
|
|
<div class="action has-count">
|
|
|
|
|
<StatusButton
|
|
|
|
|
title="Reply"
|
|
|
|
|
alt="Comments"
|
|
|
|
|
class="reply-button"
|
|
|
|
|
icon="comment"
|
|
|
|
|
count={repliesCount}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
states.showCompose = {
|
|
|
|
|
replyToStatus: status,
|
|
|
|
|
};
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2022-12-11 16:22:22 +03:00
|
|
|
|
{/* TODO: if visibility = private, only can reblog own statuses */}
|
|
|
|
|
{visibility !== 'direct' && (
|
2022-12-19 08:38:16 +03: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 {
|
2022-12-20 05:09:05 +03:00
|
|
|
|
if (!reblogged) {
|
|
|
|
|
const yes = confirm(
|
|
|
|
|
'Are you sure that you want to boost this post?',
|
|
|
|
|
);
|
|
|
|
|
if (!yes) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-12-19 08:38:16 +03:00
|
|
|
|
// 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);
|
2022-12-25 08:22:41 +03:00
|
|
|
|
// Revert optimistism
|
|
|
|
|
states.statuses.set(id, status);
|
2022-12-19 08:38:16 +03:00
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div class="action has-count">
|
2022-12-17 12:26:41 +03:00
|
|
|
|
<StatusButton
|
2022-12-19 08:38:16 +03:00
|
|
|
|
checked={favourited}
|
|
|
|
|
title={['Favourite', 'Unfavourite']}
|
|
|
|
|
alt={['Favourite', 'Favourited']}
|
|
|
|
|
class="favourite-button"
|
|
|
|
|
icon="heart"
|
|
|
|
|
count={favouritesCount}
|
2022-12-17 12:26:41 +03:00
|
|
|
|
onClick={async () => {
|
2022-12-11 16:22:22 +03:00
|
|
|
|
try {
|
2022-12-17 12:26:41 +03:00
|
|
|
|
// Optimistic
|
2022-12-19 08:38:16 +03:00
|
|
|
|
states.statuses.set(statusID, {
|
2022-12-17 12:26:41 +03:00
|
|
|
|
...status,
|
2022-12-19 08:38:16 +03:00
|
|
|
|
favourited: !favourited,
|
|
|
|
|
favouritesCount:
|
|
|
|
|
favouritesCount + (favourited ? -1 : 1),
|
2022-12-17 12:26:41 +03:00
|
|
|
|
});
|
2022-12-19 08:38:16 +03:00
|
|
|
|
if (favourited) {
|
|
|
|
|
const newStatus = await masto.statuses.unfavourite(id);
|
2022-12-11 16:22:22 +03:00
|
|
|
|
states.statuses.set(newStatus.id, newStatus);
|
|
|
|
|
} else {
|
2022-12-19 08:38:16 +03:00
|
|
|
|
const newStatus = await masto.statuses.favourite(id);
|
2022-12-11 16:22:22 +03:00
|
|
|
|
states.statuses.set(newStatus.id, newStatus);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
2022-12-25 08:22:41 +03:00
|
|
|
|
// Revert optimistism
|
|
|
|
|
states.statuses.set(statusID, status);
|
2022-12-11 16:22:22 +03:00
|
|
|
|
}
|
|
|
|
|
}}
|
2022-12-17 12:26:41 +03:00
|
|
|
|
/>
|
2022-12-19 08:38:16 +03: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.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);
|
2022-12-25 08:22:41 +03:00
|
|
|
|
// Revert optimistism
|
|
|
|
|
states.statuses.set(statusID, status);
|
2022-12-11 16:22:22 +03:00
|
|
|
|
}
|
2022-12-19 08:38:16 +03:00
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2022-12-12 16:54:31 +03:00
|
|
|
|
{isSelf && (
|
|
|
|
|
<span class="menu-container">
|
|
|
|
|
<button type="button" title="More" class="plain more-button">
|
|
|
|
|
<Icon icon="more" size="l" alt="More" />
|
|
|
|
|
</button>
|
|
|
|
|
<menu>
|
|
|
|
|
{isSelf && (
|
|
|
|
|
<li>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="plain"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
states.showCompose = {
|
|
|
|
|
editStatus: status,
|
|
|
|
|
};
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Edit…
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
)}
|
|
|
|
|
</menu>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2022-12-11 16:22:22 +03:00
|
|
|
|
</div>
|
|
|
|
|
</>
|
2022-12-10 12:14:48 +03:00
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{showMediaModal !== false && (
|
|
|
|
|
<Modal>
|
2022-12-18 17:56:00 +03:00
|
|
|
|
<Carousel
|
|
|
|
|
mediaAttachments={mediaAttachments}
|
|
|
|
|
index={showMediaModal}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setShowMediaModal(false);
|
2022-12-18 16:10:05 +03:00
|
|
|
|
}}
|
2022-12-18 17:56:00 +03:00
|
|
|
|
/>
|
2022-12-18 16:10:05 +03:00
|
|
|
|
</Modal>
|
|
|
|
|
)}
|
|
|
|
|
{!!showEdited && (
|
|
|
|
|
<Modal
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
if (e.target === e.currentTarget) {
|
|
|
|
|
setShowEdited(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<EditedAtModal
|
|
|
|
|
statusID={showEdited}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setShowEdited(false);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</Modal>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
Media type
|
|
|
|
|
===
|
|
|
|
|
unknown = unsupported or unrecognized file type
|
|
|
|
|
image = Static image
|
|
|
|
|
gifv = Looping, soundless animation
|
|
|
|
|
video = Video clip
|
|
|
|
|
audio = Audio track
|
|
|
|
|
*/
|
|
|
|
|
|
2022-12-20 20:18:37 +03:00
|
|
|
|
function Media({ media, showOriginal, onClick = () => {} }) {
|
2022-12-18 16:10:05 +03: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="lazy"
|
|
|
|
|
style={
|
|
|
|
|
!showOriginal && {
|
|
|
|
|
backgroundColor:
|
|
|
|
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
|
|
|
backgroundPosition: focalBackgroundPosition || 'center',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
} else if (type === 'gifv' || type === 'video') {
|
|
|
|
|
// 20 seconds, treat as a gif
|
2022-12-24 05:23:06 +03:00
|
|
|
|
const shortDuration = original.duration <= 20;
|
|
|
|
|
const isGIF = type === 'gifv' || shortDuration;
|
|
|
|
|
const loopable = original.duration <= 60;
|
2022-12-18 16:10:05 +03:00
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
class={`media media-${isGIF ? 'gif' : 'video'}`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor:
|
|
|
|
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
|
|
|
}}
|
|
|
|
|
onClick={(e) => {
|
2022-12-24 09:15:34 +03:00
|
|
|
|
if (showOriginal && isGIF) {
|
2022-12-18 16:10:05 +03:00
|
|
|
|
try {
|
2022-12-24 09:15:34 +03:00
|
|
|
|
if (videoRef.current.paused) {
|
|
|
|
|
videoRef.current.play();
|
|
|
|
|
} else {
|
|
|
|
|
videoRef.current.pause();
|
|
|
|
|
}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
onClick(e);
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={() => {
|
2022-12-24 09:15:34 +03:00
|
|
|
|
if (!showOriginal && isGIF) {
|
2022-12-18 16:10:05 +03:00
|
|
|
|
try {
|
2022-12-24 09:15:34 +03:00
|
|
|
|
videoRef.current.play();
|
2022-12-18 16:10:05 +03:00
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={() => {
|
2022-12-24 09:15:34 +03:00
|
|
|
|
if (!showOriginal && isGIF) {
|
2022-12-18 16:10:05 +03:00
|
|
|
|
try {
|
2022-12-24 09:15:34 +03:00
|
|
|
|
videoRef.current.pause();
|
2022-12-18 16:10:05 +03:00
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{showOriginal ? (
|
|
|
|
|
<video
|
2022-12-24 09:15:34 +03:00
|
|
|
|
ref={videoRef}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
src={url}
|
|
|
|
|
poster={previewUrl}
|
|
|
|
|
width={width}
|
|
|
|
|
height={height}
|
|
|
|
|
preload="auto"
|
|
|
|
|
autoplay
|
|
|
|
|
muted={isGIF}
|
|
|
|
|
controls={!isGIF}
|
|
|
|
|
playsinline
|
2022-12-24 05:23:06 +03:00
|
|
|
|
loop={loopable}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
></video>
|
|
|
|
|
) : 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') {
|
|
|
|
|
return (
|
|
|
|
|
<div class="media media-audio">
|
|
|
|
|
<audio src={remoteUrl || url} preload="none" controls />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<a
|
|
|
|
|
href={url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="nofollow noopener noreferrer"
|
|
|
|
|
class="card link"
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
class="image"
|
|
|
|
|
src={image}
|
|
|
|
|
width={width}
|
|
|
|
|
height={height}
|
|
|
|
|
loading="lazy"
|
|
|
|
|
alt=""
|
|
|
|
|
onError={(e) => {
|
|
|
|
|
try {
|
|
|
|
|
e.target.style.display = 'none';
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<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 }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-21 14:29:37 +03:00
|
|
|
|
function Poll({ poll, readOnly, onUpdate = () => {} }) {
|
2022-12-18 16:10:05 +03:00
|
|
|
|
const [uiState, setUIState] = useState('default');
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
expired,
|
|
|
|
|
expiresAt,
|
|
|
|
|
id,
|
|
|
|
|
multiple,
|
|
|
|
|
options,
|
|
|
|
|
ownVotes,
|
|
|
|
|
voted,
|
|
|
|
|
votersCount,
|
|
|
|
|
votesCount,
|
2022-12-21 14:29:37 +03:00
|
|
|
|
} = poll;
|
2022-12-18 16:10:05 +03:00
|
|
|
|
|
|
|
|
|
const expiresAtDate = !!expiresAt && new Date(expiresAt);
|
|
|
|
|
|
2022-12-22 16:52:59 +03: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 {
|
|
|
|
|
const pollResponse = await masto.poll.fetch(id);
|
|
|
|
|
onUpdate(pollResponse);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Silent fail
|
|
|
|
|
}
|
|
|
|
|
setUIState('default');
|
|
|
|
|
})();
|
|
|
|
|
}, ms);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
};
|
|
|
|
|
}, [expired, expiresAtDate]);
|
|
|
|
|
|
2022-12-22 09:59:36 +03:00
|
|
|
|
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 16:10:05 +03:00
|
|
|
|
return (
|
2022-12-21 14:46:38 +03:00
|
|
|
|
<div
|
|
|
|
|
class={`poll ${readOnly ? 'read-only' : ''} ${
|
|
|
|
|
uiState === 'loading' ? 'loading' : ''
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2022-12-18 16:10:05 +03:00
|
|
|
|
{voted || expired ? (
|
|
|
|
|
options.map((option, i) => {
|
|
|
|
|
const { title, votesCount: optionVotesCount } = option;
|
|
|
|
|
const percentage =
|
2022-12-22 09:59:36 +03:00
|
|
|
|
((optionVotesCount / pollVotesCount) * 100).toFixed(
|
|
|
|
|
roundPrecision,
|
|
|
|
|
) || 0;
|
2022-12-18 16:10:05 +03: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 18:06:05 +03:00
|
|
|
|
<Icon icon="check-circle" />
|
2022-12-18 16:10:05 +03: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');
|
|
|
|
|
const pollResponse = await masto.poll.vote(id, {
|
|
|
|
|
choices: votes,
|
|
|
|
|
});
|
|
|
|
|
console.log(pollResponse);
|
2022-12-21 14:29:37 +03:00
|
|
|
|
onUpdate(pollResponse);
|
2022-12-18 16:10:05 +03: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 && (
|
2022-12-10 18:39:12 +03:00
|
|
|
|
<button
|
2022-12-18 16:10:05 +03:00
|
|
|
|
class="poll-vote-button"
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={uiState === 'loading'}
|
2022-12-10 18:39:12 +03:00
|
|
|
|
>
|
2022-12-18 16:10:05 +03:00
|
|
|
|
Vote
|
2022-12-10 18:39:12 +03:00
|
|
|
|
</button>
|
2022-12-10 12:14:48 +03:00
|
|
|
|
)}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
</form>
|
2022-12-10 12:14:48 +03:00
|
|
|
|
)}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
{!readOnly && (
|
|
|
|
|
<p class="poll-meta">
|
2022-12-21 14:46:38 +03:00
|
|
|
|
{!expired && (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="textual"
|
|
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setUIState('loading');
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const pollResponse = await masto.poll.fetch(id);
|
|
|
|
|
onUpdate(pollResponse);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Silent fail
|
|
|
|
|
}
|
|
|
|
|
setUIState('default');
|
|
|
|
|
})();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Refresh
|
|
|
|
|
</button>{' '}
|
|
|
|
|
•{' '}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
|
|
|
|
{votersCount === 1 ? 'voter' : 'voters'}
|
|
|
|
|
{votersCount !== votesCount && (
|
|
|
|
|
<>
|
|
|
|
|
{' '}
|
|
|
|
|
• <span title={votesCount}>
|
|
|
|
|
{shortenNumber(votesCount)}
|
|
|
|
|
</span>{' '}
|
|
|
|
|
vote
|
|
|
|
|
{votesCount === 1 ? '' : 's'}
|
|
|
|
|
</>
|
|
|
|
|
)}{' '}
|
|
|
|
|
• {expired ? 'Ended' : 'Ending'}{' '}
|
|
|
|
|
{!!expiresAtDate && (
|
|
|
|
|
<relative-time datetime={expiresAtDate.toISOString()} />
|
|
|
|
|
)}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
2022-12-21 15:00:45 +03:00
|
|
|
|
<div id="edit-history" class="sheet">
|
|
|
|
|
{/* <button type="button" class="close-button plain large" onClick={onClose}>
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<Icon icon="x" alt="Close" />
|
2022-12-21 15:00:45 +03:00
|
|
|
|
</button> */}
|
2022-12-18 16:10:05 +03:00
|
|
|
|
<h2>Edit History</h2>
|
|
|
|
|
{uiState === 'error' && <p>Failed to load history</p>}
|
|
|
|
|
{uiState === 'loading' && (
|
|
|
|
|
<p>
|
|
|
|
|
<Loader abrupt /> Loading…
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{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>
|
2022-12-11 16:22:22 +03:00
|
|
|
|
)}
|
2022-12-10 12:14:48 +03:00
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-17 12:26:41 +03:00
|
|
|
|
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 19:13:56 +03:00
|
|
|
|
<small title={count}>{shortenNumber(count)}</small>
|
2022-12-17 12:26:41 +03:00
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-18 17:56:00 +03:00
|
|
|
|
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 (
|
|
|
|
|
<>
|
|
|
|
|
<div
|
|
|
|
|
ref={carouselRef}
|
|
|
|
|
class="carousel"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
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 (
|
|
|
|
|
<InView
|
|
|
|
|
class="carousel-item"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor:
|
|
|
|
|
rgbAverageColor && `rgba(${rgbAverageColor.join(',')}, .5)`,
|
|
|
|
|
}}
|
|
|
|
|
tabindex="0"
|
|
|
|
|
key={media.id}
|
|
|
|
|
ref={i === currentIndex ? carouselFocusItem : null} // InView options
|
|
|
|
|
root={carouselRef.current}
|
|
|
|
|
threshold={1}
|
|
|
|
|
onChange={(inView) => onSnap(inView, i)}
|
|
|
|
|
>
|
|
|
|
|
<Media media={media} showOriginal />
|
|
|
|
|
</InView>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="carousel-top-controls">
|
|
|
|
|
<span />
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="carousel-button plain2"
|
|
|
|
|
onClick={() => onClose()}
|
|
|
|
|
>
|
|
|
|
|
<Icon icon="x" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{mediaAttachments?.length > 1 && (
|
|
|
|
|
<div class="carousel-controls">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="carousel-button plain2"
|
|
|
|
|
hidden={currentIndex === 0}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setCurrentIndex(
|
|
|
|
|
(currentIndex - 1 + mediaAttachments.length) %
|
|
|
|
|
mediaAttachments.length,
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon icon="arrow-left" />
|
|
|
|
|
</button>
|
|
|
|
|
<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();
|
|
|
|
|
setCurrentIndex(i);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
•
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="carousel-button plain2"
|
|
|
|
|
hidden={currentIndex === mediaAttachments.length - 1}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setCurrentIndex((currentIndex + 1) % mediaAttachments.length);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icon icon="arrow-right" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-10 12:14:48 +03:00
|
|
|
|
export default Status;
|