Rewrite status page + media modal

Media modals now have their own URLs
This commit is contained in:
Lim Chee Aun 2023-04-14 15:30:04 +08:00
parent a60ad33b47
commit f303c6d36c
10 changed files with 553 additions and 404 deletions

View file

@ -772,6 +772,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
flex-grow: 1; flex-grow: 1;
/* backdrop-filter: saturate(0.75); */ /* 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 { @keyframes slide-in {
0% { 0% {
transform: translate3d(100%, 0, 0); transform: translate3d(100%, 0, 0);
@ -784,9 +792,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
width: var(--main-width); width: var(--main-width);
max-width: 100vw; max-width: 100vw;
background-color: var(--bg-color); background-color: var(--bg-color);
animation: slide-in 0.5s var(--timing-function);
box-shadow: -1px 0 var(--bg-color); box-shadow: -1px 0 var(--bg-color);
} }
.deck-backdrop .deck.slide-in {
animation: slide-in 0.5s var(--timing-function);
}
.deck-backdrop .deck .status { .deck-backdrop .deck .status {
max-width: var(--main-width); max-width: var(--main-width);
} }
@ -853,6 +863,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
/* CAROUSEL */ /* CAROUSEL */
/* use snap, center children, max width viewport */ /* 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 { .carousel {
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
@ -917,7 +935,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
top: env(safe-area-inset-top, 0); top: env(safe-area-inset-top, 0);
} }
:is(.carousel-top-controls, .carousel-controls) { :is(.carousel-top-controls, .carousel-controls) {
position: fixed; position: absolute;
left: 0; left: 0;
left: env(safe-area-inset-left, 0); left: env(safe-area-inset-left, 0);
right: 0; right: 0;
@ -999,6 +1017,19 @@ body:has(.status-deck) .media-post-link {
display: none; 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 (min-width: calc(40em + 350px)) {
.media-post-link .button-label { .media-post-link .button-label {
display: inline; display: inline;
@ -1024,6 +1055,26 @@ body:has(.status-deck) .media-post-link {
right: 350px; right: 350px;
width: auto; 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 */ /* COMPOSE BUTTON */
@ -1731,7 +1782,7 @@ ul.link-list li a .icon {
.deck-container { .deck-container {
transition: transform 0.4s var(--timing-function); transition: transform 0.4s var(--timing-function);
} }
.deck-container:has(~ .deck-backdrop) { .deck-container:has(~ .deck-backdrop .deck) {
transition: transform 0.4s ease-out; transition: transform 0.4s ease-out;
transform: translate3d(-5vw, 0, 0); transform: translate3d(-5vw, 0, 0);
} }

View file

@ -73,9 +73,7 @@
#compose-container .status-preview:has(.status-badge) { #compose-container .status-preview:has(.status-badge) {
border-top-right-radius: 8px; border-top-right-radius: 8px;
} }
#compose-container .status-preview :is(.hashtag, .time) { #compose-container .status-preview :is(.content-container, .time) {
/* Prevent hashtags from being clickable */
/* TODO: maybe use a different solution? */
pointer-events: none; pointer-events: none;
} }
#compose-container.standalone .status-preview * { #compose-container.standalone .status-preview * {

View file

