Merge remote-tracking branch 'origin/develop' into chats

This commit is contained in:
Chewbacca
2022-10-14 14:27:53 -04:00
55 changed files with 1310 additions and 1447 deletions

View File

@@ -79,7 +79,7 @@ const CompareHistoryModal: React.FC<ICompareHistoryModal> = ({ onClose, statusId
)}
<Text align='right' tag='span' theme='muted' size='sm'>
<FormattedDate value={new Date(version.created_at)} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
<FormattedDate value={new Date(version.created_at)} hour12 year='numeric' month='short' day='2-digit' hour='numeric' minute='2-digit' />
</Text>
</div>
);

View File

@@ -5,11 +5,11 @@ import { Banner, Button, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
const CtaBanner = () => {
const { singleUserMode } = useSoapboxConfig();
const { displayCta, singleUserMode } = useSoapboxConfig();
const siteTitle = useAppSelector((state) => state.instance.title);
const me = useAppSelector((state) => state.me);
if (me || singleUserMode) return null;
if (me || !displayCta || singleUserMode) return null;
return (
<div data-testid='cta-banner' className='hidden lg:block'>

View File

@@ -1,125 +0,0 @@
import classNames from 'clsx';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { changeUploadCompose } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
import ImageLoader from './image_loader';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = (dispatch, { id }) => ({
onSave: (x, y) => {
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
},
});
export default @connect(mapStateToProps, mapDispatchToProps)
class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
};
state = {
x: 0,
y: 0,
focusX: 0,
focusY: 0,
dragging: false,
};
componentDidMount() {
this.updatePositionFromMedia(this.props.media);
}
componentDidUpdate(prevProps) {
const { media } = this.props;
if (prevProps.media.get('id') !== media.get('id')) {
this.updatePositionFromMedia(media);
}
}
componentWillUnmount() {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
this.updatePosition(e);
this.setState({ dragging: true });
}
handleMouseMove = e => {
this.updatePosition(e);
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
}
updatePosition = e => {
const { x, y } = getPointerPosition(this.node, e);
const focusX = (x - .5) * 2;
const focusY = (y - .5) * -2;
this.setState({ x, y, focusX, focusY });
}
updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
if (focusX && focusY) {
const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5;
this.setState({ x, y, focusX, focusY });
} else {
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
}
}
setRef = c => {
this.node = c;
}
render() {
const { media } = this.props;
const { x, y, dragging } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
return (
<div className='modal-root__modal video-modal focal-point-modal'>
<div className={classNames('focal-point', { dragging })} ref={this.setRef}>
<ImageLoader
previewSrc={media.get('preview_url')}
src={media.get('url')}
width={width}
height={height}
/>
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
</div>
</div>
);
}
}

View File

