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;
/* 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);
}

View file

@ -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 * {

View file

@ -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 (
<>
<div class="media-modal-container">
<div
ref={carouselRef}
tabIndex="-1"
@ -206,7 +206,11 @@ function MediaModal({
</MenuLink>
</Menu>{' '}
<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"
onClick={() => {
// if small screen (not media query min-width 40em + 350px), run onClose
@ -267,7 +271,7 @@ function MediaModal({
<MediaAltModal alt={showMediaAlt} />
</Modal>
)}
</>
</div>
);
}

View file

@ -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) => <Link to={to} {...props} /> : 'div';
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
// Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit';
return (
<div
<Parent
class={`media media-image`}
onClick={onClick}
style={
@ -120,7 +123,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
}}
/>
)}
</div>
</Parent>
);
} else if (type === 'gifv' || type === 'video') {
const shortDuration = original.duration < 31;
@ -148,7 +151,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
`;
return (
<div
<Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
}`}
@ -226,12 +229,12 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
</div>
</>
)}
</div>
</Parent>
);
} else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration);
return (
<div
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
onClick={onClick}
@ -252,7 +255,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
<Icon icon="play" size="xxl" />
</div>
)}
</div>
</Parent>
);
}
}

View file

@ -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
}
/>
))}
</div>

View file

@ -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);

View file

@ -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 });

View file

@ -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);

View file

@ -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 (
<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 {
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,15 +543,15 @@ function StatusPage() {
distanceFromStartPx: 16,
});
const initialPageState = useRef(showMedia ? 'media+status' : 'status');
return (
<div class="deck-backdrop">
<Link to={closeLink} onClick={onClose}></Link>
<div
tabIndex="-1"
ref={scrollableRef}
class={`status-deck deck contained ${
statuses.length > 1 ? 'padded-bottom' : ''
}`}
} ${initialPageState.current === 'status' ? 'slide-in' : ''}`}
>
<header
class={`${heroInView ? 'inview' : ''}`}
@ -596,7 +675,7 @@ function StatusPage() {
onClick={() => {
const statusURL = getInstanceStatusURL(heroStatus.url);
if (statusURL) {
navigate(statusURL);
location.hash = statusURL;
} else {
alert('Unable to switch');
}
@ -609,11 +688,7 @@ function StatusPage() {
</MenuItem>
</Menu>
)}
<Link
class="button plain deck-close"
to={closeLink}
onClick={onClose}
>
<Link class="button plain deck-close" to={closeLink}>
<Icon icon="x" size="xl" />
</Link>
</div>
@ -663,8 +738,8 @@ function StatusPage() {
{uiState !== 'loading' && !authenticated ? (
<div class="post-status-banner">
<p>
You're not logged in. Interactions (reply, boost,
etc) are not possible.
You're not logged in. Interactions (reply, boost, etc)
are not possible.
</p>
<Link to="/login" class="button">
Log in
@ -675,8 +750,8 @@ function StatusPage() {
<div class="post-status-banner">
<p>
This post is from another instance (
<b>{instance}</b>). Interactions (reply, boost,
etc) are not possible.
<b>{instance}</b>). Interactions (reply, boost, etc)
are not possible.
</p>
<button
type="button"
@ -685,8 +760,7 @@ function StatusPage() {
setUIState('loading');
(async () => {
try {
const results =
await currentMasto.v2.search({
const results = await currentMasto.v2.search({
q: heroStatus.url,
type: 'statuses',
resolve: true,
@ -694,11 +768,9 @@ function StatusPage() {
});
if (results.statuses.length) {
const status = results.statuses[0];
navigate(
currentInstance
location.hash = currentInstance
? `/${currentInstance}/s/${status.id}`
: `/s/${status.id}`,
);
: `/s/${status.id}`;
} else {
throw new Error('No results');
}
@ -721,9 +793,7 @@ function StatusPage() {
<Link
class="status-link"
to={
instance
? `/${instance}/s/${statusID}`
: `/s/${statusID}`
instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
}
onClick={() => {
resetScrollPosition(statusID);
@ -735,8 +805,16 @@ function StatusPage() {
withinContext
size={thread || ancestor ? 'm' : 's'}
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">
<Icon icon="comment" />{' '}
<span title={repliesCount}>
@ -836,7 +914,6 @@ function StatusPage() {
</>
)}
</div>
</div>
);
}
@ -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 && (
<div class="replies-link">

View file

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