mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-11 14:30:35 +00:00
Merge commit '2e9b2df570af19b67a5ccc754c7b581034ca79c6' into glitch-soc/merge-upstream
Conflicts: - `app/javascript/styles/mastodon/components.scss`: Upstream removed some code that somehow had been modified in glitch-soc. Removed it as upstream did.
This commit is contained in:
@@ -587,7 +587,7 @@ GEM
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.5.9)
|
||||
pghero (3.6.1)
|
||||
pghero (3.6.2)
|
||||
activerecord (>= 6.1)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
@@ -830,7 +830,7 @@ GEM
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.5)
|
||||
strong_migrations (2.2.0)
|
||||
strong_migrations (2.2.1)
|
||||
activerecord (>= 7)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
@@ -866,7 +866,7 @@ GEM
|
||||
unf (~> 0.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.1)
|
||||
tzinfo-data (1.2025.2)
|
||||
tzinfo (>= 1.0.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
|
||||
@@ -9,13 +9,15 @@ class BackupsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_backup
|
||||
|
||||
BACKUP_LINK_TIMEOUT = 1.hour.freeze
|
||||
|
||||
def download
|
||||
case Paperclip::Attachment.default_options[:storage]
|
||||
when :s3, :azure
|
||||
redirect_to @backup.dump.expiring_url(10), allow_other_host: true
|
||||
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true
|
||||
when :fog
|
||||
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
|
||||
redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
|
||||
redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true
|
||||
else
|
||||
redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
|
||||
end
|
||||
|
||||
@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
|
||||
|
||||
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
@@ -299,7 +300,7 @@ class MediaGallery extends PureComponent {
|
||||
const { visible } = this.state;
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
||||
let children, spoilerButton;
|
||||
let children;
|
||||
|
||||
const style = {};
|
||||
|
||||
@@ -318,35 +319,11 @@ class MediaGallery extends PureComponent {
|
||||
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
}
|
||||
|
||||
if (uncached) {
|
||||
spoilerButton = (
|
||||
<button type='button' disabled className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
} else if (!visible) {
|
||||
spoilerButton = (
|
||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
|
||||
{children}
|
||||
|
||||
{(!visible || uncached) && (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
)}
|
||||
{(!visible || uncached) && <SpoilerButton uncached={uncached} sensitive={sensitive} onClick={this.handleOpen} />}
|
||||
|
||||
{(visible && !uncached) && (
|
||||
<div className='media-gallery__actions'>
|
||||
|
||||
73
app/javascript/mastodon/components/spoiler_button.tsx
Normal file
73
app/javascript/mastodon/components/spoiler_button.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
hidden?: boolean;
|
||||
sensitive: boolean;
|
||||
uncached?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export const SpoilerButton: React.FC<Props> = ({
|
||||
hidden = false,
|
||||
sensitive,
|
||||
uncached = false,
|
||||
onClick,
|
||||
}) => {
|
||||
let warning;
|
||||
let action;
|
||||
|
||||
if (uncached) {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='status.uncached_media_warning'
|
||||
defaultMessage='Preview not available'
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.open' defaultMessage='Click to open' />
|
||||
);
|
||||
} else if (sensitive) {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='status.sensitive_warning'
|
||||
defaultMessage='Sensitive content'
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
|
||||
);
|
||||
} else {
|
||||
warning = (
|
||||
<FormattedMessage
|
||||
id='status.media_hidden'
|
||||
defaultMessage='Media hidden'
|
||||
/>
|
||||
);
|
||||
action = (
|
||||
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('spoiler-button', {
|
||||
'spoiler-button--hidden': hidden,
|
||||
'spoiler-button--click-thru': uncached,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='spoiler-button__overlay'
|
||||
onClick={onClick}
|
||||
disabled={uncached}
|
||||
>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'>{action}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -16,6 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||
|
||||
import { Blurhash } from '../../components/blurhash';
|
||||
@@ -476,14 +477,6 @@ class Audio extends PureComponent {
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
const muted = this.state.muted || volume === 0;
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
|
||||
|
||||
@@ -521,14 +514,7 @@ class Audio extends PureComponent {
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} />
|
||||
|
||||
{(revealed || editable) && <img
|
||||
src={this.props.poster}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LoadingBar } from 'react-redux-loading-bar';
|
||||
|
||||
import ZoomableImage from './zoomable_image';
|
||||
|
||||
export default class ImageLoader extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
alt: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
src: PropTypes.string.isRequired,
|
||||
previewSrc: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
alt: '',
|
||||
lang: '',
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
loading: true,
|
||||
error: false,
|
||||
width: null,
|
||||
};
|
||||
|
||||
removers = [];
|
||||
canvas = null;
|
||||
|
||||
get canvasContext() {
|
||||
if (!this.canvas) {
|
||||
return null;
|
||||
}
|
||||
this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
|
||||
return this._canvasContext;
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this.loadImage(this.props);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (this.props.src !== nextProps.src) {
|
||||
this.loadImage(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.removeEventListeners();
|
||||
}
|
||||
|
||||
loadImage (props) {
|
||||
this.removeEventListeners();
|
||||
this.setState({ loading: true, error: false });
|
||||
Promise.all([
|
||||
props.previewSrc && this.loadPreviewCanvas(props),
|
||||
this.hasSize() && this.loadOriginalImage(props),
|
||||
].filter(Boolean))
|
||||
.then(() => {
|
||||
this.setState({ loading: false, error: false });
|
||||
this.clearPreviewCanvas();
|
||||
})
|
||||
.catch(() => this.setState({ loading: false, error: true }));
|
||||
}
|
||||
|
||||
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const removeEventListeners = () => {
|
||||
image.removeEventListener('error', handleError);
|
||||
image.removeEventListener('load', handleLoad);
|
||||
};
|
||||
const handleError = () => {
|
||||
removeEventListeners();
|
||||
reject();
|
||||
};
|
||||
const handleLoad = () => {
|
||||
removeEventListeners();
|
||||
this.canvasContext.drawImage(image, 0, 0, width, height);
|
||||
resolve();
|
||||
};
|
||||
image.addEventListener('error', handleError);
|
||||
image.addEventListener('load', handleLoad);
|
||||
image.src = previewSrc;
|
||||
this.removers.push(removeEventListeners);
|
||||
});
|
||||
|
||||
clearPreviewCanvas () {
|
||||
const { width, height } = this.canvas;
|
||||
this.canvasContext.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const removeEventListeners = () => {
|
||||
image.removeEventListener('error', handleError);
|
||||
image.removeEventListener('load', handleLoad);
|
||||
};
|
||||
const handleError = () => {
|
||||
removeEventListeners();
|
||||
reject();
|
||||
};
|
||||
const handleLoad = () => {
|
||||
removeEventListeners();
|
||||
resolve();
|
||||
};
|
||||
image.addEventListener('error', handleError);
|
||||
image.addEventListener('load', handleLoad);
|
||||
image.src = src;
|
||||
this.removers.push(removeEventListeners);
|
||||
});
|
||||
|
||||
removeEventListeners () {
|
||||
this.removers.forEach(listeners => listeners());
|
||||
this.removers = [];
|
||||
}
|
||||
|
||||
hasSize () {
|
||||
const { width, height } = this.props;
|
||||
return typeof width === 'number' && typeof height === 'number';
|
||||
}
|
||||
|
||||
setCanvasRef = c => {
|
||||
this.canvas = c;
|
||||
if (c) this.setState({ width: c.offsetWidth });
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
|
||||
const { loading } = this.state;
|
||||
|
||||
const className = classNames('image-loader', {
|
||||
'image-loader--loading': loading,
|
||||
'image-loader--amorphous': !this.hasSize(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className='loading-bar__container' style={{ width: this.state.width || width }}>
|
||||
<LoadingBar className='loading-bar' loading={1} />
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
className='image-loader__preview-canvas'
|
||||
ref={this.setCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ZoomableImage
|
||||
alt={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
onClick={onClick}
|
||||
width={width}
|
||||
height={height}
|
||||
zoomedIn={zoomedIn}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
import ImageLoader from './image_loader';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class ImageModal extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
navigationHidden: false,
|
||||
};
|
||||
|
||||
toggleNavigation = () => {
|
||||
this.setState(prevState => ({
|
||||
navigationHidden: !prevState.navigationHidden,
|
||||
}));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, src, alt, onClose } = this.props;
|
||||
const { navigationHidden } = this.state;
|
||||
|
||||
const navigationClassName = classNames('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div className='media-modal__closer' role='presentation' onClick={onClose} >
|
||||
<ImageLoader
|
||||
src={src}
|
||||
width={400}
|
||||
height={400}
|
||||
alt={alt}
|
||||
onClick={this.toggleNavigation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(ImageModal);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
import { ZoomableImage } from './zoomable_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
export const ImageModal: React.FC<{
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}> = ({ src, alt, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const [navigationHidden, setNavigationHidden] = useState(false);
|
||||
|
||||
const toggleNavigation = useCallback(() => {
|
||||
setNavigationHidden((prevState) => !prevState);
|
||||
}, [setNavigationHidden]);
|
||||
|
||||
const navigationClassName = classNames('media-modal__navigation', {
|
||||
'media-modal__navigation--hidden': navigationHidden,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal media-modal'>
|
||||
<div
|
||||
className='media-modal__closer'
|
||||
role='presentation'
|
||||
onClick={onClose}
|
||||
>
|
||||
<ZoomableImage
|
||||
src={src}
|
||||
width={400}
|
||||
height={400}
|
||||
alt={alt}
|
||||
onClick={toggleNavigation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={navigationClassName}>
|
||||
<div className='media-modal__buttons'>
|
||||
<IconButton
|
||||
className='media-modal__close'
|
||||
title={intl.formatMessage(messages.close)}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
||||
import Video from 'mastodon/features/video';
|
||||
import { disableSwiping } from 'mastodon/initial_state';
|
||||
|
||||
import ImageLoader from './image_loader';
|
||||
import { ZoomableImage } from './zoomable_image';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
@@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
|
||||
}));
|
||||
};
|
||||
|
||||
handleZoomChange = (zoomedIn) => {
|
||||
this.setState({
|
||||
zoomedIn,
|
||||
});
|
||||
};
|
||||
|
||||
handleSwipe = (index) => {
|
||||
this.setState({
|
||||
index: index % this.props.media.size,
|
||||
@@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
|
||||
const leftNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
|
||||
const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
|
||||
|
||||
const content = media.map((image) => {
|
||||
const content = media.map((image, idx) => {
|
||||
const width = image.getIn(['meta', 'original', 'width']) || null;
|
||||
const height = image.getIn(['meta', 'original', 'height']) || null;
|
||||
const description = image.getIn(['translation', 'description']) || image.get('description');
|
||||
|
||||
if (image.get('type') === 'image') {
|
||||
return (
|
||||
<ImageLoader
|
||||
previewSrc={image.get('preview_url')}
|
||||
<ZoomableImage
|
||||
src={image.get('url')}
|
||||
blurhash={image.get('blurhash')}
|
||||
width={width}
|
||||
height={height}
|
||||
alt={description}
|
||||
lang={lang}
|
||||
key={image.get('url')}
|
||||
onClick={this.handleToggleNavigation}
|
||||
zoomedIn={zoomedIn}
|
||||
onDoubleClick={this.handleZoomClick}
|
||||
onClose={onClose}
|
||||
onZoomChange={this.handleZoomChange}
|
||||
zoomedIn={zoomedIn && idx === index}
|
||||
/>
|
||||
);
|
||||
} else if (image.get('type') === 'video') {
|
||||
@@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
|
||||
onChangeIndex={this.handleSwipe}
|
||||
onTransitionEnd={this.handleTransitionEnd}
|
||||
index={index}
|
||||
disabled={disableSwiping}
|
||||
disabled={disableSwiping || zoomedIn}
|
||||
>
|
||||
{content}
|
||||
</ReactSwipeableViews>
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
ConfirmFollowToListModal,
|
||||
ConfirmMissingAltTextModal,
|
||||
} from './confirmation_modals';
|
||||
import ImageModal from './image_modal';
|
||||
import { ImageModal } from './image_modal';
|
||||
import MediaModal from './media_modal';
|
||||
import { ModalPlaceholder } from './modal_placeholder';
|
||||
import VideoModal from './video_modal';
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 4;
|
||||
const NAV_BAR_HEIGHT = 66;
|
||||
|
||||
const getMidpoint = (p1, p2) => ({
|
||||
x: (p1.clientX + p2.clientX) / 2,
|
||||
y: (p1.clientY + p2.clientY) / 2,
|
||||
});
|
||||
|
||||
const getDistance = (p1, p2) =>
|
||||
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
|
||||
|
||||
const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
|
||||
|
||||
// Normalizing mousewheel speed across browsers
|
||||
// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
|
||||
const normalizeWheel = event => {
|
||||
// Reasonable defaults
|
||||
const PIXEL_STEP = 10;
|
||||
const LINE_HEIGHT = 40;
|
||||
const PAGE_HEIGHT = 800;
|
||||
|
||||
let sX = 0,
|
||||
sY = 0, // spinX, spinY
|
||||
pX = 0,
|
||||
pY = 0; // pixelX, pixelY
|
||||
|
||||
// Legacy
|
||||
if ('detail' in event) {
|
||||
sY = event.detail;
|
||||
}
|
||||
if ('wheelDelta' in event) {
|
||||
sY = -event.wheelDelta / 120;
|
||||
}
|
||||
if ('wheelDeltaY' in event) {
|
||||
sY = -event.wheelDeltaY / 120;
|
||||
}
|
||||
if ('wheelDeltaX' in event) {
|
||||
sX = -event.wheelDeltaX / 120;
|
||||
}
|
||||
|
||||
// side scrolling on FF with DOMMouseScroll
|
||||
if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
|
||||
sX = sY;
|
||||
sY = 0;
|
||||
}
|
||||
|
||||
pX = sX * PIXEL_STEP;
|
||||
pY = sY * PIXEL_STEP;
|
||||
|
||||
if ('deltaY' in event) {
|
||||
pY = event.deltaY;
|
||||
}
|
||||
if ('deltaX' in event) {
|
||||
pX = event.deltaX;
|
||||
}
|
||||
|
||||
if ((pX || pY) && event.deltaMode) {
|
||||
if (event.deltaMode === 1) { // delta in LINE units
|
||||
pX *= LINE_HEIGHT;
|
||||
pY *= LINE_HEIGHT;
|
||||
} else { // delta in PAGE units
|
||||
pX *= PAGE_HEIGHT;
|
||||
pY *= PAGE_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall-back if spin cannot be determined
|
||||
if (pX && !sX) {
|
||||
sX = (pX < 1) ? -1 : 1;
|
||||
}
|
||||
if (pY && !sY) {
|
||||
sY = (pY < 1) ? -1 : 1;
|
||||
}
|
||||
|
||||
return {
|
||||
spinX: sX,
|
||||
spinY: sY,
|
||||
pixelX: pX,
|
||||
pixelY: pY,
|
||||
};
|
||||
};
|
||||
|
||||
class ZoomableImage extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
alt: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
src: PropTypes.string.isRequired,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
onClick: PropTypes.func,
|
||||
zoomedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
alt: '',
|
||||
lang: '',
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
|
||||
state = {
|
||||
scale: MIN_SCALE,
|
||||
zoomMatrix: {
|
||||
type: null, // 'width' 'height'
|
||||
fullScreen: null, // bool
|
||||
rate: null, // full screen scale rate
|
||||
clientWidth: null,
|
||||
clientHeight: null,
|
||||
offsetWidth: null,
|
||||
offsetHeight: null,
|
||||
clientHeightFixed: null,
|
||||
scrollTop: null,
|
||||
scrollLeft: null,
|
||||
translateX: null,
|
||||
translateY: null,
|
||||
},
|
||||
dragPosition: { top: 0, left: 0, x: 0, y: 0 },
|
||||
dragged: false,
|
||||
lockScroll: { x: 0, y: 0 },
|
||||
lockTranslate: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
removers = [];
|
||||
container = null;
|
||||
image = null;
|
||||
lastTouchEndTime = 0;
|
||||
lastDistance = 0;
|
||||
|
||||
componentDidMount () {
|
||||
let handler = this.handleTouchStart;
|
||||
this.container.addEventListener('touchstart', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
|
||||
handler = this.handleTouchMove;
|
||||
// on Chrome 56+, touch event listeners will default to passive
|
||||
// https://www.chromestatus.com/features/5093566007214080
|
||||
this.container.addEventListener('touchmove', handler, { passive: false });
|
||||
this.removers.push(() => this.container.removeEventListener('touchend', handler));
|
||||
|
||||
handler = this.mouseDownHandler;
|
||||
this.container.addEventListener('mousedown', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('mousedown', handler));
|
||||
|
||||
handler = this.mouseWheelHandler;
|
||||
this.container.addEventListener('wheel', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('wheel', handler));
|
||||
// Old Chrome
|
||||
this.container.addEventListener('mousewheel', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
|
||||
// Old Firefox
|
||||
this.container.addEventListener('DOMMouseScroll', handler);
|
||||
this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
|
||||
|
||||
this._initZoomMatrix();
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this._removeEventListeners();
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.zoomedIn !== this.props.zoomedIn) {
|
||||
this._toggleZoom();
|
||||
}
|
||||
}
|
||||
|
||||
_removeEventListeners () {
|
||||
this.removers.forEach(listeners => listeners());
|
||||
this.removers = [];
|
||||
}
|
||||
|
||||
mouseWheelHandler = e => {
|
||||
e.preventDefault();
|
||||
|
||||
const event = normalizeWheel(e);
|
||||
|
||||
if (this.state.zoomMatrix.type === 'width') {
|
||||
// full width, scroll vertical
|
||||
this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
|
||||
} else {
|
||||
// full height, scroll horizontal
|
||||
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
|
||||
}
|
||||
|
||||
// lock horizontal scroll
|
||||
this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
|
||||
};
|
||||
|
||||
mouseDownHandler = e => {
|
||||
this.setState({ dragPosition: {
|
||||
left: this.container.scrollLeft,
|
||||
top: this.container.scrollTop,
|
||||
// Get the current mouse position
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
} });
|
||||
|
||||
this.image.addEventListener('mousemove', this.mouseMoveHandler);
|
||||
this.image.addEventListener('mouseup', this.mouseUpHandler);
|
||||
};
|
||||
|
||||
mouseMoveHandler = e => {
|
||||
const dx = e.clientX - this.state.dragPosition.x;
|
||||
const dy = e.clientY - this.state.dragPosition.y;
|
||||
|
||||
this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
|
||||
this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
|
||||
|
||||
this.setState({ dragged: true });
|
||||
};
|
||||
|
||||
mouseUpHandler = () => {
|
||||
this.image.removeEventListener('mousemove', this.mouseMoveHandler);
|
||||
this.image.removeEventListener('mouseup', this.mouseUpHandler);
|
||||
};
|
||||
|
||||
handleTouchStart = e => {
|
||||
if (e.touches.length !== 2) return;
|
||||
|
||||
this.lastDistance = getDistance(...e.touches);
|
||||
};
|
||||
|
||||
handleTouchMove = e => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.container;
|
||||
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
|
||||
// prevent propagating event to MediaModal
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.touches.length !== 2) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const distance = getDistance(...e.touches);
|
||||
const midpoint = getMidpoint(...e.touches);
|
||||
const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
|
||||
const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
||||
|
||||
this._zoom(scale, midpoint);
|
||||
|
||||
this.lastMidpoint = midpoint;
|
||||
this.lastDistance = distance;
|
||||
};
|
||||
|
||||
_zoom(nextScale, midpoint) {
|
||||
const { scale, zoomMatrix } = this.state;
|
||||
const { scrollLeft, scrollTop } = this.container;
|
||||
|
||||
// math memo:
|
||||
// x = (scrollLeft + midpoint.x) / scrollWidth
|
||||
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
|
||||
// scrollWidth = clientWidth * scale
|
||||
// scrollWidth' = clientWidth * nextScale
|
||||
// Solve x = x' for nextScrollLeft
|
||||
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
|
||||
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
|
||||
|
||||
this.setState({ scale: nextScale }, () => {
|
||||
this.container.scrollLeft = nextScrollLeft;
|
||||
this.container.scrollTop = nextScrollTop;
|
||||
// reset the translateX/Y constantly
|
||||
if (nextScale < zoomMatrix.rate) {
|
||||
this.setState({
|
||||
lockTranslate: {
|
||||
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
|
||||
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleClick = e => {
|
||||
// don't propagate event to MediaModal
|
||||
e.stopPropagation();
|
||||
const dragged = this.state.dragged;
|
||||
this.setState({ dragged: false });
|
||||
if (dragged) return;
|
||||
const handler = this.props.onClick;
|
||||
if (handler) handler();
|
||||
};
|
||||
|
||||
handleMouseDown = e => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
_initZoomMatrix = () => {
|
||||
const { width, height } = this.props;
|
||||
const { clientWidth, clientHeight } = this.container;
|
||||
const { offsetWidth, offsetHeight } = this.image;
|
||||
const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
|
||||
|
||||
const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
|
||||
const fullScreen = type === 'width' ? width > clientWidth : height > clientHeightFixed;
|
||||
const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
|
||||
const scrollTop = type === 'width' ? (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
|
||||
const scrollLeft = (clientWidth - offsetWidth) / 2;
|
||||
const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
|
||||
const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
|
||||
|
||||
this.setState({
|
||||
zoomMatrix: {
|
||||
type: type,
|
||||
fullScreen: fullScreen,
|
||||
rate: rate,
|
||||
clientWidth: clientWidth,
|
||||
clientHeight: clientHeight,
|
||||
offsetWidth: offsetWidth,
|
||||
offsetHeight: offsetHeight,
|
||||
clientHeightFixed: clientHeightFixed,
|
||||
scrollTop: scrollTop,
|
||||
scrollLeft: scrollLeft,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
_toggleZoom () {
|
||||
const { scale, zoomMatrix } = this.state;
|
||||
|
||||
if ( scale >= zoomMatrix.rate ) {
|
||||
this.setState({
|
||||
scale: MIN_SCALE,
|
||||
lockScroll: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
lockTranslate: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
}, () => {
|
||||
this.container.scrollLeft = 0;
|
||||
this.container.scrollTop = 0;
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
scale: zoomMatrix.rate,
|
||||
lockScroll: {
|
||||
x: zoomMatrix.scrollLeft,
|
||||
y: zoomMatrix.scrollTop,
|
||||
},
|
||||
lockTranslate: {
|
||||
x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
|
||||
y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
|
||||
},
|
||||
}, () => {
|
||||
this.container.scrollLeft = zoomMatrix.scrollLeft;
|
||||
this.container.scrollTop = zoomMatrix.scrollTop;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setContainerRef = c => {
|
||||
this.container = c;
|
||||
};
|
||||
|
||||
setImageRef = c => {
|
||||
this.image = c;
|
||||
};
|
||||
|
||||
render () {
|
||||
const { alt, lang, src, width, height } = this.props;
|
||||
const { scale, lockTranslate, dragged } = this.state;
|
||||
const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
|
||||
const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
|
||||
|
||||
return (
|
||||
<div
|
||||
className='zoomable-image'
|
||||
ref={this.setContainerRef}
|
||||
style={{ overflow, cursor, userSelect: 'none' }}
|
||||
>
|
||||
<img
|
||||
role='presentation'
|
||||
ref={this.setImageRef}
|
||||
alt={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
draggable={false}
|
||||
onClick={this.handleClick}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ZoomableImage;
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSpring, animated, config } from '@react-spring/web';
|
||||
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
|
||||
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 4;
|
||||
const DOUBLE_CLICK_THRESHOLD = 250;
|
||||
|
||||
interface ZoomMatrix {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
initialScale: number;
|
||||
}
|
||||
|
||||
const createZoomMatrix = (
|
||||
container: HTMLElement,
|
||||
image: HTMLImageElement,
|
||||
fullWidth: number,
|
||||
fullHeight: number,
|
||||
): ZoomMatrix => {
|
||||
const { clientWidth, clientHeight } = container;
|
||||
const { offsetWidth, offsetHeight } = image;
|
||||
|
||||
const type =
|
||||
fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
|
||||
|
||||
const initialScale =
|
||||
type === 'width'
|
||||
? Math.min(clientWidth, fullWidth) / offsetWidth
|
||||
: Math.min(clientHeight, fullHeight) / offsetHeight;
|
||||
|
||||
return {
|
||||
containerWidth: clientWidth,
|
||||
containerHeight: clientHeight,
|
||||
imageWidth: offsetWidth,
|
||||
imageHeight: offsetHeight,
|
||||
initialScale,
|
||||
};
|
||||
};
|
||||
|
||||
const useGesture = createUseGesture([dragAction, pinchAction]);
|
||||
|
||||
const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
|
||||
if (!zoomMatrix || scale === MIN_SCALE) {
|
||||
return {
|
||||
left: -Infinity,
|
||||
right: Infinity,
|
||||
top: -Infinity,
|
||||
bottom: Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
const { containerWidth, containerHeight, imageWidth, imageHeight } =
|
||||
zoomMatrix;
|
||||
|
||||
const bounds = {
|
||||
left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
|
||||
right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
|
||||
top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
|
||||
bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
|
||||
};
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
interface ZoomableImageProps {
|
||||
alt?: string;
|
||||
lang?: string;
|
||||
src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onClose?: () => void;
|
||||
onZoomChange?: (zoomedIn: boolean) => void;
|
||||
zoomedIn?: boolean;
|
||||
blurhash?: string;
|
||||
}
|
||||
|
||||
export const ZoomableImage: React.FC<ZoomableImageProps> = ({
|
||||
alt = '',
|
||||
lang = '',
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onClose,
|
||||
onZoomChange,
|
||||
zoomedIn,
|
||||
blurhash,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
document.addEventListener('gesturestart', handler);
|
||||
document.addEventListener('gesturechange', handler);
|
||||
document.addEventListener('gestureend', handler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('gesturestart', handler);
|
||||
document.removeEventListener('gesturechange', handler);
|
||||
document.removeEventListener('gestureend', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
|
||||
|
||||
const [style, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
onRest: {
|
||||
scale({ value }) {
|
||||
if (!onZoomChange) {
|
||||
return;
|
||||
}
|
||||
if (value === MIN_SCALE) {
|
||||
onZoomChange(false);
|
||||
} else {
|
||||
onZoomChange(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
useGesture(
|
||||
{
|
||||
onDrag({
|
||||
pinching,
|
||||
cancel,
|
||||
active,
|
||||
last,
|
||||
offset: [x, y],
|
||||
velocity: [, vy],
|
||||
direction: [, dy],
|
||||
tap,
|
||||
}) {
|
||||
if (tap) {
|
||||
if (!doubleClickTimeoutRef.current) {
|
||||
doubleClickTimeoutRef.current = setTimeout(() => {
|
||||
onClick?.();
|
||||
doubleClickTimeoutRef.current = null;
|
||||
}, DOUBLE_CLICK_THRESHOLD);
|
||||
} else {
|
||||
clearTimeout(doubleClickTimeoutRef.current);
|
||||
doubleClickTimeoutRef.current = null;
|
||||
onDoubleClick?.();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!zoomedIn) {
|
||||
// Swipe up/down to dismiss parent
|
||||
if (last) {
|
||||
if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
void api.start({ y: 0, config: config.wobbly });
|
||||
return;
|
||||
} else if (dy !== 0) {
|
||||
void api.start({ y, immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinching) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
setDragging(true);
|
||||
} else {
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
void api.start({ x, y });
|
||||
},
|
||||
|
||||
onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
|
||||
if (!imageRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first) {
|
||||
const { width, height, x, y } =
|
||||
imageRef.current.getBoundingClientRect();
|
||||
const tx = ox - (x + width / 2);
|
||||
const ty = oy - (y + height / 2);
|
||||
|
||||
memo = [style.x.get(), style.y.get(), tx, ty];
|
||||
}
|
||||
|
||||
const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||
|
||||
void api.start({ scale: s, x, y });
|
||||
|
||||
return memo as [number, number, number, number];
|
||||
},
|
||||
},
|
||||
{
|
||||
target: imageRef,
|
||||
drag: {
|
||||
from: () => [style.x.get(), style.y.get()],
|
||||
filterTaps: true,
|
||||
bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
|
||||
rubberband: true,
|
||||
},
|
||||
pinch: {
|
||||
scaleBounds: {
|
||||
min: MIN_SCALE,
|
||||
max: MAX_SCALE,
|
||||
},
|
||||
rubberband: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || !containerRef.current || !imageRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
zoomMatrixRef.current = createZoomMatrix(
|
||||
containerRef.current,
|
||||
imageRef.current,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
if (!zoomedIn) {
|
||||
void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
|
||||
} else if (style.scale.get() === MIN_SCALE) {
|
||||
void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
|
||||
}
|
||||
}, [api, style.scale, zoomedIn, width, height, loaded]);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
// This handler exists to cancel the onClick handler on the media modal which would
|
||||
// otherwise close the modal. It cannot be used for actual click handling because
|
||||
// we don't know if the user is about to pan the image or not.
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
}, [setLoaded]);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setError(true);
|
||||
}, [setError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('zoomable-image', {
|
||||
'zoomable-image--zoomed-in': zoomedIn,
|
||||
'zoomable-image--error': error,
|
||||
'zoomable-image--dragging': dragging,
|
||||
})}
|
||||
ref={containerRef}
|
||||
>
|
||||
{!loaded && blurhash && (
|
||||
<div
|
||||
className='zoomable-image__preview'
|
||||
style={{
|
||||
aspectRatio: `${width}/${height}`,
|
||||
height: `min(${height}px, 100%)`,
|
||||
}}
|
||||
>
|
||||
<Blurhash hash={blurhash} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<animated.img
|
||||
style={style}
|
||||
role='presentation'
|
||||
ref={imageRef}
|
||||
alt={alt}
|
||||
title={alt}
|
||||
lang={lang}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
draggable={false}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
onClickCapture={handleClick}
|
||||
/>
|
||||
|
||||
{!loaded && !error && <LoadingIndicator />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -19,6 +19,7 @@ import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { playerSettings } from 'mastodon/settings';
|
||||
|
||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
||||
@@ -549,14 +550,6 @@ class Video extends PureComponent {
|
||||
preload = 'none';
|
||||
}
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
|
||||
return (
|
||||
<div style={{ aspectRatio }}>
|
||||
@@ -599,14 +592,7 @@ class Video extends PureComponent {
|
||||
style={{ width: '100%' }}
|
||||
/>}
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
{warning}
|
||||
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} />
|
||||
|
||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
||||
|
||||
@@ -358,6 +358,7 @@
|
||||
"follow_suggestions.hints.friends_of_friends": "Tsit ê個人資料tī lí跟tuè ê lâng之間真流行。",
|
||||
"follow_suggestions.hints.most_followed": "Tsit ê個人資料是 {domain} 內,有足tsē跟tuè者ê其中tsit ê。",
|
||||
"follow_suggestions.hints.most_interactions": "Tsit ê個人資料tsi̍t-tsām-á佇 {domain} 有得著真tsē關注。",
|
||||
"follow_suggestions.hints.similar_to_recently_followed": "Tsit ê個人資料kap lí最近跟tuè ê口座相siâng。",
|
||||
"follow_suggestions.personalized_suggestion": "個人化ê推薦",
|
||||
"follow_suggestions.popular_suggestion": "流行ê推薦",
|
||||
"follow_suggestions.popular_suggestion_longer": "佇{domain} 足有lâng緣",
|
||||
@@ -391,6 +392,9 @@
|
||||
"hashtag.follow": "跟tuè hashtag",
|
||||
"hashtag.unfollow": "取消跟tuè hashtag",
|
||||
"hashtags.and_other": "……kap 其他 {count, plural, other {# ê}}",
|
||||
"hints.profiles.followers_may_be_missing": "Tsit ê個人資料ê跟tuè者資訊可能有落勾ê。",
|
||||
"hints.profiles.follows_may_be_missing": "Tsit ê口座所跟tuè ê ê資訊可能有落勾ê。",
|
||||
"hints.profiles.posts_may_be_missing": "Tsit ê口座ê tsi̍t kuá PO文可能有落勾ê。",
|
||||
"hints.profiles.see_more_followers": "佇 {domain} 看koh khah tsē跟tuè lí ê",
|
||||
"hints.profiles.see_more_follows": "佇 {domain} 看koh khah tsē lí跟tuè ê",
|
||||
"hints.profiles.see_more_posts": "佇 {domain} 看koh khah tsē ê PO文",
|
||||
@@ -403,6 +407,24 @@
|
||||
"home.pending_critical_update.link": "看更新內容",
|
||||
"home.pending_critical_update.title": "有重要ê安全更新!",
|
||||
"home.show_announcements": "顯示公告",
|
||||
"ignore_notifications_modal.disclaimer": "Lí所忽略in ê通知ê用者,Mastodonbē當kā lí通知。忽略通知bē當阻擋訊息ê寄送。",
|
||||
"ignore_notifications_modal.filter_instead": "改做過濾",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Lí猶原ē當接受、拒絕猶是檢舉用者",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "過濾ē當避免可能ê bē分明。",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "Lí ē當個別檢視所過濾ê通知",
|
||||
"ignore_notifications_modal.ignore": "Kā通知忽略",
|
||||
"ignore_notifications_modal.limited_accounts_title": "Kám beh忽略受限制ê口座送來ê通知?",
|
||||
"ignore_notifications_modal.new_accounts_title": "Kám beh忽略新口座送來ê通知?",
|
||||
"ignore_notifications_modal.not_followers_title": "Kám beh忽略無跟tuè lí ê口座送來ê通知?",
|
||||
"ignore_notifications_modal.not_following_title": "Kám beh忽略lí 無跟tuè ê口座送來ê通知?",
|
||||
"ignore_notifications_modal.private_mentions_title": "忽略ka-kī主動送ê私人提起ê通知?",
|
||||
"info_button.label": "幫tsān",
|
||||
"info_button.what_is_alt_text": "<h1>Siánn物是替代文字?</h1> <p>替代文字kā視覺有障礙、網路速度khah慢,á是beh tshuē頂下文ê lâng,提供圖ê敘述。</p> <p>Lí ē當通過寫明白、簡單kap客觀ê替代文字,替逐家改善容易使用性kap幫tsān理解。</p> <ul> <li>掌握重要ê因素</li> <li>替圖寫摘要ê文字</li> <li>用規則ê語句結構</li> <li>避免重複ê資訊</li> <li>專注佇趨勢kap佇複雜視覺(比如圖表á是地圖)內底tshuē關鍵</li> </ul>",
|
||||
"interaction_modal.action.favourite": "Nā beh繼續,lí tio̍h用你ê口座收藏。",
|
||||
"interaction_modal.action.follow": "Nā beh繼續,lí tio̍h用你ê口座跟tuè。",
|
||||
"interaction_modal.action.reblog": "Nā beh繼續,lí tio̍h用你ê口座轉送。",
|
||||
"interaction_modal.action.reply": "Nā beh繼續,lí tio̍h用你ê口座回應。",
|
||||
"interaction_modal.action.vote": "Nā beh繼續,lí tio̍h用你ê口座投票。",
|
||||
"interaction_modal.go": "行",
|
||||
"interaction_modal.no_account_yet": "Tsit-má iáu bô口座?",
|
||||
"interaction_modal.on_another_server": "佇無kâng ê服侍器",
|
||||
@@ -418,6 +440,34 @@
|
||||
"intervals.full.minutes": "{number, plural, other {# 分鐘}}",
|
||||
"keyboard_shortcuts.back": "Tńg去",
|
||||
"keyboard_shortcuts.blocked": "開封鎖ê用者ê列單",
|
||||
"keyboard_shortcuts.boost": "轉送PO文",
|
||||
"keyboard_shortcuts.column": "揀tsit ê欄",
|
||||
"keyboard_shortcuts.compose": "揀寫文字ê框仔",
|
||||
"keyboard_shortcuts.description": "說明",
|
||||
"keyboard_shortcuts.direct": "phah開私人提起ê欄",
|
||||
"keyboard_shortcuts.down": "佇列單內kā suá khah 下kha",
|
||||
"keyboard_shortcuts.enter": "Phah開PO文",
|
||||
"keyboard_shortcuts.favourite": "收藏PO文",
|
||||
"keyboard_shortcuts.favourites": "Phah開收藏ê列單",
|
||||
"keyboard_shortcuts.federated": "Phah開聯邦ê時間線",
|
||||
"keyboard_shortcuts.heading": "鍵盤ê快速key",
|
||||
"keyboard_shortcuts.home": "Phah開tshù ê時間線",
|
||||
"keyboard_shortcuts.hotkey": "快速key",
|
||||
"keyboard_shortcuts.legend": "顯示tsit篇說明",
|
||||
"keyboard_shortcuts.local": "Phah開本站ê時間線",
|
||||
"keyboard_shortcuts.mention": "提起作者",
|
||||
"keyboard_shortcuts.muted": "Phah開消音ê用者列單",
|
||||
"keyboard_shortcuts.my_profile": "Phah開lí ê個人資料",
|
||||
"keyboard_shortcuts.notifications": "Phah開通知欄",
|
||||
"keyboard_shortcuts.open_media": "Phah開媒體",
|
||||
"keyboard_shortcuts.pinned": "Phah開釘起來ê PO文列單",
|
||||
"keyboard_shortcuts.profile": "Phah開作者ê個人資料",
|
||||
"keyboard_shortcuts.reply": "回應PO文",
|
||||
"keyboard_shortcuts.requests": "Phah開跟tuè請求ê列單",
|
||||
"keyboard_shortcuts.search": "揀tshiau-tshuē條á",
|
||||
"keyboard_shortcuts.spoilers": "顯示/隱藏內容警告",
|
||||
"keyboard_shortcuts.start": "Phah開「開始用」欄",
|
||||
"keyboard_shortcuts.toggle_hidden": "顯示/隱藏內容警告後壁ê PO文",
|
||||
"notification.favourite_pm": "{name} kah意lí ê私人提起",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} kap<a>{count, plural, other {另外 # ê lâng}}</a>kah意lí ê私人提起",
|
||||
"search_popout.language_code": "ISO語言代碼",
|
||||
|
||||
@@ -872,7 +872,9 @@
|
||||
"subscribed_languages.target": "更改 {target} 的订阅语言",
|
||||
"tabs_bar.home": "主页",
|
||||
"tabs_bar.notifications": "通知",
|
||||
"terms_of_service.effective_as_of": "自 {date} 起生效",
|
||||
"terms_of_service.title": "服务条款",
|
||||
"terms_of_service.upcoming_changes_on": "将于 {date} 进行变更",
|
||||
"time_remaining.days": "剩余 {number, plural, other {# 天}}",
|
||||
"time_remaining.hours": "剩余 {number, plural, other {# 小时}}",
|
||||
"time_remaining.minutes": "剩余 {number, plural, other {# 分钟}}",
|
||||
|
||||
@@ -63,6 +63,7 @@ body {
|
||||
|
||||
&.with-modals--active {
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2448,49 +2448,6 @@ a.account__display-name {
|
||||
}
|
||||
}
|
||||
|
||||
.image-loader {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE 10+ */
|
||||
|
||||
* {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE 10+ */
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: transparent; /* Chrome/Safari/Webkit */
|
||||
}
|
||||
|
||||
.image-loader__preview-canvas {
|
||||
max-width: $media-modal-media-max-width;
|
||||
max-height: $media-modal-media-max-height;
|
||||
background: url('~images/void.png') repeat;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading-bar__container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.image-loader--amorphous .image-loader__preview-canvas {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.zoomable-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -2498,13 +2455,61 @@ a.account__display-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
scrollbar-width: none;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
img {
|
||||
max-width: $media-modal-media-max-width;
|
||||
max-height: $media-modal-media-max-height;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
outline: 1px solid var(--media-outline-color);
|
||||
outline-offset: -1px;
|
||||
border-radius: 8px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
&--zoomed-in {
|
||||
z-index: 9999;
|
||||
cursor: grab;
|
||||
|
||||
img {
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&--error img {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
max-width: $media-modal-media-max-width;
|
||||
max-height: $media-modal-media-max-height;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
outline: 1px solid var(--media-outline-color);
|
||||
outline-offset: -1px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
z-index: 2;
|
||||
mix-blend-mode: luminosity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5576,6 +5581,7 @@ a.status-card {
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.modal-root__modal {
|
||||
@@ -5709,7 +5715,7 @@ a.status-card {
|
||||
.picture-in-picture__footer {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 20px 0;
|
||||
padding: 16px;
|
||||
|
||||
.icon-button {
|
||||
color: $white;
|
||||
|
||||
@@ -420,6 +420,8 @@ class MediaAttachment < ApplicationRecord
|
||||
|
||||
@paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name|
|
||||
attachment = public_send(attachment_name)
|
||||
next if attachment.blank?
|
||||
|
||||
styles = DEFAULT_STYLES | attachment.styles.keys
|
||||
styles.map { |style| attachment.url(style) }
|
||||
end.compact
|
||||
|
||||
@@ -55,6 +55,8 @@ sl:
|
||||
too_soon: je prekmalu, naj bo kasneje od %{date}
|
||||
user:
|
||||
attributes:
|
||||
date_of_birth:
|
||||
below_limit: ne dosega starostne meje
|
||||
email:
|
||||
blocked: uporablja nedovoljenega ponudnika e-poštnih storitev
|
||||
unreachable: kot kaže ne obstaja
|
||||
|
||||
@@ -88,6 +88,7 @@ sl:
|
||||
favicon: WEBP, PNG, GIF ali JPG. Zamenja privzeto ikono spletne strani Mastodon z ikono po meri.
|
||||
mascot: Preglasi ilustracijo v naprednem spletnem vmesniku.
|
||||
media_cache_retention_period: Predstavnostne datoteke iz objav uporabnikov na ostalih strežnikih se začasno hranijo na tem strežniku. Ko je nastavljeno na pozitivno vrednost, bodo predstavnostne datoteke izbrisane po nastavljenem številu dni. Če bo predstavnostna datoteka zahtevana po izbrisu, bo ponovno prenešena, če bo vir še vedno na voljo. Zaradi omejitev pogostosti prejemanja predogledov povezav z drugih strani je priporočljivo to vrednost nastaviti na vsaj 14 dni. V nasprotnem predogledi povezav pred tem časom ne bodo osveženi na zahtevo.
|
||||
min_age: Med registracijo bodo morali uporabniki potrditi svoj datum rojstva
|
||||
peers_api_enabled: Seznam imen domen, na katere je ta strežnik naletel v fediverzumu. Sem niso vključeni podatki o tem, ali ste v federaciji z danim strežnikom, zgolj to, ali vaš strežnik ve zanj. To uporabljajo storitve, ki zbirajo statistične podatke o federaciji v splošnem smislu.
|
||||
profile_directory: Imenik profilov izpiše vse uporabnike, ki so dovolili, da so v njem navedeni.
|
||||
require_invite_text: Če registracije zahtevajo ročno potrditev, nastavite vnos besedila pod »Zakaj se želite pridružiti?« za obveznega.
|
||||
@@ -145,6 +146,7 @@ sl:
|
||||
min_age: Ne smete biti mlajši od starostne omejitve, ki jo postavljajo zakoni vašega pravosodnega sistema.
|
||||
user:
|
||||
chosen_languages: Ko je označeno, bodo v javnih časovnicah prikazane samo objave v izbranih jezikih
|
||||
date_of_birth: Prepričati se moramo, da so uporabniki Mastodona stari vsaj %{age} let. Tega podatka ne bomo shranili.
|
||||
role: Vloga določa, katera dovoljenja ima uporabnik.
|
||||
user_role:
|
||||
color: Barva, uporabljena za vlogo po celem up. vmesniku, podana v šestnajstiškem zapisu RGB
|
||||
@@ -270,6 +272,7 @@ sl:
|
||||
favicon: Ikona spletne strani
|
||||
mascot: Maskota po meri (opuščeno)
|
||||
media_cache_retention_period: Obdobje hrambe predpomnilnika predstavnosti
|
||||
min_age: Spodnja starostna meja
|
||||
peers_api_enabled: Objavi seznam odkritih strežnikov v API-ju
|
||||
profile_directory: Omogoči imenik profilov
|
||||
registrations_mode: Kdo se lahko registrira
|
||||
@@ -341,12 +344,16 @@ sl:
|
||||
admin_email: E-poštni naslov za pravna obvestila
|
||||
arbitration_address: Fizični naslov za arbitražna obvestila
|
||||
arbitration_website: Spletišče za vložitev arbitražnih obvestil
|
||||
choice_of_law: Izbira prava
|
||||
dmca_address: Fizični naslov za obvestila DMCA ali o avtorskih pravicah
|
||||
dmca_email: E-poštni naslov za obvestila DMCA ali o avtorskih pravicah
|
||||
domain: Domena
|
||||
jurisdiction: Pravna pristojnost
|
||||
min_age: Najmanjša starost
|
||||
user:
|
||||
date_of_birth_1i: Dan
|
||||
date_of_birth_2i: Mesec
|
||||
date_of_birth_3i: Leto
|
||||
role: Vloga
|
||||
time_zone: Časovni pas
|
||||
user_role:
|
||||
|
||||
@@ -315,6 +315,8 @@ zh-CN:
|
||||
new:
|
||||
create: 创建公告
|
||||
title: 新公告
|
||||
preview:
|
||||
explanation_html: 此电子邮件将发送给 <strong>%{display_count} 用户</strong>。电子邮件将包含以下文本:
|
||||
publish: 发布
|
||||
published_msg: 公告已发布!
|
||||
scheduled_for: 定时在 %{time}
|
||||
|
||||
@@ -4,7 +4,7 @@ const { env, settings } = require('../configuration');
|
||||
|
||||
// Those modules contain modern ES code that need to be transpiled for Webpack to process it
|
||||
const nodeModulesToProcess = [
|
||||
'@reduxjs', 'fuzzysort', 'toygrad'
|
||||
'@reduxjs', 'fuzzysort', 'toygrad', '@react-spring'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -51,8 +51,10 @@
|
||||
"@gamestdio/websocket": "^0.3.2",
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@rails/ujs": "7.1.501",
|
||||
"@react-spring/web": "^9.7.5",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"arrow-key-navigation": "^1.2.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"atrament": "0.2.4",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Filters::StatusesController do
|
||||
render_views
|
||||
|
||||
describe 'GET #index' do
|
||||
let(:filter) { Fabricate(:custom_filter) }
|
||||
|
||||
context 'with signed out user' do
|
||||
it 'redirects' do
|
||||
get :index, params: { filter_id: filter }
|
||||
|
||||
expect(response).to be_redirect
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a signed in user' do
|
||||
context 'with the filter user signed in' do
|
||||
before do
|
||||
sign_in(filter.account.user)
|
||||
get :index, params: { filter_id: filter }
|
||||
end
|
||||
|
||||
it 'returns http success and private cache control headers' do
|
||||
expect(response).to have_http_status(200)
|
||||
|
||||
expect(response.headers['Cache-Control']).to include('private, no-store')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with another user signed in' do
|
||||
before do
|
||||
sign_in(Fabricate(:user))
|
||||
get :index, params: { filter_id: filter }
|
||||
end
|
||||
|
||||
it 'returns http not found' do
|
||||
expect(response).to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -302,6 +302,15 @@ RSpec.describe MediaAttachment, :attachment_processing do
|
||||
.to enqueue_sidekiq_job(CacheBusterWorker).with(original_url)
|
||||
.and enqueue_sidekiq_job(CacheBusterWorker).with(small_url)
|
||||
end
|
||||
|
||||
context 'with a missing remote attachment' do
|
||||
let(:media) { Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png', file: nil) }
|
||||
|
||||
it 'does not queue CacheBusterWorker jobs' do
|
||||
expect { media.destroy }
|
||||
.to_not enqueue_sidekiq_job(CacheBusterWorker)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -16,4 +16,30 @@ RSpec.describe 'Filters Statuses' do
|
||||
.to redirect_to(edit_filter_path(filter))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /filters/:filter_id/statuses' do
|
||||
let(:filter) { Fabricate(:custom_filter) }
|
||||
|
||||
context 'with signed out user' do
|
||||
it 'redirects' do
|
||||
get filter_statuses_path(filter)
|
||||
|
||||
expect(response)
|
||||
.to be_redirect
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a signed in user' do
|
||||
context 'with another user signed in' do
|
||||
before { sign_in(Fabricate(:user)) }
|
||||
|
||||
it 'returns http not found' do
|
||||
get filter_statuses_path(filter)
|
||||
|
||||
expect(response)
|
||||
.to have_http_status(404)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
51
spec/system/account_notes_spec.rb
Normal file
51
spec/system/account_notes_spec.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Account notes', :inline_jobs, :js, :streaming do
|
||||
include ProfileStories
|
||||
|
||||
let(:email) { 'test@example.com' }
|
||||
let(:password) { 'password' }
|
||||
let(:confirmed_at) { Time.zone.now }
|
||||
let(:finished_onboarding) { true }
|
||||
|
||||
let!(:other_account) { Fabricate(:account) }
|
||||
|
||||
before { as_a_logged_in_user }
|
||||
|
||||
it 'can be written and viewed' do
|
||||
visit_profile(other_account)
|
||||
|
||||
note_text = 'This is a personal note'
|
||||
fill_in 'Click to add note', with: note_text
|
||||
|
||||
# This is a bit awkward since there is no button to save the change
|
||||
# The easiest way is to send ctrl+enter ourselves
|
||||
find_field(class: 'account__header__account-note__content').send_keys [:control, :enter]
|
||||
|
||||
expect(page)
|
||||
.to have_css('.account__header__account-note .inline-alert', text: 'SAVED')
|
||||
|
||||
expect(page)
|
||||
.to have_css('.account__header__account-note__content', text: note_text)
|
||||
|
||||
# Navigate back and forth and ensure the comment is still here
|
||||
visit root_url
|
||||
visit_profile(other_account)
|
||||
|
||||
expect(AccountNote.find_by(account: bob.account, target_account: other_account).comment)
|
||||
.to eq note_text
|
||||
|
||||
expect(page)
|
||||
.to have_css('.account__header__account-note__content', text: note_text)
|
||||
end
|
||||
|
||||
def visit_profile(account)
|
||||
visit short_account_path(account)
|
||||
|
||||
expect(page)
|
||||
.to have_css('div.app-holder')
|
||||
.and have_css('form.compose-form')
|
||||
end
|
||||
end
|
||||
21
spec/system/filters/statuses_spec.rb
Normal file
21
spec/system/filters/statuses_spec.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Filters Statuses' do
|
||||
describe 'Viewing statuses under a filter' do
|
||||
let(:filter) { Fabricate(:custom_filter, title: 'good filter') }
|
||||
|
||||
context 'with the filter user signed in' do
|
||||
before { sign_in(filter.account.user) }
|
||||
|
||||
it 'returns a page with the status filters' do
|
||||
visit filter_statuses_path(filter)
|
||||
|
||||
expect(page)
|
||||
.to have_private_cache_control
|
||||
.and have_title(/good filter/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -49,7 +49,7 @@ export function configFromEnv(env, environment) {
|
||||
if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
|
||||
if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
|
||||
if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
|
||||
if (typeof parsedUrl.port === 'string') {
|
||||
if (typeof parsedUrl.port === 'string' && parsedUrl.port) {
|
||||
const parsedPort = parseInt(parsedUrl.port, 10);
|
||||
if (isNaN(parsedPort)) {
|
||||
throw new Error('Invalid port specified in DATABASE_URL environment variable');
|
||||
|
||||
104
yarn.lock
104
yarn.lock
@@ -2748,6 +2748,7 @@ __metadata:
|
||||
"@gamestdio/websocket": "npm:^0.3.2"
|
||||
"@github/webauthn-json": "npm:^2.1.1"
|
||||
"@rails/ujs": "npm:7.1.501"
|
||||
"@react-spring/web": "npm:^9.7.5"
|
||||
"@reduxjs/toolkit": "npm:^2.0.1"
|
||||
"@svgr/webpack": "npm:^5.5.0"
|
||||
"@testing-library/dom": "npm:^10.2.0"
|
||||
@@ -2783,6 +2784,7 @@ __metadata:
|
||||
"@types/webpack-env": "npm:^1.18.4"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^8.0.0"
|
||||
"@typescript-eslint/parser": "npm:^8.0.0"
|
||||
"@use-gesture/react": "npm:^10.3.1"
|
||||
arrow-key-navigation: "npm:^1.2.0"
|
||||
async-mutex: "npm:^0.5.0"
|
||||
atrament: "npm:0.2.4"
|
||||
@@ -3201,6 +3203,72 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/animated@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/animated@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/shared": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/core@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/core@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/animated": "npm:~9.7.5"
|
||||
"@react-spring/shared": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/rafz@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/rafz@npm:9.7.5"
|
||||
checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/shared@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/shared@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/rafz": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/types@npm:~9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/types@npm:9.7.5"
|
||||
checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-spring/web@npm:^9.7.5":
|
||||
version: 9.7.5
|
||||
resolution: "@react-spring/web@npm:9.7.5"
|
||||
dependencies:
|
||||
"@react-spring/animated": "npm:~9.7.5"
|
||||
"@react-spring/core": "npm:~9.7.5"
|
||||
"@react-spring/shared": "npm:~9.7.5"
|
||||
"@react-spring/types": "npm:~9.7.5"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@reduxjs/toolkit@npm:^2.0.1":
|
||||
version: 2.6.1
|
||||
resolution: "@reduxjs/toolkit@npm:2.6.1"
|
||||
@@ -4062,12 +4130,12 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:^18.2.7":
|
||||
version: 18.3.18
|
||||
resolution: "@types/react@npm:18.3.18"
|
||||
version: 18.3.19
|
||||
resolution: "@types/react@npm:18.3.19"
|
||||
dependencies:
|
||||
"@types/prop-types": "npm:*"
|
||||
csstype: "npm:^3.0.2"
|
||||
checksum: 10c0/8fb2b00672072135d0858dc9db07873ea107cc238b6228aaa2a9afd1ef7a64a7074078250db38afbeb19064be8ea6af5eac32d404efdd5f45e093cc4829d87f8
|
||||
checksum: 10c0/236bfe0c4748ada1a640f13573eca3e0fc7c9d847b442947adb352b0718d6d285357fd84c33336c8ffb8cbfabc0d58a43a647c7fd79857fecd61fb58ab6f7918
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4214,11 +4282,11 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@types/ws@npm:^8.5.9":
|
||||
version: 8.5.14
|
||||
resolution: "@types/ws@npm:8.5.14"
|
||||
version: 8.18.0
|
||||
resolution: "@types/ws@npm:8.18.0"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10c0/be88a0b6252f939cb83340bd1b4d450287f752c19271195cd97564fd94047259a9bb8c31c585a61b69d8a1b069a99df9dd804db0132d3359c54d3890c501416a
|
||||
checksum: 10c0/a56d2e0d1da7411a1f3548ce02b51a50cbe9e23f025677d03df48f87e4a3c72e1342fbf1d12e487d7eafa8dc670c605152b61bbf9165891ec0e9694b0d3ea8d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -4417,6 +4485,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@use-gesture/core@npm:10.3.1":
|
||||
version: 10.3.1
|
||||
resolution: "@use-gesture/core@npm:10.3.1"
|
||||
checksum: 10c0/2e3b5c0f7fe26cdb47be3a9c2a58a6a9edafc5b2895b07d2898eda9ab5a2b29fb0098b15597baa0856907b593075cd44cc69bba4785c9cfb7b6fabaa3b52cd3e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@use-gesture/react@npm:^10.3.1":
|
||||
version: 10.3.1
|
||||
resolution: "@use-gesture/react@npm:10.3.1"
|
||||
dependencies:
|
||||
"@use-gesture/core": "npm:10.3.1"
|
||||
peerDependencies:
|
||||
react: ">= 16.8.0"
|
||||
checksum: 10c0/978da66e4e7c424866ad52eba8fdf0ce93a4c8fc44f8837c7043e68c6a6107cd67e817fffb27f7db2ae871ef2f6addb0c8ddf1586f24c67b7e6aef1646c668cf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/ast@npm:1.9.0":
|
||||
version: 1.9.0
|
||||
resolution: "@webassemblyjs/ast@npm:1.9.0"
|
||||
@@ -15633,8 +15719,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"sass@npm:^1.62.1":
|
||||
version: 1.85.1
|
||||
resolution: "sass@npm:1.85.1"
|
||||
version: 1.86.0
|
||||
resolution: "sass@npm:1.86.0"
|
||||
dependencies:
|
||||
"@parcel/watcher": "npm:^2.4.1"
|
||||
chokidar: "npm:^4.0.0"
|
||||
@@ -15645,7 +15731,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
sass: sass.js
|
||||
checksum: 10c0/f843aa1df1dca2f0e9cb2fb247e4939fd514ae4c182cdd1900a0622c0d71b40dfb1c4225f78b78e165a318287ca137ec597695db3e496408bd16a921a2bc2b3f
|
||||
checksum: 10c0/921caea1fd8a450d4a986e5570ce13c4ca7b2a57da390811add3d2087ad8f46f53b34652ddcb237d8bdaad49c560b8d6eee130c733c787d058bc5a71a914c139
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user