@@ -1,19 +1,20 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import ZoomableImage from './zoomable_image';
import ZoomableImage from './zoomable-image';
export default class ImageLoader extends React.PureComponent {
type EventRemover = () => void;
static propTypes = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
previewSrc: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
interface IImageLoader {
alt?: string,
src: string,
previewSrc?: string,
width?: number,
height?: number,
onClick?: React.MouseEventHandler,
}
class ImageLoader extends React.PureComponent<IImageLoader> {
static defaultProps = {
alt: '',
@@ -27,8 +28,9 @@ export default class ImageLoader extends React.PureComponent {
width: null,
}
removers = [];
canvas = null;
removers: EventRemover[] = [];
canvas: HTMLCanvasElement | null = null;
_canvasContext: CanvasRenderingContext2D | null = null;
get canvasContext() {
if (!this.canvas) {
@@ -42,7 +44,7 @@ export default class ImageLoader extends React.PureComponent {
this.loadImage(this.props);
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: IImageLoader) {
if (prevProps.src !== this.props.src) {
this.loadImage(this.props);
}
@@ -52,7 +54,7 @@ export default class ImageLoader extends React.PureComponent {
this.removeEventListeners();
}
loadImage(props) {
loadImage(props: IImageLoader) {
this.removeEventListeners();
this.setState({ loading: true, error: false });
Promise.all([
@@ -66,7 +68,7 @@ export default class ImageLoader extends React.PureComponent {
.catch(() => this.setState({ loading: false, error: true }));
}
loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
loadPreviewCanvas = ({ previewSrc, width, height }: IImageLoader) => new Promise<void>((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
@@ -78,21 +80,23 @@ export default class ImageLoader extends React.PureComponent {
};
const handleLoad = () => {
removeEventListeners();
this.canvasContext.drawImage(image, 0, 0, width, height);
this.canvasContext?.drawImage(image, 0, 0, width || 0, height || 0);
resolve();
};
image.addEventListener('error', handleError);
image.addEventListener('load', handleLoad);
image.src = previewSrc;
image.src = previewSrc || '';
this.removers.push(removeEventListeners);
})
clearPreviewCanvas() {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
if (this.canvas && this.canvasContext) {
const { width, height } = this.canvas;
this.canvasContext.clearRect(0, 0, width, height);
}
}
loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
loadOriginalImage = ({ src }: IImageLoader) => new Promise<void>((resolve, reject) => {
const image = new Image();
const removeEventListeners = () => {
image.removeEventListener('error', handleError);
@@ -122,7 +126,7 @@ export default class ImageLoader extends React.PureComponent {
return typeof width === 'number' && typeof height === 'number';
}
setCanvasRef = c => {
setCanvasRef = (c: HTMLCanvasElement) => {
this.canvas = c;
if (c) this.setState({ width: c.offsetWidth });
}
@@ -157,3 +161,5 @@ export default class ImageLoader extends React.PureComponent {
}
}
export default ImageLoader;

View File

@@ -0,0 +1,300 @@
import classNames from 'clsx';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import ReactSwipeableViews from 'react-swipeable-views';
import ExtendedVideoPlayer from 'soapbox/components/extended_video_player';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon_button';
import Audio from 'soapbox/features/audio';
import Video from 'soapbox/features/video';
import ImageLoader from './image-loader';
import type { List as ImmutableList } from 'immutable';
import type { Account, Attachment, Status } from 'soapbox/types/entities';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});
interface IMediaModal {
media: ImmutableList<Attachment>,
status: Status,
account: Account,
index: number,
time?: number,
onClose: () => void,
}
const MediaModal: React.FC<IMediaModal> = (props) => {
const {
media,
status,
account,
onClose,
time = 0,
} = props;
const intl = useIntl();
const history = useHistory();
const [index, setIndex] = useState<number | null>(null);
const [navigationHidden, setNavigationHidden] = useState(false);
const handleSwipe = (index: number) => {
setIndex(index % media.size);
};
const handleNextClick = () => {
setIndex((getIndex() + 1) % media.size);
};
const handlePrevClick = () => {
setIndex((media.size + getIndex() - 1) % media.size);
};
const handleChangeIndex: React.MouseEventHandler<HTMLButtonElement> = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
setIndex(index % media.size);
};
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
handlePrevClick();
e.preventDefault();
e.stopPropagation();
break;
case 'ArrowRight':
handleNextClick();
e.preventDefault();
e.stopPropagation();
break;
}
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown, false);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
const getIndex = () => {
return index !== null ? index : props.index;
};
const toggleNavigation = () => {
setNavigationHidden(!navigationHidden);
};
const handleStatusClick: React.MouseEventHandler = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/@${account.acct}/posts/${status.id}`);
onClose();
}
};
const handleCloserClick: React.MouseEventHandler = ({ currentTarget }) => {
const whitelist = ['zoomable-image'];
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
const isClickOutside = currentTarget === activeSlide || !activeSlide?.contains(currentTarget);
const isWhitelisted = whitelist.some(w => currentTarget.classList.contains(w));
if (isClickOutside || isWhitelisted) {
onClose();
}
};
let pagination: React.ReactNode[] = [];
const leftNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--left'
onClick={handlePrevClick}
aria-label={intl.formatMessage(messages.previous)}
>
<Icon src={require('@tabler/icons/arrow-left.svg')} />
</button>
);
const rightNav = media.size > 1 && (
<button
tabIndex={0}
className='media-modal__nav media-modal__nav--right'
onClick={handleNextClick}
aria-label={intl.formatMessage(messages.next)}
>
<Icon src={require('@tabler/icons/arrow-right.svg')} />
</button>
);
if (media.size > 1) {
pagination = media.toArray().map((item, i) => {
const classes = ['media-modal__button'];
if (i === getIndex()) {
classes.push('media-modal__button--active');
}
return (
<li className='media-modal__page-dot' key={i}>
<button
tabIndex={0}
className={classes.join(' ')}
onClick={handleChangeIndex}
data-index={i}
>
{i + 1}
</button>
</li>
);
});
}
const isMultiMedia = media.map((image) => {
if (image.type !== 'image') {
return true;
}
return false;
}).toArray();
const content = media.map(attachment => {
const width = (attachment.meta.getIn(['original', 'width']) || undefined) as number | undefined;
const height = (attachment.meta.getIn(['original', 'height']) || undefined) as number | undefined;
const link = (status && account && (
<a href={status.url} onClick={handleStatusClick}>
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
</a>
));
if (attachment.type === 'image') {
return (
<ImageLoader
previewSrc={attachment.preview_url}
src={attachment.url}
width={width}
height={height}
alt={attachment.description}
key={attachment.url}
onClick={toggleNavigation}
/>
);
} else if (attachment.type === 'video') {
return (
<Video
preview={attachment.preview_url}
blurhash={attachment.blurhash}
src={attachment.url}
width={width}
height={height}
startTime={time}
onCloseVideo={onClose}
detailed
link={link}
alt={attachment.description}
key={attachment.url}
/>
);
} else if (attachment.type === 'audio') {
return (
<Audio
src={attachment.url}
alt={attachment.description}
poster={attachment.preview_url !== attachment.url ? attachment.preview_url : (status.getIn(['account', 'avatar_static'])) as string | undefined}
backgroundColor={attachment.meta.getIn(['colors', 'background']) as string | undefined}
foregroundColor={attachment.meta.getIn(['colors', 'foreground']) as string | undefined}
accentColor={attachment.meta.getIn(['colors', 'accent']) as string | undefined}
duration={attachment.meta.getIn(['original', 'duration'], 0) as number | undefined}
key={attachment.url}
/>
);
} else if (attachment.type === 'gifv') {
return (
<ExtendedVideoPlayer
src={attachment.url}
muted
controls={false}
width={width}
link={link}
height={height}
key={attachment.preview_url}
alt={attachment.description}
onClick={toggleNavigation}
/>
);
}
return null;
}).toArray();
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle: React.CSSProperties = {
width: '100%',
height: '100%',
};
const containerStyle: React.CSSProperties = {
alignItems: 'center', // center vertically
};
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={handleCloserClick}
>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={handleSwipe}
index={getIndex()}
>
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<IconButton
className='media-modal__close'
title={intl.formatMessage(messages.close)}
src={require('@tabler/icons/x.svg')}
onClick={onClose}
/>
{leftNav}
{rightNav}
{(status && !isMultiMedia[getIndex()]) && (
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.url} onClick={handleStatusClick}>
<FormattedMessage id='lightbox.view_context' defaultMessage='View context' />
</a>
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
</div>
</div>
);
};
export default MediaModal;

View File

@@ -1,274 +0,0 @@
import classNames from 'clsx';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import ReactSwipeableViews from 'react-swipeable-views';
import ExtendedVideoPlayer from 'soapbox/components/extended_video_player';
import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon_button';
import Audio from 'soapbox/features/audio';
import Video from 'soapbox/features/video';
import ImageLoader from './image_loader';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});
export default @injectIntl @withRouter
class MediaModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.list.isRequired,
status: ImmutablePropTypes.record,
account: ImmutablePropTypes.record,
index: PropTypes.number.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
history: PropTypes.object,
};
state = {
index: null,
navigationHidden: false,
};
handleSwipe = (index) => {
this.setState({ index: index % this.props.media.size });
}
handleNextClick = () => {
this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
}
handlePrevClick = () => {
this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
}
handleChangeIndex = (e) => {
const index = Number(e.currentTarget.getAttribute('data-index'));
this.setState({ index: index % this.props.media.size });
}
handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowLeft':
this.handlePrevClick();
e.preventDefault();
e.stopPropagation();
break;
case 'ArrowRight':
this.handleNextClick();
e.preventDefault();
e.stopPropagation();
break;
}
}
componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown, false);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
}
getIndex() {
return this.state.index !== null ? this.state.index : this.props.index;
}
toggleNavigation = () => {
this.setState(prevState => ({
navigationHidden: !prevState.navigationHidden,
}));
};
handleStatusClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
const { status, account } = this.props;
const acct = account.get('acct');
const statusId = status.get('id');
this.props.history.push(`/@${acct}/posts/${statusId}`);
this.props.onClose(null, true);
}
}
handleCloserClick = ({ target }) => {
const whitelist = ['zoomable-image'];
const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]');
const isClickOutside = target === activeSlide || !activeSlide.contains(target);
const isWhitelisted = whitelist.some(w => target.classList.contains(w));
if (isClickOutside || isWhitelisted) {
this.props.onClose();
}
}
render() {
const { media, status, account, intl, onClose } = this.props;
const { navigationHidden } = this.state;
const index = this.getIndex();
let pagination = [];
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 src={require('@tabler/icons/arrow-left.svg')} />
</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 src={require('@tabler/icons/arrow-right.svg')} />
</button>
);
if (media.size > 1) {
pagination = media.map((item, i) => {
const classes = ['media-modal__button'];
if (i === index) {
classes.push('media-modal__button--active');
}
return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
});
}
const isMultiMedia = media.map((image) => {
if (image.get('type') !== 'image') {
return true;
}
return false;
}).toArray();
const content = media.map(attachment => {
const width = attachment.getIn(['meta', 'original', 'width']) || null;
const height = attachment.getIn(['meta', 'original', 'height']) || null;
const link = (status && account && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>);
if (attachment.get('type') === 'image') {
return (
<ImageLoader
previewSrc={attachment.get('preview_url')}
src={attachment.get('url')}
width={width}
height={height}
alt={attachment.get('description')}
key={attachment.get('url')}
onClick={this.toggleNavigation}
/>
);
} else if (attachment.get('type') === 'video') {
const { time } = this.props;
return (
<Video
preview={attachment.get('preview_url')}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
width={attachment.get('width')}
height={attachment.get('height')}
startTime={time || 0}
onCloseVideo={onClose}
detailed
link={link}
alt={attachment.get('description')}
key={attachment.get('url')}
/>
);
} else if (attachment.get('type') === 'audio') {
return (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
poster={attachment.get('preview_url') !== attachment.get('url') ? attachment.get('preview_url') : (status && status.getIn(['account', 'avatar_static']))}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
key={attachment.get('url')}
/>
);
} else if (attachment.get('type') === 'gifv') {
return (
<ExtendedVideoPlayer
src={attachment.get('url')}
muted
controls={false}
width={width}
link={link}
height={height}
key={attachment.get('preview_url')}
alt={attachment.get('description')}
onClick={this.toggleNavigation}
/>
);
}
return null;
}).toArray();
// you can't use 100vh, because the viewport height is taller
// than the visible part of the document in some mobile
// browsers when it's address bar is visible.
// https://developers.google.com/web/updates/2016/12/url-bar-resizing
const swipeableViewsStyle = {
width: '100%',
height: '100%',
};
const containerStyle = {
alignItems: 'center', // center vertically
};
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={this.handleCloserClick}
>
<ReactSwipeableViews
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
onSwitching={this.handleSwitching}
index={index}
>
{content}
</ReactSwipeableViews>
</div>
<div className={navigationClassName}>
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} src={require('@tabler/icons/x.svg')} onClick={onClose} />
{leftNav}
{rightNav}
{(status && !isMultiMedia[index]) && (
<div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
<a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
</div>
)}
<ul className='media-modal__pagination'>
{pagination}
</ul>
</div>
</div>
);
}
}

View File

@@ -15,7 +15,6 @@ import {
ListAdder,
MissingDescriptionModal,
ActionsModal,
FocalPointModal,
HotkeysModal,
ComposeModal,
ReplyMentionsModal,
@@ -51,7 +50,6 @@ const MODAL_COMPONENTS = {
'ACTIONS': ActionsModal,
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': FocalPointModal,
'LIST_ADDER': ListAdder,
'HOTKEYS': HotkeysModal,
'COMPOSE': ComposeModal,

View File

@@ -22,8 +22,8 @@ const dateFormatOptions: FormatDateOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour12: false,
hour: '2-digit',
hour12: true,
hour: 'numeric',
minute: '2-digit',
};

View File

@@ -1,26 +1,57 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { setFilter } from 'soapbox/actions/search';
import Hashtag from 'soapbox/components/hashtag';
import { Widget } from 'soapbox/components/ui';
import { Text, Widget } from 'soapbox/components/ui';
import PlaceholderSidebarTrends from 'soapbox/features/placeholder/components/placeholder-sidebar-trends';
import { useAppDispatch } from 'soapbox/hooks';
import useTrends from 'soapbox/queries/trends';
interface ITrendsPanel {
limit: number
}
const messages = defineMessages({
viewAll: {
id: 'trendsPanel.viewAll',
defaultMessage: 'View all',
},
});
const TrendsPanel = ({ limit }: ITrendsPanel) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { data: trends, isFetching } = useTrends();
if (trends?.length === 0 || isFetching) {
const setHashtagsFilter = () => {
dispatch(setFilter('hashtags'));
};
if (!isFetching && !trends?.length) {
return null;
}
return (
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
{trends?.slice(0, limit).map((hashtag) => (
<Hashtag key={hashtag.name} hashtag={hashtag} />
))}
<Widget
title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}
action={
<Link to='/search' onClick={setHashtagsFilter}>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
{intl.formatMessage(messages.viewAll)}
</Text>
</Link>
}
>
{isFetching ? (
<PlaceholderSidebarTrends limit={limit} />
) : (
trends?.slice(0, limit).map((hashtag) => (
<Hashtag key={hashtag.name} hashtag={hashtag} />
))
)}
</Widget>
);
};

View File

@@ -1,28 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const getMidpoint = (p1, p2) => ({
type Point = { x: number, y: number };
const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({
x: (p1.clientX + p2.clientX) / 2,
y: (p1.clientY + p2.clientY) / 2,
});
const getDistance = (p1, p2) =>
const getDistance = (p1: React.Touch, p2: React.Touch): number =>
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));
const clamp = (min: number, max: number, value: number): number => Math.min(max, Math.max(min, value));
export default class ZoomableImage extends React.PureComponent {
interface IZoomableImage {
alt?: string,
src: string,
onClick?: React.MouseEventHandler,
}
static propTypes = {
alt: PropTypes.string,
src: PropTypes.string.isRequired,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
}
class ZoomableImage extends React.PureComponent<IZoomableImage> {
static defaultProps = {
alt: '',
@@ -34,39 +33,32 @@ export default class ZoomableImage extends React.PureComponent {
scale: MIN_SCALE,
}
removers = [];
container = null;
image = null;
lastTouchEndTime = 0;
container: HTMLDivElement | null = null;
image: HTMLImageElement | null = null;
lastDistance = 0;
componentDidMount() {
let handler = this.handleTouchStart;
this.container.addEventListener('touchstart', handler);
this.removers.push(() => this.container.removeEventListener('touchstart', handler));
handler = this.handleTouchMove;
this.container?.addEventListener('touchstart', this.handleTouchStart);
// 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));
this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false });
}
componentWillUnmount() {
this.removeEventListeners();
this.container?.removeEventListener('touchstart', this.handleTouchStart);
this.container?.removeEventListener('touchend', this.handleTouchMove);
}
removeEventListeners() {
this.removers.forEach(listeners => listeners());
this.removers = [];
}
handleTouchStart = e => {
handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
const [p1, p2] = Array.from(e.touches);
this.lastDistance = getDistance(...e.touches);
this.lastDistance = getDistance(p1, p2);
}
handleTouchMove = e => {
handleTouchMove = (e: TouchEvent) => {
if (!this.container) return;
const { scrollTop, scrollHeight, clientHeight } = this.container;
if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
// prevent propagating event to MediaModal
@@ -78,17 +70,19 @@ export default class ZoomableImage extends React.PureComponent {
e.preventDefault();
e.stopPropagation();
const distance = getDistance(...e.touches);
const midpoint = getMidpoint(...e.touches);
const [p1, p2] = Array.from(e.touches);
const distance = getDistance(p1, p2);
const midpoint = getMidpoint(p1, p2);
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) {
zoom(nextScale: number, midpoint: Point) {
if (!this.container) return;
const { scale } = this.state;
const { scrollLeft, scrollTop } = this.container;
@@ -102,23 +96,24 @@ export default class ZoomableImage extends React.PureComponent {
const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
this.setState({ scale: nextScale }, () => {
if (!this.container) return;
this.container.scrollLeft = nextScrollLeft;
this.container.scrollTop = nextScrollTop;
});
}
handleClick = e => {
handleClick: React.MouseEventHandler = e => {
// don't propagate event to MediaModal
e.stopPropagation();
const handler = this.props.onClick;
if (handler) handler();
if (handler) handler(e);
}
setContainerRef = c => {
setContainerRef = (c: HTMLDivElement) => {
this.container = c;
}
setImageRef = c => {
setImageRef = (c: HTMLImageElement) => {
this.image = c;
}
@@ -150,3 +145,5 @@ export default class ZoomableImage extends React.PureComponent {
}
}
export default ZoomableImage;