diff --git a/src/components/poll.jsx b/src/components/poll.jsx new file mode 100644 index 00000000..c969e565 --- /dev/null +++ b/src/components/poll.jsx @@ -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 ( +
{ + setShowResults(!showResults); + }} + > + {(showResults && optionsHaveVoteCounts) || voted || expired ? ( +
+ {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 ( +
+
+ + {voted && ownVotes.includes(i) && ( + <> + {' '} + + + )} +
+
+ {percentage}% +
+
+ ); + })} +
+ ) : ( +
{ + 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'); + }} + > +
+ {options.map((option, i) => { + const { title } = option; + return ( +
+ +
+ ); + })} +
+ {!readOnly && ( + + )} +
+ )} + {!readOnly && ( +

+ {!expired && ( + <> + {' '} + •{' '} + + )} + {shortenNumber(votesCount)} vote + {votesCount === 1 ? '' : 's'} + {!!votersCount && votersCount !== votesCount && ( + <> + {' '} + •{' '} + {shortenNumber(votersCount)}{' '} + voter + {votersCount === 1 ? '' : 's'} + + )}{' '} + • {expired ? 'Ended' : 'Ending'}{' '} + {!!expiresAtDate && } +

+ )} +
+ ); +} diff --git a/src/components/status.css b/src/components/status.css index 371d0a13..aa761529 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -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%); } diff --git a/src/components/status.jsx b/src/components/status.jsx index 971bda03..06d03400 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -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(() => { + > +
{ + // 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); + } + }, + ); + } + }); + }, + }), + }} + /> + +
{!!poll && ( {}, - 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 ( -
{ - setShowResults(!showResults); - }} - > - {(showResults && optionsHaveVoteCounts) || voted || expired ? ( -
- {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 ( -
-
- - {voted && ownVotes.includes(i) && ( - <> - {' '} - - - )} -
-
- {percentage}% -
-
- ); - })} -
- ) : ( -
{ - 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'); - }} - > -
- {options.map((option, i) => { - const { title } = option; - return ( -
- -
- ); - })} -
- {!readOnly && ( - - )} -
- )} - {!readOnly && ( -

- {!expired && ( - <> - {' '} - •{' '} - - )} - {shortenNumber(votesCount)} vote - {votesCount === 1 ? '' : 's'} - {!!votersCount && votersCount !== votesCount && ( - <> - {' '} - •{' '} - {shortenNumber(votersCount)}{' '} - voter - {votersCount === 1 ? '' : 's'} - - )}{' '} - • {expired ? 'Ended' : 'Ending'}{' '} - {!!expiresAtDate && } -

- )} -
- ); -} - 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 ( + + + + ); + }); +}); + export default memo(Status); diff --git a/src/utils/isMastodonLinkMaybe.jsx b/src/utils/isMastodonLinkMaybe.jsx new file mode 100644 index 00000000..d6fa8eda --- /dev/null +++ b/src/utils/isMastodonLinkMaybe.jsx @@ -0,0 +1,3 @@ +export default function isMastodonLinkMaybe(url) { + return /^https:\/\/.*\/\d+$/i.test(url); +} diff --git a/src/utils/states.js b/src/utils/states.js index 40839b84..d7d3f1d4 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -27,6 +27,7 @@ const states = proxy({ spoilers: {}, scrollPositions: {}, unfurledLinks: {}, + statusQuotes: {}, accounts: {}, // Modals showCompose: false,