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' : ''}`}
+ >
+
);
}
@@ -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 && (
diff --git a/src/utils/useScroll.js b/src/utils/useScroll.js
index e8a258e1..dc7c034a 100644
--- a/src/utils/useScroll.js
+++ b/src/utils/useScroll.js
@@ -19,6 +19,7 @@ export default function useScroll({
useEffect(() => {
const scrollableElement = scrollableRef.current;
+ if (!scrollableElement) return {};
let previousScrollStart = isVertical
? scrollableElement.scrollTop
: scrollableElement.scrollLeft;