phanpy/src/components/status.jsx

836 wiersze
23 KiB
JavaScript
Czysty Wina Historia

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import './status.css';
import { getBlurHashAverageColor } from 'fast-blurhash';
import mem from 'mem';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Modal from '../components/modal';
import NameText from '../components/name-text';
import enhanceContent from '../utils/enhance-content';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
/*
Media type
===
unknown = unsupported or unrecognized file type
image = Static image
gifv = Looping, soundless animation
video = Video clip
audio = Audio track
*/
function Media({ media, showOriginal, onClick }) {
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
media;
const { original, small, focus } = meta || {};
const width = showOriginal ? original?.width : small?.width;
const height = showOriginal ? original?.height : small?.height;
const mediaURL = showOriginal ? url : previewUrl;
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
const videoRef = useRef();
let focalBackgroundPosition;
if (focus) {
// Convert focal point to CSS background position
// Formula from jquery-focuspoint
// x = -1, y = 1 => 0% 0%
// x = 0, y = 0 => 50% 50%
// x = 1, y = -1 => 100% 100%
const x = ((focus.x + 1) / 2) * 100;
const y = ((1 - focus.y) / 2) * 100;
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
}
if (type === 'image') {
return (
<div
class={`media media-image`}
onClick={onClick}
style={
showOriginal && {
backgroundImage: `url(${previewUrl})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: focalBackgroundPosition || '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}
style={
!showOriginal && {
backgroundColor: `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
}
}
/>
</div>
);
} else if (type === 'gifv' || type === 'video') {
// 20 seconds, treat as a gif
const isGIF = type === 'gifv' && original.duration <= 20;
return (
<div
class={`media media-${isGIF ? 'gif' : 'video'}`}
style={{
backgroundColor: `rgb(${rgbAverageColor.join(',')})`,
}}
onClick={onClick}
onMouseEnter={() => {
if (isGIF) {
try {
videoRef.current?.play();
} catch (e) {}
}
}}
onMouseLeave={() => {
if (isGIF) {
try {
videoRef.current?.pause();
} catch (e) {}
}
}}
>
{showOriginal ? (
<video
src={url}
poster={previewUrl}
width={width}
height={height}
preload
autoplay
controls={!isGIF}
playsinline
loop
></video>
) : isGIF ? (
<video
ref={videoRef}
src={url}
poster={previewUrl}
width={width}
height={height}
preload
// controls
playsinline
loop
muted
/>
) : (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
/>
)}
</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 wont show up in practice.
*/
const hasText = title || providerName || authorName;
if (hasText && image) {
const domain = new URL(url).hostname;
return (
<a
href={url}
target="_blank"
rel="nofollow noopener noreferrer"
class="card link"
>
<img
class="image"
src={image}
width={width}
height={height}
alt=""
onError={() => {
this.style.display = 'none';
}}
/>
<div class="meta-container">
<p
class="title"
dangerouslySetInnerHTML={{
__html: title,
}}
/>
<p class="meta">{providerName || authorName}</p>
<p class="meta domain">{domain}</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}
style={{
height: 'auto',
aspectRatio: `${width}/${height}`,
}}
/>
</a>
);
} else if (type === 'video') {
return (
<div
class="card video"
style={{
aspectRatio: `${width}/${height}`,
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
}
function Poll({ poll }) {
const [pollSnapshot, setPollSnapshot] = useState(poll);
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
} = pollSnapshot;
const expiresAtDate = new Date(expiresAt);
return (
<div class="poll">
{voted || expired ? (
options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = Math.round((optionVotesCount / votesCount) * 100);
return (
<div
class="poll-option"
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
{title}
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check" size="s" />
</>
)}
</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);
setPollSnapshot(pollResponse);
setUIState('default');
}}
style={{
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
opacity: uiState === 'loading' ? 0.5 : 1,
}}
>
{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'}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
</form>
)}
<p class="poll-meta">
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
{votersCount === 1 ? 'voter' : 'voters'}
{votersCount !== votesCount && (
<>
{' '}
&bull; <span title={votesCount}>
{shortenNumber(votesCount)}
</span>{' '}
vote
{votesCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
<relative-time datetime={expiresAtDate.toISOString()} />
</p>
</div>
);
}
function fetchAccount(id) {
return masto.accounts.fetch(id);
}
const memFetchAccount = mem(fetchAccount);
function Status({ statusID, status, withinContext, size = 'm', skeleton }) {
if (skeleton) {
return (
<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>
</div>
);
}
const snapStates = useSnapshot(states);
if (!status) {
status = snapStates.statuses.get(statusID);
}
if (!status) {
return null;
}
const {
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,
} = status;
const createdAtDate = new Date(createdAt);
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) => {});
}
}
const [showSpoiler, setShowSpoiler] = useState(false);
const debugHover = (e) => {
if (e.shiftKey) {
console.log(status);
}
};
const [showMediaModal, setShowMediaModal] = useState(false);
const carouselFocusItem = useRef(null);
const prevShowMediaModal = useRef(showMediaModal);
useEffect(() => {
if (showMediaModal !== false) {
carouselFocusItem.current?.scrollIntoView({
behavior: prevShowMediaModal.current === false ? 'auto' : 'smooth',
});
}
prevShowMediaModal.current = showMediaModal;
}, [showMediaModal]);
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>
);
}
return (
<div
class={`status ${
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
} ${
{
s: 'small',
m: 'medium',
l: 'large',
}[size]
}`}
onMouseEnter={debugHover}
>
{size !== 's' && (
<a
href={url}
target="_blank"
title={`@${acct}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showAccount = status.account;
}}
>
<Avatar url={avatarStatic} size="xxl" />
</a>
)}
<div class="container">
<div class="meta">
<span>
<NameText
account={status.account}
showAvatar={size === 's'}
showAcct={size === 'l'}
/>
{inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} short />
</span>
</>
)}
</span>{' '}
<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>
</div>
<div
class={`content-container ${
sensitive || spoilerText ? 'has-spoiler' : ''
} ${showSpoiler ? 'show-spoiler' : ''}`}
>
{!!spoilerText && sensitive && (
<>
<div class="content">
<p>{spoilerText}</p>
</div>
<button
class="light spoiler"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSpoiler(!showSpoiler);
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
{showSpoiler ? 'Show less' : 'Show more'}
</button>
</>
)}
<div
class="content"
onClick={(e) => {
let { target } = e;
if (target.parentNode.tagName.toLowerCase() === 'a') {
target = target.parentNode;
}
console.log('click', target, e);
if (
target.tagName.toLowerCase() === 'a' &&
target.classList.contains('mention')
) {
e.preventDefault();
e.stopPropagation();
const username = target.querySelector('span');
const mention = mentions.find(
(mention) => mention.username === username?.innerText.trim(),
);
if (mention) {
states.showAccount = mention.acct;
} else {
const href = target.getAttribute('href');
states.showAccount = href;
}
}
}}
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
}),
}}
/>
{!!poll && <Poll poll={poll} />}
{!spoilerText && sensitive && !!mediaAttachments.length && (
<button
class="plain spoiler"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowSpoiler(!showSpoiler);
}}
>
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive
content
</button>
)}
{!!mediaAttachments.length && size !== 's' && (
<div class="media-container">
{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' && (
<div class="actions">
<button
type="button"
title="Comment"
class="plain reply-button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showCompose = {
replyToStatus: status,
};
}}
>
<Icon icon="comment" size="l" alt="Reply" />
{!!repliesCount && (
<>
{' '}
<small>{shortenNumber(repliesCount)}</small>
</>
)}
</button>
{/* TODO: if visibility = private, only can reblog own statuses */}
{visibility !== 'direct' && (
<button
type="button"
title={reblogged ? 'Unboost' : 'Boost'}
class={`plain reblog-button ${reblogged ? 'reblogged' : ''}`}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
const yes = confirm(
reblogged ? 'Unboost this status?' : 'Boost this status?',
);
if (!yes) return;
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);
}
}}
>
<Icon
icon="rocket"
size="l"
alt={reblogged ? 'Boosted' : 'Boost'}
/>
{!!reblogsCount && (
<>
{' '}
<small>{shortenNumber(reblogsCount)}</small>
</>
)}
</button>
)}
<button
type="button"
title={favourited ? 'Unfavourite' : 'Favourite'}
class={`plain favourite-button ${favourited ? 'favourited' : ''}`}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
try {
if (favourited) {
const newStatus = await masto.statuses.unfavourite(id);
states.statuses.set(newStatus.id, newStatus);
} else {
const newStatus = await masto.statuses.favourite(id);
states.statuses.set(newStatus.id, newStatus);
}
} catch (e) {}
}}
>
<Icon
icon="heart"
size="l"
alt={favourited ? 'Favourited' : 'Favourite'}
/>
{!!favouritesCount && (
<>
{' '}
<small>{shortenNumber(favouritesCount)}</small>
</>
)}
</button>
<button
type="button"
title={bookmarked ? 'Unbookmark' : 'Bookmark'}
class={`plain bookmark-button ${bookmarked ? 'bookmarked' : ''}`}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
try {
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) {}
}}
>
<Icon
icon="bookmark"
size="l"
alt={bookmarked ? 'Bookmarked' : 'Bookmark'}
/>
</button>
</div>
)}
</div>
{showMediaModal !== false && (
<Modal>
<div
class="carousel"
onClick={() => {
setShowMediaModal(false);
}}
tabindex="0"
>
{mediaAttachments?.map((media, i) => (
<div
class="carousel-item"
tabindex="0"
key={media.id}
ref={i === showMediaModal ? carouselFocusItem : null}
>
<Media media={media} showOriginal />
</div>
))}
</div>
{mediaAttachments?.length > 1 && (
<div class="carousel-controls">
<button
type="button"
class="carousel-button plain"
hidden={showMediaModal === 0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMediaModal(
(showMediaModal - 1 + mediaAttachments.length) %
mediaAttachments.length,
);
}}
>
<Icon icon="arrow-left" />
</button>
<span class="carousel-dots">
{mediaAttachments?.map((media, i) => (
<button
key={media.id}
type="button"
class={`plain carousel-dot ${
i === showMediaModal ? 'active' : ''
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMediaModal(i);
}}
>
&bull;
</button>
))}
</span>
<button
type="button"
class="carousel-button plain"
hidden={showMediaModal === mediaAttachments.length - 1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowMediaModal(
(showMediaModal + 1) % mediaAttachments.length,
);
}}
>
<Icon icon="arrow-right" />
</button>
</div>
)}
</Modal>
)}
</div>
);
}
export default Status;