diff --git a/src/app.css b/src/app.css index 52264043..54b16d93 100644 --- a/src/app.css +++ b/src/app.css @@ -732,6 +732,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { overscroll-behavior: contain; touch-action: pan-x; user-select: none; + width: 100%; } .carousel::-webkit-scrollbar { display: none; @@ -743,7 +744,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { display: flex; justify-content: center; align-items: center; - width: 100vw; + width: 100%; height: 100vh; height: 100dvh; background-color: var(--average-color-alpha); @@ -756,7 +757,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { } .carousel > * :is(img, video) { width: auto; - max-width: 100vw; + max-width: 100%; height: auto; max-height: 100vh; max-height: 100dvh; @@ -824,8 +825,8 @@ button.carousel-dot:is(.active, [disabled].active) { transition: transform 0.2s ease-in-out; } .carousel-controls { - transform: scaleX(200%); - transition: transform 0.2s ease-in-out; + transform: scale(0); + /* transition: transform 0.2s ease-in-out; */ } :is(.carousel-top-controls, .carousel-controls)[hidden] { opacity: 1; @@ -846,6 +847,28 @@ button.carousel-dot:is(.active, [disabled].active) { } } +/* CAROUSEL + STATUS PAGE COMBO */ + +@media (min-width: calc(40em + 350px)) { + #modal-container > div, + .status-deck { + transition: all 0.3s ease-in-out; + } + /* Don't do this if there's a modal sheet (.sheet) */ + :has(#modal-container .carousel):has(.status-deck):not(:has(.sheet)) + .status-deck { + width: 350px; + min-width: 0; + } + :has(#modal-container .carousel):has(.status-deck):not(:has(.sheet)) + #modal-container + > div { + left: 0; + right: 350px; + width: auto; + } +} + /* COMPOSE BUTTON */ #compose-button { diff --git a/src/app.jsx b/src/app.jsx index 8ffe6206..5c06eb28 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -20,6 +20,7 @@ import Drafts from './components/drafts'; import Icon from './components/icon'; import Link from './components/link'; import Loader from './components/loader'; +import MediaModal from './components/media-modal'; import Modal from './components/modal'; import NotFound from './pages/404'; import Bookmarks from './pages/bookmarks'; @@ -312,6 +313,27 @@ function App() { )} + {!!snapStates.showMediaModal && ( + { + if ( + e.target === e.currentTarget || + e.target.classList.contains('media') + ) { + states.showMediaModal = false; + } + }} + > + { + states.showMediaModal = false; + }} + /> + + )} ); } diff --git a/src/components/media-modal.jsx b/src/components/media-modal.jsx new file mode 100644 index 00000000..0f757643 --- /dev/null +++ b/src/components/media-modal.jsx @@ -0,0 +1,282 @@ +import { getBlurHashAverageColor } from 'fast-blurhash'; +import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { useMatch } from 'react-router-dom'; + +import Icon from './icon'; +import Link from './link'; +import Media from './media'; +import Modal from './modal'; + +function MediaModal({ + mediaAttachments, + statusID, + index = 0, + onClose = () => {}, +}) { + const carouselRef = useRef(null); + const isStatusLocation = useMatch('/s/:id'); + + const [currentIndex, setCurrentIndex] = useState(index); + const carouselFocusItem = useRef(null); + useLayoutEffect(() => { + carouselFocusItem.current?.scrollIntoView(); + }, []); + useEffect(() => { + const scrollLeft = index * carouselRef.current.clientWidth; + carouselRef.current.scrollTo({ + left: scrollLeft, + behavior: 'smooth', + }); + }, [index]); + + const [showControls, setShowControls] = useState(true); + + useEffect(() => { + let handleSwipe = () => { + onClose(); + }; + if (carouselRef.current) { + carouselRef.current.addEventListener('swiped-down', handleSwipe); + } + return () => { + if (carouselRef.current) { + carouselRef.current.removeEventListener('swiped-down', handleSwipe); + } + }; + }, []); + + useHotkeys('esc', onClose, [onClose]); + + const [showMediaAlt, setShowMediaAlt] = useState(false); + + useEffect(() => { + let handleScroll = () => { + const { clientWidth, scrollLeft } = carouselRef.current; + const index = Math.round(scrollLeft / clientWidth); + setCurrentIndex(index); + }; + if (carouselRef.current) { + carouselRef.current.addEventListener('scroll', handleScroll, { + passive: true, + }); + } + return () => { + if (carouselRef.current) { + carouselRef.current.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + return ( + <> + + + {mediaAttachments?.length > 1 && ( + + )} + {!!showMediaAlt && ( + { + if (e.target === e.currentTarget) { + setShowMediaAlt(false); + } + }} + > +
+
+

Media description

+
+
+

+ {showMediaAlt} +

+
+
+
+ )} + {!!showMediaAlt && ( + { + if (e.target === e.currentTarget) { + setShowMediaAlt(false); + } + }} + > +
+
+

Media description

+
+
+

+ {showMediaAlt} +

+
+
+
+ )} + + ); +} + +export default MediaModal; diff --git a/src/components/media.jsx b/src/components/media.jsx new file mode 100644 index 00000000..64d3ac4f --- /dev/null +++ b/src/components/media.jsx @@ -0,0 +1,198 @@ +import { getBlurHashAverageColor } from 'fast-blurhash'; +import { useRef } from 'preact/hooks'; + +import { formatDuration } from './status'; + +/* +Media type +=== +unknown = unsupported or unrecognized file type +image = Static image +gifv = Looping, soundless animation +video = Video clip +audio = Audio track +*/ + +function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { + const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = + media; + const { original, small, focus } = meta || {}; + + const width = showOriginal ? original?.width : small?.width; + const height = showOriginal ? original?.height : small?.height; + const mediaURL = showOriginal ? url : previewUrl; + + const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; + + const videoRef = useRef(); + + let focalBackgroundPosition; + if (focus) { + // Convert focal point to CSS background position + // Formula from jquery-focuspoint + // x = -1, y = 1 => 0% 0% + // x = 0, y = 0 => 50% 50% + // x = 1, y = -1 => 100% 100% + const x = ((focus.x + 1) / 2) * 100; + const y = ((1 - focus.y) / 2) * 100; + focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`; + } + + if (type === 'image' || (type === 'unknown' && previewUrl && url)) { + // Note: type: unknown might not have width/height + return ( +
+ {description} +
+ ); + } else if (type === 'gifv' || type === 'video') { + const shortDuration = original.duration < 31; + const isGIF = type === 'gifv' && shortDuration; + // If GIF is too long, treat it as a video + const loopable = original.duration < 61; + const formattedDuration = formatDuration(original.duration); + const hoverAnimate = !showOriginal && !autoAnimate && isGIF; + const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; + return ( +
{ + if (hoverAnimate) { + try { + videoRef.current.pause(); + } catch (e) {} + } + onClick(e); + }} + onMouseEnter={() => { + if (hoverAnimate) { + try { + videoRef.current.play(); + } catch (e) {} + } + }} + onMouseLeave={() => { + if (hoverAnimate) { + try { + videoRef.current.pause(); + } catch (e) {} + } + }} + > + {showOriginal || autoGIFAnimate ? ( +
+ `, + }} + /> + ) : isGIF ? ( +
+ ); + } else if (type === 'audio') { + const formattedDuration = formatDuration(original.duration); + return ( +
+ {showOriginal ? ( +
+ ); + } +} + +export default Media; diff --git a/src/components/status.jsx b/src/components/status.jsx index 9d72587a..9498690b 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -30,6 +30,7 @@ import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; +import Media from './media'; import RelativeTime from './relative-time'; function fetchAccount(id) { @@ -151,8 +152,6 @@ function Status({ } }; - const [showMediaModal, setShowMediaModal] = useState(false); - if (reblog) { return (
@@ -408,7 +407,11 @@ function Status({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - setShowMediaModal(i); + states.showMediaModal = { + mediaAttachments, + index: i, + statusID: readOnly ? null : id, + }; }} /> ))} @@ -621,17 +624,6 @@ function Status({ )}
- {showMediaModal !== false && ( - - { - setShowMediaModal(false); - }} - /> - - )} {!!showEdited && ( { @@ -654,198 +646,6 @@ function Status({ ); } -/* -Media type -=== -unknown = unsupported or unrecognized file type -image = Static image -gifv = Looping, soundless animation -video = Video clip -audio = Audio track -*/ - -function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { - const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = - media; - const { original, small, focus } = meta || {}; - - const width = showOriginal ? original?.width : small?.width; - const height = showOriginal ? original?.height : small?.height; - const mediaURL = showOriginal ? url : previewUrl; - - const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; - - const videoRef = useRef(); - - let focalBackgroundPosition; - if (focus) { - // Convert focal point to CSS background position - // Formula from jquery-focuspoint - // x = -1, y = 1 => 0% 0% - // x = 0, y = 0 => 50% 50% - // x = 1, y = -1 => 100% 100% - const x = ((focus.x + 1) / 2) * 100; - const y = ((1 - focus.y) / 2) * 100; - focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`; - } - - if (type === 'image' || (type === 'unknown' && previewUrl && url)) { - // Note: type: unknown might not have width/height - return ( -
- {description} -
- ); - } else if (type === 'gifv' || type === 'video') { - const shortDuration = original.duration < 31; - const isGIF = type === 'gifv' && shortDuration; - // If GIF is too long, treat it as a video - const loopable = original.duration < 61; - const formattedDuration = formatDuration(original.duration); - const hoverAnimate = !showOriginal && !autoAnimate && isGIF; - const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; - return ( -
{ - if (hoverAnimate) { - try { - videoRef.current.pause(); - } catch (e) {} - } - onClick(e); - }} - onMouseEnter={() => { - if (hoverAnimate) { - try { - videoRef.current.play(); - } catch (e) {} - } - }} - onMouseLeave={() => { - if (hoverAnimate) { - try { - videoRef.current.pause(); - } catch (e) {} - } - }} - > - {showOriginal || autoGIFAnimate ? ( -
- `, - }} - /> - ) : isGIF ? ( -
- ); - } else if (type === 'audio') { - const formattedDuration = formatDuration(original.duration); - return ( -
- {showOriginal ? ( -
- ); - } -} - function Card({ card }) { const { blurhash, @@ -1261,224 +1061,7 @@ function StatusButton({ ); } -function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) { - const carouselRef = useRef(null); - - const [currentIndex, setCurrentIndex] = useState(index); - const carouselFocusItem = useRef(null); - useLayoutEffect(() => { - carouselFocusItem.current?.scrollIntoView(); - }, []); - - const [showControls, setShowControls] = useState(true); - - useEffect(() => { - let handleSwipe = () => { - onClose(); - }; - if (carouselRef.current) { - carouselRef.current.addEventListener('swiped-down', handleSwipe); - } - return () => { - if (carouselRef.current) { - carouselRef.current.removeEventListener('swiped-down', handleSwipe); - } - }; - }, []); - - useHotkeys('esc', onClose, [onClose]); - - const [showMediaAlt, setShowMediaAlt] = useState(false); - - useEffect(() => { - let handleScroll = () => { - const { clientWidth, scrollLeft } = carouselRef.current; - const index = Math.round(scrollLeft / clientWidth); - setCurrentIndex(index); - }; - if (carouselRef.current) { - carouselRef.current.addEventListener('scroll', handleScroll, { - passive: true, - }); - } - return () => { - if (carouselRef.current) { - carouselRef.current.removeEventListener('scroll', handleScroll); - } - }; - }, []); - - return ( - <> - - - {mediaAttachments?.length > 1 && ( - - )} - {!!showMediaAlt && ( - { - if (e.target === e.currentTarget) { - setShowMediaAlt(false); - } - }} - > -
-
-

Media description

-
-
-

- {showMediaAlt} -

-
-
-
- )} - - ); -} - -function formatDuration(time) { +export function formatDuration(time) { if (!time) return; let hours = Math.floor(time / 3600); let minutes = Math.floor((time % 3600) / 60); diff --git a/src/pages/status.jsx b/src/pages/status.jsx index a76e7ced..26556c71 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -316,6 +316,9 @@ function StatusPage() { if (!pathname || pathname.startsWith('/s/')) return '/'; return pathname; }, []); + const onClose = () => { + states.showMediaModal = false; + }; const [limit, setLimit] = useState(LIMIT); const showMore = useMemo(() => { @@ -338,6 +341,7 @@ function StatusPage() { useHotkeys(['esc', 'backspace'], () => { // location.hash = closeLink; + onClose(); navigate(closeLink); }); @@ -413,7 +417,7 @@ function StatusPage() { return (
- +
Show all sensitive content - +
diff --git a/src/utils/states.js b/src/utils/states.js index c6dafdf8..06daef98 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -26,6 +26,7 @@ const states = proxy({ showSettings: false, showAccount: false, showDrafts: false, + showMediaModal: false, composeCharacterCount: 0, settings: { boostsCarousel: store.local.get('settings:boostsCarousel')