From a0d2037007e7d61c9d61f81ce85ea59303d80998 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Thu, 11 Apr 2024 17:18:17 +0800 Subject: [PATCH] Early implementation of media-first UI experience --- src/app.css | 28 ++ src/components/status.css | 228 ++++++++++++- src/components/status.jsx | 623 +++++++++++++++++++++++------------- src/components/timeline.jsx | 28 +- src/utils/store-utils.js | 5 + 5 files changed, 682 insertions(+), 230 deletions(-) diff --git a/src/app.css b/src/app.css index 01e0a0dc..ecd56adc 100644 --- a/src/app.css +++ b/src/app.css @@ -301,6 +301,34 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { } } } + +.deck-container-media-first { + .timeline { + > li:not(.timeline-item-carousel, .timeline-item-container) { + &:has(.status-media-first) { + width: fit-content; + background-color: transparent !important; + border: 0 !important; + box-shadow: none !important; + max-width: min(480px, 100%); + margin-inline: auto !important; + + &:has(.skeleton) { + width: 100%; + } + } + + &:has(.media[data-orientation='landscape']) { + max-width: 100%; + } + } + + .status-link:has(.status-media-first):hover { + background-color: transparent; + } + } +} + .timeline.grow { /* min-height: 100vh; min-height: 100dvh; */ diff --git a/src/components/status.css b/src/components/status.css index 64b90399..eea4c280 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -618,6 +618,7 @@ ~ *:not( .content.truncated, .media-container, + .media-first-container, .card, .media-figure-multiple, .spoiler-media-button @@ -638,6 +639,7 @@ ~ *:not( .media-container, + .media-first-container, .card, .media-figure-multiple, .spoiler-media-button @@ -708,11 +710,12 @@ } } - ~ :is(.media-container, .media-figure-multiple) .media { + ~ :is(.media-container, .media-first-container, .media-figure-multiple) + .media { background-image: radial-gradient( circle at 50% 50%, var(--average-color, var(--bg-faded-color)), - var(--bg-color) 20em + var(--bg-color) 25em ); > *:not(.media-play, .alt-badge) { @@ -1316,6 +1319,227 @@ body:has(#modal-container .carousel) .status .media img:hover { background-blend-mode: multiply; } +.status.skeleton .media-first-container { + min-height: 3em; + background-color: var(--outline-color); +} + +.status-media-first { + .meta-name { + opacity: 0.65; + transition: opacity 0.5s ease-in-out; + + b + i { + opacity: 0; + transition: opacity 0.5s ease-in-out; + } + } + :is(:hover, :focus) > & .meta-name { + opacity: 1; + b + i { + opacity: 0.5; + } + } + + .media-first-spoiler-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + transition: opacity 0.5s ease-in-out; + opacity: 0.5; + } + &:hover .media-first-spoiler-content { + opacity: 1; + } + + .media-first-spoiler-button { + display: inline-flex !important; + } + .media-first-container { + margin-top: 8px; + display: flex; + max-height: 80vh; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + user-select: none; + margin-inline: -16px; + position: relative; + scrollbar-width: none; + /* border: var(--hairline-width) solid var(--outline-color); + border-inline-width: 0; + background-color: var(--bg-faded-color); */ + + @media (min-width: 40em) { + margin-inline: 0; + /* border-radius: 4px; */ + border-inline-width: var(--hairline-width); + } + + &::-webkit-scrollbar { + display: none; + } + + > .media-first-item { + scroll-snap-align: center; + scroll-snap-stop: always; + flex-shrink: 0; + display: flex; + width: 100%; + align-items: center; + justify-content: center; + + &:not(:only-child) { + background-color: var(--bg-blur-color); + box-shadow: inset 0 0 0 var(--hairline-width) var(--outline-color); + } + + .media { + /* background-color: var(--average-color, var(--bg-faded-color)); */ + width: var(--width); + max-width: 100%; + max-height: 100%; + min-height: var(--min-dimension); + /* max-height: min(var(--height), 80vh); */ + + &:active { + transform: none; + } + + img, + video { + object-fit: scale-down; + animation: none; + + &:not([data-loaded='true']) { + background-color: var(--bg-color); + } + } + } + } + + .media-carousel-controls { + flex-shrink: 0; + width: 100%; + position: sticky; + right: 0; + left: 0; + pointer-events: none; + display: flex; + justify-content: space-between; + } + + .carousel-indexer { + z-index: 1; + position: absolute; + top: 8px; + right: 8px; + color: var(--media-fg-color); + background-color: var(--media-bg-color); + padding: 2px 8px; + border-radius: 16px; + font-size: 0.8em; + font-variant-numeric: tabular-nums; + opacity: 0.6; + transition: opacity 1.5s ease-in-out; + border: var(--hairline-width) solid var(--media-outline-color); + } + + .media-carousel-button { + display: flex; + flex-shrink: 0; + padding-inline: 8px; + margin-block: 3em; + pointer-events: auto; + cursor: pointer; + align-items: center; + justify-content: center; + } + .carousel-button { + @media (pointer: coarse) { + display: none; + } + + + .carousel-button { + left: auto; + right: 8px; + } + } + + @media (hover: hover) and (pointer: fine) { + .carousel-button { + filter: opacity(0); + } + &:hover .carousel-button { + filter: opacity(1); + } + } + } + :is(:hover, :focus) > & .carousel-indexer { + opacity: 0; + } + + .media-carousel-dots { + pointer-events: none; + display: flex; + gap: 5px; + justify-content: center; + margin-top: 8px; + padding: 8px; + + .carousel-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background-color: var(--text-color); + transition: all 0.3s ease-in-out; + opacity: 0.3; + + &.active { + opacity: 1; + background-color: var(--text-color); + transform: scale(1.5); + } + } + } + + .media-first-content { + margin-top: 8px; + height: 1.75em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.9em; + mask-image: linear-gradient(to bottom, black 1.5em, transparent 1.75em); + opacity: 0.5; + transition: opacity 0.5s ease-in-out; + + @media (min-width: 40em) { + margin-inline: 16px; + } + + * { + text-align: center; + /* Brute force ellipsis */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap !important; + pointer-events: none; + } + + a { + filter: grayscale(0.5); + } + } + + :is(:hover, :focus) > & .media-first-content { + opacity: 1; + } +} + .status:not(.large) .hashtag-stuffing { opacity: 0.75; transition: opacity 0.2s ease-in-out; diff --git a/src/components/status.jsx b/src/components/status.jsx index 2d739824..9c09314b 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -169,15 +169,19 @@ function Status({ allowContextMenu, showActionsBar, showReplyParent, + mediaFirst, }) { if (skeleton) { return ( -
- +
+ {!mediaFirst && }
-
███ ████████
+
+ {(size === 's' || mediaFirst) && } ███ ████████ +
-
+ {mediaFirst &&
} +

████ ████████

@@ -247,6 +251,10 @@ function Status({ emojiReactions, } = status; + // if (!mediaAttachments?.length) mediaFirst = false; + const hasMediaAttachments = !!mediaAttachments?.length; + if (mediaFirst && hasMediaAttachments) size = 's'; + const currentAccount = useMemo(() => { return store.session.get('currentAccount'); }, []); @@ -354,6 +362,7 @@ function Status({ size={size} contentTextWeight={contentTextWeight} readOnly={readOnly} + mediaFirst={mediaFirst} />
); @@ -378,6 +387,7 @@ function Status({ contentTextWeight={contentTextWeight} readOnly={readOnly} enableCommentHint + mediaFirst={mediaFirst} />
); @@ -411,6 +421,7 @@ function Status({ contentTextWeight={contentTextWeight} readOnly={readOnly} enableCommentHint + mediaFirst={mediaFirst} />
); @@ -848,56 +859,62 @@ function Status({ )} - {(enableTranslate || !language || differentLanguage) && } - {enableTranslate ? ( -
- { - setForceTranslate(true); - }} - > - - Translate - - {supportsTTS && ( - { - const postText = getPostText(status); - if (postText) { - speak(postText, language); - } - }} - > - - Speak - + {!mediaFirst && ( + <> + {(enableTranslate || !language || differentLanguage) && ( + )} -
- ) : ( - (!language || differentLanguage) && ( -
- - - Translate - - {supportsTTS && ( + {enableTranslate ? ( +
{ - const postText = getPostText(status); - if (postText) { - speak(postText, language); - } + setForceTranslate(true); }} > - - Speak + + Translate - )} -
- ) + {supportsTTS && ( + { + const postText = getPostText(status); + if (postText) { + speak(postText, language); + } + }} + > + + Speak + + )} +
+ ) : ( + (!language || differentLanguage) && ( +
+ + + Translate + + {supportsTTS && ( + { + const postText = getPostText(status); + if (postText) { + speak(postText, language); + } + }} + > + + Speak + + )} +
+ ) + )} + )} {((!isSizeLarge && sameInstance) || enableTranslate || @@ -1384,7 +1401,7 @@ function Status({ }[size] } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''} ${ isContextMenuOpen ? 'status-menu-open' : '' - }`} + } ${mediaFirst && hasMediaAttachments ? 'status-media-first' : ''}`} onMouseEnter={debugHover} onContextMenu={(e) => { if (!showContextMenu) return; @@ -1712,188 +1729,253 @@ function Status({ } } > - {!!spoilerText && ( + {mediaFirst && hasMediaAttachments ? ( <> -
-

- -

-
- {readingExpandSpoilers || previewMode ? ( -
- Content warning + {(!!spoilerText || !!sensitive) && !readingExpandSpoilers && ( + <> + {!!spoilerText && ( + + {' '} + + )} + + + )} + + {!!content && ( +
+
- ) : ( - )} - )} - {!!content && ( -
- - -
- )} - {!!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) => ( - + {!!spoilerText && ( + <> +
{ - onMediaClick(e, i, media, status); + dir="auto" + ref={spoilerContentRef} + data-read-more={readMoreText} + > +

+ +

+
+ {readingExpandSpoilers || previewMode ? ( +
+ Content warning +
+ ) : ( + + )} + + )} + {!!content && ( +
+ - ))} -
- + +
+ )} + {!!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] && ( + + )} + )} - {!!card && - /^https/i.test(card?.url) && - !sensitive && - !spoilerText && - !poll && - !mediaAttachments.length && - !snapStates.statusQuotes[sKey] && ( - - )}
{!isSizeLarge && showCommentCount && (
@@ -2171,6 +2253,101 @@ function MultipleMediaFigure(props) { ); } +function MediaFirstContainer(props) { + const { mediaAttachments, language, postID, instance } = props; + const moreThanOne = mediaAttachments.length > 1; + + const carouselRef = useRef(); + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + let handleScroll = () => { + const { clientWidth, scrollLeft } = carouselRef.current; + const index = Math.round(scrollLeft / clientWidth); + setCurrentIndex(index); + }; + if (carouselRef.current) { + carouselRef.current.addEventListener('scroll', handleScroll, { + passive: true, + }); + } + return () => { + if (carouselRef.current) { + carouselRef.current.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + return ( + <> +
+ {mediaAttachments.map((media, i) => ( +
+ +
+ ))} + {moreThanOne && ( + + )} +
+ {moreThanOne && ( + + )} + + ); +} + function Card({ card, selfReferential, instance }) { const snapStates = useSnapshot(states); const { diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 1a878dc9..6fe1273c 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -1,5 +1,11 @@ import { memo } from 'preact/compat'; -import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { useDebouncedCallback } from 'use-debounce'; @@ -9,6 +15,7 @@ import FilterContext from '../utils/filter-context'; import { filteredItems, isFiltered } from '../utils/filters'; import states, { statusKey } from '../utils/states'; import statusPeek from '../utils/status-peek'; +import { isMediaFirstInstance } from '../utils/store-utils'; import { groupBoosts, groupContext } from '../utils/timeline-utils'; import useInterval from '../utils/useInterval'; import usePageVisibility from '../utils/usePageVisibility'; @@ -59,6 +66,8 @@ function Timeline({ console.debug('RENDER Timeline', id, refresh); + const mediaFirst = useMemo(() => isMediaFirstInstance(), []); + const allowGrouping = view !== 'media'; const loadItems = useDebouncedCallback( (firstLoad) => { @@ -355,7 +364,9 @@ function Timeline({
{ scrollableRef.current = node; jRef.current = node; @@ -432,6 +443,7 @@ function Timeline({ view={view} showFollowedTags={showFollowedTags} showReplyParent={showReplyParent} + mediaFirst={mediaFirst} /> ))} {showMore && @@ -443,14 +455,14 @@ function Timeline({ height: '20vh', }} > - +
  • - +
  • ))} @@ -490,7 +502,7 @@ function Timeline({ /> ) : (
  • - +
  • ), )} @@ -525,6 +537,7 @@ const TimelineItem = memo( view, showFollowedTags, showReplyParent, + mediaFirst, }) => { console.debug('RENDER TimelineItem', status.id); const { id: statusID, reblog, items, type, _pinned } = status; @@ -533,6 +546,7 @@ const TimelineItem = memo( const url = instance ? `/${instance}/s/${actualStatusID}` : `/s/${actualStatusID}`; + if (items) { const fItems = filteredItems(items, filterContext); let title = ''; @@ -585,6 +599,7 @@ const TimelineItem = memo( contentTextWeight enableCommentHint // allowFilters={allowFilters} + mediaFirst={mediaFirst} /> ) : ( )} @@ -689,6 +705,7 @@ const TimelineItem = memo( showFollowedTags={showFollowedTags} showReplyParent={showReplyParent} // allowFilters={allowFilters} + mediaFirst={mediaFirst} /> ) : ( )} diff --git a/src/utils/store-utils.js b/src/utils/store-utils.js index ed92b7d6..aff33e4a 100644 --- a/src/utils/store-utils.js +++ b/src/utils/store-utils.js @@ -126,3 +126,8 @@ export function getCurrentInstanceConfiguration() { const instance = getCurrentInstance(); return getInstanceConfiguration(instance); } + +export function isMediaFirstInstance() { + const instance = getCurrentInstance(); + return /pixelfed/i.test(instance?.version); +}