From f3d77dd04eeec766009e9d73dc3361435fc5a7d1 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Tue, 30 Jan 2024 14:34:54 +0800 Subject: [PATCH] Experimental reply parent hint --- src/components/status.css | 56 ++ src/components/status.jsx | 1443 ++++++++++++++++++---------------- src/components/timeline.jsx | 6 +- src/pages/following.jsx | 1 + src/pages/list.jsx | 1 + src/utils/states.js | 1 + src/utils/timeline-utils.jsx | 39 +- 7 files changed, 847 insertions(+), 700 deletions(-) diff --git a/src/components/status.css b/src/components/status.css index c286b80f..8ec6b6ac 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -330,6 +330,62 @@ font-size: 90%; } +.status.compact-reply { + --avatar-size: 20px; + --line-start: 40px; + --line-width: 3px; + --line-end: calc(var(--line-start) + var(--line-width)); + + display: flex; + gap: 12px; + --top-padding: 8px; + padding-top: var(--top-padding); + padding-bottom: 0; + background-image: linear-gradient( + 160deg, + var(--reply-to-faded-color), + transparent + ); + + > * { + opacity: 0.65; + transition: opacity 1s ease-out; + } + .status-link:hover & > * { + opacity: 1; + } + + &:before { + content: ''; + position: absolute; + top: calc(var(--top-padding) + var(--avatar-size)); + left: var(--line-start); + width: var(--line-width); + height: calc(100% - var(--top-padding) - var(--avatar-size) + 16px); + background-color: var(--comment-line-color); + z-index: 0; + mask-image: linear-gradient(to bottom, #000 8px, transparent); + } + + .avatar { + margin-left: calc((50px - var(--avatar-size)) / 2); + justify-self: center; + z-index: 1; + } + + .content-compact { + overflow: hidden; + display: -webkit-box; + display: box; + -webkit-box-orient: vertical; + box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + font-size: 90%; + line-height: var(--avatar-size); + } +} + .status .container { flex-grow: 1; min-width: 0; diff --git a/src/components/status.jsx b/src/components/status.jsx index 2549d0bd..47ba1b78 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -126,6 +126,7 @@ function Status({ showFollowedTags, allowContextMenu, showActionsBar, + showReplyParent, }) { if (skeleton) { return ( @@ -1271,225 +1272,149 @@ function Status({ ]); return ( -
{ - statusRef.current = node; - // Use parent node if it's in focus - // Use case: - // When navigating (j/k), the is focused instead of - // Hotkey binding doesn't bubble up thus this hack - const nodeRef = - node?.closest?.( - '.timeline-item, .timeline-item-alt, .status-link, .status-focus', - ) || node; - rRef.current = nodeRef; - fRef.current = nodeRef; - dRef.current = nodeRef; - bRef.current = nodeRef; - xRef.current = nodeRef; - }} - tabindex="-1" - class={`status ${ - !withinContext && inReplyToId && inReplyToAccount - ? 'status-reply-to' - : '' - } visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${ - { - s: 'small', - m: 'medium', - l: 'large', - }[size] - } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${ - isContextMenuOpen ? 'status-menu-open' : '' - }`} - onMouseEnter={debugHover} - onContextMenu={(e) => { - if (!showContextMenu) return; - if (e.metaKey) return; - // console.log('context menu', e); - const link = e.target.closest('a'); - if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; + <> + {showReplyParent && !!(inReplyToId && inReplyToAccount) && ( + + )} +
{ + statusRef.current = node; + // Use parent node if it's in focus + // Use case: + // When navigating (j/k), the is focused instead of + // Hotkey binding doesn't bubble up thus this hack + const nodeRef = + node?.closest?.( + '.timeline-item, .timeline-item-alt, .status-link, .status-focus', + ) || node; + rRef.current = nodeRef; + fRef.current = nodeRef; + dRef.current = nodeRef; + bRef.current = nodeRef; + xRef.current = nodeRef; + }} + tabindex="-1" + class={`status ${ + !withinContext && inReplyToId && inReplyToAccount + ? 'status-reply-to' + : '' + } visibility-${visibility} ${_pinned ? 'status-pinned' : ''} ${ + { + s: 'small', + m: 'medium', + l: 'large', + }[size] + } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${ + isContextMenuOpen ? 'status-menu-open' : '' + }`} + onMouseEnter={debugHover} + onContextMenu={(e) => { + if (!showContextMenu) return; + if (e.metaKey) return; + // console.log('context menu', e); + const link = e.target.closest('a'); + if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; - // If there's selected text, don't show custom context menu - const selection = window.getSelection?.(); - if (selection.toString().length > 0) { - const { anchorNode } = selection; - if (statusRef.current?.contains(anchorNode)) { - return; - } - } - e.preventDefault(); - setContextMenuProps({ - anchorPoint: { - x: e.clientX, - y: e.clientY, - }, - direction: 'right', - }); - setIsContextMenuOpen(true); - }} - {...(showContextMenu ? bindLongPressContext() : {})} - > - {showContextMenu && ( - { - setIsContextMenuOpen(false); - // statusRef.current?.focus?.(); - if (e?.reason === 'click') { - statusRef.current?.closest('[tabindex]')?.focus?.(); + // If there's selected text, don't show custom context menu + const selection = window.getSelection?.(); + if (selection.toString().length > 0) { + const { anchorNode } = selection; + if (statusRef.current?.contains(anchorNode)) { + return; } - }} - portal={{ - target: document.body, - }} - containerProps={{ - style: { - // Higher than the backdrop - zIndex: 1001, + } + e.preventDefault(); + setContextMenuProps({ + anchorPoint: { + x: e.clientX, + y: e.clientY, }, - onClick: () => { - contextMenuRef.current?.closeMenu?.(); - }, - }} - overflow="auto" - boundingBoxPadding={safeBoundingBoxPadding()} - unmountOnClose - > - {StatusMenuItems} - - )} - {showActionsBar && - size !== 'l' && - !previewMode && - !readOnly && - !_deleted && - !quoted && ( -
+ {showContextMenu && ( + { + setIsContextMenuOpen(false); + // statusRef.current?.focus?.(); + if (e?.reason === 'click') { + statusRef.current?.closest('[tabindex]')?.focus?.(); + } + }} + portal={{ + target: document.body, + }} + containerProps={{ + style: { + // Higher than the backdrop + zIndex: 1001, + }, + onClick: () => { + contextMenuRef.current?.closeMenu?.(); + }, + }} + overflow="auto" + boundingBoxPadding={safeBoundingBoxPadding()} + unmountOnClose > - - { - try { - favouriteStatus(); - showToast( - favourited - ? `Unliked @${username || acct}'s post` - : `Liked @${username || acct}'s post`, - ); - } catch (e) {} - }} - /> - -
+ {StatusMenuItems} + )} - {size !== 'l' && ( -
- {reblogged && } - {favourited && } - {bookmarked && } - {_pinned && } -
- )} - {size !== 's' && ( -
{ - e.preventDefault(); - e.stopPropagation(); - states.showAccount = { - account: status.account, - instance, - }; - }} - > - - - )} -
-
- - - - {/* {inReplyToAccount && !withinContext && size !== 's' && ( - <> - {' '} - - {' '} - - - - )} */} - {/* */}{' '} - {size !== 'l' && - (_deleted ? ( - Deleted - ) : url && !previewMode && !quoted ? ( - + + { + try { + favouriteStatus(); + showToast( + favourited + ? `Unliked @${username || acct}'s post` + : `Liked @${username || acct}'s post`, + ); + } catch (e) {} + }} + /> + +
+ )} + {size !== 'l' && ( +
+ {reblogged && } + {favourited && } + {bookmarked && } + {_pinned && } +
+ )} + {size !== 's' && ( + { + e.preventDefault(); + e.stopPropagation(); + states.showAccount = { + account: status.account, + instance, + }; + }} + > + + + )} +
+
+ + + + {/* {inReplyToAccount && !withinContext && size !== 's' && ( + <> + {' '} + + {' '} + + + + )} */} + {/* */}{' '} + {size !== 'l' && + (_deleted ? ( + Deleted + ) : url && !previewMode && !quoted ? ( + { + if ( + e.metaKey || + e.ctrlKey || + e.shiftKey || + e.altKey || + e.which === 2 + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + onStatusLinkClick?.(e, status); + setContextMenuProps({ + anchorRef: { + current: e.currentTarget, + }, + align: 'end', + direction: 'bottom', + gap: 4, + }); + setIsContextMenuOpen(true); + }} + class={`time ${ + isContextMenuOpen && contextMenuProps?.anchorRef + ? 'is-open' + : '' + }`} + > + {showCommentHint && !showCommentCount ? ( + + ) : ( + + )}{' '} + + {!previewMode && } + + ) : ( + // { + // if (e.target === e.currentTarget) + // menuInstanceRef.current?.closeMenu?.(); + // }, + // }} + // align="end" + // gap={4} + // overflow="auto" + // viewScroll="close" + // boundingBoxPadding="8 8 8 8" + // unmountOnClose + // menuButton={({ open }) => ( + // { + // e.preventDefault(); + // e.stopPropagation(); + // onStatusLinkClick?.(e, status); + // }} + // class={`time ${open ? 'is-open' : ''}`} + // > + // {' '} + // + // + // )} + // > + // {StatusMenuItems} + // + - )}{' '} - - {!previewMode && } - - ) : ( - // { - // if (e.target === e.currentTarget) - // menuInstanceRef.current?.closeMenu?.(); - // }, - // }} - // align="end" - // gap={4} - // overflow="auto" - // viewScroll="close" - // boundingBoxPadding="8 8 8 8" - // unmountOnClose - // menuButton={({ open }) => ( - // { - // e.preventDefault(); - // e.stopPropagation(); - // onStatusLinkClick?.(e, status); - // }} - // class={`time ${open ? 'is-open' : ''}`} - // > - // {' '} - // - // - // )} - // > - // {StatusMenuItems} - // - - {' '} - - - ))} -
- {visibility === 'direct' && ( - <> -
Private mention
{' '} - - )} - {!withinContext && ( - <> - {isThread ? ( -
- - Thread - {snapStates.statusThreadNumber[sKey] - ? ` ${snapStates.statusThreadNumber[sKey]}/X` - : ''} -
- ) : ( - !!inReplyToId && - !!inReplyToAccount && - (!!spoilerText || - !mentions.find((mention) => { - return mention.id === inReplyToAccountId; - })) && ( -
- {' '} - -
- ) - )} - - )} -
- {!!spoilerText && ( + />{' '} + + + ))} +
+ {visibility === 'direct' && ( <> -
-

- -

-
- {readingExpandSpoilers || previewMode ? ( -
- Content warning +
Private mention
{' '} + + )} + {!withinContext && ( + <> + {isThread ? ( +
+ + Thread + {snapStates.statusThreadNumber[sKey] + ? ` ${snapStates.statusThreadNumber[sKey]}/X` + : ''}
) : ( - + !!inReplyToId && + !!inReplyToAccount && + (!!spoilerText || + !mentions.find((mention) => { + return mention.id === inReplyToAccountId; + })) && ( +
+ {' '} + +
+ ) )} )} - {!!content && ( -
-
{ - // 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 - // Array.from( - // dom.querySelectorAll( - // 'a[href]:not(.u-url):not(.mention):not(.hashtag)', - // ), - // ) - // .filter((a) => { - // const url = a.href; - // const isPostItself = - // url === status.url || url === status.uri; - // return !isPostItself && isMastodonLinkMaybe(url); - // }) - // .forEach((a, i) => { - // unfurlMastodonLink(currentInstance, a.href).then( - // (result) => { - // if (!result) return; - // a.removeAttribute('target'); - // if (!sKey) return; - // if (!Array.isArray(states.statusQuotes[sKey])) { - // states.statusQuotes[sKey] = []; - // } - // if (!states.statusQuotes[sKey][i]) { - // states.statusQuotes[sKey].splice(i, 0, result); - // } - // }, - // ); - // }); - }, - }), - }} - /> - -
- )} - {!!poll && ( - { - states.statuses[sKey].poll = newPoll; - }} - refresh={() => { - return masto.v1.polls - .$select(poll.id) - .fetch() - .then((pollResponse) => { - states.statuses[sKey].poll = pollResponse; - }) - .catch((e) => {}); // Silently fail - }} - votePoll={(choices) => { - return masto.v1.polls - .$select(poll.id) - .votes.create({ - choices, - }) - .then((pollResponse) => { - states.statuses[sKey].poll = pollResponse; - }) - .catch((e) => {}); // Silently fail - }} - /> - )} - {(((enableTranslate || inlineTranslate) && - !!content.trim() && - !!getHTMLText(emojifyText(content, emojis)) && - differentLanguage) || - forceTranslate) && ( - - )} - {!previewMode && - sensitive && - !!mediaAttachments.length && - readingExpandMedia !== 'show_all' && ( - +
+ {!!spoilerText && ( + <> +
+

+ +

+
+ {readingExpandSpoilers || previewMode ? ( +
+ Content warning +
+ ) : ( + + )} + )} - {!!mediaAttachments.length && ( - + {!!content && (
2 ? 'media-gt2' : '' - } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} + class="content" + ref={contentRef} + data-read-more={readMoreText} > - {displayedMediaAttachments.map((media, i) => ( - { - onMediaClick(e, i, media, status); - } - : undefined - } - /> - ))} -
-
- )} - {!!card && - /^https/i.test(card?.url) && - !sensitive && - !spoilerText && - !poll && - !mediaAttachments.length && - !snapStates.statusQuotes[sKey] && ( - - )} -
- {!isSizeLarge && showCommentCount && ( -
- {repliesCount} -
- )} - {isSizeLarge && ( - <> -
- {_deleted ? ( - Deleted - ) : ( - <> - {' '} - - - - {editedAt && ( - <> - {' '} - • {' '} - - - )} - - )} -
-
-
- { + // 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 + // Array.from( + // dom.querySelectorAll( + // 'a[href]:not(.u-url):not(.mention):not(.hashtag)', + // ), + // ) + // .filter((a) => { + // const url = a.href; + // const isPostItself = + // url === status.url || url === status.uri; + // return !isPostItself && isMastodonLinkMaybe(url); + // }) + // .forEach((a, i) => { + // unfurlMastodonLink(currentInstance, a.href).then( + // (result) => { + // if (!result) return; + // a.removeAttribute('target'); + // if (!sKey) return; + // if (!Array.isArray(states.statusQuotes[sKey])) { + // states.statusQuotes[sKey] = []; + // } + // if (!states.statusQuotes[sKey][i]) { + // states.statusQuotes[sKey].splice(i, 0, result); + // } + // }, + // ); + // }); + }, + }), + }} /> +
- {/*
+ )} + {!!poll && ( + { + states.statuses[sKey].poll = newPoll; + }} + refresh={() => { + return masto.v1.polls + .$select(poll.id) + .fetch() + .then((pollResponse) => { + states.statuses[sKey].poll = pollResponse; + }) + .catch((e) => {}); // Silently fail + }} + votePoll={(choices) => { + return masto.v1.polls + .$select(poll.id) + .votes.create({ + choices, + }) + .then((pollResponse) => { + states.statuses[sKey].poll = pollResponse; + }) + .catch((e) => {}); // Silently fail + }} + /> + )} + {(((enableTranslate || inlineTranslate) && + !!content.trim() && + !!getHTMLText(emojifyText(content, emojis)) && + differentLanguage) || + forceTranslate) && ( + + )} + {!previewMode && + sensitive && + !!mediaAttachments.length && + readingExpandMedia !== 'show_all' && ( + + )} + {!!mediaAttachments.length && ( + +
2 ? 'media-gt2' : '' + } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} + > + {displayedMediaAttachments.map((media, i) => ( + { + onMediaClick(e, i, media, status); + } + : undefined + } + /> + ))} +
+
+ )} + {!!card && + /^https/i.test(card?.url) && + !sensitive && + !spoilerText && + !poll && + !mediaAttachments.length && + !snapStates.statusQuotes[sKey] && ( + + )} +
+ {!isSizeLarge && showCommentCount && ( +
+ {repliesCount} +
+ )} + {isSizeLarge && ( + <> +
+ {_deleted ? ( + Deleted + ) : ( + <> + {' '} + + + + {editedAt && ( + <> + {' '} + • {' '} + + + )} + + )} +
+
+
+ +
+ {/*
*/} - - - {reblogged ? 'Unboost?' : 'Boost to everyone?'} - - } - menuFooter={ - mediaNoDesc && - !reblogged && ( - - ) - } - > + + + + {reblogged ? 'Unboost?' : 'Boost to everyone?'} + + + } + menuFooter={ + mediaNoDesc && + !reblogged && ( + + ) + } + > +
+ +
+
-
-
- +
+ +
+ + +
+ } + > + {StatusMenuItems} +
-
- -
- - -
- } - > - {StatusMenuItems} - -
- + + )} +
+ {!!showEdited && ( + { + if (e.target === e.currentTarget) { + setShowEdited(false); + // statusRef.current?.focus(); + } + }} + > + { + return masto.v1.statuses.$select(showEdited).history.list(); + }} + onClose={() => { + setShowEdited(false); + statusRef.current?.focus(); + }} + /> + )} -
- {!!showEdited && ( - { - if (e.target === e.currentTarget) { - setShowEdited(false); - // statusRef.current?.focus(); - } - }} - > - { - return masto.v1.statuses.$select(showEdited).history.list(); - }} - onClose={() => { - setShowEdited(false); - statusRef.current?.focus(); - }} - /> - - )} -
+
+ ); } @@ -2412,6 +2426,41 @@ function nicePostURL(url) { ); } +function StatusCompact({ sKey }) { + const snapStates = useSnapshot(states); + const statusReply = snapStates.statusReply[sKey]; + if (!statusReply) return null; + + const { id, instance } = statusReply; + const status = getStatus(id, instance); + if (!status) return null; + + const { + sensitive, + spoilerText, + account: { avatar, avatarStatic, bot }, + visibility, + content, + } = status; + if (sensitive || spoilerText) return null; + if (!content) return null; + + const statusPeekText = statusPeek(status); + return ( +
+ +
+ {statusPeekText} +
+
+ ); +} + function FilteredStatus({ status, filterInfo, diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 7f806e45..7c1c0caa 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -46,6 +46,7 @@ function Timeline({ view, filterContext, showFollowedTags, + showReplyParent, }) { const snapStates = useSnapshot(states); const [items, setItems] = useState([]); @@ -84,7 +85,7 @@ function Timeline({ if (boostsCarousel) { value = groupBoosts(value); } - value = groupContext(value); + value = groupContext(value, instance); } if (pinnedPosts.length) { value = pinnedPosts.concat(value); @@ -522,6 +523,7 @@ function TimelineItem({ filterContext, view, showFollowedTags, + showReplyParent, }) { const { id: statusID, reblog, items, type, _pinned } = status; if (_pinned) useItemID = false; @@ -680,6 +682,7 @@ function TimelineItem({ instance={instance} enableCommentHint showFollowedTags={showFollowedTags} + showReplyParent // allowFilters={allowFilters} /> ) : ( @@ -688,6 +691,7 @@ function TimelineItem({ instance={instance} enableCommentHint showFollowedTags={showFollowedTags} + showReplyParent // allowFilters={allowFilters} /> )} diff --git a/src/pages/following.jsx b/src/pages/following.jsx index b6f3639a..d5def903 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -129,6 +129,7 @@ function Following({ title, path, id, ...props }) { // allowFilters filterContext="home" showFollowedTags + showReplyParent /> ); } diff --git a/src/pages/list.jsx b/src/pages/list.jsx index 1bc6b845..e59025a1 100644 --- a/src/pages/list.jsx +++ b/src/pages/list.jsx @@ -104,6 +104,7 @@ function List(props) { boostsCarousel={snapStates.settings.boostsCarousel} // allowFilters filterContext="home" + showReplyParent // refresh={reloadCount} headerStart={ diff --git a/src/utils/states.js b/src/utils/states.js index b550f29a..21f59e20 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -37,6 +37,7 @@ const states = proxy({ unfurledLinks: {}, statusQuotes: {}, statusFollowedTags: {}, + statusReply: {}, accounts: {}, routeNotification: null, // Modals diff --git a/src/utils/timeline-utils.jsx b/src/utils/timeline-utils.jsx index f15dff8d..612dd7d9 100644 --- a/src/utils/timeline-utils.jsx +++ b/src/utils/timeline-utils.jsx @@ -1,6 +1,8 @@ +import { api } from './api'; import { extractTagsFromStatus, getFollowedTags } from './followed-tags'; +import pmem from './pmem'; import { fetchRelationships } from './relationships'; -import states, { statusKey } from './states'; +import states, { saveStatus, statusKey } from './states'; import store from './store'; export function groupBoosts(values) { @@ -81,7 +83,7 @@ export function dedupeBoosts(items, instance) { return filteredItems; } -export function groupContext(items) { +export function groupContext(items, instance) { const contexts = []; let contextIndex = 0; items.forEach((item) => { @@ -173,12 +175,45 @@ export function groupContext(items) { return; } } + + if (item.inReplyToId && item.inReplyToAccountId !== item.account.id) { + const sKey = statusKey(item.id, instance); + if (states.statusReply[sKey]) { + return; + } + // If it's a reply and not a thread + queueMicrotask(async () => { + try { + const { masto } = api({ instance }); + // const replyToStatus = await masto.v1.statuses + // .$select(item.inReplyToId) + // .fetch(); + const replyToStatus = await fetchStatus(item.inReplyToId, masto); + saveStatus(replyToStatus, instance, { + skipThreading: true, + skipUnfurling: true, + }); + states.statusReply[sKey] = { + id: replyToStatus.id, + instance, + }; + } catch (e) { + // Silently fail + console.error(e); + } + }); + } + newItems.push(item); }); return newItems; } +const fetchStatus = pmem((statusID, masto) => { + return masto.v1.statuses.$select(statusID).fetch(); +}); + export async function assignFollowedTags(items, instance) { const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}] if (!followedTags.length) return;