diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index 925a63b16..21e44f33b 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -1,6 +1,6 @@ 'use strict'; -import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import { List as ImmutableList } from 'immutable'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; @@ -23,6 +23,7 @@ import MovedNote from 'soapbox/features/account_timeline/components/moved_note'; import ActionButton from 'soapbox/features/ui/components/action-button'; import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { normalizeAttachment } from 'soapbox/normalizers'; import { Account } from 'soapbox/types/entities'; import { isRemote } from 'soapbox/utils/accounts'; @@ -207,12 +208,9 @@ const Header: React.FC = ({ account }) => { }; const onAvatarClick = () => { - const avatar_url = account.avatar; - const avatar = ImmutableMap({ + const avatar = normalizeAttachment({ type: 'image', - preview_url: avatar_url, - url: avatar_url, - description: '', + url: account.avatar, }); dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 })); }; @@ -225,12 +223,9 @@ const Header: React.FC = ({ account }) => { }; const onHeaderClick = () => { - const header_url = account.header; - const header = ImmutableMap({ + const header = normalizeAttachment({ type: 'image', - preview_url: header_url, - url: header_url, - description: '', + url: account.header, }); dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 })); }; diff --git a/app/soapbox/features/ui/components/image_loader.js b/app/soapbox/features/ui/components/image-loader.tsx similarity index 74% rename from app/soapbox/features/ui/components/image_loader.js rename to app/soapbox/features/ui/components/image-loader.tsx index 8e6e8ec55..ae60420ca 100644 --- a/app/soapbox/features/ui/components/image_loader.js +++ b/app/soapbox/features/ui/components/image-loader.tsx @@ -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 { 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((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((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; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/media-modal.tsx b/app/soapbox/features/ui/components/media-modal.tsx new file mode 100644 index 000000000..a04808ece --- /dev/null +++ b/app/soapbox/features/ui/components/media-modal.tsx @@ -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, + status: Status, + account: Account, + index: number, + time?: number, + onClose: () => void, +} + +const MediaModal: React.FC = (props) => { + const { + media, + status, + account, + onClose, + time = 0, + } = props; + + const intl = useIntl(); + const history = useHistory(); + + const [index, setIndex] = useState(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 = (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 && ( + + ); + + const rightNav = media.size > 1 && ( + + ); + + 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 ( +
  • + +
  • + ); + }); + } + + 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 && ( + + + + )); + + if (attachment.type === 'image') { + return ( + + ); + } else if (attachment.type === 'video') { + return ( +