@@ -521,14 +514,7 @@ class Audio extends PureComponent {
lang={lang}
/>
-
-
-
+
{(revealed || editable) &&

{
- 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 (
-
- {loading ? (
- <>
-
-
-
-
-
- >
- ) : (
-
- )}
-
- );
- }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.jsx b/app/javascript/mastodon/features/ui/components/image_modal.jsx
deleted file mode 100644
index f08ce15342..0000000000
--- a/app/javascript/mastodon/features/ui/components/image_modal.jsx
+++ /dev/null
@@ -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 (
-
- );
- }
-
-}
-
-export default injectIntl(ImageModal);
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.tsx b/app/javascript/mastodon/features/ui/components/image_modal.tsx
new file mode 100644
index 0000000000..fa94cfcc3c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_modal.tsx
@@ -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 (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index d69ceba539..9312805b5c 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -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 &&
;
const rightNav = media.size > 1 &&
;
- 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 (
-
);
} 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}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index cb1e499dbb..74fe59f322 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -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';
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
deleted file mode 100644
index c4129bf260..0000000000
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
+++ /dev/null
@@ -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 (
-
-

-
- );
- }
-}
-
-export default ZoomableImage;
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.tsx b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
new file mode 100644
index 0000000000..85e29e6aea
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
@@ -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
= ({
+ 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(null);
+ const imageRef = useRef(null);
+ const doubleClickTimeoutRef = useRef | null>();
+ const zoomMatrixRef = useRef(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 (
+
+ {!loaded && blurhash && (
+
+
+
+ )}
+
+
+
+ {!loaded && !error &&
}
+
+ );
+};
diff --git a/app/javascript/mastodon/features/video/index.jsx b/app/javascript/mastodon/features/video/index.jsx
index 89a8ba560a..33194f0271 100644
--- a/app/javascript/mastodon/features/video/index.jsx
+++ b/app/javascript/mastodon/features/video/index.jsx
@@ -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 = ;
- } else {
- warning = ;
- }
-
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
return (
@@ -599,14 +592,7 @@ class Video extends PureComponent {
style={{ width: '100%' }}
/>}
-
-
-
+
diff --git a/app/javascript/mastodon/locales/nan.json b/app/javascript/mastodon/locales/nan.json
index 99b880c61f..57eef0a874 100644
--- a/app/javascript/mastodon/locales/nan.json
+++ b/app/javascript/mastodon/locales/nan.json
@@ -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": "
Siánn物是替代文字?
替代文字kā視覺有障礙、網路速度khah慢,á是beh tshuē頂下文ê lâng,提供圖ê敘述。
Lí ē當通過寫明白、簡單kap客觀ê替代文字,替逐家改善容易使用性kap幫tsān理解。
- 掌握重要ê因素
- 替圖寫摘要ê文字
- 用規則ê語句結構
- 避免重複ê資訊
- 專注佇趨勢kap佇複雜視覺(比如圖表á是地圖)內底tshuē關鍵
",
+ "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
{count, plural, other {另外 # ê lâng}}kah意lí ê私人提起",
"search_popout.language_code": "ISO語言代碼",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index bb7ee3c086..b116008fac 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -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 {# 分钟}}",
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index ec6e4d0a9a..ed3fe0ee0a 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -63,6 +63,7 @@ body {
&.with-modals--active {
overflow-y: hidden;
+ overscroll-behavior: none;
}
}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 5da6d1fef3..f2feedaad3 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 422253344d..6953893a9f 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -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
diff --git a/config/locales/activerecord.sl.yml b/config/locales/activerecord.sl.yml
index 8b05d5d2cd..e4c4fe598f 100644
--- a/config/locales/activerecord.sl.yml
+++ b/config/locales/activerecord.sl.yml
@@ -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
diff --git a/config/locales/simple_form.sl.yml b/config/locales/simple_form.sl.yml
index 4562b7004b..5d55844aa9 100644
--- a/config/locales/simple_form.sl.yml
+++ b/config/locales/simple_form.sl.yml
@@ -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:
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 12b3aa44d4..457f676e84 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -315,6 +315,8 @@ zh-CN:
new:
create: 创建公告
title: 新公告
+ preview:
+ explanation_html: 此电子邮件将发送给
%{display_count} 用户。电子邮件将包含以下文本:
publish: 发布
published_msg: 公告已发布!
scheduled_for: 定时在 %{time}
diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js
index a051827c85..2e136543e8 100644
--- a/config/webpack/rules/babel.js
+++ b/config/webpack/rules/babel.js
@@ -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 = {
diff --git a/package.json b/package.json
index 8e03a7e458..e1e9f71850 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/spec/controllers/filters/statuses_controller_spec.rb b/spec/controllers/filters/statuses_controller_spec.rb
deleted file mode 100644
index 7bad403571..0000000000
--- a/spec/controllers/filters/statuses_controller_spec.rb
+++ /dev/null
@@ -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
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index bf818c1e1e..43e9ed087b 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -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
diff --git a/spec/requests/filters/statuses_spec.rb b/spec/requests/filters/statuses_spec.rb
index aa1d049da7..b462b56223 100644
--- a/spec/requests/filters/statuses_spec.rb
+++ b/spec/requests/filters/statuses_spec.rb
@@ -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
diff --git a/spec/system/account_notes_spec.rb b/spec/system/account_notes_spec.rb
new file mode 100644
index 0000000000..f8be96be86
--- /dev/null
+++ b/spec/system/account_notes_spec.rb
@@ -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
diff --git a/spec/system/filters/statuses_spec.rb b/spec/system/filters/statuses_spec.rb
new file mode 100644
index 0000000000..b353bd8674
--- /dev/null
+++ b/spec/system/filters/statuses_spec.rb
@@ -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
diff --git a/streaming/database.js b/streaming/database.js
index 60a3b34ef0..553c9149cc 100644
--- a/streaming/database.js
+++ b/streaming/database.js
@@ -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');
diff --git a/yarn.lock b/yarn.lock
index e043ec2234..48fd5b57a4 100644
--- a/yarn.lock
+++ b/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