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,287 +543,286 @@ function StatusPage() {
distanceFromStartPx: 16, distanceFromStartPx: 16,
}); });
const initialPageState = useRef(showMedia ? 'media+status' : 'status');
return ( return (
<div class="deck-backdrop"> <div
<Link to={closeLink} onClick={onClose}></Link> tabIndex="-1"
<div ref={scrollableRef}
tabIndex="-1" class={`status-deck deck contained ${
ref={scrollableRef} statuses.length > 1 ? 'padded-bottom' : ''
class={`status-deck deck contained ${ } ${initialPageState.current === 'status' ? 'slide-in' : ''}`}
statuses.length > 1 ? 'padded-bottom' : '' >
}`} <header
class={`${heroInView ? 'inview' : ''}`}
onDblClick={(e) => {
// reload statuses
states.reloadStatusPage++;
}}
> >
<header {/* <div>
class={`${heroInView ? 'inview' : ''}`}
onDblClick={(e) => {
// reload statuses
states.reloadStatusPage++;
}}
>
{/* <div>
<Link class="button plain deck-close" href={closeLink}> <Link class="button plain deck-close" href={closeLink}>
<Icon icon="chevron-left" size="xl" /> <Icon icon="chevron-left" size="xl" />
</Link> </Link>
</div> */} </div> */}
<div class="header-grid header-grid-2"> <div class="header-grid header-grid-2">
<h1> <h1>
{!heroInView && heroStatus && uiState !== 'loading' ? ( {!heroInView && heroStatus && uiState !== 'loading' ? (
<> <>
<span class="hero-heading"> <span class="hero-heading">
<NameText <NameText
account={heroStatus.account} account={heroStatus.account}
instance={instance} instance={instance}
showAvatar showAvatar
short short
/>{' '} />{' '}
<span class="insignificant"> <span class="insignificant">
&bull;{' '} &bull;{' '}
<RelativeTime <RelativeTime
datetime={heroStatus.createdAt} datetime={heroStatus.createdAt}
format="micro" format="micro"
/>
</span>
</span>{' '}
<button
type="button"
class="ancestors-indicator light small"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
heroStatusRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Icon
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
/> />
</button> </span>
</> </span>{' '}
) : ( <button
<> type="button"
Status{' '} class="ancestors-indicator light small"
<button onClick={(e) => {
type="button" e.preventDefault();
class="ancestors-indicator light small" e.stopPropagation();
onClick={(e) => { heroStatusRef.current.scrollIntoView({
// Scroll to top behavior: 'smooth',
e.preventDefault(); block: 'start',
e.stopPropagation(); });
scrollableRef.current.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
hidden={!ancestors.length || nearReachStart}
>
<Icon icon="arrow-up" />
<Icon icon="comment" />{' '}
<span class="insignificant">
{shortenNumber(ancestors.length)}
</span>
</button>
</>
)}
</h1>
<div class="header-side">
{uiState === 'loading' ? (
<Loader abrupt />
) : (
<Menu
align="end"
portal={{
// Need this, else the menu click will cause scroll jump
target: scrollableRef.current,
}} }}
menuButton={
<button type="button" class="button plain4">
<Icon icon="more" alt="Actions" size="xl" />
</button>
}
> >
<MenuItem <Icon
disabled={uiState === 'loading'} icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
onClick={() => { />
states.reloadStatusPage++; </button>
}} </>
> ) : (
<Icon icon="refresh" /> <>
<span>Refresh</span> Status{' '}
</MenuItem> <button
<MenuItem type="button"
onClick={() => { class="ancestors-indicator light small"
// Click all buttons with class .spoiler but not .spoiling onClick={(e) => {
const buttons = Array.from( // Scroll to top
scrollableRef.current.querySelectorAll( e.preventDefault();
'button.spoiler:not(.spoiling)', e.stopPropagation();
), scrollableRef.current.scrollTo({
); top: 0,
buttons.forEach((button) => { behavior: 'smooth',
button.click(); });
}); }}
}} hidden={!ancestors.length || nearReachStart}
> >
<Icon icon="eye-open" />{' '} <Icon icon="arrow-up" />
<span>Show all sensitive content</span> <Icon icon="comment" />{' '}
</MenuItem> <span class="insignificant">
<MenuDivider /> {shortenNumber(ancestors.length)}
<MenuHeader className="plain">Experimental</MenuHeader> </span>
<MenuItem </button>
disabled={postSameInstance} </>
onClick={() => { )}
const statusURL = getInstanceStatusURL(heroStatus.url); </h1>
if (statusURL) { <div class="header-side">
navigate(statusURL); {uiState === 'loading' ? (
} else { <Loader abrupt />
alert('Unable to switch'); ) : (
} <Menu
}} align="end"
> portal={{
<Icon icon="transfer" /> // Need this, else the menu click will cause scroll jump
<small class="menu-double-lines"> target: scrollableRef.current,
Switch to post's instance (<b>{postInstance}</b>) }}
</small> menuButton={
</MenuItem> <button type="button" class="button plain4">
</Menu> <Icon icon="more" alt="Actions" size="xl" />
)} </button>
<Link }
class="button plain deck-close"
to={closeLink}
onClick={onClose}
> >
<Icon icon="x" size="xl" /> <MenuItem
</Link> disabled={uiState === 'loading'}
</div> onClick={() => {
</div> states.reloadStatusPage++;
</header> }}
{!!statuses.length && heroStatus ? (
<ul
class={`timeline flat contextual grow ${
uiState === 'loading' ? 'loading' : ''
}`}
>
{statuses.slice(0, limit).map((status) => {
const {
id: statusID,
ancestor,
isThread,
descendant,
thread,
replies,
repliesCount,
} = status;
const isHero = statusID === id;
return (
<li
key={statusID}
ref={isHero ? heroStatusRef : null}
class={`${ancestor ? 'ancestor' : ''} ${
descendant ? 'descendant' : ''
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
> >
{isHero ? ( <Icon icon="refresh" />
<> <span>Refresh</span>
<InView </MenuItem>
threshold={0.1} <MenuItem
onChange={onView} onClick={() => {
class="status-focus" // Click all buttons with class .spoiler but not .spoiling
tabIndex={0} const buttons = Array.from(
> scrollableRef.current.querySelectorAll(
<Status 'button.spoiler:not(.spoiling)',
statusID={statusID} ),
instance={instance} );
withinContext buttons.forEach((button) => {
size="l" button.click();
enableTranslate });
/> }}
</InView> >
{uiState !== 'loading' && !authenticated ? ( <Icon icon="eye-open" />{' '}
<div class="post-status-banner"> <span>Show all sensitive content</span>
<p> </MenuItem>
You're not logged in. Interactions (reply, boost, <MenuDivider />
etc) are not possible. <MenuHeader className="plain">Experimental</MenuHeader>
</p> <MenuItem
<Link to="/login" class="button"> disabled={postSameInstance}
Log in onClick={() => {
</Link> const statusURL = getInstanceStatusURL(heroStatus.url);
</div> if (statusURL) {
) : ( location.hash = statusURL;
!sameInstance && ( } else {
<div class="post-status-banner"> alert('Unable to switch');
<p> }
This post is from another instance ( }}
<b>{instance}</b>). Interactions (reply, boost, >
etc) are not possible. <Icon icon="transfer" />
</p> <small class="menu-double-lines">
<button Switch to post's instance (<b>{postInstance}</b>)
type="button" </small>
disabled={uiState === 'loading'} </MenuItem>
onClick={() => { </Menu>
setUIState('loading'); )}
(async () => { <Link class="button plain deck-close" to={closeLink}>
try { <Icon icon="x" size="xl" />
const results = </Link>
await currentMasto.v2.search({ </div>
q: heroStatus.url, </div>
type: 'statuses', </header>
resolve: true, {!!statuses.length && heroStatus ? (
limit: 1, <ul
}); class={`timeline flat contextual grow ${
if (results.statuses.length) { uiState === 'loading' ? 'loading' : ''
const status = results.statuses[0]; }`}
navigate( >
currentInstance {statuses.slice(0, limit).map((status) => {
? `/${currentInstance}/s/${status.id}` const {
: `/s/${status.id}`, id: statusID,
); ancestor,
} else { isThread,
throw new Error('No results'); descendant,
} thread,
} catch (e) { replies,
setUIState('default'); repliesCount,
alert('Error: ' + e); } = status;
console.error(e); const isHero = statusID === id;
} return (
})(); <li
}} key={statusID}
> ref={isHero ? heroStatusRef : null}
<Icon icon="transfer" /> Switch to my instance to class={`${ancestor ? 'ancestor' : ''} ${
enable interactions descendant ? 'descendant' : ''
</button> } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
</div> >
) {isHero ? (
)} <>
</> <InView
) : ( threshold={0.1}
<Link onChange={onView}
class="status-link" class="status-focus"
to={ tabIndex={0}
instance
? `/${instance}/s/${statusID}`
: `/s/${statusID}`
}
onClick={() => {
resetScrollPosition(statusID);
}}
> >
<Status <Status
statusID={statusID} statusID={statusID}
instance={instance} instance={instance}
withinContext withinContext
size={thread || ancestor ? 'm' : 's'} size="l"
enableTranslate enableTranslate
/> />
{ancestor && isThread && !!repliesCount && ( </InView>
<div class="replies-link"> {uiState !== 'loading' && !authenticated ? (
<Icon icon="comment" />{' '} <div class="post-status-banner">
<span title={repliesCount}> <p>
{shortenNumber(repliesCount)} You're not logged in. Interactions (reply, boost, etc)
</span> are not possible.
</p>
<Link to="/login" class="button">
Log in
</Link>
</div>
) : (
!sameInstance && (
<div class="post-status-banner">
<p>
This post is from another instance (
<b>{instance}</b>). Interactions (reply, boost, etc)
are not possible.
</p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
const results = await currentMasto.v2.search({
q: heroStatus.url,
type: 'statuses',
resolve: true,
limit: 1,
});
if (results.statuses.length) {
const status = results.statuses[0];
location.hash = currentInstance
? `/${currentInstance}/s/${status.id}`
: `/s/${status.id}`;
} else {
throw new Error('No results');
}
} catch (e) {
setUIState('default');
alert('Error: ' + e);
console.error(e);
}
})();
}}
>
<Icon icon="transfer" /> Switch to my instance to
enable interactions
</button>
</div> </div>
)}{' '} )
{/* {replies?.length > LIMIT && ( )}
</>
) : (
<Link
class="status-link"
to={
instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
}
onClick={() => {
resetScrollPosition(statusID);
}}
>
<Status
statusID={statusID}
instance={instance}
withinContext
size={thread || ancestor ? 'm' : 's'}
enableTranslate
onMediaClick={(e, i) => {
e.preventDefault();
e.stopPropagation();
setSearchParams({
media: i + 1,
mediaStatusID: statusID,
});
}}
/>
{ancestor && isThread && repliesCount > 1 && (
<div class="replies-link">
<Icon icon="comment" />{' '}
<span title={repliesCount}>
{shortenNumber(repliesCount)}
</span>
</div>
)}{' '}
{/* {replies?.length > LIMIT && (
<div class="replies-link"> <div class="replies-link">
<Icon icon="comment" />{' '} <Icon icon="comment" />{' '}
<span title={replies.length}> <span title={replies.length}>
@ -752,90 +830,89 @@ function StatusPage() {
</span> </span>
</div> </div>
)} */} )} */}
</Link> </Link>
)}
{descendant && replies?.length > 0 && (
<SubComments
instance={instance}
hasManyStatuses={hasManyStatuses}
replies={replies}
hasParentThread={thread}
level={1}
/>
)}
{uiState === 'loading' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-loading">
<Loader />
</div>
)} )}
{descendant && replies?.length > 0 && ( {uiState === 'error' &&
<SubComments isHero &&
instance={instance} !!heroStatus?.repliesCount &&
hasManyStatuses={hasManyStatuses} !hasDescendants && (
replies={replies} <div class="status-error">
hasParentThread={thread} Unable to load replies.
level={1} <br />
/> <button
type="button"
class="plain"
onClick={() => {
states.reloadStatusPage++;
}}
>
Try again
</button>
</div>
)} )}
{uiState === 'loading' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-loading">
<Loader />
</div>
)}
{uiState === 'error' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-error">
Unable to load replies.
<br />
<button
type="button"
class="plain"
onClick={() => {
states.reloadStatusPage++;
}}
>
Try again
</button>
</div>
)}
</li>
);
})}
{showMore > 0 && (
<li>
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => setLimit((l) => l + LIMIT)}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;{' '}
<span class="tag">
{showMore > LIMIT ? `${LIMIT}+` : showMore}
</span>
</button>
</li> </li>
)} );
</ul> })}
) : ( {showMore > 0 && (
<> <li>
{uiState === 'loading' && ( <button
<ul class="timeline flat contextual grow loading"> type="button"
<li> class="plain block"
<Status skeleton size="l" /> disabled={uiState === 'loading'}
</li> onClick={() => setLimit((l) => l + LIMIT)}
</ul> style={{ marginBlockEnd: '6em' }}
)} >
{uiState === 'error' && ( Show more&hellip;{' '}
<p class="ui-state"> <span class="tag">
Unable to load status {showMore > LIMIT ? `${LIMIT}+` : showMore}
<br /> </span>
<br /> </button>
<button </li>
type="button" )}
onClick={() => { </ul>
states.reloadStatusPage++; ) : (
}} <>
> {uiState === 'loading' && (
Try again <ul class="timeline flat contextual grow loading">
</button> <li>
</p> <Status skeleton size="l" />
)} </li>
</> </ul>
)} )}
</div> {uiState === 'error' && (
<p class="ui-state">
Unable to load status
<br />
<br />
<button
type="button"
onClick={() => {
states.reloadStatusPage++;
}}
>
Try again
</button>
</p>
)}
</>
)}
</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;