From e001cb887daba136ca265d5eebd9ae98849c241e Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Sun, 7 Dec 2025 20:05:15 +0800 Subject: [PATCH] Quote chain and shallow quote --- src/components/quote-chain-modal.css | 135 +++++++++++++++++++++ src/components/quote-chain-modal.jsx | 175 +++++++++++++++++++++++++++ src/components/quotes-modal.jsx | 1 + src/components/status.css | 5 + src/components/status.jsx | 68 ++++++++++- src/utils/states.js | 3 +- 6 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 src/components/quote-chain-modal.css create mode 100644 src/components/quote-chain-modal.jsx diff --git a/src/components/quote-chain-modal.css b/src/components/quote-chain-modal.css new file mode 100644 index 00000000..81ef177e --- /dev/null +++ b/src/components/quote-chain-modal.css @@ -0,0 +1,135 @@ +#quote-chain-modal { + background-color: var(--bg-faded-color); + + @supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) { + header { + animation-timeline: none; + } + } + + .quoted-posts-list { + list-style: none; + margin: 0; + padding: 8px 0; + display: flex; + flex-direction: column; + gap: 8px; + + @supports ((animation-timeline: scroll()) and (animation-range: 0% 100%)) { + @media (prefers-reduced-motion: no-preference) { + & > li { + z-index: 1; + animation: scroll-quotes linear both; + animation-timeline: view(); + /* animation-range: entry 0% entry 100%; */ + } + } + } + } + + .quoted-post-item { + margin: 0; + padding: 0; + + .status-link { + display: block; + text-decoration: none; + color: inherit; + border: 1px solid var(--outline-color); + border-radius: 8px; + background-color: var(--bg-color); + box-shadow: 0 0 4px 4px var(--bg-faded-color); + max-height: 50svh; + overflow: hidden; + + &:hover { + border-color: var(--outline-hover-color); + } + + &.truncated { + position: relative; + + &:after { + content: attr(data-read-more); + line-height: 1; + display: inline-block; + position: absolute; + --inset-offset: 16px; + inset-block-end: var(--inset-offset); + inset-inline-end: var(--inset-offset); + color: var(--text-color); + background-color: var(--bg-faded-color); + border: 1px dashed var(--link-color); + box-shadow: + 0 0 0 1px var(--bg-color), + 0 -5px 10px var(--bg-color), + 0 -5px 15px var(--bg-color), + 0 -5px 20px var(--bg-color); + padding: 0.5em 0.75em; + border-radius: 10em; + font-size: 90%; + white-space: nowrap; + text-shadow: 0 -1px var(--bg-color); + transition: transform 0.2s ease-out; + } + } + + &:is(:hover, :focus).truncated:after { + color: var(--link-color); + transform: translate(2px, 0); + } + + > * { + pointer-events: none; + } + } + + .status { + font-size: calc(var(--text-size) * 0.9); + + .meta-name { + pointer-events: auto; + } + + .status-card-container { + display: none; + } + + .quote-inline { + display: none; + + &:is(p:first-child) + p { + margin-block-start: 0; + } + } + } + } +} + +@keyframes scroll-quotes { + entry 0% { + transform-origin: top; + transform: scale(0.9) translateY(-100%); + opacity: 0; + z-index: 0; + } + entry 99% { + z-index: 0; + } + entry 102.5% { + transform-origin: top; + transform: scale(1) translateY(0%); + opacity: 1; + z-index: 1; + } + exit 0% { + transform-origin: bottom; + transform: scale(1) translateY(0%); + opacity: 1; + } + exit 100% { + transform-origin: bottom; + transform: scale(0.9) translateY(100%); + opacity: 0; + } +} diff --git a/src/components/quote-chain-modal.jsx b/src/components/quote-chain-modal.jsx new file mode 100644 index 00000000..9c539c7a --- /dev/null +++ b/src/components/quote-chain-modal.jsx @@ -0,0 +1,175 @@ +import './quote-chain-modal.css'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import { useEffect, useRef, useState } from 'preact/hooks'; + +import { api } from '../utils/api'; +import { getStatus } from '../utils/states'; +import useTruncated from '../utils/useTruncated'; + +import Icon from './icon'; +import Link from './link'; +import Loader from './loader'; +import Status from './status'; + +function TruncatedLink(props) { + const { t } = useLingui(); + const ref = useTruncated(); + return ; +} + +const FETCH_DELAY = 500; // Delay between fetches to avoid rate limiting +const BATCH_LIMIT = 30; // The chain might get very long, so fetch in batches + +export default function QuoteChainModal({ + statusId, + instance, + onClose = () => {}, +}) { + const { t } = useLingui(); + const { masto } = api(); + + const [posts, setPosts] = useState([]); + const [uiState, setUIState] = useState('default'); + const [nextPostID, setNextPostID] = useState(null); + + const abortControllerRef = useRef(null); + + const fetchQuoteChain = async (postID) => { + abortControllerRef.current = new AbortController(); + const signal = abortControllerRef.current.signal; + + setUIState('loading'); + let fetchCount = 0; + let currentPostID = postID; + + while (currentPostID && !signal.aborted && fetchCount < BATCH_LIMIT) { + console.log('🔗 WHILE', { currentPostID, fetchCount }); + // Break circular reference if it somehow happens + // Note that origin post could be edited to add a quote that might reference to any post in the chain ♻️ + if (posts.some((p) => p.id === currentPostID)) { + break; + } + + let fullStatus = getStatus(currentPostID, instance); + const cached = !!fullStatus; + + if (!cached) { + try { + fullStatus = await masto.v1.statuses.$select(currentPostID).fetch(); + fetchCount++; + } catch (e) { + console.error('Error fetching quote:', e); + setUIState('error'); + break; + } + } + + console.log('🔗 PUSH', fullStatus); + setPosts((prev) => [...prev, fullStatus]); + + currentPostID = + fullStatus.quote?.quotedStatusId || fullStatus.quote?.quotedStatus?.id; + + // Add delay before next fetch to avoid rate limiting + if ( + !cached && + currentPostID && + !signal.aborted && + fetchCount < BATCH_LIMIT + ) { + await new Promise((resolve) => setTimeout(resolve, FETCH_DELAY)); + } + } + + if (!signal.aborted) { + setNextPostID(currentPostID || null); + setUIState('default'); + } + }; + + useEffect(() => { + fetchQuoteChain(statusId); + return () => { + abortControllerRef.current?.abort(); + }; + }, [statusId]); + + return ( +
+ {onClose && ( + + )} +
+ + Quote chain + {' '} + {posts.length > 0 && ( + + )} +
+
+
    + {posts.map((post) => ( +
  • + { + const postEl = e.target.querySelector('.status'); + if (postEl) { + if (e.metaKey) return; + e.preventDefault(); + postEl.dispatchEvent( + new MouseEvent('contextmenu', { + clientX: e.clientX, + clientY: e.clientY, + }), + ); + } + }} + > + c > 1} + /> + +
  • + ))} +
