phanpy/src/components/status.jsx

2102 lines
61 KiB
React
Raw Normal View History

2022-12-10 12:14:48 +03:00
import './status.css';
import { match } from '@formatjs/intl-localematcher';
import '@justinribeiro/lite-youtube';
2023-03-02 10:15:49 +03:00
import {
ControlledMenu,
Menu,
MenuDivider,
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { decodeBlurHash } from 'fast-blurhash';
2022-12-10 12:14:48 +03:00
import mem from 'mem';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2023-04-06 17:51:48 +03:00
import { InView } from 'react-intersection-observer';
2022-12-28 14:43:02 +03:00
import 'swiped-events';
2023-03-07 19:01:51 +03:00
import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer';
2022-12-10 12:14:48 +03:00
import { useSnapshot } from 'valtio';
2023-04-06 17:51:48 +03:00
import AccountBlock from '../components/account-block';
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 { api } from '../utils/api';
2023-04-14 18:03:04 +03:00
import emojifyText from '../utils/emojify-text';
2022-12-10 12:14:48 +03:00
import enhanceContent from '../utils/enhance-content';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
2023-03-28 20:12:59 +03:00
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
2023-03-01 15:07:22 +03:00
import niceDateTime from '../utils/nice-date-time';
2022-12-10 12:14:48 +03:00
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
2023-03-17 12:14:54 +03:00
import states, { getStatus, saveStatus, statusKey } from '../utils/states';
2023-03-21 19:09:36 +03:00
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
2022-12-10 12:14:48 +03:00
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
2023-03-28 10:59:20 +03:00
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
2022-12-10 12:14:48 +03:00
const throttle = pThrottle({
limit: 1,
interval: 1000,
});
function fetchAccount(id, masto) {
try {
return masto.v1.accounts.fetch(id);
} catch (e) {
return Promise.reject(e);
}
2022-12-18 16:10:05 +03:00
}
const memFetchAccount = mem(fetchAccount);
2022-12-10 12:14:48 +03:00
const visibilityText = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Followers only',
2023-04-06 13:21:56 +03:00
direct: 'Private mention',
};
2022-12-18 16:10:05 +03:00
function Status({
statusID,
status,
instance: propInstance,
2022-12-18 16:10:05 +03:00
withinContext,
size = 'm',
skeleton,
readOnly,
contentTextWeight,
enableTranslate,
2023-03-16 08:02:46 +03:00
previewMode,
2023-03-21 19:09:36 +03:00
allowFilters,
onMediaClick,
2022-12-18 16:10:05 +03:00
}) {
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" />
2022-12-18 16:10:05 +03:00
<div class="container">
2023-02-11 16:09:36 +03:00
<div class="meta"> </div>
2022-12-18 16:10:05 +03:00
<div class="content-container">
<div class="content">
2023-02-11 16:09:36 +03:00
<p> </p>
2022-12-18 16:10:05 +03:00
</div>
</div>
</div>
2022-12-10 12:14:48 +03:00
</div>
);
}
2023-02-19 09:49:53 +03:00
const { masto, instance, authenticated } = api({ instance: propInstance });
const { instance: currentInstance } = api();
const sameInstance = instance === currentInstance;
2022-12-10 12:14:48 +03:00
const sKey = statusKey(statusID, instance);
2022-12-18 16:10:05 +03:00
const snapStates = useSnapshot(states);
if (!status) {
2023-02-11 13:55:21 +03:00
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
2022-12-18 16:10:05 +03:00
}
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,
2023-02-21 09:29:25 +03:00
url: accountURL,
2022-12-18 16:10:05 +03:00
displayName,
username,
emojis: accountEmojis,
2023-04-10 19:26:43 +03:00
bot,
2022-12-18 16:10:05 +03:00
},
id,
repliesCount,
reblogged,
reblogsCount,
favourited,
favouritesCount,
bookmarked,
poll,
muted,
sensitive,
spoilerText,
visibility, // public, unlisted, private, direct
language,
editedAt,
filtered,
card,
createdAt,
inReplyToId,
2022-12-18 16:10:05 +03:00
inReplyToAccountId,
content,
mentions,
mediaAttachments,
reblog,
uri,
2023-02-21 09:29:25 +03:00
url,
2022-12-18 16:10:05 +03:00
emojis,
2023-02-17 05:12:59 +03:00
// Non-API props
_deleted,
2023-02-17 05:12:59 +03:00
_pinned,
2023-03-21 19:09:36 +03:00
_filtered,
2022-12-18 16:10:05 +03:00
} = status;
2022-12-10 12:14:48 +03:00
console.debug('RENDER Status', id, status?.account.displayName);
2023-03-23 16:48:29 +03:00
const debugHover = (e) => {
if (e.shiftKey) {
console.log(status);
}
};
2023-03-21 19:09:36 +03:00
if (allowFilters && size !== 'l' && _filtered) {
return (
<FilteredStatus
status={status}
filterInfo={_filtered}
instance={instance}
2023-03-23 16:48:29 +03:00
containerProps={{
onMouseEnter: debugHover,
}}
2023-03-21 19:09:36 +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
2023-04-09 19:30:32 +03:00
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
2022-12-18 16:10:05 +03:00
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
2023-04-09 19:30:32 +03:00
}, [accountId, currentAccount]);
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) {
2023-02-21 09:29:25 +03:00
inReplyToAccountRef = { url: accountURL, username, displayName };
2022-12-18 16:10:05 +03:00
}
const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef);
if (!withinContext && !inReplyToAccount && inReplyToAccountId) {
const account = states.accounts[inReplyToAccountId];
2022-12-18 16:10:05 +03:00
if (account) {
setInReplyToAccount(account);
} else {
memFetchAccount(inReplyToAccountId, masto)
2022-12-18 16:10:05 +03:00
.then((account) => {
setInReplyToAccount(account);
states.accounts[account.id] = account;
2022-12-18 16:10:05 +03:00
})
.catch((e) => {});
}
2022-12-10 12:14:48 +03:00
}
2023-04-09 19:30:32 +03:00
const mentionSelf =
inReplyToAccountId === currentAccount ||
mentions?.find((mention) => mention.id === currentAccount);
2022-12-10 12:14:48 +03:00
const showSpoiler = !!snapStates.spoilers[id] || false;
2022-12-10 12:14:48 +03:00
2022-12-18 16:10:05 +03:00
if (reblog) {
2023-03-21 19:09:36 +03:00
// If has statusID, means useItemID (cached in states)
2022-12-18 16:10:05 +03:00
return (
<div class="status-reblog" onMouseEnter={debugHover}>
<div class="status-pre-meta">
<Icon icon="rocket" size="l" />{' '}
<NameText account={status.account} instance={instance} showAvatar />{' '}
2023-04-06 08:21:53 +03:00
<span>boosted</span>
2022-12-18 16:10:05 +03:00
</div>
<Status
2023-03-21 19:09:36 +03:00
status={statusID ? null : reblog}
statusID={statusID ? reblog.id : null}
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
/>
2022-12-18 16:10:05 +03:00
</div>
);
}
2022-12-10 12:14:48 +03:00
const [forceTranslate, setForceTranslate] = useState(false);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
if (!snapStates.settings.contentTranslation) enableTranslate = false;
2022-12-18 16:10:05 +03:00
const [showEdited, setShowEdited] = useState(false);
2023-04-06 17:51:48 +03:00
const [showReactions, setShowReactions] = useState(false);
2022-12-18 16:10:05 +03:00
const spoilerContentRef = useRef(null);
useResizeObserver({
ref: spoilerContentRef,
onResize: () => {
if (spoilerContentRef.current) {
const { scrollHeight, clientHeight } = spoilerContentRef.current;
2023-03-16 11:16:15 +03:00
if (scrollHeight < window.innerHeight * 0.4) {
spoilerContentRef.current.classList.remove('truncated');
} else {
spoilerContentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
2022-12-18 16:10:05 +03:00
}
},
});
const contentRef = useRef(null);
useResizeObserver({
ref: contentRef,
onResize: () => {
if (contentRef.current) {
const { scrollHeight, clientHeight } = contentRef.current;
2023-03-16 11:16:15 +03:00
if (scrollHeight < window.innerHeight * 0.4) {
contentRef.current.classList.remove('truncated');
} else {
contentRef.current.classList.toggle(
'truncated',
scrollHeight > clientHeight,
);
}
2022-12-18 16:10:05 +03:00
}
},
});
const readMoreText = 'Read more →';
2022-12-10 12:14:48 +03:00
2022-12-30 15:37:57 +03:00
const statusRef = useRef(null);
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`;
const textWeight = () =>
Math.max(
Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1,
1,
);
2023-03-01 15:07:22 +03:00
const createdDateText = niceDateTime(createdAtDate);
const editedDateText = editedAt && niceDateTime(editedAtDate);
const isSizeLarge = size === 'l';
// 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 = () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
states.showCompose = {
replyToStatus: status,
};
};
const boostStatus = async () => {
if (!sameInstance || !authenticated) {
alert(unauthInteractionErrorMessage);
return false;
}
try {
if (!reblogged) {
// Check if media has no descriptions
const hasNoDescriptions = mediaAttachments.some(
(attachment) => !attachment.description?.trim?.(),
);
let confirmText = 'Boost this post?';
if (hasNoDescriptions) {
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.unreblog(id);
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.reblog(id);
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.unfavourite(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.favourite(id);
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.unbookmark(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.bookmark(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
};
const menuInstanceRef = useRef();
const StatusMenuItems = (
<>
{!isSizeLarge && (
<>
<MenuHeader>
<span class="ib">
<Icon icon={visibilityIconsMap[visibility]} size="s" />{' '}
<span>{visibilityText[visibility]}</span>
</span>{' '}
<span class="ib">
{repliesCount > 0 && (
<span>
<Icon icon="reply" alt="Replies" size="s" />{' '}
<span>{shortenNumber(repliesCount)}</span>
</span>
)}{' '}
{reblogsCount > 0 && (
<span>
<Icon icon="rocket" alt="Boosts" size="s" />{' '}
<span>{shortenNumber(reblogsCount)}</span>
</span>
)}{' '}
{favouritesCount > 0 && (
<span>
<Icon icon="heart" alt="Favourites" size="s" />{' '}
<span>{shortenNumber(favouritesCount)}</span>
</span>
)}
</span>
<br />
{createdDateText}
</MenuHeader>
<MenuLink to={instance ? `/${instance}/s/${id}` : `/s/${id}`}>
<Icon icon="arrow-right" />
2023-03-09 16:51:50 +03:00
<span>View post by @{username || acct}</span>
</MenuLink>
</>
)}
{!!editedAt && (
<MenuItem
onClick={() => {
setShowEdited(id);
}}
>
<Icon icon="history" />
<span>
Show Edit History
<br />
<small class="more-insignificant">Edited: {editedDateText}</small>
</span>
</MenuItem>
)}
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
2023-04-06 17:51:48 +03:00
{isSizeLarge && (
<MenuItem onClick={() => setShowReactions(true)}>
<Icon icon="react" />
<span>
Boosted/Favourited by<span class="more-insignificant"></span>
</span>
</MenuItem>
)}
{!isSizeLarge && sameInstance && (
<>
2023-04-09 20:21:02 +03:00
<div class="menu-horizontal">
<MenuItem
2023-04-09 20:21:02 +03:00
disabled={!canBoost}
onClick={async () => {
try {
const done = await boostStatus();
if (!isSizeLarge && done) {
showToast(reblogged ? 'Unboosted' : 'Boosted');
}
} catch (e) {}
}}
>
2023-03-09 16:51:50 +03:00
<Icon
icon="rocket"
style={{
color: reblogged && 'var(--reblog-color)',
}}
/>
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
</MenuItem>
2023-04-09 20:21:02 +03:00
<MenuItem
onClick={() => {
try {
favouriteStatus();
if (!isSizeLarge)
showToast(favourited ? 'Unfavourited' : 'Favourited');
} catch (e) {}
2023-03-09 16:51:50 +03:00
}}
2023-04-09 20:21:02 +03:00
>
<Icon
icon="heart"
style={{
color: favourited && 'var(--favourite-color)',
}}
/>
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
</MenuItem>
</div>
<div class="menu-horizontal">
<MenuItem onClick={replyStatus}>
<Icon icon="reply" />
<span>Reply</span>
</MenuItem>
2023-04-09 20:21:02 +03:00
<MenuItem
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge)
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
} catch (e) {}
2023-03-09 16:51:50 +03:00
}}
2023-04-09 20:21:02 +03:00
>
<Icon
icon="bookmark"
style={{
color: bookmarked && 'var(--link-color)',
}}
/>
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
</MenuItem>
</div>
</>
)}
{enableTranslate && (
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
)}
{((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />}
<MenuItem href={url} target="_blank">
<Icon icon="external" />
2023-03-09 16:51:50 +03:00
<small class="menu-double-lines">{nicePostURL(url)}</small>
</MenuItem>
2023-03-09 16:51:50 +03:00
<div class="menu-horizontal">
<MenuItem
onClick={() => {
2023-03-09 16:51:50 +03:00
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
2023-03-09 16:51:50 +03:00
<Icon icon="link" />
<span>Copy</span>
</MenuItem>
2023-03-09 16:51:50 +03:00
{navigator?.share &&
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
</div>
2023-04-09 19:30:32 +03:00
{(isSelf || mentionSelf) && <MenuDivider />}
{(isSelf || mentionSelf) && (
<MenuItem
onClick={async () => {
try {
const newStatus = await masto.v1.statuses[
muted ? 'unmute' : 'mute'
](id);
saveStatus(newStatus, instance);
showToast(muted ? 'Conversation unmuted' : 'Conversation muted');
} catch (e) {
console.error(e);
showToast(
muted
? 'Unable to unmute conversation'
: 'Unable to mute conversation',
);
}
}}
>
{muted ? (
<>
<Icon icon="unmute" />
<span>Unmute conversation</span>
</>
) : (
<>
<Icon icon="mute" />
<span>Mute conversation</span>
</>
)}
</MenuItem>
)}
{isSelf && (
2023-04-09 20:21:02 +03:00
<div class="menu-horizontal">
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
>
<Icon icon="pencil" />
<span>Edit</span>
</MenuItem>
2023-03-17 12:14:54 +03:00
{isSizeLarge && (
<MenuItem
onClick={() => {
const yes = confirm('Delete this post?');
if (yes) {
(async () => {
try {
await masto.v1.statuses.remove(id);
const cachedStatus = getStatus(id, instance);
cachedStatus._deleted = true;
showToast('Deleted');
} catch (e) {
console.error(e);
showToast('Unable to delete');
}
})();
}
}}
>
<Icon icon="trash" />
<span>Delete</span>
</MenuItem>
)}
2023-04-09 20:21:02 +03:00
</div>
)}
</>
);
2023-03-07 19:01:51 +03:00
const contextMenuRef = useRef();
2023-03-02 10:15:49 +03:00
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({
x: 0,
y: 0,
});
2023-03-07 19:01:51 +03:00
const bindLongPress = useLongPress(
(e) => {
const { clientX, clientY } = e.touches?.[0] || e;
setContextMenuAnchorPoint({
2023-03-07 19:01:51 +03:00
x: clientX,
y: clientY,
});
setIsContextMenuOpen(true);
2023-03-07 19:01:51 +03:00
},
{
2023-04-15 07:04:31 +03:00
threshold: 500,
2023-03-07 19:01:51 +03:00
captureEvent: true,
detect: 'touch',
cancelOnMovement: true,
},
);
2023-03-02 10:15:49 +03:00
2022-12-10 12:14:48 +03:00
return (
2022-12-29 11:12:09 +03:00
<article
2022-12-30 15:37:57 +03:00
ref={statusRef}
tabindex="-1"
2022-12-18 16:10:05 +03:00
class={`status ${
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
2023-02-17 05:12:59 +03:00
} visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${
2022-12-18 16:10:05 +03:00
{
s: 'small',
m: 'medium',
l: 'large',
}[size]
2023-03-17 12:14:54 +03:00
} ${_deleted ? 'status-deleted' : ''}`}
2022-12-18 16:10:05 +03:00
onMouseEnter={debugHover}
2023-03-02 10:15:49 +03:00
onContextMenu={(e) => {
if (size === 'l') return;
2023-03-02 10:15:49 +03:00
if (e.metaKey) return;
2023-03-16 08:02:46 +03:00
if (previewMode) return;
2023-03-17 12:14:54 +03:00
if (_deleted) return;
// console.log('context menu', e);
2023-03-09 16:51:50 +03:00
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
2023-03-02 10:15:49 +03:00
e.preventDefault();
setContextMenuAnchorPoint({
x: e.clientX,
y: e.clientY,
});
setIsContextMenuOpen(true);
}}
2023-03-07 19:01:51 +03:00
{...bindLongPress()}
2022-12-18 16:10:05 +03:00
>
{size !== 'l' && (
<ControlledMenu
2023-03-07 19:01:51 +03:00
ref={contextMenuRef}
state={isContextMenuOpen ? 'open' : undefined}
anchorPoint={contextMenuAnchorPoint}
direction="right"
onClose={() => setIsContextMenuOpen(false)}
portal={{
target: document.body,
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
2023-03-07 19:01:51 +03:00
onClick: () => {
contextMenuRef.current?.closeMenu?.();
},
}}
overflow="auto"
boundingBoxPadding={safeBoundingBoxPadding()}
unmountOnClose
>
{StatusMenuItems}
</ControlledMenu>
)}
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" />}
2023-02-17 05:12:59 +03:00
{_pinned && <Icon class="pin" icon="pin" size="s" />}
2022-12-20 15:17:38 +03:00
</div>
)}
2022-12-18 16:10:05 +03:00
{size !== 's' && (
<a
2023-02-21 09:29:25 +03:00
href={accountURL}
2022-12-29 11:11:58 +03:00
tabindex="-1"
2022-12-18 16:10:05 +03:00
// 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 = {
account: status.account,
instance,
};
2022-12-10 12:14:48 +03:00
}}
>
2023-04-10 19:26:43 +03:00
<Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} />
2022-12-18 16:10:05 +03:00
</a>
2022-12-10 12:14:48 +03:00
)}
2022-12-18 16:10:05 +03:00
<div class="container">
<div class="meta">
{/* <span> */}
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
2022-12-18 16:10:05 +03:00
<>
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} instance={instance} short />
2022-12-18 16:10:05 +03:00
</span>
</>
)} */}
{/* </span> */}{' '}
2022-12-18 16:10:05 +03:00
{size !== 'l' &&
2023-03-17 12:14:54 +03:00
(_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : url && !previewMode ? (
<Menu
instanceRef={menuInstanceRef}
portal={{
target: document.body,
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
onClick: () => {
menuInstanceRef.current?.closeMenu?.();
},
}}
align="end"
offsetY={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
unmountOnClose
menuButton={({ open }) => (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
class={`time ${open ? 'is-open' : ''}`}
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
)}
>
{StatusMenuItems}
</Menu>
2022-12-18 16:10:05 +03:00
) : (
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
2022-12-18 16:10:05 +03:00
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
2022-12-18 16:10:05 +03:00
</span>
))}
</div>
2023-04-06 13:21:56 +03:00
{visibility === 'direct' && (
<>
<div class="status-direct-badge">Private mention</div>{' '}
</>
)}
{!withinContext && (
2023-01-10 14:59:02 +03:00
<>
{inReplyToAccountId === status.account?.id ||
!!snapStates.statusThreadNumber[sKey] ? (
2023-01-10 14:59:02 +03:00
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
Thread
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
2023-01-10 14:59:02 +03:00
: ''}
</div>
) : (
!!inReplyToId &&
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText
account={inReplyToAccount}
instance={instance}
short
/>
</div>
2023-01-10 14:59:02 +03:00
)
)}
</>
)}
2022-12-18 16:10:05 +03:00
<div
2023-02-07 07:56:26 +03:00
class={`content-container ${
spoilerText || sensitive ? 'has-spoiler' : ''
} ${showSpoiler ? 'show-spoiler' : ''}`}
data-content-text-weight={contentTextWeight ? textWeight() : null}
2022-12-18 16:10:05 +03:00
style={
(isSizeLarge || contentTextWeight) && {
'--content-text-weight': textWeight(),
2022-12-18 16:10:05 +03:00
}
}
>
{!!spoilerText && (
2022-12-14 16:48:17 +03:00
<>
2022-12-18 16:10:05 +03:00
<div
class="content"
lang={language}
2023-04-10 15:23:40 +03:00
dir="auto"
2022-12-18 16:10:05 +03:00
ref={spoilerContentRef}
data-read-more={readMoreText}
>
2023-04-14 18:05:46 +03:00
<p
dangerouslySetInnerHTML={{
__html: emojifyText(spoilerText, emojis),
}}
/>
2022-12-18 16:10:05 +03:00
</div>
<button
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
2022-12-18 16:10:05 +03:00
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
} else {
states.spoilers[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"
lang={language}
2023-04-10 15:23:40 +03:00
dir="auto"
ref={contentRef}
data-read-more={readMoreText}
2023-03-16 08:02:46 +03:00
onClick={handleContentLinks({ mentions, instance, previewMode })}
2022-12-10 12:14:48 +03:00
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
postEnhanceDOM: (dom) => {
// Remove target="_blank" from links
dom
.querySelectorAll('a.u-url[target="_blank"]')
.forEach((a) => {
if (!/http/i.test(a.innerText.trim())) {
a.removeAttribute('target');
}
});
2023-03-16 08:02:46 +03:00
if (previewMode) return;
// Unfurl Mastodon links
dom
.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
)
.forEach((a) => {
if (isMastodonLinkMaybe(a.href)) {
unfurlMastodonLink(currentInstance, a.href).then(() => {
a.removeAttribute('target');
});
}
});
},
2022-12-10 12:14:48 +03:00
}),
}}
/>
2022-12-21 14:29:37 +03:00
{!!poll && (
<Poll
lang={language}
2022-12-21 14:29:37 +03:00
poll={poll}
2023-02-19 09:49:53 +03:00
readOnly={readOnly || !sameInstance || !authenticated}
2022-12-21 14:29:37 +03:00
onUpdate={(newPoll) => {
states.statuses[sKey].poll = newPoll;
2022-12-21 14:29:37 +03:00
}}
refresh={() => {
return masto.v1.polls
.fetch(poll.id)
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
votePoll={(choices) => {
return masto.v1.polls
.vote(poll.id, {
choices,
})
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
2022-12-21 14:29:37 +03:00
/>
)}
{((enableTranslate &&
!!content.trim() &&
language &&
language !== targetLanguage &&
!match([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => language === l || match([language], [l]),
)) ||
forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate}
sourceLanguage={language}
text={
(spoilerText ? `${spoilerText}\n\n` : '') +
getHTMLText(content) +
(poll?.options?.length
? `\n\nPoll:\n${poll.options
.map((option) => `- ${option.title}`)
.join('\n')}`
: '')
}
/>
)}
2022-12-10 12:14:48 +03:00
{!spoilerText && sensitive && !!mediaAttachments.length && (
<button
class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
2022-12-10 12:14:48 +03:00
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (showSpoiler) {
delete states.spoilers[id];
} else {
states.spoilers[id] = true;
}
2022-12-10 12:14:48 +03:00
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive
content
</button>
)}
{!!mediaAttachments.length && (
<div
2023-01-23 15:35:15 +03:00
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{mediaAttachments
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only'
}=${i + 1}`}
onClick={
onMediaClick
? (e) => {
2023-04-16 19:14:09 +03:00
onMediaClick(e, i, media, status);
}
: undefined
}
/>
))}
2022-12-10 12:14:48 +03:00
</div>
)}
{!!card &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length && (
<Card card={card} instance={currentInstance} />
)}
2022-12-10 12:14:48 +03:00
</div>
{isSizeLarge && (
<>
<div class="extra-meta">
2023-03-17 12:14:54 +03:00
{_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : (
2022-12-10 12:14:48 +03:00
<>
2023-03-17 12:14:54 +03:00
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
/>{' '}
<a href={url} target="_blank">
<time
class="created"
datetime={createdAtDate.toISOString()}
>
{createdDateText}
</time>
</a>
{editedAt && (
<>
{' '}
&bull; <Icon icon="pencil" alt="Edited" />{' '}
<time
class="edited"
datetime={editedAtDate.toISOString()}
onClick={() => {
setShowEdited(id);
}}
>
{editedDateText}
</time>
</>
)}
2022-12-10 12:14:48 +03:00
</>
)}
</div>
2023-03-17 12:14:54 +03:00
<div class={`actions ${_deleted ? 'disabled' : ''}`}>
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={replyStatus}
2022-12-19 08:38:16 +03:00
/>
</div>
<div class="action has-count">
<StatusButton
checked={reblogged}
title={['Boost', 'Unboost']}
alt={['Boost', 'Boosted']}
class="reblog-button"
icon="rocket"
count={reblogsCount}
onClick={boostStatus}
disabled={!canBoost}
/>
</div>
2022-12-19 08:38:16 +03:00
<div class="action has-count">
<StatusButton
2022-12-19 08:38:16 +03:00
checked={favourited}
title={['Favourite', 'Unfavourite']}
alt={['Favourite', 'Favourited']}
class="favourite-button"
icon="heart"
count={favouritesCount}
onClick={favouriteStatus}
/>
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={bookmarkStatus}
2022-12-19 08:38:16 +03:00
/>
</div>
<Menu
portal={{
target:
document.querySelector('.status-deck') || document.body,
}}
align="end"
offsetY={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{StatusMenuItems}
</Menu>
</div>
</>
2022-12-10 12:14:48 +03:00
)}
</div>
2022-12-18 16:10:05 +03:00
{!!showEdited && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEdited(false);
2022-12-30 15:37:57 +03:00
statusRef.current?.focus();
2022-12-18 16:10:05 +03:00
}
}}
>
<EditedAtModal
statusID={showEdited}
instance={instance}
fetchStatusHistory={() => {
return masto.v1.statuses.listHistory(showEdited);
}}
2022-12-18 16:10:05 +03:00
onClose={() => {
setShowEdited(false);
2022-12-30 15:37:57 +03:00
statusRef.current?.focus();
2022-12-18 16:10:05 +03:00
}}
/>
</Modal>
)}
2023-04-06 17:51:48 +03:00
{showReactions && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowReactions(false);
}
}}
>
<ReactionsModal statusID={id} instance={instance} />
</Modal>
)}
2022-12-29 11:12:09 +03:00
</article>
2022-12-18 16:10:05 +03:00
);
}
function Card({ card, instance }) {
2022-12-18 16:10:05 +03: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 16:10:05 +03:00
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.fetch(id);
// saveStatus(status, instance);
// setCardStatusID(id);
// })();
});
}
}, [hasText, image]);
// if (cardStatusID) {
// return (
// <Status statusID={cardStatusID} instance={instance} size="s" readOnly />
// );
// }
if (hasText && (image || (!type !== 'photo' && blurhash))) {
2022-12-18 16:10:05 +03:00
const domain = new URL(url).hostname.replace(/^www\./, '');
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();
}
2022-12-18 16:10:05 +03:00
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
2022-12-18 16:10:05 +03:00
rel="nofollow noopener noreferrer"
class={`card link ${blurhashImage ? '' : size}`}
2022-12-18 16:10:05 +03:00
>
2023-01-07 15:25:13 +03:00
<div class="card-image">
<img
src={image || blurhashImage}
2023-01-07 15:25:13 +03:00
width={width}
height={height}
loading="lazy"
alt=""
onError={(e) => {
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
</div>
2022-12-18 16:10:05 +03:00
<div class="meta-container">
<p class="meta domain">{domain}</p>
2023-02-12 09:24:27 +03:00
<p class="title">{title}</p>
2022-12-18 16:10:05 +03:00
<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') {
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 <lite-youtube videoid={videoID} nocookie></lite-youtube>;
}
}
2022-12-18 16:10:05 +03:00
return (
<div
class="card video"
style={{
aspectRatio: `${width}/${height}`,
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
2023-04-18 18:46:59 +03:00
} else if (hasText && !image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link no-image`}
>
<div class="meta-container">
<p class="meta domain">{domain}</p>
<p class="title">{title}</p>
<p class="meta">{description || providerName || authorName}</p>
</div>
</a>
);
2022-12-18 16:10:05 +03:00
}
}
function Poll({
poll,
lang,
readOnly,
refresh = () => {},
votePoll = () => {},
}) {
2022-12-18 16:10:05 +03:00
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
2023-04-14 18:03:04 +03:00
emojis,
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
// NOTE: Disable this because setTimeout runs immediately if delay is too large
// https://stackoverflow.com/a/56718027/20838
// 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 () => {
// // await refresh();
// setUIState('default');
// })();
// }, ms);
// }
// }
// return () => {
// clearTimeout(timeout);
// };
// }, [expired, expiresAtDate]);
2022-12-22 16:52:59 +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;
}
2023-03-30 06:11:35 +03:00
const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
2022-12-18 16:10:05 +03:00
return (
2022-12-21 14:46:38 +03:00
<div
lang={lang}
2023-04-10 15:23:40 +03:00
dir="auto"
2022-12-21 14:46:38 +03:00
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
2023-03-30 06:11:35 +03:00
onDblClick={() => {
setShowResults(!showResults);
}}
2022-12-21 14:46:38 +03:00
>
2023-03-30 06:11:35 +03:00
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
<div class="poll-options">
{options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((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 (
2022-12-18 16:10:05 +03:00
<div
2023-03-30 06:11:35 +03:00
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option poll-result ${
isLeading ? 'poll-option-leading' : ''
2022-12-18 16:10:05 +03:00
}`}
2023-03-30 06:11:35 +03:00
style={{
'--percentage': `${percentage}%`,
}}
2022-12-18 16:10:05 +03:00
>
2023-03-30 06:11:35 +03:00
<div class="poll-option-title">
2023-04-14 18:03:04 +03:00
<span
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
2023-03-30 06:11:35 +03:00
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
2022-12-18 16:10:05 +03:00
</div>
2023-03-30 06:11:35 +03:00
);
})}
</div>
2022-12-18 16:10:05 +03:00
) : (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const choices = [];
2022-12-18 16:10:05 +03:00
formData.forEach((value, key) => {
if (key === 'poll') {
choices.push(value);
2022-12-18 16:10:05 +03:00
}
});
2023-02-24 18:38:59 +03:00
if (!choices.length) return;
2022-12-18 16:10:05 +03:00
setUIState('loading');
await votePoll(choices);
2022-12-18 16:10:05 +03:00
setUIState('default');
}}
>
2023-03-30 06:11:35 +03:00
<div class="poll-options">
{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}
/>
2023-04-14 18:03:04 +03:00
<span
class="poll-option-title"
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
2023-03-30 06:11:35 +03:00
</label>
</div>
);
})}
</div>
2022-12-18 16:10:05 +03:00
{!readOnly && (
<button
2022-12-18 16:10:05 +03:00
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
2022-12-18 16:10:05 +03:00
Vote
</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 () => {
await refresh();
2022-12-21 14:46:38 +03:00
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
2022-12-18 16:10:05 +03:00
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
2022-12-18 16:10:05 +03:00
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
2022-12-18 16:10:05 +03:00
</p>
)}
</div>
);
}
function EditedAtModal({
statusID,
instance,
fetchStatusHistory = () => {},
onClose = () => {},
}) {
2022-12-18 16:10:05 +03:00
const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]);
useEffect(() => {
setUIState('loading');
(async () => {
try {
const editHistory = await fetchStatusHistory();
2022-12-18 16:10:05 +03:00
console.log(editHistory);
setEditHistory(editHistory);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, []);
return (
2022-12-30 15:37:57 +03:00
<div id="edit-history" class="sheet">
<header>
{/* <button type="button" class="close-button plain large" onClick={onClose}>
2022-12-18 16:10:05 +03: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 15:37:57 +03:00
<main tabIndex="-1">
{editHistory.length > 0 && (
<ol>
{editHistory.map((status) => {
const { createdAt } = status;
const createdAtDate = new Date(createdAt);
return (
<li key={createdAt} class="history-item">
<h3>
<time>
{niceDateTime(createdAtDate, {
formatOpts: {
weekday: 'short',
second: 'numeric',
},
})}
</time>
</h3>
<Status
status={status}
instance={instance}
size="s"
withinContext
readOnly
previewMode
/>
</li>
);
})}
</ol>
)}
</main>
2022-12-10 12:14:48 +03:00
</div>
);
2023-04-06 17:51:48 +03:00
}
const REACTIONS_LIMIT = 80;
function ReactionsModal({ statusID, instance }) {
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.listRebloggedBy(statusID, {
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses.listFavouritedBy(
statusID,
{
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 (
<div id="reactions-container" class="sheet">
<header>
<h2>Boosted/Favourited by</h2>
</header>
<main>
{accounts.length > 0 ? (
<>
<ul class="reactions-list">
{accounts.map((account) => {
const { _types } = account;
return (
<li key={account.id + _types}>
<div class="reactions-block">
{_types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
<AccountBlock account={account} instance={instance} />
</li>
);
})}
</ul>
2023-04-07 15:09:59 +03:00
{uiState === 'default' ? (
showMore ? (
2023-04-06 17:51:48 +03:00
<InView
onChange={(inView) => {
if (inView) {
fetchAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => fetchAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
2023-04-07 15:09:59 +03:00
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
2023-04-06 17:51:48 +03:00
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load accounts</p>
) : (
<p class="ui-state insignificant">No one yet.</p>
)}
</main>
</div>
);
2022-12-10 12:14:48 +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>
</>
)}
</button>
);
}
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')}`;
}
}
function isMastodonLinkMaybe(url) {
return /^https:\/\/.*\/\d+$/i.test(url);
}
const denylistDomains = /(twitter|github)\.com/i;
2023-02-23 17:53:28 +03:00
const failedUnfurls = {};
function _unfurlMastodonLink(instance, url) {
if (denylistDomains.test(url)) {
return;
}
2023-02-23 17:53:28 +03:00
if (failedUnfurls[url]) {
return;
}
const instanceRegex = new RegExp(instance + '/');
if (instanceRegex.test(states.unfurledLinks[url]?.url)) {
return Promise.resolve(states.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
const urlObj = new URL(url);
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
.fetch(id)
.then((status) => {
if (status?.id) {
const statusURL = `/${domain}/s/${id}`;
const result = {
id,
url: statusURL,
};
console.debug('🦦 Unfurled URL', url, id, statusURL);
states.unfurledLinks[url] = result;
return result;
} else {
failedUnfurls[url] = true;
throw new Error('No results');
}
})
.catch((e) => {
failedUnfurls[url] = true;
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2
.search({
q: url,
type: 'statuses',
resolve: true,
limit: 1,
})
.then((results) => {
if (results.statuses.length > 0) {
const status = results.statuses[0];
const { id } = status;
const statusURL = `/${instance}/s/${id}`;
const result = {
id,
url: statusURL,
};
console.debug('🦦 Unfurled URL', url, id, statusURL);
states.unfurledLinks[url] = result;
return result;
} else {
2023-02-23 17:53:28 +03:00
failedUnfurls[url] = true;
throw new Error('No results');
}
})
.catch((e) => {
2023-02-23 17:53:28 +03:00
failedUnfurls[url] = true;
2023-02-23 20:26:37 +03:00
// console.warn(e);
// Silently fail
});
return Promise.any([remoteInstanceFetch, mastoSearchFetch]);
}
2023-03-09 16:51:50 +03:00
function nicePostURL(url) {
if (!url) return;
2023-03-09 16:51:50 +03:00
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}
2023-03-10 14:34:04 +03:00
<wbr />
2023-03-09 16:51:50 +03:00
<span class="more-insignificant">/{restPath}</span>
</>
) : (
<span class="more-insignificant">{path}</span>
)}
</>
);
}
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
const root = document.documentElement;
const defaultBoundingBoxPadding = 8;
function safeBoundingBoxPadding() {
// Get safe area inset variables from root
const style = getComputedStyle(root);
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
const str = [
safeAreaInsetTop,
safeAreaInsetRight,
safeAreaInsetBottom,
safeAreaInsetLeft,
]
.map((v) => parseInt(v, 10) || defaultBoundingBoxPadding)
.join(' ');
// console.log(str);
return str;
}
2023-03-23 16:48:29 +03:00
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
2023-03-21 19:09:36 +03:00
const {
2023-04-10 19:26:43 +03:00
account: { avatar, avatarStatic, bot },
2023-03-21 19:09:36 +03:00
createdAt,
visibility,
reblog,
2023-03-21 19:09:36 +03:00
} = status;
const isReblog = !!reblog;
2023-03-21 19:09:36 +03:00
const filterTitleStr = filterInfo?.titlesStr || '';
const createdAtDate = new Date(createdAt);
const statusPeekText = statusPeek(status.reblog || status);
2023-03-21 19:09:36 +03:00
const [showPeek, setShowPeek] = useState(false);
const bindLongPress = useLongPress(
() => {
setShowPeek(true);
},
{
captureEvent: true,
detect: 'touch',
cancelOnMovement: true,
},
);
return (
<div
class={isReblog ? 'status-reblog' : ''}
2023-03-23 16:48:29 +03:00
{...containerProps}
2023-03-21 19:09:36 +03:00
title={statusPeekText}
onContextMenu={(e) => {
e.preventDefault();
setShowPeek(true);
}}
{...bindLongPress()}
>
<article class="status filtered" tabindex="-1">
<b
2023-03-22 07:26:28 +03:00
class="status-filtered-badge clickable badge-meta"
2023-03-21 19:09:36 +03:00
title={filterTitleStr}
onClick={(e) => {
e.preventDefault();
setShowPeek(true);
}}
>
2023-03-22 07:26:28 +03:00
<span>Filtered</span>
<span>{filterTitleStr}</span>
2023-03-21 19:09:36 +03:00
</b>{' '}
2023-04-10 19:26:43 +03:00
<Avatar url={avatarStatic || avatar} squircle={bot} />
2023-03-21 19:09:36 +03:00
<span class="status-filtered-info">
<span class="status-filtered-info-1">
<NameText account={status.account} instance={instance} />{' '}
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
{isReblog ? (
'boosted'
) : (
<RelativeTime datetime={createdAtDate} format="micro" />
)}
</span>
<span class="status-filtered-info-2">
{isReblog && (
<>
<Avatar
url={reblog.account.avatarStatic || reblog.account.avatar}
2023-04-10 19:26:43 +03:00
squircle={bot}
/>{' '}
</>
)}
{statusPeekText}
2023-03-21 19:09:36 +03:00
</span>
</span>
</article>
{!!showPeek && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowPeek(false);
}
}}
>
<div id="filtered-status-peek" class="sheet">
<main tabIndex="-1">
<p class="heading">
<b class="status-filtered-badge">Filtered</b> {filterTitleStr}
</p>
<Link
class="status-link"
to={`/${instance}/s/${status.id}`}
onClick={() => {
setShowPeek(false);
}}
>
<Status status={status} instance={instance} size="s" readOnly />
<button type="button" class="status-post-link plain3">
See post &raquo;
</button>
</Link>
</main>
</div>
</Modal>
)}
</div>
);
}
export default memo(Status);