@ -24,16 +24,16 @@ function MediaModal({
useLayoutEffect(() => { useLayoutEffect(() => {
carouselFocusItem.current?.scrollIntoView(); carouselFocusItem.current?.scrollIntoView();
history.pushState({ mediaModal: true }, ''); // history.pushState({ mediaModal: true }, '');
const handlePopState = (e) => { // const handlePopState = (e) => {
if (e.state?.mediaModal) { // if (e.state?.mediaModal) {
onClose(); // onClose();
} // }
}; // };
window.addEventListener('popstate', handlePopState); // window.addEventListener('popstate', handlePopState);
return () => { // return () => {
window.removeEventListener('popstate', handlePopState); // window.removeEventListener('popstate', handlePopState);
}; // };
}, []); }, []);
const prevStatusID = useRef(statusID); const prevStatusID = useRef(statusID);
useEffect(() => { useEffect(() => {
@ -85,7 +85,7 @@ function MediaModal({
}, []); }, []);
return ( return (
<> <div class="media-modal-container">
<div <div
ref={carouselRef} ref={carouselRef}
tabIndex="-1" tabIndex="-1"
@ -206,7 +206,11 @@ function MediaModal({
</MenuLink> </MenuLink>
</Menu>{' '} </Menu>{' '}
<Link <Link
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`} to={`${instance ? `/${instance}` : ''}/s/${statusID}${
window.matchMedia('(min-width: calc(40em + 350px))').matches
? `?media=${currentIndex + 1}`
: ''
}`}
class="button carousel-button media-post-link plain3" class="button carousel-button media-post-link plain3"
onClick={() => { onClick={() => {
// if small screen (not media query min-width 40em + 350px), run onClose // if small screen (not media query min-width 40em + 350px), run onClose
@ -267,7 +271,7 @@ function MediaModal({
<MediaAltModal alt={showMediaAlt} /> <MediaAltModal alt={showMediaAlt} />
</Modal> </Modal>
)} )}
</> </div>
); );
} }

View file

@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import Icon from './icon'; import Icon from './icon';
import Link from './link';
import { formatDuration } from './status'; import { formatDuration } from './status';
/* /*
@ -15,7 +16,7 @@ video = Video clip
audio = Audio track audio = Audio track
*/ */
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
media; media;
const { original = {}, small, focus } = meta || {}; const { original = {}, small, focus } = meta || {};
@ -73,11 +74,13 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
onUpdate, onUpdate,
}; };
const Parent = to ? (props) => <Link to={to} {...props} /> : 'div';
if (type === 'image' || (type === 'unknown' && previewUrl && url)) { if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
// Note: type: unknown might not have width/height // Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit'; quickPinchZoomProps.containerProps.style.display = 'inherit';
return ( return (
<div <Parent
class={`media media-image`} class={`media media-image`}
onClick={onClick} onClick={onClick}
style={ style={
@ -120,7 +123,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
}} }}
/> />
)} )}
</div> </Parent>
); );
} else if (type === 'gifv' || type === 'video') { } else if (type === 'gifv' || type === 'video') {
const shortDuration = original.duration < 31; const shortDuration = original.duration < 31;
@ -148,7 +151,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
`; `;
return ( return (
<div <Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${ class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : '' autoGIFAnimate ? 'media-contain' : ''
}`} }`}
@ -226,12 +229,12 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
</div> </div>
</> </>
)} )}
</div> </Parent>
); );
} else if (type === 'audio') { } else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration); const formattedDuration = formatDuration(original.duration);
return ( return (
<div <Parent
class="media media-audio" class="media media-audio"
data-formatted-duration={formattedDuration} data-formatted-duration={formattedDuration}
onClick={onClick} onClick={onClick}
@ -252,7 +255,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
<Icon icon="play" size="xxl" /> <Icon icon="play" size="xxl" />
</div> </div>
)} )}
</div> </Parent>
); );
} }
} }

View file

@ -79,6 +79,7 @@ function Status({
enableTranslate, enableTranslate,
previewMode, previewMode,
allowFilters, allowFilters,
onMediaClick,
}) { }) {
if (skeleton) { if (skeleton) {
return ( return (
@ -1024,16 +1025,16 @@ function Status({
key={media.id} key={media.id}
media={media} media={media}
autoAnimate={isSizeLarge} autoAnimate={isSizeLarge}
onClick={(e) => { to={`/${instance}/s/${id}?${
e.preventDefault(); withinContext ? 'media' : 'media-only'
e.stopPropagation(); }=${i + 1}`}
states.showMediaModal = { onClick={
mediaAttachments, onMediaClick
index: i, ? (e) => {
instance, onMediaClick(e, i, media);
statusID: readOnly ? null : id, }
}; : undefined
}} }
/> />
))} ))}
</div> </div>

View file

@ -42,6 +42,8 @@ function Timeline({
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
const scrollableRef = useRef(); const scrollableRef = useRef();
console.debug('RENDER Timeline', id, refresh);
const loadItems = useDebouncedCallback( const loadItems = useDebouncedCallback(
(firstLoad) => { (firstLoad) => {
setShowNew(false); setShowNew(false);

View file

@ -18,6 +18,8 @@ function Following({ title, path, id, ...props }) {
const homeIterator = useRef(); const homeIterator = useRef();
const latestItem = useRef(); const latestItem = useRef();
console.debug('RENDER Following', title, id);
async function fetchHome(firstLoad) { async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) { if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });

View file

@ -1,3 +1,4 @@
import { memo } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -75,4 +76,4 @@ function Home() {
); );
} }
export default Home; export default memo(Home);

View file

@ -6,7 +6,7 @@ import pRetry from 'p-retry';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; 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 { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -14,6 +14,7 @@ import Avatar from '../components/avatar';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link'; import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import MediaModal from '../components/media-modal';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
import Status from '../components/status'; import Status from '../components/status';
@ -21,6 +22,7 @@ import { api } from '../utils/api';
import htmlContentLength from '../utils/html-content-length'; import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states, { import states, {
getStatus,
saveStatus, saveStatus,
statusKey, statusKey,
threadifyStatus, threadifyStatus,
@ -43,13 +45,97 @@ function resetScrollPosition(id) {
function StatusPage() { function StatusPage() {
const { id, ...params } = useParams(); const { id, ...params } = useParams();
const { masto, instance } = api({ instance: params.instance }); 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 (
<div class="deck-backdrop">
{showMedia ? (
mediaAttachments?.length ? (
<MediaModal
mediaAttachments={mediaAttachments}
statusID={mediaStatusID || id}
instance={instance}
index={mediaIndex - 1}
onClose={() => {
if (showMediaOnly) {
location.hash = closeLink;
} else {
searchParams.delete('media');
searchParams.delete('mediaStatusID');
setSearchParams(searchParams);
}
}}
/>
) : (
<div class="deck-loader">
<Loader />
</div>
)
) : (
<Link to={closeLink} />
)}
{!showMediaOnly && <StatusThread closeLink={closeLink} />}
</div>
);
}
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 { const {
masto: currentMasto, masto: currentMasto,
instance: currentInstance, instance: currentInstance,
authenticated, authenticated,
} = api(); } = api();
const sameInstance = instance === currentInstance; const sameInstance = instance === currentInstance;
const navigate = useNavigate();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [statuses, setStatuses] = useState([]); const [statuses, setStatuses] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
@ -69,7 +155,7 @@ function StatusPage() {
states.scrollPositions[id] = scrollTop; states.scrollPositions[id] = scrollTop;
} }
}, 50); }, 50);
scrollableRef.current.addEventListener('scroll', onScroll, { scrollableRef.current?.addEventListener('scroll', onScroll, {
passive: true, passive: true,
}); });
onScroll(); onScroll();
@ -331,23 +417,6 @@ function StatusPage() {
return postInstance === instance; return postInstance === instance;
}, [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 [limit, setLimit] = useState(LIMIT);
const showMore = useMemo(() => { const showMore = useMemo(() => {
// return number of statuses to show // return number of statuses to show
@ -367,10 +436,20 @@ function StatusPage() {
return top > 0 ? 'down' : 'up'; return top > 0 ? 'down' : 'up';
}, [heroInView]); }, [heroInView]);
useHotkeys(['esc', 'backspace'], () => { useHotkeys(
// location.hash = closeLink; 'esc',
onClose(); () => {
navigate(closeLink); 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', () => { useHotkeys('j', () => {
@ -464,15 +543,15 @@ function StatusPage() {
distanceFromStartPx: 16, distanceFromStartPx: 16,
}); });
const initialPageState = useRef(showMedia ? 'media+status' : 'status');
return ( return (
<div class="deck-backdrop">
<Link to={closeLink} onClick={onClose}></Link>
<div <div
tabIndex="-1" tabIndex="-1"
ref={scrollableRef} ref={scrollableRef}
class={`status-deck deck contained ${ class={`status-deck deck contained ${
statuses.length > 1 ? 'padded-bottom' : '' statuses.length > 1 ? 'padded-bottom' : ''
}`} } ${initialPageState.current === 'status' ? 'slide-in' : ''}`}
> >
<header <header
class={`${heroInView ? 'inview' : ''}`} class={`${heroInView ? 'inview' : ''}`}
@ -596,7 +675,7 @@ function StatusPage() {
onClick={() => { onClick={() => {
const statusURL = getInstanceStatusURL(heroStatus.url); const statusURL = getInstanceStatusURL(heroStatus.url);
if (statusURL) { if (statusURL) {
navigate(statusURL); location.hash = statusURL;
} else { } else {
alert('Unable to switch'); alert('Unable to switch');
} }
@ -609,11 +688,7 @@ function StatusPage() {
</MenuItem> </MenuItem>
</Menu> </Menu>
)} )}
<Link <Link class="button plain deck-close" to={closeLink}>
class="button plain deck-close"
to={closeLink}
onClick={onClose}
>
<Icon icon="x" size="xl" /> <Icon icon="x" size="xl" />
</Link> </Link>
</div> </div>
@ -663,8 +738,8 @@ function StatusPage() {
{uiState !== 'loading' && !authenticated ? ( {uiState !== 'loading' && !authenticated ? (
<div class="post-status-banner"> <div class="post-status-banner">
<p> <p>
You're not logged in. Interactions (reply, boost, You're not logged in. Interactions (reply, boost, etc)
etc) are not possible. are not possible.
</p> </p>
<Link to="/login" class="button"> <Link to="/login" class="button">
Log in Log in
@ -675,8 +750,8 @@ function StatusPage() {
<div class="post-status-banner"> <div class="post-status-banner">
<p> <p>
This post is from another instance ( This post is from another instance (
<b>{instance}</b>). Interactions (reply, boost, <b>{instance}</b>). Interactions (reply, boost, etc)
etc) are not possible. are not possible.
</p> </p>
<button <button
type="button" type="button"
@ -685,8 +760,7 @@ function StatusPage() {
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
try { try {
const results = const results = await currentMasto.v2.search({
await currentMasto.v2.search({
q: heroStatus.url, q: heroStatus.url,
type: 'statuses', type: 'statuses',
resolve: true, resolve: true,
@ -694,11 +768,9 @@ function StatusPage() {
}); });
if (results.statuses.length) { if (results.statuses.length) {
const status = results.statuses[0]; const status = results.statuses[0];
navigate( location.hash = currentInstance
currentInstance
? `/${currentInstance}/s/${status.id}` ? `/${currentInstance}/s/${status.id}`
: `/s/${status.id}`, : `/s/${status.id}`;
);
} else { } else {
throw new Error('No results'); throw new Error('No results');
} }
@ -721,9 +793,7 @@ function StatusPage() {
<Link <Link
class="status-link" class="status-link"
to={ to={
instance instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
? `/${instance}/s/${statusID}`
: `/s/${statusID}`
} }
onClick={() => { onClick={() => {
resetScrollPosition(statusID); resetScrollPosition(statusID);
@ -735,8 +805,16 @@ function StatusPage() {
withinContext withinContext
size={thread || ancestor ? 'm' : 's'} size={thread || ancestor ? 'm' : 's'}
enableTranslate enableTranslate
onMediaClick={(e, i) => {
e.preventDefault();
e.stopPropagation();
setSearchParams({
media: i + 1,
mediaStatusID: statusID,
});
}}
/> />
{ancestor && isThread && !!repliesCount && ( {ancestor && isThread && repliesCount > 1 && (
<div class="replies-link"> <div class="replies-link">
<Icon icon="comment" />{' '} <Icon icon="comment" />{' '}
<span title={repliesCount}> <span title={repliesCount}>
@ -836,7 +914,6 @@ function StatusPage() {
</> </>
)} )}
</div> </div>
</div>
); );
} }
@ -847,6 +924,7 @@ function SubComments({
hasParentThread, hasParentThread,
level, level,
}) { }) {
const [searchParams, setSearchParams] = useSearchParams();
// Set isBrief = true: // Set isBrief = true:
// - if less than or 2 replies // - if less than or 2 replies
// - if replies have no sub-replies // - if replies have no sub-replies
@ -946,6 +1024,14 @@ function SubComments({
withinContext withinContext
size="s" size="s"
enableTranslate enableTranslate
onMediaClick={(e, i) => {
e.preventDefault();
e.stopPropagation();
setSearchParams({
media: i + 1,
mediaStatusID: r.id,
});
}}
/> />
{!r.replies?.length && r.repliesCount > 0 && ( {!r.replies?.length && r.repliesCount > 0 && (
<div class="replies-link"> <div class="replies-link">

View file

@ -19,6 +19,7 @@ export default function useScroll({
useEffect(() => { useEffect(() => {
const scrollableElement = scrollableRef.current; const scrollableElement = scrollableRef.current;
if (!scrollableElement) return {};
let previousScrollStart = isVertical let previousScrollStart = isVertical
? scrollableElement.scrollTop ? scrollableElement.scrollTop
: scrollableElement.scrollLeft; : scrollableElement.scrollLeft;