+ {uiState === 'error' ? ( +

+ Failed to unwrap quote chain +

+ ) : uiState === 'loading' ? ( +

+ +

+ ) : nextPostID ? ( + + ) : ( +

+ The end. +

+ )} +
+
+ ); +} diff --git a/src/components/quotes-modal.jsx b/src/components/quotes-modal.jsx index fdf52fca..588ffe60 100644 --- a/src/components/quotes-modal.jsx +++ b/src/components/quotes-modal.jsx @@ -113,6 +113,7 @@ export default function QuotesModal({ size="s" readOnly showCommentCount + showQuoteCount /> diff --git a/src/components/status.css b/src/components/status.css index 8168c90c..497cdc2f 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -297,6 +297,11 @@ content: none !important; } +.status-shallow-card { + mask-image: linear-gradient(to bottom, #000, transparent); + padding-bottom: 0; +} + .status-card-unfulfilled { display: flex; flex-direction: row; diff --git a/src/components/status.jsx b/src/components/status.jsx index 9378ecad..36120526 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -72,6 +72,7 @@ import NameText from './name-text'; import Poll from './poll'; import PostContent from './post-content'; import PostEmbedModal from './post-embed-modal'; +import QuoteChainModal from './quote-chain-modal'; import QuoteSettingsSheet from './quote-settings-sheet'; import QuotesModal from './quotes-modal'; import RelativeTime from './relative-time'; @@ -328,6 +329,7 @@ function Status({ showReplyParent, mediaFirst, showCommentCount: forceShowCommentCount, + showQuoteCount: forceShowQuoteCount, ghost, }) { const { _, t, i18n } = useLingui(); @@ -723,6 +725,7 @@ function Status({ const [showEmbed, setShowEmbed] = useState(false); const [showQuoteSettings, setShowQuoteSettings] = useState(false); const [showQuotes, setShowQuotes] = useState(false); + const [showQuoteChain, setShowQuoteChain] = useState(false); const spoilerContentRef = useTruncated(); const contentRef = useTruncated(); @@ -1308,6 +1311,18 @@ function Status({ )} + {quote?.quotedStatus?.quote && ( + { + setShowQuoteChain(true); + }} + > + + + Unwrap quote chain + + + )} )} {(isSizeLarge || @@ -2019,6 +2034,12 @@ function Status({ contentLength, ]); + // Keep this simple for now, unlike showCommentCount + const showQuoteCount = + typeof forceShowQuoteCount === 'function' + ? forceShowQuoteCount(quotesCount) + : forceShowQuoteCount && quotesCount > 0; + return ( {showReplyParent && !!(inReplyToId && inReplyToAccountId) && ( @@ -2730,9 +2751,18 @@ function Status({ )} - {!isSizeLarge && showCommentCount && ( + {!isSizeLarge && (showCommentCount || showQuoteCount) && (
- {repliesCount} + {showCommentCount && ( + <> + {repliesCount} + + )}{' '} + {showQuoteCount && ( + <> + {quotesCount} + + )}
)} {isSizeLarge && ( @@ -3086,6 +3116,21 @@ function Status({ /> )} + {!!showQuoteChain && ( + { + setShowQuoteChain(false); + }} + > + { + setShowQuoteChain(false); + }} + /> + + )}
); @@ -3240,6 +3285,21 @@ const QuoteStatus = memo(({ quote, level = 0 }) => { ); }); +const ShallowQuote = ({ quote } = {}) => { + const { account, native, instance } = quote || {}; + if (!account) return null; + return ( +
+
+
+ {' '} + … +
+
+
+ ); +}; + const QuoteStatuses = memo(({ id, instance, level = 0, collapsed = false }) => { if (!id || !instance) return; const { _ } = useLingui(); @@ -3251,7 +3311,9 @@ const QuoteStatuses = memo(({ id, instance, level = 0, collapsed = false }) => { ); if (!uniqueQuotes?.length) return; - if (level > 2) return; + if (level > 2) { + return ; + } if (collapsed) { // Only show the first quote if "collapsed" diff --git a/src/utils/states.js b/src/utils/states.js index 2b83973b..77ecc550 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -236,7 +236,7 @@ export function saveStatus(status, instance, opts) { if (theQuote?.state) { const { quotedStatus, state } = theQuote; if (quotedStatus?.id) { - const { id } = quotedStatus; + const { id, account } = quotedStatus; const selfURL = `/${instance}/s/${id}`; const sKey = statusKey(id, instance); states.statuses[sKey] = quotedStatus; @@ -246,6 +246,7 @@ export function saveStatus(status, instance, opts) { instance, url: selfURL, state, + account, native: true, }, ]; -- 2.51.2