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}%
+
+
+ );
+ })}
+
+ ) : (
+
+ )}
+ {!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}%
-
-
- );
- })}
-
- ) : (
-
- )}
- {!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,