diff --git a/src/app.css b/src/app.css index cbc12749..24c5f155 100644 --- a/src/app.css +++ b/src/app.css @@ -772,6 +772,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { flex-grow: 1; /* backdrop-filter: saturate(0.75); */ } +.deck-backdrop > .deck-loader { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(24px); + background-image: radial-gradient(closest-side, var(--bg-color), transparent); +} @keyframes slide-in { 0% { transform: translate3d(100%, 0, 0); @@ -784,9 +792,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { width: var(--main-width); max-width: 100vw; background-color: var(--bg-color); - animation: slide-in 0.5s var(--timing-function); box-shadow: -1px 0 var(--bg-color); } +.deck-backdrop .deck.slide-in { + animation: slide-in 0.5s var(--timing-function); +} .deck-backdrop .deck .status { max-width: var(--main-width); } @@ -853,6 +863,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { /* CAROUSEL */ /* use snap, center children, max width viewport */ +.media-modal-container { + position: relative; + width: 100%; + background-color: var(--backdrop-color); + backdrop-filter: blur(24px); + animation: appear 0.3s var(--timing-function) both; +} + .carousel { display: flex; overflow-x: auto; @@ -917,7 +935,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { top: env(safe-area-inset-top, 0); } :is(.carousel-top-controls, .carousel-controls) { - position: fixed; + position: absolute; left: 0; left: env(safe-area-inset-left, 0); right: 0; @@ -999,6 +1017,19 @@ body:has(.status-deck) .media-post-link { display: none; } +/* ✨ New */ +body:has(.media-modal-container + .status-deck) .media-post-link { + display: inline-block; +} +.media-modal-container + .status-deck { + /* display: none; */ + position: absolute; + z-index: -1; + pointer-events: none; + user-select: none; + animation: none; +} + @media (min-width: calc(40em + 350px)) { .media-post-link .button-label { display: inline; @@ -1024,6 +1055,26 @@ body:has(.status-deck) .media-post-link { right: 350px; width: auto; } + /* ✨ New */ + .deck-backdrop > a { + width: 100%; + flex-grow: 0; + } + .deck-backdrop .media-modal-container + .status-deck { + /* display: block; */ + /* width: 350px; */ + min-width: 350px; + position: static; + z-index: 1; + pointer-events: auto; + user-select: auto; + } + .deck-backdrop .media-modal-container + .status-deck:not(.slide-in) { + animation: appear 0.3s ease-in-out; + } + body:has(.media-modal-container + .status-deck) .media-post-link { + display: none; + } } /* COMPOSE BUTTON */ @@ -1731,7 +1782,7 @@ ul.link-list li a .icon { .deck-container { transition: transform 0.4s var(--timing-function); } - .deck-container:has(~ .deck-backdrop) { + .deck-container:has(~ .deck-backdrop .deck) { transition: transform 0.4s ease-out; transform: translate3d(-5vw, 0, 0); } diff --git a/src/components/compose.css b/src/components/compose.css index 81ac8c51..4dfac418 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -73,9 +73,7 @@ #compose-container .status-preview:has(.status-badge) { border-top-right-radius: 8px; } -#compose-container .status-preview :is(.hashtag, .time) { - /* Prevent hashtags from being clickable */ - /* TODO: maybe use a different solution? */ +#compose-container .status-preview :is(.content-container, .time) { pointer-events: none; } #compose-container.standalone .status-preview * { diff --git a/src/components/media-modal.jsx b/src/components/media-modal.jsx index 38802d28..77c5dfee 100644 --- a/src/components/media-modal.jsx +++ b/src/components/media-modal.jsx @@ -24,16 +24,16 @@ function MediaModal({ useLayoutEffect(() => { carouselFocusItem.current?.scrollIntoView(); - history.pushState({ mediaModal: true }, ''); - const handlePopState = (e) => { - if (e.state?.mediaModal) { - onClose(); - } - }; - window.addEventListener('popstate', handlePopState); - return () => { - window.removeEventListener('popstate', handlePopState); - }; + // history.pushState({ mediaModal: true }, ''); + // const handlePopState = (e) => { + // if (e.state?.mediaModal) { + // onClose(); + // } + // }; + // window.addEventListener('popstate', handlePopState); + // return () => { + // window.removeEventListener('popstate', handlePopState); + // }; }, []); const prevStatusID = useRef(statusID); useEffect(() => { @@ -85,7 +85,7 @@ function MediaModal({ }, []); return ( - <> +
{' '} { // if small screen (not media query min-width 40em + 350px), run onClose @@ -267,7 +271,7 @@ function MediaModal({ )} - +
); } diff --git a/src/components/media.jsx b/src/components/media.jsx index 4a8e3c70..220da460 100644 --- a/src/components/media.jsx +++ b/src/components/media.jsx @@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from 'preact/hooks'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import Icon from './icon'; +import Link from './link'; import { formatDuration } from './status'; /* @@ -15,7 +16,7 @@ video = Video clip audio = Audio track */ -function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { +function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) { const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = media; const { original = {}, small, focus } = meta || {}; @@ -73,11 +74,13 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { onUpdate, }; + const Parent = to ? (props) => : 'div'; + if (type === 'image' || (type === 'unknown' && previewUrl && url)) { // Note: type: unknown might not have width/height quickPinchZoomProps.containerProps.style.display = 'inherit'; return ( -
{} }) { }} /> )} -
+ ); } else if (type === 'gifv' || type === 'video') { const shortDuration = original.duration < 31; @@ -148,7 +151,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { `; return ( -
{} }) {
)} -
+ ); } else if (type === 'audio') { const formattedDuration = formatDuration(original.duration); return ( -
{} }) {
)} - + ); } } diff --git a/src/components/status.jsx b/src/components/status.jsx index 53a199bb..584422db 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -79,6 +79,7 @@ function Status({ enableTranslate, previewMode, allowFilters, + onMediaClick, }) { if (skeleton) { return ( @@ -1024,16 +1025,16 @@ function Status({ key={media.id} media={media} autoAnimate={isSizeLarge} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - states.showMediaModal = { - mediaAttachments, - index: i, - instance, - statusID: readOnly ? null : id, - }; - }} + to={`/${instance}/s/${id}?${ + withinContext ? 'media' : 'media-only' + }=${i + 1}`} + onClick={ + onMediaClick + ? (e) => { + onMediaClick(e, i, media); + } + : undefined + } /> ))} diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index f9c0cbce..ab0c0100 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -42,6 +42,8 @@ function Timeline({ const [visible, setVisible] = useState(true); const scrollableRef = useRef(); + console.debug('RENDER Timeline', id, refresh); + const loadItems = useDebouncedCallback( (firstLoad) => { setShowNew(false); diff --git a/src/pages/following.jsx b/src/pages/following.jsx index 60beb217..4b7a8947 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -18,6 +18,8 @@ function Following({ title, path, id, ...props }) { const homeIterator = useRef(); const latestItem = useRef(); + console.debug('RENDER Following', title, id); + async function fetchHome(firstLoad) { if (firstLoad || !homeIterator.current) { homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); diff --git a/src/pages/home.jsx b/src/pages/home.jsx index 4cd84d98..850f4098 100644 --- a/src/pages/home.jsx +++ b/src/pages/home.jsx @@ -1,3 +1,4 @@ +import { memo } from 'preact/compat'; import { useEffect } from 'preact/hooks'; import { useSnapshot } from 'valtio'; @@ -75,4 +76,4 @@ function Home() { ); } -export default Home; +export default memo(Home); diff --git a/src/pages/status.jsx b/src/pages/status.jsx index f1771592..d5d76e27 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -6,7 +6,7 @@ import pRetry from 'p-retry'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; -import { matchPath, useNavigate, useParams } from 'react-router-dom'; +import { matchPath, useParams, useSearchParams } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; import { useSnapshot } from 'valtio'; @@ -14,6 +14,7 @@ import Avatar from '../components/avatar'; import Icon from '../components/icon'; import Link from '../components/link'; import Loader from '../components/loader'; +import MediaModal from '../components/media-modal'; import NameText from '../components/name-text'; import RelativeTime from '../components/relative-time'; import Status from '../components/status'; @@ -21,6 +22,7 @@ import { api } from '../utils/api'; import htmlContentLength from '../utils/html-content-length'; import shortenNumber from '../utils/shorten-number'; import states, { + getStatus, saveStatus, statusKey, threadifyStatus, @@ -43,13 +45,97 @@ function resetScrollPosition(id) { function StatusPage() { const { id, ...params } = useParams(); const { masto, instance } = api({ instance: params.instance }); + const [searchParams, setSearchParams] = useSearchParams(); + const mediaParam = searchParams.get('media'); + const mediaOnlyParam = searchParams.get('media-only'); + const mediaIndex = parseInt(mediaParam || mediaOnlyParam, 10); + let showMedia = mediaIndex > 0; + const mediaStatusID = searchParams.get('mediaStatusID'); + const mediaStatus = getStatus(mediaStatusID, instance); + if (mediaStatusID && !mediaStatus) { + showMedia = false; + } + const showMediaOnly = showMedia && !!mediaOnlyParam; + + const sKey = statusKey(id, instance); + const [heroStatus, setHeroStatus] = useState(states.statuses[sKey]); + + const closeLink = useMemo(() => { + const { prevLocation } = states; + const pathname = + (prevLocation?.pathname || '') + (prevLocation?.search || ''); + const matchStatusPath = + matchPath('/:instance/s/:id', pathname) || matchPath('/s/:id', pathname); + if (!pathname || matchStatusPath) { + return '/'; + } + return pathname; + }, []); + + useEffect(() => { + if (!heroStatus && showMedia) { + (async () => { + try { + const status = await masto.v1.statuses.fetch(id); + saveStatus(status, instance); + setHeroStatus(status); + } catch (err) { + console.error(err); + alert('Unable to load status.'); + location.hash = closeLink; + } + })(); + } + }, []); + + const mediaAttachments = mediaStatusID + ? mediaStatus?.mediaAttachments + : heroStatus?.mediaAttachments; + + return ( +
+ {showMedia ? ( + mediaAttachments?.length ? ( + { + if (showMediaOnly) { + location.hash = closeLink; + } else { + searchParams.delete('media'); + searchParams.delete('mediaStatusID'); + setSearchParams(searchParams); + } + }} + /> + ) : ( +
+ +
+ ) + ) : ( + + )} + {!showMediaOnly && } +
+ ); +} + +function StatusThread({ closeLink = '/' }) { + const { id, ...params } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const mediaParam = searchParams.get('media'); + const showMedia = parseInt(mediaParam, 10) > 0; + const { masto, instance } = api({ instance: params.instance }); const { masto: currentMasto, instance: currentInstance, authenticated, } = api(); const sameInstance = instance === currentInstance; - const navigate = useNavigate(); const snapStates = useSnapshot(states); const [statuses, setStatuses] = useState([]); const [uiState, setUIState] = useState('default'); @@ -69,7 +155,7 @@ function StatusPage() { states.scrollPositions[id] = scrollTop; } }, 50); - scrollableRef.current.addEventListener('scroll', onScroll, { + scrollableRef.current?.addEventListener('scroll', onScroll, { passive: true, }); onScroll(); @@ -331,23 +417,6 @@ function StatusPage() { return postInstance === instance; }, [postInstance, instance]); - const closeLink = useMemo(() => { - const { prevLocation } = snapStates; - const pathname = - (prevLocation?.pathname || '') + (prevLocation?.search || ''); - if ( - !pathname || - matchPath('/:instance/s/:id', pathname) || - matchPath('/s/:id', pathname) - ) { - return '/'; - } - return pathname; - }, []); - const onClose = () => { - states.showMediaModal = false; - }; - const [limit, setLimit] = useState(LIMIT); const showMore = useMemo(() => { // return number of statuses to show @@ -367,10 +436,20 @@ function StatusPage() { return top > 0 ? 'down' : 'up'; }, [heroInView]); - useHotkeys(['esc', 'backspace'], () => { - // location.hash = closeLink; - onClose(); - navigate(closeLink); + useHotkeys( + 'esc', + () => { + location.hash = closeLink; + }, + { + // If media is open, esc to close media first + // Else close the status page + enabled: !showMedia, + }, + ); + // For backspace, will always close both media and status page + useHotkeys('backspace', () => { + location.hash = closeLink; }); useHotkeys('j', () => { @@ -464,287 +543,286 @@ function StatusPage() { distanceFromStartPx: 16, }); + const initialPageState = useRef(showMedia ? 'media+status' : 'status'); + return ( -
- -
1 ? 'padded-bottom' : '' - }`} +
1 ? 'padded-bottom' : '' + } ${initialPageState.current === 'status' ? 'slide-in' : ''}`} + > +
{ + // reload statuses + states.reloadStatusPage++; + }} > -
{ - // reload statuses - states.reloadStatusPage++; - }} - > - {/*
+ {/*
*/} -
-

- {!heroInView && heroStatus && uiState !== 'loading' ? ( - <> - - {' '} - - •{' '} - - - {' '} - - - ) : ( - <> - Status{' '} - - - )} -

-
- {uiState === 'loading' ? ( - - ) : ( - + {' '} + - } > - { - states.reloadStatusPage++; - }} - > - - Refresh - - { - // Click all buttons with class .spoiler but not .spoiling - const buttons = Array.from( - scrollableRef.current.querySelectorAll( - 'button.spoiler:not(.spoiling)', - ), - ); - buttons.forEach((button) => { - button.click(); - }); - }} - > - {' '} - Show all sensitive content - - - Experimental - { - const statusURL = getInstanceStatusURL(heroStatus.url); - if (statusURL) { - navigate(statusURL); - } else { - alert('Unable to switch'); - } - }} - > - - - Switch to post's instance ({postInstance}) - - - - )} - + + + ) : ( + <> + Status{' '} + + + )} + +
+ {uiState === 'loading' ? ( + + ) : ( + + + + } > - - -
-
-
- {!!statuses.length && heroStatus ? ( -
    - {statuses.slice(0, limit).map((status) => { - const { - id: statusID, - ancestor, - isThread, - descendant, - thread, - replies, - repliesCount, - } = status; - const isHero = statusID === id; - return ( -
  • { + states.reloadStatusPage++; + }} > - {isHero ? ( - <> - - - - {uiState !== 'loading' && !authenticated ? ( -
    -

    - You're not logged in. Interactions (reply, boost, - etc) are not possible. -

    - - Log in - -
    - ) : ( - !sameInstance && ( -
    -

    - This post is from another instance ( - {instance}). Interactions (reply, boost, - etc) are not possible. -

    - -
    - ) - )} - - ) : ( - { - resetScrollPosition(statusID); - }} + + Refresh + + { + // Click all buttons with class .spoiler but not .spoiling + const buttons = Array.from( + scrollableRef.current.querySelectorAll( + 'button.spoiler:not(.spoiling)', + ), + ); + buttons.forEach((button) => { + button.click(); + }); + }} + > + {' '} + Show all sensitive content + + + Experimental + { + const statusURL = getInstanceStatusURL(heroStatus.url); + if (statusURL) { + location.hash = statusURL; + } else { + alert('Unable to switch'); + } + }} + > + + + Switch to post's instance ({postInstance}) + + + + )} + + + +
+
+ + {!!statuses.length && heroStatus ? ( + - ) : ( - <> - {uiState === 'loading' && ( - - )} - {uiState === 'error' && ( -

- Unable to load status -
-
- -

- )} - - )} -
+ ); + })} + {showMore > 0 && ( +
  • + +
  • + )} + + ) : ( + <> + {uiState === 'loading' && ( + + )} + {uiState === 'error' && ( +

    + Unable to load status +
    +
    + +

    + )} + + )} ); } @@ -847,6 +924,7 @@ function SubComments({ hasParentThread, level, }) { + const [searchParams, setSearchParams] = useSearchParams(); // Set isBrief = true: // - if less than or 2 replies // - if replies have no sub-replies @@ -946,6 +1024,14 @@ function SubComments({ withinContext size="s" enableTranslate + onMediaClick={(e, i) => { + e.preventDefault(); + e.stopPropagation(); + setSearchParams({ + media: i + 1, + mediaStatusID: r.id, + }); + }} /> {!r.replies?.length && r.repliesCount > 0 && (