Changeset View
Changeset View
Standalone View
Standalone View
web/media/multimedia.react.js
// @flow | // @flow | ||||
import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
import invariant from 'invariant'; | import invariant from 'invariant'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { CircularProgressbar } from 'react-circular-progressbar'; | import { CircularProgressbar } from 'react-circular-progressbar'; | ||||
import 'react-circular-progressbar/dist/styles.css'; | import 'react-circular-progressbar/dist/styles.css'; | ||||
import { | import { | ||||
XCircle as XCircleIcon, | XCircle as XCircleIcon, | ||||
AlertCircle as AlertCircleIcon, | AlertCircle as AlertCircleIcon, | ||||
} from 'react-feather'; | } from 'react-feather'; | ||||
import { | import { useModalContext } from 'lib/components/modal-provider.react.js'; | ||||
useModalContext, | |||||
type PushModal, | |||||
} from 'lib/components/modal-provider.react.js'; | |||||
import { fetchableMediaURI } from 'lib/media/media-utils.js'; | import { fetchableMediaURI } from 'lib/media/media-utils.js'; | ||||
import type { MediaType, EncryptedMediaType } from 'lib/types/media-types.js'; | import type { MediaType, EncryptedMediaType } from 'lib/types/media-types.js'; | ||||
import EncryptedMultimedia from './encrypted-multimedia.react.js'; | import EncryptedMultimedia from './encrypted-multimedia.react.js'; | ||||
import css from './media.css'; | import css from './media.css'; | ||||
import MultimediaModal from './multimedia-modal.react.js'; | import MultimediaModal from './multimedia-modal.react.js'; | ||||
import Button from '../components/button.react.js'; | import Button from '../components/button.react.js'; | ||||
import { type PendingMultimediaUpload } from '../input/input-state.js'; | import { type PendingMultimediaUpload } from '../input/input-state.js'; | ||||
type MediaSource = | type MediaSource = | ||||
| { | | { | ||||
+type: MediaType, | +type: MediaType, | ||||
+uri: string, | +uri: string, | ||||
} | } | ||||
| { | | { | ||||
+type: EncryptedMediaType, | +type: EncryptedMediaType, | ||||
+holder: string, | +holder: string, | ||||
+encryptionKey: string, | +encryptionKey: string, | ||||
}; | }; | ||||
type BaseProps = { | type Props = { | ||||
+mediaSource: MediaSource, | +mediaSource: MediaSource, | ||||
+pendingUpload?: ?PendingMultimediaUpload, | +pendingUpload?: ?PendingMultimediaUpload, | ||||
+remove?: (uploadID: string) => void, | +remove?: (uploadID: string) => void, | ||||
+multimediaCSSClass: string, | +multimediaCSSClass: string, | ||||
+multimediaImageCSSClass: string, | +multimediaImageCSSClass: string, | ||||
}; | }; | ||||
type Props = { | |||||
...BaseProps, | |||||
+pushModal: PushModal, | |||||
}; | |||||
class Multimedia extends React.PureComponent<Props> { | function Multimedia(props: Props): React.Node { | ||||
componentDidUpdate(prevProps: Props) { | const { mediaSource, pendingUpload } = props; | ||||
const { mediaSource, pendingUpload } = this.props; | const prevPropsRef = React.useRef({ mediaSource, pendingUpload }); | ||||
React.useEffect(() => { | |||||
const prevProps = prevPropsRef.current; | |||||
prevPropsRef.current = { mediaSource, pendingUpload }; | |||||
if ( | if ( | ||||
prevProps.mediaSource.type === 'encrypted_photo' || | prevProps.mediaSource.type === 'encrypted_photo' || | ||||
prevProps.mediaSource.type === 'encrypted_video' | prevProps.mediaSource.type === 'encrypted_video' | ||||
) { | ) { | ||||
return; | return; | ||||
} | } | ||||
const prevUri = prevProps.mediaSource?.uri; | const prevUri = prevProps.mediaSource.uri; | ||||
if (!prevUri || mediaSource.uri === prevUri) { | if (!prevUri || mediaSource.uri === prevUri) { | ||||
return; | return; | ||||
} | } | ||||
if ( | if ( | ||||
(!pendingUpload || pendingUpload.uriIsReal) && | (!pendingUpload || pendingUpload.uriIsReal) && | ||||
(!prevProps.pendingUpload || !prevProps.pendingUpload.uriIsReal) | (!prevProps.pendingUpload || !prevProps.pendingUpload.uriIsReal) | ||||
) { | ) { | ||||
URL.revokeObjectURL(prevUri); | URL.revokeObjectURL(prevUri); | ||||
} | } | ||||
} | }, [mediaSource, pendingUpload]); | ||||
const { remove: removeProp } = props; | |||||
const handleRemove = React.useCallback( | |||||
(event: SyntheticEvent<HTMLElement>) => { | |||||
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(() => { | |||||
pushModal(<MultimediaModal media={mediaSource} />); | |||||
}, [pushModal, mediaSource]); | |||||
render(): React.Node { | |||||
let progressIndicator, errorIndicator, removeButton; | let progressIndicator, errorIndicator, removeButton; | ||||
const { | const { multimediaImageCSSClass, multimediaCSSClass } = props; | ||||
pendingUpload, | |||||
remove, | |||||
mediaSource, | |||||
multimediaImageCSSClass, | |||||
multimediaCSSClass, | |||||
} = this.props; | |||||
if (pendingUpload) { | if (pendingUpload) { | ||||
const { progressPercent, failed } = pendingUpload; | const { progressPercent, failed } = pendingUpload; | ||||
if (progressPercent !== 0 && progressPercent !== 1) { | if (progressPercent !== 0 && progressPercent !== 1) { | ||||
const outOfHundred = Math.floor(progressPercent * 100); | const outOfHundred = Math.floor(progressPercent * 100); | ||||
const text = `${outOfHundred}%`; | const text = `${outOfHundred}%`; | ||||
progressIndicator = ( | progressIndicator = ( | ||||
<CircularProgressbar | <CircularProgressbar | ||||
value={outOfHundred} | value={outOfHundred} | ||||
text={text} | text={text} | ||||
background | background | ||||
backgroundPadding={6} | backgroundPadding={6} | ||||
className={css.progressIndicator} | className={css.progressIndicator} | ||||
/> | /> | ||||
); | ); | ||||
} | } | ||||
if (failed) { | if (failed) { | ||||
errorIndicator = ( | errorIndicator = ( | ||||
<AlertCircleIcon className={css.uploadError} size={36} /> | <AlertCircleIcon className={css.uploadError} size={36} /> | ||||
); | ); | ||||
} | } | ||||
if (remove) { | if (removeProp) { | ||||
removeButton = ( | removeButton = ( | ||||
<Button onClick={this.remove}> | <Button onClick={handleRemove}> | ||||
<XCircleIcon className={css.removeUpload} /> | <XCircleIcon className={css.removeUpload} /> | ||||
</Button> | </Button> | ||||
); | ); | ||||
} | } | ||||
} | } | ||||
const imageContainerClasses = [ | const imageContainerClasses = [css.multimediaImage, multimediaImageCSSClass]; | ||||
css.multimediaImage, | |||||
multimediaImageCSSClass, | |||||
]; | |||||
imageContainerClasses.push(css.clickable); | imageContainerClasses.push(css.clickable); | ||||
// Media element is the actual image or video element (or encrypted version) | // Media element is the actual image or video element (or encrypted version) | ||||
let mediaElement; | let mediaElement; | ||||
if (mediaSource.type === 'photo') { | if (mediaSource.type === 'photo') { | ||||
const uri = fetchableMediaURI(mediaSource.uri); | const uri = fetchableMediaURI(mediaSource.uri); | ||||
mediaElement = <img src={uri} />; | mediaElement = <img src={uri} />; | ||||
} else if (mediaSource.type === 'video') { | } else if (mediaSource.type === 'video') { | ||||
const uri = fetchableMediaURI(mediaSource.uri); | const uri = fetchableMediaURI(mediaSource.uri); | ||||
mediaElement = ( | mediaElement = ( | ||||
<video controls> | <video controls> | ||||
<source src={uri} /> | <source src={uri} /> | ||||
</video> | </video> | ||||
); | ); | ||||
} else if ( | } else if ( | ||||
mediaSource.type === 'encrypted_photo' || | mediaSource.type === 'encrypted_photo' || | ||||
mediaSource.type === 'encrypted_video' | mediaSource.type === 'encrypted_video' | ||||
) { | ) { | ||||
const { ...encryptedMediaProps } = mediaSource; | mediaElement = <EncryptedMultimedia {...mediaSource} />; | ||||
mediaElement = <EncryptedMultimedia {...encryptedMediaProps} />; | |||||
} | } | ||||
// Media node is the container for the media element (button if photo) | // Media node is the container for the media element (button if photo) | ||||
let mediaNode; | let mediaNode; | ||||
if ( | if (mediaSource.type === 'photo' || mediaSource.type === 'encrypted_photo') { | ||||
mediaSource.type === 'photo' || | |||||
mediaSource.type === 'encrypted_photo' | |||||
) { | |||||
mediaNode = ( | mediaNode = ( | ||||
<Button | <Button | ||||
className={classNames(imageContainerClasses)} | className={classNames(imageContainerClasses)} | ||||
onClick={this.onClick} | onClick={handleClick} | ||||
> | > | ||||
{mediaElement} | {mediaElement} | ||||
{removeButton} | {removeButton} | ||||
</Button> | </Button> | ||||
); | ); | ||||
} else { | } else { | ||||
mediaNode = ( | mediaNode = ( | ||||
<div className={classNames(imageContainerClasses)}>{mediaElement}</div> | <div className={classNames(imageContainerClasses)}>{mediaElement}</div> | ||||
); | ); | ||||
} | } | ||||
const containerClasses = [css.multimedia, multimediaCSSClass]; | const containerClasses = [css.multimedia, multimediaCSSClass]; | ||||
return ( | return ( | ||||
<span className={classNames(containerClasses)}> | <span className={classNames(containerClasses)}> | ||||
{mediaNode} | {mediaNode} | ||||
{progressIndicator} | {progressIndicator} | ||||
{errorIndicator} | {errorIndicator} | ||||
</span> | </span> | ||||
); | ); | ||||
} | } | ||||
remove: (event: SyntheticEvent<HTMLElement>) => void = event => { | const MemoizedMultimedia: React.ComponentType<Props> = | ||||
event.stopPropagation(); | React.memo<Props>(Multimedia); | ||||
const { remove, pendingUpload } = this.props; | |||||
invariant( | |||||
remove && pendingUpload, | |||||
'Multimedia cannot be removed as either remove or pendingUpload ' + | |||||
'are unspecified', | |||||
); | |||||
remove(pendingUpload.localID); | |||||
}; | |||||
onClick: () => void = () => { | |||||
const { pushModal, mediaSource } = this.props; | |||||
pushModal(<MultimediaModal media={mediaSource} />); | |||||
}; | |||||
} | |||||
function ConnectedMultimediaContainer(props: BaseProps): React.Node { | |||||
const modalContext = useModalContext(); | |||||
return <Multimedia {...props} pushModal={modalContext.pushModal} />; | |||||
} | |||||
export default ConnectedMultimediaContainer; | export default MemoizedMultimedia; |