mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-03-12 08:58:50 +01:00
New UI experiment: media modal + status detail page
- Extracted out the media modal and media component from Status - Use :has CSS selector to do most of the layout work - Expecting edge case UI bugs
This commit is contained in:
parent
ae37d58826
commit
292186e918
7 changed files with 547 additions and 430 deletions
31
src/app.css
31
src/app.css
|
@ -732,6 +732,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
touch-action: pan-x;
|
touch-action: pan-x;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.carousel::-webkit-scrollbar {
|
.carousel::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -743,7 +744,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
background-color: var(--average-color-alpha);
|
background-color: var(--average-color-alpha);
|
||||||
|
@ -756,7 +757,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
}
|
}
|
||||||
.carousel > * :is(img, video) {
|
.carousel > * :is(img, video) {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100vw;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
|
@ -824,8 +825,8 @@ button.carousel-dot:is(.active, [disabled].active) {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
.carousel-controls {
|
.carousel-controls {
|
||||||
transform: scaleX(200%);
|
transform: scale(0);
|
||||||
transition: transform 0.2s ease-in-out;
|
/* transition: transform 0.2s ease-in-out; */
|
||||||
}
|
}
|
||||||
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
||||||
opacity: 1;
|
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 */
|
||||||
|
|
||||||
#compose-button {
|
#compose-button {
|
||||||
|
|
22
src/app.jsx
22
src/app.jsx
|
@ -20,6 +20,7 @@ import Drafts from './components/drafts';
|
||||||
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 Modal from './components/modal';
|
import Modal from './components/modal';
|
||||||
import NotFound from './pages/404';
|
import NotFound from './pages/404';
|
||||||
import Bookmarks from './pages/bookmarks';
|
import Bookmarks from './pages/bookmarks';
|
||||||
|
@ -312,6 +313,27 @@ function App() {
|
||||||
<Drafts />
|
<Drafts />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
{!!snapStates.showMediaModal && (
|
||||||
|
<Modal
|
||||||
|
onClick={(e) => {
|
||||||
|
if (
|
||||||
|
e.target === e.currentTarget ||
|
||||||
|
e.target.classList.contains('media')
|
||||||
|
) {
|
||||||
|
states.showMediaModal = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MediaModal
|
||||||
|
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
|
||||||
|
index={snapStates.showMediaModal.index}
|
||||||
|
statusID={snapStates.showMediaModal.statusID}
|
||||||
|
onClose={() => {
|
||||||
|
states.showMediaModal = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
282
src/components/media-modal.jsx
Normal file
282
src/components/media-modal.jsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
tabIndex="-1"
|
||||||
|
data-swipe-threshold="44"
|
||||||
|
class="carousel"
|
||||||
|
onClick={(e) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
class="carousel-item"
|
||||||
|
style={{
|
||||||
|
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
||||||
|
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
||||||
|
',',
|
||||||
|
)}, .5)`,
|
||||||
|
}}
|
||||||
|
tabindex="0"
|
||||||
|
key={media.id}
|
||||||
|
ref={i === currentIndex ? carouselFocusItem : null}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target !== e.currentTarget) {
|
||||||
|
setShowControls(!showControls);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!!media.description && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain2 media-alt"
|
||||||
|
hidden={!showControls}
|
||||||
|
onClick={() => {
|
||||||
|
setShowMediaAlt(media.description);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="tag">ALT</span>{' '}
|
||||||
|
<span class="media-alt-desc">{media.description}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Media media={media} showOriginal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-top-controls" hidden={!showControls}>
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain3"
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{mediaAttachments?.length > 1 ? (
|
||||||
|
<span class="carousel-dots">
|
||||||
|
{mediaAttachments?.map((media, i) => (
|
||||||
|
<button
|
||||||
|
key={media.id}
|
||||||
|
type="button"
|
||||||
|
disabled={i === currentIndex}
|
||||||
|
class={`plain carousel-dot ${
|
||||||
|
i === currentIndex ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * i,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{!isStatusLocation && (
|
||||||
|
<Link
|
||||||
|
to={`/s/${statusID}`}
|
||||||
|
class="button carousel-button plain3"
|
||||||
|
onClick={() => {
|
||||||
|
// if small screen (not media query min-width 40em + 350px), run onClose
|
||||||
|
if (
|
||||||
|
!window.matchMedia('(min-width: calc(40em + 350px))').matches
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See post »
|
||||||
|
</Link>
|
||||||
|
)}{' '}
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
mediaAttachments[currentIndex]?.remoteUrl ||
|
||||||
|
mediaAttachments[currentIndex]?.url
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
class="button carousel-button plain3"
|
||||||
|
title="Open original media in new window"
|
||||||
|
>
|
||||||
|
<Icon icon="popout" alt="Open original media in new window" />
|
||||||
|
</a>{' '}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mediaAttachments?.length > 1 && (
|
||||||
|
<div class="carousel-controls" hidden={!showControls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain3"
|
||||||
|
hidden={currentIndex === 0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-left" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain3"
|
||||||
|
hidden={currentIndex === mediaAttachments.length - 1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
carouselRef.current.scrollTo({
|
||||||
|
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!showMediaAlt && (
|
||||||
|
<Modal
|
||||||
|
class="light"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowMediaAlt(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<h2>Media description</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showMediaAlt}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{!!showMediaAlt && (
|
||||||
|
<Modal
|
||||||
|
class="light"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowMediaAlt(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<h2>Media description</h2>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showMediaAlt}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MediaModal;
|
198
src/components/media.jsx
Normal file
198
src/components/media.jsx
Normal file
|
@ -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 (
|
||||||
|
<div
|
||||||
|
class={`media media-image`}
|
||||||
|
onClick={onClick}
|
||||||
|
style={
|
||||||
|
showOriginal && {
|
||||||
|
backgroundImage: `url(${previewUrl})`,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
aspectRatio: `${width}/${height}`,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={mediaURL}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading={showOriginal ? 'eager' : 'lazy'}
|
||||||
|
style={
|
||||||
|
!showOriginal && {
|
||||||
|
backgroundColor:
|
||||||
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
backgroundPosition: focalBackgroundPosition || 'center',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} 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 (
|
||||||
|
<div
|
||||||
|
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||||
|
autoGIFAnimate ? 'media-contain' : ''
|
||||||
|
}`}
|
||||||
|
data-formatted-duration={formattedDuration}
|
||||||
|
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
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 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
<video
|
||||||
|
src="${url}"
|
||||||
|
poster="${previewUrl}"
|
||||||
|
width="${width}"
|
||||||
|
height="${height}"
|
||||||
|
preload="auto"
|
||||||
|
autoplay
|
||||||
|
muted="${isGIF}"
|
||||||
|
${isGIF ? '' : 'controls'}
|
||||||
|
playsinline
|
||||||
|
loop="${loopable}"
|
||||||
|
${
|
||||||
|
isGIF
|
||||||
|
? 'ondblclick="this.paused ? this.play() : this.pause()"'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
></video>
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : isGIF ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={url}
|
||||||
|
poster={previewUrl}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
preload="auto"
|
||||||
|
// controls
|
||||||
|
playsinline
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (type === 'audio') {
|
||||||
|
const formattedDuration = formatDuration(original.duration);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="media media-audio"
|
||||||
|
data-formatted-duration={formattedDuration}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{showOriginal ? (
|
||||||
|
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
||||||
|
) : previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={description}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Media;
|
|
@ -30,6 +30,7 @@ import visibilityIconsMap from '../utils/visibility-icons-map';
|
||||||
import Avatar from './avatar';
|
import Avatar from './avatar';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import Link from './link';
|
import Link from './link';
|
||||||
|
import Media from './media';
|
||||||
import RelativeTime from './relative-time';
|
import RelativeTime from './relative-time';
|
||||||
|
|
||||||
function fetchAccount(id) {
|
function fetchAccount(id) {
|
||||||
|
@ -151,8 +152,6 @@ function Status({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showMediaModal, setShowMediaModal] = useState(false);
|
|
||||||
|
|
||||||
if (reblog) {
|
if (reblog) {
|
||||||
return (
|
return (
|
||||||
<div class="status-reblog" onMouseEnter={debugHover}>
|
<div class="status-reblog" onMouseEnter={debugHover}>
|
||||||
|
@ -408,7 +407,11 @@ function Status({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowMediaModal(i);
|
states.showMediaModal = {
|
||||||
|
mediaAttachments,
|
||||||
|
index: i,
|
||||||
|
statusID: readOnly ? null : id,
|
||||||
|
};
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -621,17 +624,6 @@ function Status({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showMediaModal !== false && (
|
|
||||||
<Modal>
|
|
||||||
<Carousel
|
|
||||||
mediaAttachments={mediaAttachments}
|
|
||||||
index={showMediaModal}
|
|
||||||
onClose={() => {
|
|
||||||
setShowMediaModal(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
{!!showEdited && (
|
{!!showEdited && (
|
||||||
<Modal
|
<Modal
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -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 (
|
|
||||||
<div
|
|
||||||
class={`media media-image`}
|
|
||||||
onClick={onClick}
|
|
||||||
style={
|
|
||||||
showOriginal && {
|
|
||||||
backgroundImage: `url(${previewUrl})`,
|
|
||||||
backgroundSize: 'contain',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
aspectRatio: `${width}/${height}`,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={mediaURL}
|
|
||||||
alt={description}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
loading={showOriginal ? 'eager' : 'lazy'}
|
|
||||||
style={
|
|
||||||
!showOriginal && {
|
|
||||||
backgroundColor:
|
|
||||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
||||||
backgroundPosition: focalBackgroundPosition || 'center',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} 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 (
|
|
||||||
<div
|
|
||||||
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
|
||||||
autoGIFAnimate ? 'media-contain' : ''
|
|
||||||
}`}
|
|
||||||
data-formatted-duration={formattedDuration}
|
|
||||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
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 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
<video
|
|
||||||
src="${url}"
|
|
||||||
poster="${previewUrl}"
|
|
||||||
width="${width}"
|
|
||||||
height="${height}"
|
|
||||||
preload="auto"
|
|
||||||
autoplay
|
|
||||||
muted="${isGIF}"
|
|
||||||
${isGIF ? '' : 'controls'}
|
|
||||||
playsinline
|
|
||||||
loop="${loopable}"
|
|
||||||
${
|
|
||||||
isGIF
|
|
||||||
? 'ondblclick="this.paused ? this.play() : this.pause()"'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
></video>
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : isGIF ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={url}
|
|
||||||
poster={previewUrl}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
preload="auto"
|
|
||||||
// controls
|
|
||||||
playsinline
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt={description}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (type === 'audio') {
|
|
||||||
const formattedDuration = formatDuration(original.duration);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class="media media-audio"
|
|
||||||
data-formatted-duration={formattedDuration}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{showOriginal ? (
|
|
||||||
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
|
||||||
) : previewUrl ? (
|
|
||||||
<img
|
|
||||||
src={previewUrl}
|
|
||||||
alt={description}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ card }) {
|
function Card({ card }) {
|
||||||
const {
|
const {
|
||||||
blurhash,
|
blurhash,
|
||||||
|
@ -1261,224 +1061,7 @@ function StatusButton({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
export function formatDuration(time) {
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={carouselRef}
|
|
||||||
tabIndex="-1"
|
|
||||||
data-swipe-threshold="44"
|
|
||||||
class="carousel"
|
|
||||||
onClick={(e) => {
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
class="carousel-item"
|
|
||||||
style={{
|
|
||||||
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
|
|
||||||
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
|
|
||||||
',',
|
|
||||||
)}, .5)`,
|
|
||||||
}}
|
|
||||||
tabindex="0"
|
|
||||||
key={media.id}
|
|
||||||
ref={i === currentIndex ? carouselFocusItem : null}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target !== e.currentTarget) {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!!media.description && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="plain2 media-alt"
|
|
||||||
hidden={!showControls}
|
|
||||||
onClick={() => {
|
|
||||||
setShowMediaAlt(media.description);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="tag">ALT</span>{' '}
|
|
||||||
<span class="media-alt-desc">{media.description}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Media media={media} showOriginal />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div class="carousel-top-controls" hidden={!showControls}>
|
|
||||||
<span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-button plain3"
|
|
||||||
onClick={() => onClose()}
|
|
||||||
>
|
|
||||||
<Icon icon="x" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{mediaAttachments?.length > 1 ? (
|
|
||||||
<span class="carousel-dots">
|
|
||||||
{mediaAttachments?.map((media, i) => (
|
|
||||||
<button
|
|
||||||
key={media.id}
|
|
||||||
type="button"
|
|
||||||
disabled={i === currentIndex}
|
|
||||||
class={`plain carousel-dot ${
|
|
||||||
i === currentIndex ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
carouselRef.current.scrollTo({
|
|
||||||
left: carouselRef.current.clientWidth * i,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
•
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
mediaAttachments[currentIndex]?.remoteUrl ||
|
|
||||||
mediaAttachments[currentIndex]?.url
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
class="button carousel-button plain3"
|
|
||||||
title="Open original media in new window"
|
|
||||||
>
|
|
||||||
<Icon icon="popout" alt="Open original media in new window" />
|
|
||||||
</a>{' '}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{mediaAttachments?.length > 1 && (
|
|
||||||
<div class="carousel-controls" hidden={!showControls}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-button plain3"
|
|
||||||
hidden={currentIndex === 0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
carouselRef.current.scrollTo({
|
|
||||||
left: carouselRef.current.clientWidth * (currentIndex - 1),
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="arrow-left" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="carousel-button plain3"
|
|
||||||
hidden={currentIndex === mediaAttachments.length - 1}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
carouselRef.current.scrollTo({
|
|
||||||
left: carouselRef.current.clientWidth * (currentIndex + 1),
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="arrow-right" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!!showMediaAlt && (
|
|
||||||
<Modal
|
|
||||||
class="light"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
setShowMediaAlt(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="sheet">
|
|
||||||
<header>
|
|
||||||
<h2>Media description</h2>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showMediaAlt}
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(time) {
|
|
||||||
if (!time) return;
|
if (!time) return;
|
||||||
let hours = Math.floor(time / 3600);
|
let hours = Math.floor(time / 3600);
|
||||||
let minutes = Math.floor((time % 3600) / 60);
|
let minutes = Math.floor((time % 3600) / 60);
|
||||||
|
|
|
@ -316,6 +316,9 @@ function StatusPage() {
|
||||||
if (!pathname || pathname.startsWith('/s/')) return '/';
|
if (!pathname || pathname.startsWith('/s/')) return '/';
|
||||||
return pathname;
|
return pathname;
|
||||||
}, []);
|
}, []);
|
||||||
|
const onClose = () => {
|
||||||
|
states.showMediaModal = false;
|
||||||
|
};
|
||||||
|
|
||||||
const [limit, setLimit] = useState(LIMIT);
|
const [limit, setLimit] = useState(LIMIT);
|
||||||
const showMore = useMemo(() => {
|
const showMore = useMemo(() => {
|
||||||
|
@ -338,6 +341,7 @@ function StatusPage() {
|
||||||
|
|
||||||
useHotkeys(['esc', 'backspace'], () => {
|
useHotkeys(['esc', 'backspace'], () => {
|
||||||
// location.hash = closeLink;
|
// location.hash = closeLink;
|
||||||
|
onClose();
|
||||||
navigate(closeLink);
|
navigate(closeLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -413,7 +417,7 @@ function StatusPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="deck-backdrop">
|
<div class="deck-backdrop">
|
||||||
<Link to={closeLink}></Link>
|
<Link to={closeLink} onClick={onClose}></Link>
|
||||||
<div
|
<div
|
||||||
tabIndex="-1"
|
tabIndex="-1"
|
||||||
ref={scrollableRef}
|
ref={scrollableRef}
|
||||||
|
@ -519,7 +523,11 @@ function StatusPage() {
|
||||||
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
<Link class="button plain deck-close" to={closeLink}>
|
<Link
|
||||||
|
class="button plain deck-close"
|
||||||
|
to={closeLink}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
<Icon icon="x" size="xl" />
|
<Icon icon="x" size="xl" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -26,6 +26,7 @@ const states = proxy({
|
||||||
showSettings: false,
|
showSettings: false,
|
||||||
showAccount: false,
|
showAccount: false,
|
||||||
showDrafts: false,
|
showDrafts: false,
|
||||||
|
showMediaModal: false,
|
||||||
composeCharacterCount: 0,
|
composeCharacterCount: 0,
|
||||||
settings: {
|
settings: {
|
||||||
boostsCarousel: store.local.get('settings:boostsCarousel')
|
boostsCarousel: store.local.get('settings:boostsCarousel')
|
||||||
|
|
Loading…
Reference in a new issue