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:
Claire
2025-03-24 20:06:25 +01:00
30 changed files with 808 additions and 815 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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'>

View 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>
);
};

View File

@@ -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}

View File

@@ -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>
);
}
}

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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';

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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}>

View File

@@ -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語言代碼",

View File

@@ -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 {# 分钟}}",

View File

@@ -63,6 +63,7 @@ body {
&.with-modals--active {
overflow-y: hidden;
overscroll-behavior: none;
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -315,6 +315,8 @@ zh-CN:
new:
create: 创建公告
title: 新公告
preview:
explanation_html: 此电子邮件将发送给 <strong>%{display_count} 用户</strong>。电子邮件将包含以下文本:
publish: 发布
published_msg: 公告已发布!
scheduled_for: 定时在 %{time}

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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
View File

@@ -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