diff --git a/web/media/media.css b/web/media/media.css index 5bbd59796..1892566c8 100644 --- a/web/media/media.css +++ b/web/media/media.css @@ -1,117 +1,128 @@ span.clickable { cursor: pointer; } span.multimedia { display: inline-flex; align-items: center; justify-content: center; position: relative; vertical-align: top; } span.multimedia > .multimediaImage { position: relative; min-height: 50px; min-width: 50px; } span.multimedia > .multimediaImage > img, span.multimedia > .multimediaImage > video { /* this should be in sync with the MAX_THUMBNAIL_HEIGHT */ /* in multimedia.react.js */ max-height: 200px; max-width: 100%; } span.multimedia > .multimediaImage svg.removeUpload { display: none; position: absolute; top: 3px; right: 3px; color: white; border-radius: 50%; box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.5); background-color: rgba(34, 34, 34, 0.67); } span.multimedia:hover > .multimediaImage svg.removeUpload { display: inherit; } span.multimedia svg.uploadError { position: absolute; top: 0; bottom: 0; left: 0; right: 0; margin: auto auto; color: white; border-radius: 50%; box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.5); background-color: #dd2222; } span.multimedia > svg.progressIndicator { position: absolute; top: 0; bottom: 0; left: 0; right: 0; margin: auto auto; width: 50px; height: 50px; } span.multimedia .loadingIndicator { position: absolute; top: 0; bottom: 0; left: 0; right: 0; margin: auto auto; width: 25px; height: 25px; } :global(.CircularProgressbar-background) { fill: #666 !important; } :global(.CircularProgressbar-text) { fill: #fff !important; } :global(.CircularProgressbar-path) { stroke: #fff !important; } :global(.CircularProgressbar-trail) { stroke: transparent !important; } div.multimediaModalOverlay { position: fixed; left: 0; top: 0; z-index: 4; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.9); overflow: auto; padding: 10px; box-sizing: border-box; display: flex; justify-content: center; } -div.multimediaModalOverlay > img, -div.multimediaModalOverlay > video { - object-fit: scale-down; +div.multimediaModalOverlay > .mediaContainer { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} +div.mediaContainer > img, +div.mediaContainer > video { width: auto; height: auto; max-width: 100%; max-height: 100%; + display: block; + margin: auto; + background-position: center; + background-size: cover; + background-repeat: no-repeat; } svg.closeMultimediaModal { position: absolute; cursor: pointer; top: 15px; right: 15px; color: white; border-radius: 50%; box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.5); background-color: rgba(34, 34, 34, 0.67); height: 36px; width: 36px; } diff --git a/web/media/multimedia-modal.react.js b/web/media/multimedia-modal.react.js index 0a01e3473..03db752e7 100644 --- a/web/media/multimedia-modal.react.js +++ b/web/media/multimedia-modal.react.js @@ -1,112 +1,187 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { XCircle as XCircleIcon } from 'react-feather'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { fetchableMediaURI } from 'lib/media/media-utils.js'; -import type { EncryptedMediaType, MediaType } from 'lib/types/media-types.js'; +import type { + EncryptedMediaType, + MediaType, + Dimensions, +} from 'lib/types/media-types.js'; import EncryptedMultimedia from './encrypted-multimedia.react.js'; +import LoadableVideo from './loadable-video.react.js'; +import { usePlaceholder } from './media-utils.js'; import css from './media.css'; type MediaInfo = | { +type: MediaType, +uri: string, + +dimensions: ?Dimensions, + +thumbHash: ?string, + +thumbnailURI: ?string, } | { +type: EncryptedMediaType, +holder: string, +encryptionKey: string, + +dimensions: ?Dimensions, + +thumbHash: ?string, + +thumbnailHolder: ?string, + +thumbnailEncryptionKey: ?string, }; type BaseProps = { +media: MediaInfo, }; type Props = { ...BaseProps, +popModal: (modal: ?React.Node) => void, + +placeholderImage: ?string, }; -class MultimediaModal extends React.PureComponent { +type State = { + +dimensions: ?Dimensions, +}; + +class MultimediaModal extends React.PureComponent { overlay: ?HTMLDivElement; + constructor(props: Props) { + super(props); + this.state = { dimensions: null }; + } + componentDidMount() { invariant(this.overlay, 'overlay ref unset'); this.overlay.focus(); + this.calculateMediaDimensions(); + window.addEventListener('resize', this.calculateMediaDimensions); + } + + componentWillUnmount() { + window.removeEventListener('resize', this.calculateMediaDimensions); } render(): React.Node { let mediaModalItem; - const { media } = this.props; + const { media, placeholderImage } = this.props; + const style = { + backgroundImage: placeholderImage + ? `url(${placeholderImage})` + : undefined, + }; if (media.type === 'photo') { const uri = fetchableMediaURI(media.uri); - mediaModalItem = ; + mediaModalItem = ; } else if (media.type === 'video') { const uri = fetchableMediaURI(media.uri); + const { thumbnailURI } = media; + invariant(thumbnailURI, 'video missing thumbnail'); mediaModalItem = ( - + ); } else { invariant( media.type === 'encrypted_photo' || media.type === 'encrypted_video', 'invalid media type', ); const { type, holder, encryptionKey } = media; mediaModalItem = ( ); } return (
- {mediaModalItem} +
{mediaModalItem}
); } overlayRef: (overlay: ?HTMLDivElement) => void = overlay => { this.overlay = overlay; }; onBackgroundClick: (event: SyntheticEvent) => void = event => { if (event.target === this.overlay) { this.props.popModal(); } }; onKeyDown: (event: SyntheticKeyboardEvent) => void = event => { if (event.key === 'Escape') { this.props.popModal(); } }; + + calculateMediaDimensions: () => void = () => { + if (!this.overlay || !this.props.media.dimensions) { + return; + } + const containerWidth = this.overlay.clientWidth; + const containerHeight = this.overlay.clientHeight; + const containerAspectRatio = containerWidth / containerHeight; + + const { width: mediaWidth, height: mediaHeight } = + this.props.media.dimensions; + const mediaAspectRatio = mediaWidth / mediaHeight; + + let newWidth, newHeight; + if (containerAspectRatio > mediaAspectRatio) { + newWidth = Math.min(mediaWidth, containerHeight * mediaAspectRatio); + newHeight = newWidth / mediaAspectRatio; + } else { + newHeight = Math.min(mediaHeight, containerWidth / mediaAspectRatio); + newWidth = newHeight * mediaAspectRatio; + } + this.setState({ + dimensions: { + width: newWidth, + height: newHeight, + }, + }); + }; } function ConnectedMultiMediaModal(props: BaseProps): React.Node { const modalContext = useModalContext(); - - return ; + const { thumbHash, encryptionKey, thumbnailEncryptionKey } = props.media; + const thumbHashEncryptionKey = thumbnailEncryptionKey ?? encryptionKey; + const placeholderImage = usePlaceholder(thumbHash, thumbHashEncryptionKey); + + return ( + + ); } export default ConnectedMultiMediaModal; diff --git a/web/media/multimedia.react.js b/web/media/multimedia.react.js index 11afcf771..f1363b256 100644 --- a/web/media/multimedia.react.js +++ b/web/media/multimedia.react.js @@ -1,241 +1,229 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { CircularProgressbar } from 'react-circular-progressbar'; import 'react-circular-progressbar/dist/styles.css'; import { XCircle as XCircleIcon, AlertCircle as AlertCircleIcon, } from 'react-feather'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { fetchableMediaURI } from 'lib/media/media-utils.js'; import type { Dimensions, EncryptedMediaType, MediaType, } from 'lib/types/media-types.js'; import EncryptedMultimedia from './encrypted-multimedia.react.js'; import LoadableVideo from './loadable-video.react.js'; import { usePlaceholder } from './media-utils.js'; import css from './media.css'; import MultimediaModal from './multimedia-modal.react.js'; import Button from '../components/button.react.js'; import { type PendingMultimediaUpload } from '../input/input-state.js'; // this should be in sync with the max-height value // for span.multimedia > .multimediaImage in media.css const MAX_THUMBNAIL_HEIGHT = 200; type MediaSource = | { +type: MediaType, +uri: string, +dimensions: ?Dimensions, +thumbHash: ?string, +thumbnailURI: ?string, } | { +type: EncryptedMediaType, +holder: string, +encryptionKey: string, +dimensions: ?Dimensions, +thumbHash: ?string, +thumbnailHolder: ?string, +thumbnailEncryptionKey: ?string, }; type Props = { +mediaSource: MediaSource, +pendingUpload?: ?PendingMultimediaUpload, +remove?: (uploadID: string) => void, +multimediaCSSClass: string, +multimediaImageCSSClass: string, }; function Multimedia(props: Props): React.Node { const { mediaSource, pendingUpload } = props; const prevPropsRef = React.useRef({ mediaSource, pendingUpload }); React.useEffect(() => { const prevProps = prevPropsRef.current; prevPropsRef.current = { mediaSource, pendingUpload }; if ( prevProps.mediaSource.type === 'encrypted_photo' || prevProps.mediaSource.type === 'encrypted_video' ) { return; } const prevUri = prevProps.mediaSource.uri; if (!prevUri || mediaSource.uri === prevUri) { return; } if ( (!pendingUpload || pendingUpload.uriIsReal) && (!prevProps.pendingUpload || !prevProps.pendingUpload.uriIsReal) ) { URL.revokeObjectURL(prevUri); } }, [mediaSource, pendingUpload]); const { remove: removeProp } = props; const handleRemove = React.useCallback( (event: SyntheticEvent) => { event.stopPropagation(); invariant( removeProp && pendingUpload, 'Multimedia cannot be removed as either remove or pendingUpload ' + 'are unspecified', ); removeProp(pendingUpload.localID); }, [removeProp, pendingUpload], ); const { pushModal } = useModalContext(); const handleClick = React.useCallback(() => { - let media; - if ( - mediaSource.type === 'encrypted_photo' || - mediaSource.type === 'encrypted_video' - ) { - const { type, holder, encryptionKey } = mediaSource; - media = { type, holder, encryptionKey }; - } else { - const { type, uri } = mediaSource; - invariant(uri, 'uri is missing for media modal'); - media = { type, uri }; - } - pushModal(); + pushModal(); }, [pushModal, mediaSource]); let progressIndicator, errorIndicator, removeButton; const { multimediaImageCSSClass, multimediaCSSClass } = props; if (pendingUpload) { const { progressPercent, failed } = pendingUpload; if (progressPercent !== 0 && progressPercent !== 1) { const outOfHundred = Math.floor(progressPercent * 100); const text = `${outOfHundred}%`; progressIndicator = ( ); } if (failed) { errorIndicator = ( ); } if (removeProp) { removeButton = ( ); } } const imageContainerClasses = [css.multimediaImage, multimediaImageCSSClass]; imageContainerClasses.push(css.clickable); const thumbHash = mediaSource.thumbHash ?? pendingUpload?.thumbHash; const { encryptionKey, thumbnailEncryptionKey } = mediaSource; const thumbHashEncryptionKey = thumbnailEncryptionKey ?? encryptionKey; const placeholderImage = usePlaceholder(thumbHash, thumbHashEncryptionKey); const { dimensions } = mediaSource; const elementStyle = React.useMemo(() => { if (!dimensions) { return undefined; } const { width, height } = dimensions; // Resize the image to fit in max width while preserving aspect ratio const calculatedWidth = Math.min(MAX_THUMBNAIL_HEIGHT, height) * (width / height); return { background: placeholderImage ? `center / cover url(${placeholderImage})` : undefined, width: `${calculatedWidth}px`, // height is limited by the max-height style in media.css height: `${height}px`, }; }, [dimensions, placeholderImage]); // Media element is the actual image or video element (or encrypted version) let mediaElement; if (mediaSource.type === 'photo') { const uri = fetchableMediaURI(mediaSource.uri); mediaElement = ; } else if (mediaSource.type === 'video') { const uri = fetchableMediaURI(mediaSource.uri); const { thumbnailURI } = mediaSource; invariant(thumbnailURI, 'video missing thumbnail'); mediaElement = ( ); } else if ( mediaSource.type === 'encrypted_photo' || mediaSource.type === 'encrypted_video' ) { const { type, holder } = mediaSource; invariant(encryptionKey, 'encryptionKey undefined for encrypted media'); mediaElement = ( ); } // Media node is the container for the media element (button if photo) let mediaNode; if (mediaSource.type === 'photo' || mediaSource.type === 'encrypted_photo') { mediaNode = ( ); } else { mediaNode = (
{mediaElement}
); } const containerClasses = [css.multimedia, multimediaCSSClass]; return ( {mediaNode} {progressIndicator} {errorIndicator} ); } const MemoizedMultimedia: React.ComponentType = React.memo(Multimedia); export default MemoizedMultimedia; diff --git a/web/modals/threads/gallery/thread-settings-media-gallery.react.js b/web/modals/threads/gallery/thread-settings-media-gallery.react.js index fcbb0bea6..9f10a8b75 100644 --- a/web/modals/threads/gallery/thread-settings-media-gallery.react.js +++ b/web/modals/threads/gallery/thread-settings-media-gallery.react.js @@ -1,144 +1,159 @@ // @flow import * as React from 'react'; import { fetchThreadMedia } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import type { Media } from 'lib/types/media-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall } from 'lib/utils/action-utils.js'; import css from './thread-settings-media-gallery.css'; import Tabs from '../../../components/tabs.react.js'; import MultimediaModal from '../../../media/multimedia-modal.react.js'; import Modal from '../../modal.react.js'; type MediaGalleryTab = 'All' | 'Images' | 'Videos'; type ThreadSettingsMediaGalleryModalProps = { +onClose: () => void, +parentThreadInfo: ThreadInfo, +limit: number, +activeTab: MediaGalleryTab, }; function ThreadSettingsMediaGalleryModal( props: ThreadSettingsMediaGalleryModalProps, ): React.Node { const { pushModal } = useModalContext(); const { onClose, parentThreadInfo, limit, activeTab } = props; const { id: threadID } = parentThreadInfo; const modalName = 'Media'; const callFetchThreadMedia = useServerCall(fetchThreadMedia); const [mediaInfos, setMediaInfos] = React.useState([]); const [tab, setTab] = React.useState(activeTab); React.useEffect(() => { const fetchData = async () => { const result = await callFetchThreadMedia({ threadID, limit, offset: 0, }); setMediaInfos(result.media); }; fetchData(); }, [callFetchThreadMedia, threadID, limit]); const onClick = React.useCallback( (media: Media) => { - // This branching is needed for Flow. - let mediaInfo; + const thumbHash = media.thumbnailThumbHash ?? media.thumbHash; + let mediaInfo = { + thumbHash, + dimensions: media.dimensions, + }; if (media.type === 'photo' || media.type === 'video') { + const { uri, thumbnailURI } = media; mediaInfo = { + ...mediaInfo, type: media.type, - uri: media.uri, + uri, + thumbnailURI, }; } else { + const { + holder, + encryptionKey, + thumbnailHolder, + thumbnailEncryptionKey, + } = media; mediaInfo = { + ...mediaInfo, type: media.type, - holder: media.holder, - encryptionKey: media.encryptionKey, + holder, + encryptionKey, + thumbnailHolder, + thumbnailEncryptionKey, }; } pushModal(); }, [pushModal], ); const filteredMediaInfos = React.useMemo(() => { if (tab === 'Images') { return mediaInfos.filter(mediaInfo => mediaInfo.type === 'photo'); } else if (tab === 'Videos') { return mediaInfos.filter(mediaInfo => mediaInfo.type === 'video'); } return mediaInfos; }, [tab, mediaInfos]); const mediaCoverPhotos = React.useMemo( () => filteredMediaInfos.map(media => media.thumbnailURI || media.uri), [filteredMediaInfos], ); const mediaGalleryItems = React.useMemo( () => filteredMediaInfos.map((media, i) => (
onClick(media)} className={css.mediaContainer} >
)), [filteredMediaInfos, onClick, mediaCoverPhotos], ); const handleScroll = React.useCallback( async event => { const container = event.target; // Load more data when the user is within 1000 pixels of the end const buffer = 1000; if ( container.scrollHeight - container.scrollTop > container.clientHeight + buffer ) { return; } const result = await callFetchThreadMedia({ threadID, limit, offset: mediaInfos.length, }); setMediaInfos([...mediaInfos, ...result.media]); }, [callFetchThreadMedia, threadID, limit, mediaInfos], ); return (
{mediaGalleryItems}
{mediaGalleryItems}
{mediaGalleryItems}
); } export default ThreadSettingsMediaGalleryModal;