fix regressiosn

This commit is contained in:
2026-02-14 14:39:03 +00:00
parent 2612e7b53b
commit ffa3f2a8b5
6 changed files with 237 additions and 174 deletions

View File

@ -185,7 +185,7 @@ const Item: React.FC<IItem> = ({
target='_blank'
>
<StillImage
className='block size-full'
className='size-full'
src={mediaPreview ? attachment.preview_url : attachment.url}
alt={attachment.description}
letterboxed={letterboxed}

View File

@ -188,7 +188,7 @@ function parseContent({
const transformText = (data: string, key?: React.Key) => {
const text = speakAsCat ? nyaize(data) : data;
return <Emojify key={key} text={text} emojis={emojiMap} />;
return <Emojify key={key} text={text} emojis={emojiMap} large />;
};
const options: HTMLReactParserOptions = {

View File

@ -195,7 +195,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
{...(expandable ? { onClick: toggleExpanded, role: 'button' } : {})}
>
<span ref={spoilerNode}>
<Emojify text={spoilerText} emojis={status.emojis} />
<Emojify text={spoilerText} emojis={status.emojis} large />
</span>
{expandable && (
<button onClick={toggleExpanded}>

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import split from 'graphemesplit';
import React from 'react';
@ -21,9 +22,10 @@ import type { CustomEmoji } from 'pl-api';
interface IMaybeEmoji {
text: string;
emojis: Record<string, CustomEmoji>;
large?: boolean;
}
const MaybeEmoji: React.FC<IMaybeEmoji> = ({ text, emojis }) => {
const MaybeEmoji: React.FC<IMaybeEmoji> = ({ text, emojis, large }) => {
if (text.length < 3) return text;
if (text in emojis) {
const emoji = emojis[text];
@ -34,14 +36,13 @@ const MaybeEmoji: React.FC<IMaybeEmoji> = ({ text, emojis }) => {
<Tooltip text={text} delay={750}>
<span>
<Emoji
className='emojione size-16 transition-transform hover:scale-125'
className={clsx('emojione transition-transform hover:scale-125', { 'size-16': large })}
emoji={text}
src={filename}
/>
</span>
</Tooltip>
);
// return <img draggable={false} className='emojione transition-transform hover:scale-125' alt={text} title={text} src={filename} />;
}
}
@ -51,9 +52,10 @@ const MaybeEmoji: React.FC<IMaybeEmoji> = ({ text, emojis }) => {
interface IEmojify {
text: string;
emojis?: Array<CustomEmoji> | Record<string, CustomEmoji>;
large?: boolean;
}
const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {} }) => {
const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {}, large }) => {
const { disableUserProvidedMedia, systemEmojiFont } = useSettings();
if (Array.isArray(emojis)) emojis = makeEmojiMap(emojis);
@ -121,7 +123,7 @@ const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {} }) => {
// we see another : we convert it and clear the stack buffer
if (open) {
nodes.push(<MaybeEmoji key={index} text={stack} emojis={emojis} />);
nodes.push(<MaybeEmoji key={index} text={stack} emojis={emojis} large={large} />);
stack = '';
}

View File

@ -44,7 +44,7 @@ const CompareHistoryModal: React.FC<BaseModalProps & CompareHistoryModalProps> =
{version.spoiler_text.length > 0 && (
<>
<span>
<Emojify text={version.spoiler_text} emojis={version.emojis} />
<Emojify text={version.spoiler_text} emojis={version.emojis} large />
</span>
<hr />
</>

View File

@ -1,8 +1,6 @@
import { animated, useSpring } from '@react-spring/web';
import { Link } from '@tanstack/react-router';
import { useDrag } from '@use-gesture/react';
import clsx from 'clsx';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { type RefCallback, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { fetchStatusWithContext } from '@/actions/statuses';
@ -12,6 +10,7 @@ import StatusActionBar from '@/components/status-action-bar';
import HStack from '@/components/ui/hstack';
import Icon from '@/components/ui/icon';
import IconButton from '@/components/ui/icon-button';
import Stack from '@/components/ui/stack';
import Audio from '@/features/audio';
import PlaceholderStatus from '@/features/placeholder/components/placeholder-status';
import Thread from '@/features/status/components/thread';
@ -25,8 +24,6 @@ import { makeGetStatus } from '@/selectors';
import type { BaseModalProps } from '@/features/ui/components/modal-root';
import type { MediaAttachment } from 'pl-api';
const MIN_SWIPE_DISTANCE = 400;
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
expand: { id: 'lightbox.expand', defaultMessage: 'Expand' },
@ -66,93 +63,95 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
const [navigationHidden, setNavigationHidden] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(!status);
const [wrapperStyles, api] = useSpring(() => ({
x: `-${index * 100}%`,
}), [index]);
const handleChangeIndex = useCallback(
(newIndex: number) => {
if (newIndex < 0) {
newIndex = media.length + newIndex;
} else if (newIndex >= media.length) {
newIndex = newIndex % media.length;
}
setIndex(newIndex);
setZoomedIn(false);
},
[media.length],
);
const handleChangeIndex = useCallback((newIndex: number, animate = false) => {
if (newIndex < 0) {
newIndex = media.length + newIndex;
} else if (newIndex >= media.length) {
newIndex = newIndex % media.length;
}
setIndex(newIndex);
setZoomedIn(false);
if (animate) {
void api.start({ x: `calc(-${newIndex * 100}% + 0px)` });
}
}, [api, media.length]);
const handlePrevClick = useCallback(() => {
handleChangeIndex(index - 1, true);
}, [handleChangeIndex, index]);
const handleNextClick = useCallback(() => {
handleChangeIndex(index + 1, true);
const handlePrevClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
handleChangeIndex(index - 1);
}, [handleChangeIndex, index]);
// const [viewportDimensions, setViewportDimensions] = useState<{
// width: number;
// height: number;
// }>({ width: 0, height: 0 });
const handleNextClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
handleChangeIndex(index + 1);
}, [handleChangeIndex, index]);
// const handleRef: RefCallback<HTMLDivElement> = useCallback((ele) => {
// if (ele?.clientWidth && ele.clientHeight) {
// setViewportDimensions({
// width: ele.clientWidth,
// height: ele.clientHeight,
// });
// }
// }, []);
const [viewportDimensions, setViewportDimensions] = useState<{
width: number;
height: number;
}>({ width: 0, height: 0 });
const handleRef: RefCallback<HTMLDivElement> = useCallback((ele) => {
if (ele?.clientWidth && ele.clientHeight) {
setViewportDimensions({
width: ele.clientWidth,
height: ele.clientHeight,
});
}
}, []);
const hasMultipleImages = media.length > 1;
const navigationHiddenClassName = navigationHidden ? 'pointer-events-none opacity-0' : '';
const handleKeyDown = (e: KeyboardEvent) => {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
handlePrevClick();
handleChangeIndex(index - 1);
e.preventDefault();
e.stopPropagation();
break;
case 'ArrowRight':
handleNextClick();
handleChangeIndex(index + 1);
e.preventDefault();
e.stopPropagation();
break;
}
};
}, [handleChangeIndex, index]);
const bind = useDrag(
({ active, movement: [mx], direction: [xDir], cancel, event }) => {
// Disable swipe when zoomed in.
if (zoomedIn) {
return;
}
// Touch swipe handling
const touchStartX = useRef<number | null>(null);
const touchStartY = useRef<number | null>(null);
const carouselRef = useRef<HTMLDivElement>(null);
// Disable swipe when interacting with video/audio controls or other interactive elements
const target = event?.target as HTMLElement | null;
if (target) {
const interactiveParent = target.closest('.video-player__controls, button');
if (interactiveParent) {
cancel();
return;
}
}
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (zoomedIn) return;
touchStartX.current = e.touches[0].clientX;
touchStartY.current = e.touches[0].clientY;
}, [zoomedIn]);
// If dragging and swipe distance is enough, change the index.
if (
active &&
Math.abs(mx) > Math.min(window.innerWidth / 4, MIN_SWIPE_DISTANCE)
) {
handleChangeIndex(index - xDir);
cancel();
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (zoomedIn || touchStartX.current === null || touchStartY.current === null) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartX.current;
const deltaY = touchEndY - touchStartY.current;
// Only trigger if horizontal swipe is greater than vertical (to avoid conflicts with scroll)
// and if the swipe distance is significant enough
const minSwipeDistance = 50;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0) {
handleChangeIndex(index - 1); // Swipe right = previous
} else {
handleChangeIndex(index + 1); // Swipe left = next
}
// Set the x position via calc to ensure proper centering regardless of screen size.
const x = active ? mx : 0;
void api.start({ x: `calc(-${index * 100}% + ${x}px)` });
},
{ pointer: { capture: false } },
);
}
touchStartX.current = null;
touchStartY.current = null;
}, [zoomedIn, handleChangeIndex, index]);
const handleDownload = () => {
const mediaItem = hasMultipleImages ? media[index as number] : media[0];
@ -163,17 +162,17 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
setNavigationHidden(value => !value && userTouching.matches);
};
// const currentMedia = media[index];
const currentMedia = media[index];
// const zoomable =
// currentMedia.type === 'image' && currentMedia.meta.original &&
// (currentMedia.meta.original.width > viewportDimensions.width || currentMedia.meta.original.height > viewportDimensions.height);
const zoomable =
currentMedia.type === 'image' && currentMedia.meta.original &&
(currentMedia.meta.original.width > viewportDimensions.width || currentMedia.meta.original.height > viewportDimensions.height);
const handleZoomClick = useCallback(() => {
setZoomedIn((prev) => !prev);
}, []);
const content = useMemo(() => media.map((attachment, idx) => {
const content = useMemo(() => media.map((attachment, i) => {
let width: number | undefined, height: number | undefined;
if (attachment.type === 'image' || attachment.type === 'gifv' || attachment.type === 'video') {
width = (attachment.meta?.original?.width);
@ -186,65 +185,71 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
</Link>
));
const slideStyle = { width: `${100 / media.length}%` };
if (attachment.type === 'image') {
return (
<ZoomableImage
src={attachment.url}
blurhash={attachment.blurhash || undefined}
width={width!}
height={height!}
alt={attachment.description}
lang={props.lang}
key={attachment.url}
onClick={toggleNavigation}
onDoubleClick={handleZoomClick}
onClose={onClose}
onZoomChange={setZoomedIn}
zoomedIn={zoomedIn && idx === index}
/>
<div key={attachment.url} className='h-full shrink-0' style={slideStyle}>
<ZoomableImage
blurhash={attachment.blurhash || undefined}
src={attachment.url}
width={width!}
height={height!}
alt={attachment.description}
lang={props.lang}
onClick={toggleNavigation}
onDoubleClick={handleZoomClick}
onClose={onClose}
onZoomChange={setZoomedIn}
zoomedIn={zoomedIn && i === index}
/>
</div>
);
} else if (attachment.type === 'video') {
return (
<Video
preview={attachment.preview_url}
blurhash={attachment.blurhash}
src={attachment.url}
width={width}
height={height}
startTime={time}
detailed
autoFocus={idx === index}
link={link}
alt={attachment.description}
key={attachment.url}
visible
/>
<div key={attachment.url} className='h-full shrink-0' style={slideStyle}>
<Video
preview={attachment.preview_url}
blurhash={attachment.blurhash}
src={attachment.url}
width={width}
height={height}
startTime={time}
detailed
autoFocus={i === index}
link={link}
alt={attachment.description}
visible
/>
</div>
);
} else if (attachment.type === 'audio') {
return (
<Audio
src={attachment.url}
alt={attachment.description}
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : (status?.account.avatar_static) as string | undefined}
backgroundColor={attachment.meta.colors?.background as string | undefined}
foregroundColor={attachment.meta.colors?.foreground as string | undefined}
accentColor={attachment.meta.colors?.accent as string | undefined}
duration={attachment.meta.original?.duration || 0}
key={attachment.url}
/>
<div key={attachment.url} className='h-full shrink-0' style={slideStyle}>
<Audio
src={attachment.url}
alt={attachment.description}
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : (status?.account.avatar_static) as string | undefined}
backgroundColor={attachment.meta.colors?.background as string | undefined}
foregroundColor={attachment.meta.colors?.foreground as string | undefined}
accentColor={attachment.meta.colors?.accent as string | undefined}
duration={attachment.meta.original?.duration || 0}
/>
</div>
);
} else if (attachment.type === 'gifv') {
return (
<ExtendedVideoPlayer
src={attachment.url}
muted
controls={false}
width={width}
height={height}
key={attachment.preview_url}
alt={attachment.description}
onClick={toggleNavigation}
/>
<div key={attachment.preview_url} className='h-full shrink-0' style={slideStyle}>
<ExtendedVideoPlayer
src={attachment.url}
muted
controls={false}
width={width}
height={height}
alt={attachment.description}
onClick={toggleNavigation}
/>
</div>
);
}
@ -287,27 +292,26 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
};
return (
<div className={clsx('⁂-media-modal', { '⁂-media-modal--fullscreen': isFullScreen })} role='presentation'>
<div className='⁂-media-modal media-modal pointer-events-auto fixed inset-0 z-[9999] h-full bg-gray-900/90'>
<div
{...bind()}
onClick={handleClickOutside}
className='⁂-media-modal__content'
// ref={handleRef}
className='absolute inset-0'
role='presentation'
>
<animated.div
style={wrapperStyles}
className='⁂-media-modal__closer'
role='presentation'
onClick={() => onClose()}
<Stack
onClick={handleClickOutside}
className={
clsx('⁂-media-modal__content fixed inset-0 h-full grow transition-all', {
'xl:pr-96': !isFullScreen,
'xl:pr-0': isFullScreen,
})
}
justifyContent='between'
ref={handleRef}
>
{content}
</animated.div>
<div className='⁂-media-modal__navigation'>
<HStack
alignItems='center'
justifyContent='between'
className={clsx('pointer-events-auto z-10 flex-[0_0_60px] p-4 transition-opacity', navigationHiddenClassName)}
className={clsx('flex-[0_0_60px] p-4 transition-opacity', navigationHiddenClassName)}
>
<IconButton
title={intl.formatMessage(messages.close)}
@ -319,7 +323,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
/>
<HStack alignItems='center' space={2}>
{/* {zoomable && (
{zoomable && (
<IconButton
title={intl.formatMessage(zoomedIn ? messages.zoomOut : messages.zoomIn)}
src={zoomedIn ? require('@phosphor-icons/core/regular/magnifying-glass-minus.svg') : require('@phosphor-icons/core/regular/magnifying-glass-plus.svg')}
@ -328,7 +332,7 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
iconClassName='h-5 w-5'
onClick={handleZoomClick}
/>
)} */}
)}
<IconButton
title={intl.formatMessage(messages.download)}
@ -351,9 +355,13 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
)}
</HStack>
</HStack>
{hasMultipleImages && (
<HStack className='z-10 mx-5' justifyContent='between'>
<div className={clsx('pointer-events-auto z-10 flex h-fit items-center transition-opacity', navigationHiddenClassName)}>
{/* Height based on height of top/bottom bars */}
<div
className='relative h-[calc(100vh-120px)] w-full grow'
>
{hasMultipleImages && (
<div className={clsx('absolute inset-y-0 left-5 z-10 flex items-center transition-opacity', navigationHiddenClassName)}>
<button
tabIndex={0}
className='flex size-10 items-center justify-center rounded-full bg-gray-900 text-white'
@ -363,7 +371,28 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
<Icon src={require('@phosphor-icons/core/regular/arrow-left.svg')} className='size-5' />
</button>
</div>
<div className={clsx('pointer-events-auto z-10 flex h-fit items-center transition-opacity', navigationHiddenClassName)}>
)}
<div
ref={carouselRef}
className='absolute inset-0 overflow-hidden'
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div
style={{
width: `${media.length * 100}%`,
transform: `translateX(-${index * (100 / media.length)}%)`,
}}
className='flex h-full transition-transform duration-300 ease-out'
role='presentation'
>
{content}
</div>
</div>
{hasMultipleImages && (
<div className={clsx('absolute inset-y-0 right-5 z-10 flex items-center transition-opacity', navigationHiddenClassName)}>
<button
tabIndex={0}
className='flex size-10 items-center justify-center rounded-full bg-gray-900 text-white'
@ -373,12 +402,44 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
<Icon src={require('@phosphor-icons/core/regular/arrow-right.svg')} className='size-5' />
</button>
</div>
</HStack>
)}
</div>
{hasMultipleImages && (
<div className={clsx('flex-none overflow-x-auto py-2 transition-opacity', navigationHiddenClassName)}>
<HStack
justifyContent='center'
space={2}
className='min-w-min px-4'
>
{media.map((attachment, i) => (
<button
key={attachment.id || i}
onClick={(e) => {
e.stopPropagation();
setIndex(i);
}}
className={clsx(
'size-12 flex-none overflow-hidden rounded-lg border-2 transition-all hover:scale-105',
i === index ? 'border-white' : 'border-transparent opacity-60 hover:opacity-100',
)}
aria-label={`View image ${i + 1}`}
>
<img
src={attachment.preview_url || attachment.url}
alt={attachment.description || `Image ${i + 1}`}
className='size-full object-cover'
/>
</button>
))}
</HStack>
</div>
)}
{status ? (
{status && (
<HStack
justifyContent='center'
className={clsx('pointer-events-auto flex-[0_0_60px] transition-opacity', navigationHiddenClassName)}
className={clsx('flex-[0_0_60px] transition-opacity', navigationHiddenClassName)}
>
<StatusActionBar
status={status}
@ -386,26 +447,26 @@ const MediaModal: React.FC<MediaModalProps & BaseModalProps> = (props) => {
expandable
/>
</HStack>
) : <span />}
</div>
</div>
)}
</Stack>
{status && (
<div
className={
clsx('-right-96 hidden bg-white transition-all xl:fixed xl:inset-y-0 xl:right-0 xl:flex xl:w-96 xl:flex-col', {
'xl:!-right-96': isFullScreen,
})
}
>
<Thread
status={status}
withMedia={false}
itemClassName='px-4'
isModal
/>
</div>
)}
{status && (
<div
className={
clsx('-right-96 hidden bg-white transition-all xl:fixed xl:inset-y-0 xl:right-0 xl:flex xl:w-96 xl:flex-col', {
'xl:!-right-96': isFullScreen,
})
}
>
<Thread
status={status}
withMedia={false}
itemClassName='px-4'
isModal
/>
</div>
)}
</div>
</div>
);
};