From d162bc5d175c87073670685ac969af18740873bd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 12:03:10 -0500 Subject: [PATCH 01/15] Webpack: only use [chunkhash] and [contenthash] in production builds https://webpack.js.org/guides/build-performance/#avoid-production-specific-tooling --- webpack/production.js | 7 +++++++ webpack/shared.js | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/webpack/production.js b/webpack/production.js index e1d833abc..1f458ece6 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -17,6 +17,13 @@ module.exports = merge(sharedConfig, { devtool: 'source-map', stats: 'errors-warnings', bail: true, + + output: { + filename: 'packs/js/[name]-[chunkhash].js', + chunkFilename: 'packs/js/[name]-[chunkhash].chunk.js', + hotUpdateChunkFilename: 'packs/js/[id]-[contenthash].hot-update.js', + }, + optimization: { minimize: true, }, diff --git a/webpack/shared.js b/webpack/shared.js index 6cb916290..8d1d1fcf5 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -52,9 +52,9 @@ module.exports = { }, output: { - filename: 'packs/js/[name]-[chunkhash].js', - chunkFilename: 'packs/js/[name]-[chunkhash].chunk.js', - hotUpdateChunkFilename: 'packs/js/[id]-[contenthash].hot-update.js', + filename: 'packs/js/[name].js', + chunkFilename: 'packs/js/[name].chunk.js', + hotUpdateChunkFilename: 'packs/js/[id].hot-update.js', path: output.path, publicPath: join(FE_SUBDIRECTORY, '/'), }, From 3b2b1ab05f0956a1e94eb6181ea5e41caf6b25e8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 12:27:52 -0500 Subject: [PATCH 02/15] Move .instance-description to css file --- app/soapbox/features/landing_page/index.tsx | 2 ++ .../landing_page/instance-description.css | 14 ++++++++++++++ app/styles/basics.scss | 17 ----------------- 3 files changed, 16 insertions(+), 17 deletions(-) create mode 100644 app/soapbox/features/landing_page/instance-description.css diff --git a/app/soapbox/features/landing_page/index.tsx b/app/soapbox/features/landing_page/index.tsx index 76e77bcfa..937e599c8 100644 --- a/app/soapbox/features/landing_page/index.tsx +++ b/app/soapbox/features/landing_page/index.tsx @@ -8,6 +8,8 @@ import RegistrationForm from 'soapbox/features/auth_login/components/registratio import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; import { capitalize } from 'soapbox/utils/strings'; +import './instance-description.css'; + const LandingPage = () => { const dispatch = useAppDispatch(); const features = useFeatures(); diff --git a/app/soapbox/features/landing_page/instance-description.css b/app/soapbox/features/landing_page/instance-description.css new file mode 100644 index 000000000..f67097de0 --- /dev/null +++ b/app/soapbox/features/landing_page/instance-description.css @@ -0,0 +1,14 @@ +/* Instance HTML from the API. */ +.instance-description a { + @apply underline; +} + +.instance-description b, +.instance-description strong { + @apply font-bold; +} + +.instance-description i, +.instance-description em { + @apply italic; +} diff --git a/app/styles/basics.scss b/app/styles/basics.scss index 9384c94f2..9db26912a 100644 --- a/app/styles/basics.scss +++ b/app/styles/basics.scss @@ -112,20 +112,3 @@ noscript { div[data-viewport-type="window"] { position: static !important; } - -// Instance HTML from the API. -.instance-description { - a { - @apply underline; - } - - b, - strong { - @apply font-bold; - } - - i, - em { - @apply italic; - } -} From 0d5dfad8cc9b3ad1a2023d71dba81fcd48183eab Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 12:59:19 -0500 Subject: [PATCH 03/15] Move StatusContent css into status-content.css --- app/soapbox/components/status-content.css | 60 +++++++++ app/soapbox/components/status_content.tsx | 15 ++- .../normalizers/soapbox/soapbox_config.ts | 1 + app/soapbox/utils/greentext.ts | 2 +- app/styles/basics.scss | 8 -- app/styles/components/status.scss | 122 ------------------ tailwind.config.js | 6 + 7 files changed, 76 insertions(+), 138 deletions(-) create mode 100644 app/soapbox/components/status-content.css diff --git a/app/soapbox/components/status-content.css b/app/soapbox/components/status-content.css new file mode 100644 index 000000000..1c9e9dff9 --- /dev/null +++ b/app/soapbox/components/status-content.css @@ -0,0 +1,60 @@ +.status-content strong { + @apply font-bold; +} + +.status-content em { + @apply italic; +} + +.status-content ul, +.status-content ol { + @apply pl-10 mb-5; +} + +.status-content ul { + @apply list-disc list-outside; +} + +.status-content ol { + @apply list-decimal list-outside; +} + +.status-content blockquote { + @apply py-1 pl-4 mb-5 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400; +} + +.status-content code { + @apply cursor-text font-mono; +} + +.status-content p > code, +.status-content pre { + @apply bg-gray-100 dark:bg-primary-800; +} + +/* Inline code */ +.status-content p > code { + @apply py-0.5 px-1 rounded-sm; +} + +/* Code block */ +.status-content pre { + @apply py-2 px-3 mb-5 leading-6 overflow-x-auto rounded-md break-all; +} + +.status-content pre:last-child { + @apply mb-0; +} + +/* Markdown images */ +.status-content img:not(.emojione):not([width][height]) { + @apply w-full h-72 object-contain rounded-lg overflow-hidden my-5 block; +} + +.status-content .big-emoji img.emojione { + @apply inline w-9 h-9 p-1; +} + +.status-content .status-link { + @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue; +} diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx index 2a8972315..468cb6d72 100644 --- a/app/soapbox/components/status_content.tsx +++ b/app/soapbox/components/status_content.tsx @@ -11,6 +11,7 @@ import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content'; import { isRtl } from '../rtl'; import Poll from './polls/poll'; +import './status-content.css'; import type { Status, Mention } from 'soapbox/types/entities'; @@ -28,7 +29,7 @@ interface IReadMoreButton { /** Button to expand a truncated status (due to too much content) */ const ReadMoreButton: React.FC = ({ onClick }) => ( - @@ -216,11 +217,11 @@ const StatusContent: React.FC = ({ status, expanded = false, onE const content = { __html: parsedHtml }; const spoilerContent = { __html: status.spoilerHtml }; const directionStyle: React.CSSProperties = { direction: 'ltr' }; - const className = classNames('status__content', { - 'status__content--with-action': onClick, + const className = classNames('status-content', { + 'cursor-pointer': onClick, 'status__content--with-spoiler': status.spoiler_text.length > 0, - 'status__content--collapsed': collapsed, - 'status__content--big': onlyEmoji, + 'max-h-[300px]': collapsed, + 'leading-normal big-emoji': onlyEmoji, }); if (isRtl(status.search_index)) { @@ -286,8 +287,8 @@ const StatusContent: React.FC = ({ status, expanded = false, onE ref={node} tabIndex={0} key='content' - className={classNames('status__content', { - 'status__content--big': onlyEmoji, + className={classNames('status-content', { + 'leading-normal big-emoji': onlyEmoji, })} style={directionStyle} dangerouslySetInnerHTML={content} diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index 0e6b5c280..75426e6c0 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -44,6 +44,7 @@ const DEFAULT_COLORS = ImmutableMap({ 900: '#7f1d1d', }), 'sea-blue': '#2feecc', + 'greentext': '#789922', }); export const PromoPanelItemRecord = ImmutableRecord({ diff --git a/app/soapbox/utils/greentext.ts b/app/soapbox/utils/greentext.ts index 52a25e2be..eab93b22a 100644 --- a/app/soapbox/utils/greentext.ts +++ b/app/soapbox/utils/greentext.ts @@ -11,7 +11,7 @@ export const addGreentext = (html: string): string => { .replace(/@\w+/gi, '') // remove mentions (even failed ones) .trim() .startsWith('>')) { - return `${string}`; + return `${string}`; } else { return string; } diff --git a/app/styles/basics.scss b/app/styles/basics.scss index 9db26912a..bc3d64ec3 100644 --- a/app/styles/basics.scss +++ b/app/styles/basics.scss @@ -99,14 +99,6 @@ noscript { } } -.floating-link { - @apply w-full h-full inset-0 absolute z-10; -} - -.greentext { - color: #789922; -} - // Virtuoso empty placeholder fix. // https://gitlab.com/petyosi/soapbox-fe/-/commit/1e22c39934b60e5e186de804060ecfdf1955b506 div[data-viewport-type="window"] { diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index 83670fc38..c46dfc7ee 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -1,125 +1,3 @@ -.status__content { - p, - li { - strong { - font-weight: bold; - } - } - - p, - li { - em { - font-style: italic; - } - } - - ul, - ol { - @apply pl-10; - } - - ul { - list-style: disc outside none; - } - - ol { - list-style: decimal outside none; - } - - blockquote { - @apply py-1 pl-4 border-l-4 border-solid border-gray-400 text-gray-500 dark:text-gray-400; - } - - code { - font-family: 'Roboto Mono', monospace; - cursor: text; - } - - p > code, - pre { - @apply bg-gray-100 dark:bg-primary-800; - } - - /* Inline code */ - p > code { - padding: 2px 4px; - border-radius: 4px; - } - - /* Code block */ - pre { - line-height: 1.6em; - overflow-x: auto; - border-radius: 6px; - padding: 8px 12px; - margin-bottom: 20px; - word-break: break-all; - - &:last-child { - margin-bottom: 0; - } - } - - /* Markdown images */ - img:not(.emojione):not([width][height]) { - width: 100%; - height: 285.188px; - object-fit: contain; - background: var(--background-color); - border-radius: 4px; - overflow: hidden; - margin: 20px 0; - display: block; - } - - &--big { - line-height: normal !important; - - img.emojione { - display: inline; - width: 36px; - height: 36px; - padding: 5px; - } - } - - &--quote { - ul, - ol { - @apply pl-4; - } - - blockquote { - @apply pl-2; - } - } -} - -.status__content > ul, -.status__content > ol { - margin-bottom: 20px; -} - -.status__content > blockquote { - margin-bottom: 20px; -} - -.status__content--with-action { - cursor: pointer; -} - -.status__content.status__content--collapsed { - max-height: 20px * 15; // 15 lines is roughly above 500 characters -} - -.status__content__read-more-button { - @apply flex items-center text-gray-900 dark:text-gray-300 border-0 bg-transparent p-0 pt-2 hover:underline active:underline; -} - -.status-link { - @apply hover:underline text-primary-600 dark:text-accent-blue hover:text-primary-800 dark:hover:text-accent-blue; -} - .status { @apply min-h-[54px] cursor-default; diff --git a/tailwind.config.js b/tailwind.config.js index ff34b2da3..d24150dbf 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -35,6 +35,11 @@ module.exports = { 'Segoe UI Symbol', 'Noto Color Emoji', ], + 'mono': [ + 'Roboto Mono', + 'ui-monospace', + 'mono', + ], }, colors: parseColorMatrix({ // Define color matrix (of available colors) @@ -49,6 +54,7 @@ module.exports = { 'gradient-start': true, 'gradient-end': true, 'sea-blue': true, + 'greentext': true, }), animation: { 'sonar-scale-4': 'sonar-scale-4 3s linear infinite', From d3683ab7ef64c6c4c0ed092dd622b14a227a9822 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 13:15:37 -0500 Subject: [PATCH 04/15] StatusContent: migrate more to css, fix SpoilerButton on dark theme --- app/soapbox/components/status-content.css | 22 +++++++++ app/soapbox/components/status_content.tsx | 17 ++++--- app/styles/components/status.scss | 57 ----------------------- 3 files changed, 33 insertions(+), 63 deletions(-) diff --git a/app/soapbox/components/status-content.css b/app/soapbox/components/status-content.css index 1c9e9dff9..3d38b6eb4 100644 --- a/app/soapbox/components/status-content.css +++ b/app/soapbox/components/status-content.css @@ -1,3 +1,15 @@ +.status-content p { + @apply mb-5 whitespace-pre-wrap; +} + +.status-content p:last-child { + @apply mb-0.5; +} + +.status-content a { + @apply text-primary-600 dark:text-accent-blue hover:underline; +} + .status-content strong { @apply font-bold; } @@ -46,11 +58,21 @@ @apply mb-0; } +/* Inline emojis */ +.status-content .emojione { + @apply w-5 h-5 -mt-[3px] inline; +} + /* Markdown images */ .status-content img:not(.emojione):not([width][height]) { @apply w-full h-72 object-contain rounded-lg overflow-hidden my-5 block; } +/* User setting to underline links */ +body.underline-links .status-content a { + @apply underline; +} + .status-content .big-emoji img.emojione { @apply inline w-9 h-9 p-1; } diff --git a/app/soapbox/components/status_content.tsx b/app/soapbox/components/status_content.tsx index 468cb6d72..7d771ec37 100644 --- a/app/soapbox/components/status_content.tsx +++ b/app/soapbox/components/status_content.tsx @@ -49,7 +49,7 @@ const SpoilerButton: React.FC = ({ onClick, hidden, tabIndex }) 'inline-block rounded-md px-1.5 py-0.5 ml-[0.5em]', 'text-gray-900 dark:text-gray-100', 'font-bold text-[11px] uppercase', - 'bg-primary-100 dark:bg-primary-900', + 'bg-primary-100 dark:bg-primary-800', 'hover:bg-primary-300 dark:hover:bg-primary-600', 'focus:bg-primary-200 dark:focus:bg-primary-600', 'hover:no-underline', @@ -213,13 +213,16 @@ const StatusContent: React.FC = ({ status, expanded = false, onE } const isHidden = onExpandedToggle ? !expanded : hidden; + const withSpoiler = status.spoiler_text.length > 0; + + const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none'; const content = { __html: parsedHtml }; const spoilerContent = { __html: status.spoilerHtml }; const directionStyle: React.CSSProperties = { direction: 'ltr' }; - const className = classNames('status-content', { + const className = classNames(baseClassName, 'status-content', { 'cursor-pointer': onClick, - 'status__content--with-spoiler': status.spoiler_text.length > 0, + 'whitespace-normal': withSpoiler, 'max-h-[300px]': collapsed, 'leading-normal big-emoji': onlyEmoji, }); @@ -243,8 +246,10 @@ const StatusContent: React.FC = ({ status, expanded = false, onE
= ({ status, expanded = false, onE ref={node} tabIndex={0} key='content' - className={classNames('status-content', { + className={classNames(baseClassName, 'status-content', { 'leading-normal big-emoji': onlyEmoji, })} style={directionStyle} diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index c46dfc7ee..a8c8e4561 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -94,63 +94,6 @@ padding: 10px; } -.status__content { - @apply text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative; - - &:focus { - @apply outline-none; - } - - &.status__content--with-spoiler { - @apply whitespace-normal; - - .status__content__text { - @apply whitespace-pre-wrap; - } - } - - .emojione { - width: 20px; - height: 20px; - margin: -3px 0 0; - display: inline; - } - - p { - margin-bottom: 20px; - white-space: pre-wrap; - - &:last-child { - margin-bottom: 2px; - } - } - - a { - @apply text-primary-600 dark:text-accent-blue hover:underline; - - .fa { - color: var(--primary-text-color); - } - } - - .status__content__text { - display: none; - - &.status__content__text--visible { - display: block; - } - } -} - -.underline-links { - .status__content, - .reply-indicator__content { - a { - text-decoration: underline; - } - } -} - .focusable:focus, .focusable-within:focus-within { outline: 0; /* Required b/c HotKeys lib sets this outline */ From 6d77e0327fb6c001b59607f5f06ffea484686db1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 13:35:26 -0500 Subject: [PATCH 05/15] Make .emojione styles global again --- app/soapbox/components/status-content.css | 5 ----- app/styles/basics.scss | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/soapbox/components/status-content.css b/app/soapbox/components/status-content.css index 3d38b6eb4..7c1317d16 100644 --- a/app/soapbox/components/status-content.css +++ b/app/soapbox/components/status-content.css @@ -58,11 +58,6 @@ @apply mb-0; } -/* Inline emojis */ -.status-content .emojione { - @apply w-5 h-5 -mt-[3px] inline; -} - /* Markdown images */ .status-content img:not(.emojione):not([width][height]) { @apply w-full h-72 object-contain rounded-lg overflow-hidden my-5 block; diff --git a/app/styles/basics.scss b/app/styles/basics.scss index bc3d64ec3..9a9eac717 100644 --- a/app/styles/basics.scss +++ b/app/styles/basics.scss @@ -99,6 +99,10 @@ noscript { } } +.emojione { + @apply w-5 h-5 -mt-[3px] inline; +} + // Virtuoso empty placeholder fix. // https://gitlab.com/petyosi/soapbox-fe/-/commit/1e22c39934b60e5e186de804060ecfdf1955b506 div[data-viewport-type="window"] { From 775cfad4dcb5d3783e566e09c910fcdd8766ceba Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 14:19:22 -0500 Subject: [PATCH 06/15] Restore .floating-link for now --- app/styles/chats.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/styles/chats.scss b/app/styles/chats.scss index 5bfb5362c..fe3d2182a 100644 --- a/app/styles/chats.scss +++ b/app/styles/chats.scss @@ -400,3 +400,7 @@ font-size: 13px; opacity: 0.8; } + +.floating-link { + @apply w-full h-full inset-0 absolute z-10; +} From 1069546b50df87727c9c95602922d7e90f7a0b56 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 18:05:23 -0500 Subject: [PATCH 07/15] MediaGallery: convert to TSX+FC (first pass) --- app/soapbox/components/media_gallery.js | 653 ----------------------- app/soapbox/components/media_gallery.tsx | 648 ++++++++++++++++++++++ app/soapbox/normalizers/attachment.ts | 2 +- 3 files changed, 649 insertions(+), 654 deletions(-) delete mode 100644 app/soapbox/components/media_gallery.js create mode 100644 app/soapbox/components/media_gallery.tsx diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js deleted file mode 100644 index 7bbbbd5b7..000000000 --- a/app/soapbox/components/media_gallery.js +++ /dev/null @@ -1,653 +0,0 @@ -import classNames from 'clsx'; -import { Map as ImmutableMap, is } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { getSettings } from 'soapbox/actions/settings'; -import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; -import StillImage from 'soapbox/components/still_image'; -import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload'; -import { truncateFilename } from 'soapbox/utils/media'; - -import { isIOS } from '../is_mobile'; -import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio'; - -import { Button, Text } from './ui'; - -const ATTACHMENT_LIMIT = 4; -const MAX_FILENAME_LENGTH = 45; - -const messages = defineMessages({ - toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' }, -}); - -const mapStateToItemProps = state => ({ - autoPlayGif: getSettings(state).get('autoPlayGif'), -}); - -const withinLimits = aspectRatio => { - return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio; -}; - -const shouldLetterbox = attachment => { - const aspectRatio = attachment.getIn(['meta', 'original', 'aspect']); - if (!aspectRatio) return true; - - return !withinLimits(aspectRatio); -}; - -@connect(mapStateToItemProps) -class Item extends React.PureComponent { - - static propTypes = { - attachment: ImmutablePropTypes.record.isRequired, - standalone: PropTypes.bool, - index: PropTypes.number.isRequired, - size: PropTypes.number.isRequired, - onClick: PropTypes.func.isRequired, - displayWidth: PropTypes.number, - visible: PropTypes.bool.isRequired, - dimensions: PropTypes.object, - autoPlayGif: PropTypes.bool, - last: PropTypes.bool, - total: PropTypes.number, - }; - - static defaultProps = { - standalone: false, - index: 0, - size: 1, - }; - - state = { - loaded: false, - }; - - handleMouseEnter = (e) => { - if (this.hoverToPlay()) { - e.target.play(); - } - } - - handleMouseLeave = (e) => { - if (this.hoverToPlay()) { - e.target.pause(); - e.target.currentTime = 0; - } - } - - hoverToPlay() { - const { attachment, autoPlayGif } = this.props; - return !autoPlayGif && attachment.get('type') === 'gifv'; - } - - handleClick = (e) => { - const { index, onClick } = this.props; - - if (isIOS() && !e.target.autoPlay) { - e.target.autoPlay = true; - e.preventDefault(); - } else { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - if (this.hoverToPlay()) { - e.target.pause(); - e.target.currentTime = 0; - } - e.preventDefault(); - onClick(index); - } - } - - e.stopPropagation(); - } - - handleImageLoad = () => { - this.setState({ loaded: true }); - } - - handleVideoHover = ({ target: video }) => { - video.playbackRate = 3.0; - video.play(); - } - - handleVideoLeave = ({ target: video }) => { - video.pause(); - video.currentTime = 0; - } - - render() { - const { attachment, standalone, visible, dimensions, autoPlayGif, last, total } = this.props; - - let width = 100; - let height = '100%'; - let top = 'auto'; - let left = 'auto'; - let bottom = 'auto'; - let right = 'auto'; - let float = 'left'; - let position = 'relative'; - - if (dimensions) { - width = dimensions.w; - height = dimensions.h; - top = dimensions.t || 'auto'; - right = dimensions.r || 'auto'; - bottom = dimensions.b || 'auto'; - left = dimensions.l || 'auto'; - float = dimensions.float || 'left'; - position = dimensions.pos || 'relative'; - } - - let thumbnail = ''; - - if (attachment.get('type') === 'unknown') { - const filename = truncateFilename(attachment.get('remote_url'), MAX_FILENAME_LENGTH); - const attachmentIcon = ( - - ); - - return ( - - ); - } else if (attachment.get('type') === 'image') { - const originalUrl = attachment.get('url'); - const letterboxed = shouldLetterbox(attachment); - - thumbnail = ( - - - - ); - } else if (attachment.get('type') === 'gifv') { - const conditionalAttributes = {}; - if (isIOS()) { - conditionalAttributes.playsInline = '1'; - } - if (autoPlayGif) { - conditionalAttributes.autoPlay = '1'; - } - - thumbnail = ( -
-
- ); - } else if (attachment.get('type') === 'audio') { - const ext = attachment.get('url').split('.').pop().toUpperCase(); - thumbnail = ( - - - {ext} - - ); - } else if (attachment.get('type') === 'video') { - const ext = attachment.get('url').split('.').pop().toUpperCase(); - thumbnail = ( - - - {ext} - - ); - } - - return ( -
- {last && total > ATTACHMENT_LIMIT && ( -
- +{total - ATTACHMENT_LIMIT + 1} -
- )} - - {visible && thumbnail} -
- ); - } - -} - -const mapStateToMediaGalleryProps = state => ({ - displayMedia: getSettings(state).get('displayMedia'), -}); - -export default @connect(mapStateToMediaGalleryProps) -@injectIntl -class MediaGallery extends React.PureComponent { - - static propTypes = { - sensitive: PropTypes.bool, - standalone: PropTypes.bool, - media: ImmutablePropTypes.list.isRequired, - size: PropTypes.object, - height: PropTypes.number.isRequired, - onOpenMedia: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - defaultWidth: PropTypes.number, - cacheWidth: PropTypes.func, - visible: PropTypes.bool, - onToggleVisibility: PropTypes.func, - displayMedia: PropTypes.string, - compact: PropTypes.bool, - }; - - static defaultProps = { - standalone: false, - }; - - state = { - visible: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'), - width: this.props.defaultWidth, - }; - - componentDidUpdate(prevProps) { - const { media, visible, sensitive } = this.props; - if (!is(media, prevProps.media) && visible === undefined) { - this.setState({ visible: prevProps.displayMedia !== 'hide_all' && !sensitive || prevProps.displayMedia === 'show_all' }); - } else if (!is(visible, prevProps.visible) && visible !== undefined) { - this.setState({ visible }); - } - } - - handleOpen = (e) => { - e.stopPropagation(); - - if (this.props.onToggleVisibility) { - this.props.onToggleVisibility(); - } else { - this.setState({ visible: !this.state.visible }); - } - } - - handleClick = (index) => { - this.props.onOpenMedia(this.props.media, index); - } - - handleRef = (node) => { - if (node) { - // offsetWidth triggers a layout, so only calculate when we need to - if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); - - this.setState({ - width: node.offsetWidth, - }); - } - } - - getSizeDataSingle = () => { - const { media, defaultWidth } = this.props; - const width = this.state.width || defaultWidth; - const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']); - - const getHeight = () => { - if (!aspectRatio) return width * 9 / 16; - if (isPanoramic(aspectRatio)) return Math.floor(width / maximumAspectRatio); - if (isPortrait(aspectRatio)) return Math.floor(width / minimumAspectRatio); - return Math.floor(width / aspectRatio); - }; - - return ImmutableMap({ - style: { height: getHeight() }, - itemsDimensions: [], - size: 1, - width, - }); - } - - getSizeDataMultiple = size => { - const { media, defaultWidth } = this.props; - const width = this.state.width || defaultWidth; - const panoSize = Math.floor(width / maximumAspectRatio); - const panoSize_px = `${Math.floor(width / maximumAspectRatio)}px`; - - const style = {}; - let itemsDimensions = []; - - const ratios = Array(size).fill().map((_, i) => - media.getIn([i, 'meta', 'original', 'aspect']), - ); - - const [ar1, ar2, ar3, ar4] = ratios; - - if (size === 2) { - if (isPortrait(ar1) && isPortrait(ar2)) { - style.height = width - (width / maximumAspectRatio); - } else if (isPanoramic(ar1) && isPanoramic(ar2)) { - style.height = panoSize * 2; - } else if ( - (isPanoramic(ar1) && isPortrait(ar2)) || - (isPortrait(ar1) && isPanoramic(ar2)) || - (isPanoramic(ar1) && isNonConformingRatio(ar2)) || - (isNonConformingRatio(ar1) && isPanoramic(ar2)) - ) { - style.height = (width * 0.6) + (width / maximumAspectRatio); - } else { - style.height = width / 2; - } - - // - - if (isPortrait(ar1) && isPortrait(ar2)) { - itemsDimensions = [ - { w: 50, h: '100%', r: '2px' }, - { w: 50, h: '100%', l: '2px' }, - ]; - } else if (isPanoramic(ar1) && isPanoramic(ar2)) { - itemsDimensions = [ - { w: 100, h: panoSize_px, b: '2px' }, - { w: 100, h: panoSize_px, t: '2px' }, - ]; - } else if ( - (isPanoramic(ar1) && isPortrait(ar2)) || - (isPanoramic(ar1) && isNonConformingRatio(ar2)) - ) { - itemsDimensions = [ - { w: 100, h: `${(width / maximumAspectRatio)}px`, b: '2px' }, - { w: 100, h: `${(width * 0.6)}px`, t: '2px' }, - ]; - } else if ( - (isPortrait(ar1) && isPanoramic(ar2)) || - (isNonConformingRatio(ar1) && isPanoramic(ar2)) - ) { - itemsDimensions = [ - { w: 100, h: `${(width * 0.6)}px`, b: '2px' }, - { w: 100, h: `${(width / maximumAspectRatio)}px`, t: '2px' }, - ]; - } else { - itemsDimensions = [ - { w: 50, h: '100%', r: '2px' }, - { w: 50, h: '100%', l: '2px' }, - ]; - } - } else if (size === 3) { - if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) { - style.height = panoSize * 3; - } else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) { - style.height = Math.floor(width / minimumAspectRatio); - } else { - style.height = width; - } - - // - - if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) { - itemsDimensions = [ - { w: 100, h: '50%', b: '2px' }, - { w: 50, h: '50%', t: '2px', r: '2px' }, - { w: 50, h: '50%', t: '2px', l: '2px' }, - ]; - } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) { - itemsDimensions = [ - { w: 100, h: panoSize_px, b: '4px' }, - { w: 100, h: panoSize_px }, - { w: 100, h: panoSize_px, t: '4px' }, - ]; - } else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) { - itemsDimensions = [ - { w: 50, h: '100%', r: '2px' }, - { w: 50, h: '50%', b: '2px', l: '2px' }, - { w: 50, h: '50%', t: '2px', l: '2px' }, - ]; - } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) { - itemsDimensions = [ - { w: 50, h: '50%', b: '2px', r: '2px' }, - { w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' }, - { w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' }, - ]; - } else if ( - (isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) || - (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) - ) { - itemsDimensions = [ - { w: 50, h: '50%', b: '2px', r: '2px' }, - { w: 50, h: '100%', l: '2px', float: 'right' }, - { w: 50, h: '50%', t: '2px', r: '2px' }, - ]; - } else if ( - (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) || - (isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3)) - ) { - itemsDimensions = [ - { w: 50, h: panoSize_px, b: '2px', r: '2px' }, - { w: 50, h: panoSize_px, b: '2px', l: '2px' }, - { w: 100, h: `${width - panoSize}px`, t: '2px' }, - ]; - } else if ( - (isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) || - (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) - ) { - itemsDimensions = [ - { w: 100, h: `${width - panoSize}px`, b: '2px' }, - { w: 50, h: panoSize_px, t: '2px', r: '2px' }, - { w: 50, h: panoSize_px, t: '2px', l: '2px' }, - ]; - } else { - itemsDimensions = [ - { w: 50, h: '50%', b: '2px', r: '2px' }, - { w: 50, h: '50%', b: '2px', l: '2px' }, - { w: 100, h: '50%', t: '2px' }, - ]; - } - } else if (size >= 4) { - if ( - (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) || - (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) || - (isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) || - (isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) || - (isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) - ) { - style.height = Math.floor(width / minimumAspectRatio); - } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) { - style.height = panoSize * 2; - } else if ( - (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) || - (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) - ) { - style.height = panoSize + (width / 2); - } else { - style.height = width; - } - - // - - if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) { - itemsDimensions = [ - { w: 50, h: panoSize_px, b: '2px', r: '2px' }, - { w: 50, h: panoSize_px, b: '2px', l: '2px' }, - { w: 50, h: `${(width / 2)}px`, t: '2px', r: '2px' }, - { w: 50, h: `${(width / 2)}px`, t: '2px', l: '2px' }, - ]; - } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) { - itemsDimensions = [ - { w: 50, h: `${(width / 2)}px`, b: '2px', r: '2px' }, - { w: 50, h: `${(width / 2)}px`, b: '2px', l: '2px' }, - { w: 50, h: panoSize_px, t: '2px', r: '2px' }, - { w: 50, h: panoSize_px, t: '2px', l: '2px' }, - ]; - } else if ( - (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) || - (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) - ) { - itemsDimensions = [ - { w: 67, h: '100%', r: '2px' }, - { w: 33, h: '33%', b: '4px', l: '2px' }, - { w: 33, h: '33%', l: '2px' }, - { w: 33, h: '33%', t: '4px', l: '2px' }, - ]; - } else { - itemsDimensions = [ - { w: 50, h: '50%', b: '2px', r: '2px' }, - { w: 50, h: '50%', b: '2px', l: '2px' }, - { w: 50, h: '50%', t: '2px', r: '2px' }, - { w: 50, h: '50%', t: '2px', l: '2px' }, - ]; - } - } - - return ImmutableMap({ - style, - itemsDimensions, - size: size, - width, - }); - - } - - getSizeData = size => { - const { height, defaultWidth } = this.props; - const width = this.state.width || defaultWidth; - - if (width) { - if (size === 1) return this.getSizeDataSingle(); - if (size > 1) return this.getSizeDataMultiple(size); - } - - // Default - return ImmutableMap({ - style: { height }, - itemsDimensions: [], - size, - width, - }); - } - - render() { - const { media, intl, sensitive, compact } = this.props; - const { visible } = this.state; - const sizeData = this.getSizeData(media.size); - - const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => ( - - )); - - let warning; - - if (sensitive) { - warning = ; - } else { - warning = ; - } - - return ( -
-
- {sensitive && ( - (visible || compact) ? ( - -
-
- ) - )} -
- - {children} - - ); - } - -} diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx new file mode 100644 index 000000000..0744361a4 --- /dev/null +++ b/app/soapbox/components/media_gallery.tsx @@ -0,0 +1,648 @@ +import classNames from 'clsx'; +import React, { useState, useRef, useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import Blurhash from 'soapbox/components/blurhash'; +import Icon from 'soapbox/components/icon'; +import StillImage from 'soapbox/components/still_image'; +import { MIMETYPE_ICONS } from 'soapbox/features/compose/components/upload'; +import { useSettings } from 'soapbox/hooks'; +import { Attachment } from 'soapbox/types/entities'; +import { truncateFilename } from 'soapbox/utils/media'; + +import { isIOS } from '../is_mobile'; +import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio'; + +import { Button, Text } from './ui'; + +import type { Property } from 'csstype'; +import type { List as ImmutableList } from 'immutable'; + +const ATTACHMENT_LIMIT = 4; +const MAX_FILENAME_LENGTH = 45; + +interface Dimensions { + w: Property.Width | number, + h: Property.Height | number, + t?: Property.Top, + r?: Property.Right, + b?: Property.Bottom, + l?: Property.Left, + float?: Property.Float, + pos?: Property.Position, +} + +interface SizeData { + style: React.CSSProperties, + itemsDimensions: Dimensions[], + size: number, + width: number, +} + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide' }, +}); + +const withinLimits = (aspectRatio: number) => { + return aspectRatio >= minimumAspectRatio && aspectRatio <= maximumAspectRatio; +}; + +const shouldLetterbox = (attachment: Attachment): boolean => { + const aspectRatio = attachment.getIn(['meta', 'original', 'aspect']) as number | undefined; + if (!aspectRatio) return true; + + return !withinLimits(aspectRatio); +}; + +interface IItem { + attachment: Attachment, + standalone?: boolean, + index: number, + size: number, + onClick: (index: number) => void, + displayWidth?: number, + visible: boolean, + dimensions: Dimensions, + last?: boolean, + total: number, +} + +const Item: React.FC = ({ + attachment, + index, + onClick, + standalone = false, + visible, + dimensions, + last, + total, +}) => { + const settings = useSettings(); + const autoPlayGif = settings.get('autoPlayGif') === true; + + const [loaded, setLoaded] = useState(false); + + const handleMouseEnter: React.MouseEventHandler = ({ currentTarget: video }) => { + if (hoverToPlay()) { + video.play(); + } + }; + + const handleMouseLeave: React.MouseEventHandler = ({ currentTarget: video }) => { + if (hoverToPlay()) { + video.pause(); + video.currentTime = 0; + } + }; + + const hoverToPlay = () => { + return !autoPlayGif && attachment.type === 'gifv'; + }; + + // FIXME: wtf? + const handleClick: React.MouseEventHandler = (e: any) => { + if (isIOS() && !e.target.autoPlay) { + e.target.autoPlay = true; + e.preventDefault(); + } else { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + e.preventDefault(); + onClick(index); + } + } + + e.stopPropagation(); + }; + + const handleImageLoad = () => { + setLoaded(true); + }; + + const handleVideoHover: React.MouseEventHandler = ({ currentTarget: video }) => { + video.playbackRate = 3.0; + video.play(); + }; + + const handleVideoLeave: React.MouseEventHandler = ({ currentTarget: video }) => { + video.pause(); + video.currentTime = 0; + }; + + let width: Dimensions['w'] = 100; + let height: Dimensions['h'] = '100%'; + let top: Dimensions['t'] = 'auto'; + let left: Dimensions['l'] = 'auto'; + let bottom: Dimensions['b'] = 'auto'; + let right: Dimensions['r'] = 'auto'; + let float: Dimensions['float'] = 'left'; + let position: Dimensions['pos'] = 'relative'; + + if (dimensions) { + width = dimensions.w; + height = dimensions.h; + top = dimensions.t || 'auto'; + right = dimensions.r || 'auto'; + bottom = dimensions.b || 'auto'; + left = dimensions.l || 'auto'; + float = dimensions.float || 'left'; + position = dimensions.pos || 'relative'; + } + + let thumbnail: React.ReactNode = ''; + + if (attachment.type === 'unknown') { + const filename = truncateFilename(attachment.url, MAX_FILENAME_LENGTH); + const attachmentIcon = ( + + ); + + return ( + + ); + } else if (attachment.type === 'image') { + const letterboxed = shouldLetterbox(attachment); + + thumbnail = ( + + + + ); + } else if (attachment.type === 'gifv') { + const conditionalAttributes: React.VideoHTMLAttributes = {}; + if (isIOS()) { + conditionalAttributes.playsInline = true; + } + if (autoPlayGif) { + conditionalAttributes.autoPlay = true; + } + + thumbnail = ( +
+
+ ); + } else if (attachment.type === 'audio') { + const ext = attachment.url.split('.').pop()?.toUpperCase(); + thumbnail = ( + + + {ext} + + ); + } else if (attachment.type === 'video') { + const ext = attachment.url.split('.').pop()?.toUpperCase(); + thumbnail = ( + + + {ext} + + ); + } + + return ( +
+ {last && total > ATTACHMENT_LIMIT && ( +
+ +{total - ATTACHMENT_LIMIT + 1} +
+ )} + + {visible && thumbnail} +
+ ); +}; + +interface IMediaGallery { + sensitive?: boolean, + media: ImmutableList, + size: number, + height: number, + onOpenMedia: (media: ImmutableList, index: number) => void, + defaultWidth: number, + cacheWidth: (width: number) => void, + visible?: boolean, + onToggleVisibility?: () => void, + displayMedia: string, + compact: boolean, +} + +const MediaGallery: React.FC = (props) => { + const { + media, + sensitive = false, + defaultWidth, + onToggleVisibility, + onOpenMedia, + cacheWidth, + compact, + height, + } = props; + + const intl = useIntl(); + + const settings = useSettings(); + const displayMedia = settings.get('displayMedia') as string | undefined; + + const [visible, setVisible] = useState(props.visible !== undefined ? props.visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all')); + const [width, setWidth] = useState(defaultWidth); + + const node = useRef(null); + + // const componentDidUpdate = (prevProps) => { + // const { visible } = props; + // if (!is(media, prevProps.media) && visible === undefined) { + // this.setState({ visible: prevProps.displayMedia !== 'hide_all' && !sensitive || prevProps.displayMedia === 'show_all' }); + // } else if (!is(visible, prevProps.visible) && visible !== undefined) { + // setVisible(visible); + // } + // }; + + const handleOpen: React.MouseEventHandler = (e) => { + e.stopPropagation(); + + if (onToggleVisibility) { + onToggleVisibility(); + } else { + setVisible(!visible); + } + }; + + const handleClick = (index: number) => { + onOpenMedia(media, index); + }; + + useEffect(() => { + if (node.current) { + const { offsetWidth } = node.current; + + if (cacheWidth) { + cacheWidth(offsetWidth); + } + + setWidth(offsetWidth); + } + }, [node.current]); + + const getSizeDataSingle = (): SizeData => { + const w = width || defaultWidth; + const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined; + + const getHeight = () => { + if (!aspectRatio) return w * 9 / 16; + if (isPanoramic(aspectRatio)) return Math.floor(w / maximumAspectRatio); + if (isPortrait(aspectRatio)) return Math.floor(w / minimumAspectRatio); + return Math.floor(w / aspectRatio); + }; + + return { + style: { height: getHeight() }, + itemsDimensions: [], + size: 1, + width, + }; + }; + + const getSizeDataMultiple = (size: number): SizeData => { + const w = width || defaultWidth; + const panoSize = Math.floor(w / maximumAspectRatio); + const panoSize_px = `${Math.floor(w / maximumAspectRatio)}px`; + + const style: React.CSSProperties = {}; + let itemsDimensions: Dimensions[] = []; + + const ratios = Array(size).fill(null).map((_, i) => + media.getIn([i, 'meta', 'original', 'aspect']) as number, + ); + + const [ar1, ar2, ar3, ar4] = ratios; + + if (size === 2) { + if (isPortrait(ar1) && isPortrait(ar2)) { + style.height = w - (w / maximumAspectRatio); + } else if (isPanoramic(ar1) && isPanoramic(ar2)) { + style.height = panoSize * 2; + } else if ( + (isPanoramic(ar1) && isPortrait(ar2)) || + (isPortrait(ar1) && isPanoramic(ar2)) || + (isPanoramic(ar1) && isNonConformingRatio(ar2)) || + (isNonConformingRatio(ar1) && isPanoramic(ar2)) + ) { + style.height = (w * 0.6) + (w / maximumAspectRatio); + } else { + style.height = w / 2; + } + + if (isPortrait(ar1) && isPortrait(ar2)) { + itemsDimensions = [ + { w: 50, h: '100%', r: '2px' }, + { w: 50, h: '100%', l: '2px' }, + ]; + } else if (isPanoramic(ar1) && isPanoramic(ar2)) { + itemsDimensions = [ + { w: 100, h: panoSize_px, b: '2px' }, + { w: 100, h: panoSize_px, t: '2px' }, + ]; + } else if ( + (isPanoramic(ar1) && isPortrait(ar2)) || + (isPanoramic(ar1) && isNonConformingRatio(ar2)) + ) { + itemsDimensions = [ + { w: 100, h: `${(w / maximumAspectRatio)}px`, b: '2px' }, + { w: 100, h: `${(w * 0.6)}px`, t: '2px' }, + ]; + } else if ( + (isPortrait(ar1) && isPanoramic(ar2)) || + (isNonConformingRatio(ar1) && isPanoramic(ar2)) + ) { + itemsDimensions = [ + { w: 100, h: `${(w * 0.6)}px`, b: '2px' }, + { w: 100, h: `${(w / maximumAspectRatio)}px`, t: '2px' }, + ]; + } else { + itemsDimensions = [ + { w: 50, h: '100%', r: '2px' }, + { w: 50, h: '100%', l: '2px' }, + ]; + } + } else if (size === 3) { + if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) { + style.height = panoSize * 3; + } else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) { + style.height = Math.floor(w / minimumAspectRatio); + } else { + style.height = w; + } + + if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) { + itemsDimensions = [ + { w: 100, h: '50%', b: '2px' }, + { w: 50, h: '50%', t: '2px', r: '2px' }, + { w: 50, h: '50%', t: '2px', l: '2px' }, + ]; + } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) { + itemsDimensions = [ + { w: 100, h: panoSize_px, b: '4px' }, + { w: 100, h: panoSize_px }, + { w: 100, h: panoSize_px, t: '4px' }, + ]; + } else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) { + itemsDimensions = [ + { w: 50, h: '100%', r: '2px' }, + { w: 50, h: '50%', b: '2px', l: '2px' }, + { w: 50, h: '50%', t: '2px', l: '2px' }, + ]; + } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' }, + { w: 50, h: '100%', r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' }, + ]; + } else if ( + (isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) || + (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) + ) { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '100%', l: '2px', float: 'right' }, + { w: 50, h: '50%', t: '2px', r: '2px' }, + ]; + } else if ( + (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) || + (isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3)) + ) { + itemsDimensions = [ + { w: 50, h: panoSize_px, b: '2px', r: '2px' }, + { w: 50, h: panoSize_px, b: '2px', l: '2px' }, + { w: 100, h: `${w - panoSize}px`, t: '2px' }, + ]; + } else if ( + (isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) || + (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) + ) { + itemsDimensions = [ + { w: 100, h: `${w - panoSize}px`, b: '2px' }, + { w: 50, h: panoSize_px, t: '2px', r: '2px' }, + { w: 50, h: panoSize_px, t: '2px', l: '2px' }, + ]; + } else { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '50%', b: '2px', l: '2px' }, + { w: 100, h: '50%', t: '2px' }, + ]; + } + } else if (size >= 4) { + if ( + (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) || + (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) || + (isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) || + (isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) || + (isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) + ) { + style.height = Math.floor(w / minimumAspectRatio); + } else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) { + style.height = panoSize * 2; + } else if ( + (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) || + (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) + ) { + style.height = panoSize + (w / 2); + } else { + style.height = w; + } + + if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) { + itemsDimensions = [ + { w: 50, h: panoSize_px, b: '2px', r: '2px' }, + { w: 50, h: panoSize_px, b: '2px', l: '2px' }, + { w: 50, h: `${(w / 2)}px`, t: '2px', r: '2px' }, + { w: 50, h: `${(w / 2)}px`, t: '2px', l: '2px' }, + ]; + } else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) { + itemsDimensions = [ + { w: 50, h: `${(w / 2)}px`, b: '2px', r: '2px' }, + { w: 50, h: `${(w / 2)}px`, b: '2px', l: '2px' }, + { w: 50, h: panoSize_px, t: '2px', r: '2px' }, + { w: 50, h: panoSize_px, t: '2px', l: '2px' }, + ]; + } else if ( + (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) || + (isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) + ) { + itemsDimensions = [ + { w: 67, h: '100%', r: '2px' }, + { w: 33, h: '33%', b: '4px', l: '2px' }, + { w: 33, h: '33%', l: '2px' }, + { w: 33, h: '33%', t: '4px', l: '2px' }, + ]; + } else { + itemsDimensions = [ + { w: 50, h: '50%', b: '2px', r: '2px' }, + { w: 50, h: '50%', b: '2px', l: '2px' }, + { w: 50, h: '50%', t: '2px', r: '2px' }, + { w: 50, h: '50%', t: '2px', l: '2px' }, + ]; + } + } + + return { + style, + itemsDimensions, + size, + width: w, + }; + }; + + const getSizeData = (size: number): Readonly => { + const w = width || defaultWidth; + + if (w) { + if (size === 1) return getSizeDataSingle(); + if (size > 1) return getSizeDataMultiple(size); + } + + return { + style: { height }, + itemsDimensions: [], + size, + width: w, + }; + }; + + const sizeData: SizeData = getSizeData(media.size); + + const children = media.take(ATTACHMENT_LIMIT).map((attachment, i) => ( + + )); + + let warning; + + if (sensitive) { + warning = ; + } else { + warning = ; + } + + return ( +
+
+ {sensitive && ( + (visible || compact) ? ( + +
+
+ ) + )} + + + {children} + + ); +}; + +export default MediaGallery; diff --git a/app/soapbox/normalizers/attachment.ts b/app/soapbox/normalizers/attachment.ts index 23cce3686..f5e00135e 100644 --- a/app/soapbox/normalizers/attachment.ts +++ b/app/soapbox/normalizers/attachment.ts @@ -20,7 +20,7 @@ export const AttachmentRecord = ImmutableRecord({ meta: ImmutableMap(), pleroma: ImmutableMap(), preview_url: '', - remote_url: null, + remote_url: null as string | null, type: 'unknown', url: '', From cc3e8ebd3be5bdf647613e6e8de35952b5062168 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 18:11:05 -0500 Subject: [PATCH 08/15] MediaGallery: clean up unused code, rearrange useEffect --- app/soapbox/components/media_gallery.tsx | 47 ++++++++---------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/app/soapbox/components/media_gallery.tsx b/app/soapbox/components/media_gallery.tsx index 0744361a4..5d5db8753 100644 --- a/app/soapbox/components/media_gallery.tsx +++ b/app/soapbox/components/media_gallery.tsx @@ -80,8 +80,6 @@ const Item: React.FC = ({ const settings = useSettings(); const autoPlayGif = settings.get('autoPlayGif') === true; - const [loaded, setLoaded] = useState(false); - const handleMouseEnter: React.MouseEventHandler = ({ currentTarget: video }) => { if (hoverToPlay()) { video.play(); @@ -118,10 +116,6 @@ const Item: React.FC = ({ e.stopPropagation(); }; - const handleImageLoad = () => { - setLoaded(true); - }; - const handleVideoHover: React.MouseEventHandler = ({ currentTarget: video }) => { video.playbackRate = 3.0; video.play(); @@ -166,7 +160,7 @@ const Item: React.FC = ({ return (
- + {attachmentIcon} {filename} @@ -258,10 +252,8 @@ const Item: React.FC = ({
)} {visible && thumbnail} @@ -304,15 +296,6 @@ const MediaGallery: React.FC = (props) => { const node = useRef(null); - // const componentDidUpdate = (prevProps) => { - // const { visible } = props; - // if (!is(media, prevProps.media) && visible === undefined) { - // this.setState({ visible: prevProps.displayMedia !== 'hide_all' && !sensitive || prevProps.displayMedia === 'show_all' }); - // } else if (!is(visible, prevProps.visible) && visible !== undefined) { - // setVisible(visible); - // } - // }; - const handleOpen: React.MouseEventHandler = (e) => { e.stopPropagation(); @@ -327,18 +310,6 @@ const MediaGallery: React.FC = (props) => { onOpenMedia(media, index); }; - useEffect(() => { - if (node.current) { - const { offsetWidth } = node.current; - - if (cacheWidth) { - cacheWidth(offsetWidth); - } - - setWidth(offsetWidth); - } - }, [node.current]); - const getSizeDataSingle = (): SizeData => { const w = width || defaultWidth; const aspectRatio = media.getIn([0, 'meta', 'original', 'aspect']) as number | undefined; @@ -590,6 +561,18 @@ const MediaGallery: React.FC = (props) => { warning = ; } + useEffect(() => { + if (node.current) { + const { offsetWidth } = node.current; + + if (cacheWidth) { + cacheWidth(offsetWidth); + } + + setWidth(offsetWidth); + } + }, [node.current]); + return (
Date: Fri, 30 Sep 2022 19:33:46 -0500 Subject: [PATCH 09/15] Video: convert to TSX+FC --- app/soapbox/features/video/index.js | 625 -------------------------- app/soapbox/features/video/index.tsx | 636 +++++++++++++++++++++++++++ 2 files changed, 636 insertions(+), 625 deletions(-) delete mode 100644 app/soapbox/features/video/index.js create mode 100644 app/soapbox/features/video/index.tsx diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js deleted file mode 100644 index 1d6b55ff6..000000000 --- a/app/soapbox/features/video/index.js +++ /dev/null @@ -1,625 +0,0 @@ -import classNames from 'clsx'; -import { fromJS, is } from 'immutable'; -import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { getSettings } from 'soapbox/actions/settings'; -import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; -import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media_aspect_ratio'; - -import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; - -const DEFAULT_HEIGHT = 300; - -const messages = defineMessages({ - play: { id: 'video.play', defaultMessage: 'Play' }, - pause: { id: 'video.pause', defaultMessage: 'Pause' }, - mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, - unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, - hide: { id: 'video.hide', defaultMessage: 'Hide video' }, - expand: { id: 'video.expand', defaultMessage: 'Expand video' }, - close: { id: 'video.close', defaultMessage: 'Close video' }, - fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, - exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, -}); - -export const formatTime = secondsNum => { - let hours = Math.floor(secondsNum / 3600); - let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); - let seconds = secondsNum - (hours * 3600) - (minutes * 60); - - if (hours < 10) hours = '0' + hours; - if (minutes < 10) minutes = '0' + minutes; - if (seconds < 10) seconds = '0' + seconds; - - return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`; -}; - -export const findElementPosition = el => { - let box; - - if (el.getBoundingClientRect && el.parentNode) { - box = el.getBoundingClientRect(); - } - - if (!box) { - return { - left: 0, - top: 0, - }; - } - - const docEl = document.documentElement; - const body = document.body; - - const clientLeft = docEl.clientLeft || body.clientLeft || 0; - const scrollLeft = window.pageXOffset || body.scrollLeft; - const left = (box.left + scrollLeft) - clientLeft; - - const clientTop = docEl.clientTop || body.clientTop || 0; - const scrollTop = window.pageYOffset || body.scrollTop; - const top = (box.top + scrollTop) - clientTop; - - return { - left: Math.round(left), - top: Math.round(top), - }; -}; - -export const getPointerPosition = (el, event) => { - const position = {}; - const box = findElementPosition(el); - const boxW = el.offsetWidth; - const boxH = el.offsetHeight; - const boxY = box.top; - const boxX = box.left; - - let pageY = event.pageY; - let pageX = event.pageX; - - if (event.changedTouches) { - pageX = event.changedTouches[0].pageX; - pageY = event.changedTouches[0].pageY; - } - - position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)); - position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); - - return position; -}; - -export const fileNameFromURL = str => { - const url = new URL(str); - const pathname = url.pathname; - const index = pathname.lastIndexOf('/'); - - return pathname.substring(index + 1); -}; - -const mapStateToProps = state => ({ - displayMedia: getSettings(state).get('displayMedia'), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Video extends React.PureComponent { - - static propTypes = { - preview: PropTypes.string, - src: PropTypes.string.isRequired, - alt: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - sensitive: PropTypes.bool, - startTime: PropTypes.number, - onOpenVideo: PropTypes.func, - onCloseVideo: PropTypes.func, - detailed: PropTypes.bool, - inline: PropTypes.bool, - cacheWidth: PropTypes.func, - visible: PropTypes.bool, - onToggleVisibility: PropTypes.func, - intl: PropTypes.object.isRequired, - blurhash: PropTypes.string, - link: PropTypes.node, - aspectRatio: PropTypes.number, - displayMedia: PropTypes.string, - }; - - state = { - currentTime: 0, - duration: 0, - volume: 0.5, - paused: true, - dragging: false, - containerWidth: this.props.width, - fullscreen: false, - hovered: false, - muted: false, - revealed: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'), - }; - - setPlayerRef = c => { - this.player = c; - - if (this.player) { - this._setDimensions(); - } - } - - _setDimensions() { - const width = this.player.offsetWidth; - - if (this.props.cacheWidth) { - this.props.cacheWidth(width); - } - - this.setState({ - containerWidth: width, - }); - } - - setVideoRef = c => { - this.video = c; - - if (this.video) { - this.setState({ volume: this.video.volume, muted: this.video.muted }); - } - } - - setSeekRef = c => { - this.seek = c; - } - - setVolumeRef = c => { - this.volume = c; - } - - handleClickRoot = e => e.stopPropagation(); - - handlePlay = () => { - this.setState({ paused: false }); - } - - handlePause = () => { - this.setState({ paused: true }); - } - - handleTimeUpdate = () => { - this.setState({ - currentTime: Math.floor(this.video.currentTime), - duration: Math.floor(this.video.duration), - }); - } - - handleVolumeMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseVolSlide, true); - document.addEventListener('mouseup', this.handleVolumeMouseUp, true); - document.addEventListener('touchmove', this.handleMouseVolSlide, true); - document.addEventListener('touchend', this.handleVolumeMouseUp, true); - - this.handleMouseVolSlide(e); - - e.preventDefault(); - e.stopPropagation(); - } - - handleVolumeMouseUp = () => { - document.removeEventListener('mousemove', this.handleMouseVolSlide, true); - document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); - document.removeEventListener('touchmove', this.handleMouseVolSlide, true); - document.removeEventListener('touchend', this.handleVolumeMouseUp, true); - } - - handleMouseVolSlide = throttle(e => { - const { x } = getPointerPosition(this.volume, e); - - if (!isNaN(x)) { - let slideamt = x; - - if (x > 1) { - slideamt = 1; - } else if (x < 0) { - slideamt = 0; - } - - this.video.volume = slideamt; - this.setState({ volume: slideamt }); - } - }, 60); - - handleMouseDown = e => { - document.addEventListener('mousemove', this.handleMouseMove, true); - document.addEventListener('mouseup', this.handleMouseUp, true); - document.addEventListener('touchmove', this.handleMouseMove, true); - document.addEventListener('touchend', this.handleMouseUp, true); - - this.setState({ dragging: true }); - this.video.pause(); - this.handleMouseMove(e); - - e.preventDefault(); - e.stopPropagation(); - } - - handleMouseUp = () => { - document.removeEventListener('mousemove', this.handleMouseMove, true); - document.removeEventListener('mouseup', this.handleMouseUp, true); - document.removeEventListener('touchmove', this.handleMouseMove, true); - document.removeEventListener('touchend', this.handleMouseUp, true); - - this.setState({ dragging: false }); - this.video.play(); - } - - handleMouseMove = throttle(e => { - const { x } = getPointerPosition(this.seek, e); - const currentTime = Math.floor(this.video.duration * x); - - if (!isNaN(currentTime)) { - this.video.currentTime = currentTime; - this.setState({ currentTime }); - } - }, 60); - - seekBy(time) { - const currentTime = this.video.currentTime + time; - - if (!isNaN(currentTime)) { - this.setState({ currentTime }, () => { - this.video.currentTime = currentTime; - }); - } - } - - handleVideoKeyDown = e => { - // On the video element or the seek bar, we can safely use the space bar - // for playback control because there are no buttons to press - - if (e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this.togglePlay(); - } - } - - handleKeyDown = e => { - const frameTime = 1 / 25; - - switch (e.key) { - case 'k': - e.preventDefault(); - e.stopPropagation(); - this.togglePlay(); - break; - case 'm': - e.preventDefault(); - e.stopPropagation(); - this.toggleMute(); - break; - case 'f': - e.preventDefault(); - e.stopPropagation(); - this.toggleFullscreen(); - break; - case 'j': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(-10); - break; - case 'l': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(10); - break; - case ',': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(-frameTime); - break; - case '.': - e.preventDefault(); - e.stopPropagation(); - this.seekBy(frameTime); - break; - } - - // If we are in fullscreen mode, we don't want any hotkeys - // interacting with the UI that's not visible - - if (this.state.fullscreen) { - e.preventDefault(); - e.stopPropagation(); - - if (e.key === 'Escape') { - exitFullscreen(); - } - } - } - - togglePlay = (e) => { - if (e) { - e.stopPropagation(); - } - - if (this.state.paused) { - this.setState({ paused: false }, () => this.video.play()); - } else { - this.setState({ paused: true }, () => this.video.pause()); - } - } - - toggleFullscreen = () => { - if (isFullscreen()) { - exitFullscreen(); - } else { - requestFullscreen(this.player); - } - } - - componentDidMount() { - document.addEventListener('fullscreenchange', this.handleFullscreenChange, true); - document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); - document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); - document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); - - window.addEventListener('scroll', this.handleScroll); - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount() { - window.removeEventListener('scroll', this.handleScroll); - window.removeEventListener('resize', this.handleResize); - - document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); - document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); - document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); - document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); - } - - componentDidUpdate(prevProps, prevState) { - const { visible } = this.props; - - if (!is(visible, prevProps.visible) && visible !== undefined) { - this.setState({ revealed: visible }); - } - - if (prevState.revealed && !this.state.revealed && this.video) { - this.video.pause(); - } - } - - handleResize = debounce(() => { - if (this.player) { - this._setDimensions(); - } - }, 250, { - trailing: true, - }); - - handleScroll = throttle(() => { - if (!this.video) { - return; - } - - const { top, height } = this.video.getBoundingClientRect(); - const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); - - if (!this.state.paused && !inView) { - this.setState({ paused: true }, () => this.video.pause()); - } - }, 150, { trailing: true }) - - handleFullscreenChange = () => { - this.setState({ fullscreen: isFullscreen() }); - } - - handleMouseEnter = () => { - this.setState({ hovered: true }); - } - - handleMouseLeave = () => { - this.setState({ hovered: false }); - } - - toggleMute = () => { - const muted = !this.video.muted; - - this.setState({ muted }, () => { - this.video.muted = muted; - }); - } - - toggleReveal = (e) => { - e.stopPropagation(); - - if (this.props.onToggleVisibility) { - this.props.onToggleVisibility(); - } else { - this.setState({ revealed: !this.state.revealed }); - } - } - - handleLoadedData = () => { - if (this.props.startTime) { - this.video.currentTime = this.props.startTime; - this.video.play(); - } - } - - handleProgress = () => { - if (this.video.buffered.length > 0) { - this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 }); - } - } - - handleVolumeChange = () => { - this.setState({ volume: this.video.volume, muted: this.video.muted }); - } - - handleOpenVideo = () => { - const { src, preview, width, height, alt } = this.props; - - const media = fromJS({ - type: 'video', - url: src, - preview_url: preview, - description: alt, - width, - height, - }); - - this.video.pause(); - this.props.onOpenVideo(media, this.video.currentTime); - } - - handleCloseVideo = () => { - this.video.pause(); - this.props.onCloseVideo(); - } - - getPreload = () => { - const { startTime, detailed } = this.props; - const { dragging, fullscreen } = this.state; - - if (startTime || fullscreen || dragging) { - return 'auto'; - } else if (detailed) { - return 'metadata'; - } else { - return 'none'; - } - } - - render() { - const { src, inline, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio, blurhash } = this.props; - const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; - const progress = (currentTime / duration) * 100; - const playerStyle = {}; - - let { width, height } = this.props; - - if (inline && containerWidth) { - width = containerWidth; - const minSize = containerWidth / (16 / 9); - - if (isPanoramic(aspectRatio)) { - height = Math.max(Math.floor(containerWidth / maximumAspectRatio), minSize); - } else if (isPortrait(aspectRatio)) { - height = Math.max(Math.floor(containerWidth / minimumAspectRatio), minSize); - } else { - height = Math.floor(containerWidth / aspectRatio); - } - - playerStyle.height = height || DEFAULT_HEIGHT; - } - - let warning; - - if (sensitive) { - warning = ; - } else { - warning = ; - } - - return ( -
- - - {revealed &&