New experiment: rendering quote toots

This commit is contained in:
Lim Chee Aun 2023-04-23 00:55:47 +08:00
parent 7e302770d1
commit 3eaf3ef62b
5 changed files with 352 additions and 250 deletions

216
src/components/poll.jsx Normal file
View file

@ -0,0 +1,216 @@
import { useState } from 'preact/hooks';
import emojifyText from '../utils/emojify-text';
import shortenNumber from '../utils/shorten-number';
import RelativeTime from './relative-time';
export default function Poll({
poll,
lang,
readOnly,
refresh = () => {},
votePoll = () => {},
}) {
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
emojis,
} = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt); // 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]);
const pollVotesCount = votersCount || votesCount;
let roundPrecision = 0;
if (pollVotesCount <= 1000) {
roundPrecision = 0;
} else if (pollVotesCount <= 10000) {
roundPrecision = 1;
} else if (pollVotesCount <= 100000) {
roundPrecision = 2;
}
const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
return (
<div
lang={lang}
dir="auto"
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
onDblClick={() => {
setShowResults(!showResults);
}}
>
{(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 (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option poll-result ${
isLeading ? 'poll-option-leading' : ''
}`}
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
<span
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
</div>
);
})}
</div>
) : (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const choices = [];
formData.forEach((value, key) => {
if (key === 'poll') {
choices.push(value);
}
});
if (!choices.length) return;
setUIState('loading');
await votePoll(choices);
setUIState('default');
}}
>
<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}
/>
<span
class="poll-option-title"
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
</label>
</div>
);
})}
</div>
{!readOnly && (
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
)}
</form>
)}
{!readOnly && (
<p class="poll-meta">
{!expired && (
<>
<button
type="button"
class="textual"
disabled={uiState === 'loading'}
onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
await refresh();
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>
)}
</div>
);
}

View file

@ -60,6 +60,7 @@
line-height: 1.4;
align-items: flex-start;
position: relative;
font-size: var(--text-size);
}
.status.large {
--fade-in-out-bg: linear-gradient(
@ -76,6 +77,51 @@
background-image: var(--fade-in-out-bg), var(--yellow-stripes);
}
.status-card-link {
display: inline-block;
text-decoration: none;
color: var(--text-color);
}
.status-card-link:is(:hover, :focus) .status-card {
border-color: var(--outline-hover-color);
box-shadow: inset 0 0 0 4px var(--bg-faded-blur-color);
}
.status-card-link:is(:active) .status-card {
background-color: var(--bg-faded-color);
}
.status-card {
font-size: calc(var(--text-size) * 0.9);
margin: 1em 0 0;
border-radius: 16px;
padding: 12px;
border: 1px solid var(--outline-color);
background-color: var(--bg-color);
/* box-shadow: inset 0 0 0 2px var(--bg-faded-color); */
}
.status-card:has(.status-badge:not(:empty)) {
border-top-right-radius: 8px;
}
.status-card > * {
pointer-events: none;
}
.status-card :is(.content, .poll, .media-container) {
max-height: 160px !important;
overflow: hidden;
}
.status.small .status-card :is(.content, .poll, .media-container) {
max-height: 80px !important;
}
.status-card :is(.content, .poll) {
font-size: inherit !important;
mask-image: linear-gradient(to bottom, #000 80px, transparent);
}
.status.small .status-card :is(.content, .poll) {
mask-image: linear-gradient(to bottom, #000 40px, transparent);
}
.status-card .card {
display: none;
}
@keyframes skeleton-breathe {
0% {
opacity: 1;
@ -170,7 +216,7 @@
flex-grow: 1;
min-width: 0;
}
.status:not(.small) .container {
.status:not(.small) > .container {
padding-left: 12px;
}
@ -325,7 +371,7 @@
text-align: center;
}
.status.large .content-container {
.status.large > .container > .content-container {
margin-left: calc(-50px - 16px);
padding-top: 10px;
padding-bottom: 10px;
@ -460,7 +506,7 @@
.status .content .ellipsis::after {
content: '…';
}
.status.large .content {
.status.large .content:not(.content .content) {
font-size: 150%;
font-size: min(calc(100% + 50% / var(--content-text-weight)), 150%);
}

View file

@ -24,6 +24,7 @@ import AccountBlock from '../components/account-block';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NameText from '../components/name-text';
import Poll from '../components/poll';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content';
@ -31,6 +32,7 @@ import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
@ -81,6 +83,7 @@ function Status({
previewMode,
allowFilters,
onMediaClick,
quoted,
}) {
if (skeleton) {
return (
@ -689,7 +692,7 @@ function Status({
m: 'medium',
l: 'large',
}[size]
} ${_deleted ? 'status-deleted' : ''}`}
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''}`}
onMouseEnter={debugHover}
onContextMenu={(e) => {
if (size === 'l') return;
@ -922,36 +925,50 @@ function Status({
dir="auto"
ref={contentRef}
data-read-more={readMoreText}
onClick={handleContentLinks({ mentions, instance, previewMode })}
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');
}
});
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(() => {
>
<div
onClick={handleContentLinks({ mentions, instance, previewMode })}
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');
});
}
});
},
}),
}}
/>
}
});
if (previewMode) return;
// Unfurl Mastodon links
dom
.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
)
.forEach((a, i) => {
if (isMastodonLinkMaybe(a.href)) {
unfurlMastodonLink(currentInstance, a.href).then(
(result) => {
if (!result) return;
console.log('TAG', result);
a.removeAttribute('target');
if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = [];
}
if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result);
}
},
);
}
});
},
}),
}}
/>
<QuoteStatuses id={id} instance={instance} />
</div>
{!!poll && (
<Poll
lang={language}
@ -1208,6 +1225,7 @@ function Status({
}
function Card({ card, instance }) {
const snapStates = useSnapshot(states);
const {
blurhash,
title,
@ -1260,6 +1278,8 @@ function Card({ card, instance }) {
// );
// }
if (snapStates.unfurledLinks[url]) return null;
if (hasText && (image || (!type !== 'photo' && blurhash))) {
const domain = new URL(url).hostname.replace(/^www\./, '');
let blurhashImage;
@ -1361,219 +1381,6 @@ function Card({ card, instance }) {
}
}
function Poll({
poll,
lang,
readOnly,
refresh = () => {},
votePoll = () => {},
}) {
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
emojis,
} = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt);
// 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]);
const pollVotesCount = votersCount || votesCount;
let roundPrecision = 0;
if (pollVotesCount <= 1000) {
roundPrecision = 0;
} else if (pollVotesCount <= 10000) {
roundPrecision = 1;
} else if (pollVotesCount <= 100000) {
roundPrecision = 2;
}
const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
return (
<div
lang={lang}
dir="auto"
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
onDblClick={() => {
setShowResults(!showResults);
}}
>
{(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 (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option poll-result ${
isLeading ? 'poll-option-leading' : ''
}`}
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
<span
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
</div>
);
})}
</div>
) : (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const choices = [];
formData.forEach((value, key) => {
if (key === 'poll') {
choices.push(value);
}
});
if (!choices.length) return;
setUIState('loading');
await votePoll(choices);
setUIState('default');
}}
>
<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}
/>
<span
class="poll-option-title"
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
</label>
</div>
);
})}
</div>
{!readOnly && (
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
)}
</form>
)}
{!readOnly && (
<p class="poll-meta">
{!expired && (
<>
<button
type="button"
class="textual"
disabled={uiState === 'loading'}
onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
await refresh();
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>
)}
</div>
);
}
function EditedAtModal({
statusID,
instance,
@ -1871,10 +1678,6 @@ export function formatDuration(time) {
}
}
function isMastodonLinkMaybe(url) {
return /^https:\/\/.*\/\d+$/i.test(url);
}
const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {};
@ -1908,10 +1711,14 @@ function _unfurlMastodonLink(instance, url) {
const statusURL = `/${domain}/s/${id}`;
const result = {
id,
instance: domain,
url: statusURL,
};
console.debug('🦦 Unfurled URL', url, id, statusURL);
states.unfurledLinks[url] = result;
saveStatus(status, domain, {
skipThreading: true,
});
return result;
} else {
failedUnfurls[url] = true;
@ -1938,10 +1745,14 @@ function _unfurlMastodonLink(instance, url) {
const statusURL = `/${instance}/s/${id}`;
const result = {
id,
instance,
url: statusURL,
};
console.debug('🦦 Unfurled URL', url, id, statusURL);
states.unfurledLinks[url] = result;
saveStatus(status, instance, {
skipThreading: true,
});
return result;
} else {
failedUnfurls[url] = true;
@ -2119,4 +1930,29 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
);
}
const QuoteStatuses = memo(({ id, instance }) => {
const snapStates = useSnapshot(states);
const sKey = statusKey(id, instance);
const quotes = snapStates.statusQuotes[sKey];
if (!quotes?.length) return;
return quotes.map((q) => {
return (
<Link
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link"
>
<Status
statusID={q.id}
instance={q.instance}
size="s"
quoted
previewMode
/>
</Link>
);
});
});
export default memo(Status);

View file

@ -0,0 +1,3 @@
export default function isMastodonLinkMaybe(url) {
return /^https:\/\/.*\/\d+$/i.test(url);
}

View file

@ -27,6 +27,7 @@ const states = proxy({
spoilers: {},
scrollPositions: {},
unfurledLinks: {},
statusQuotes: {},
accounts: {},
// Modals
showCompose: false,