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 (
+ <>
+
{
+ if (
+ e.target.classList.contains('carousel-item') ||
+ e.target.classList.contains('media')
+ ) {
+ onClose();
+ }
+ }}
+ >
+ {mediaAttachments?.map((media, i) => {
+ const { blurhash } = media;
+ const rgbAverageColor = blurhash
+ ? getBlurHashAverageColor(blurhash)
+ : null;
+ return (
+
{
+ if (e.target !== e.currentTarget) {
+ setShowControls(!showControls);
+ }
+ }}
+ >
+ {!!media.description && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+ {mediaAttachments?.length > 1 ? (
+
+ {mediaAttachments?.map((media, i) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {!isStatusLocation && (
+ {
+ // if small screen (not media query min-width 40em + 350px), run onClose
+ if (
+ !window.matchMedia('(min-width: calc(40em + 350px))').matches
+ ) {
+ onClose();
+ }
+ }}
+ >
+ See post »
+
+ )}{' '}
+
+
+ {' '}
+
+
+ {mediaAttachments?.length > 1 && (
+
+
+
+
+ )}
+ {!!showMediaAlt && (
+ {
+ if (e.target === e.currentTarget) {
+ setShowMediaAlt(false);
+ }
+ }}
+ >
+
+
+
+
+ {showMediaAlt}
+
+
+
+
+ )}
+ {!!showMediaAlt && (
+ {
+ if (e.target === e.currentTarget) {
+ setShowMediaAlt(false);
+ }
+ }}
+ >
+
+
+
+
+ {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 (
+
+ );
+ } 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 (
+