Add ability to specify alternative text for media attachments (#5123)
* Fix #117 - Add ability to specify alternative text for media attachments - POST /api/v1/media accepts `description` straight away - PUT /api/v1/media/:id to update `description` (only for unattached ones) - Serialized as `name` of Document object in ActivityPub - Uploads form adjusted for better performance and description input * Add tests * Change undo button blend mode to differencemaster
parent
3d9b8847d2
commit
4ec1771165
@ -1,204 +0,0 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import IconButton from './icon_button'; |
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; |
||||
import { isIOS } from '../is_mobile'; |
||||
|
||||
const messages = defineMessages({ |
||||
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, |
||||
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, |
||||
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class VideoPlayer extends React.PureComponent { |
||||
|
||||
static contextTypes = { |
||||
router: PropTypes.object, |
||||
}; |
||||
|
||||
static propTypes = { |
||||
media: ImmutablePropTypes.map.isRequired, |
||||
width: PropTypes.number, |
||||
height: PropTypes.number, |
||||
sensitive: PropTypes.bool, |
||||
intl: PropTypes.object.isRequired, |
||||
autoplay: PropTypes.bool, |
||||
onOpenVideo: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
static defaultProps = { |
||||
width: 239, |
||||
height: 110, |
||||
}; |
||||
|
||||
state = { |
||||
visible: !this.props.sensitive, |
||||
preview: true, |
||||
muted: true, |
||||
hasAudio: true, |
||||
videoError: false, |
||||
}; |
||||
|
||||
handleClick = () => { |
||||
this.setState({ muted: !this.state.muted }); |
||||
} |
||||
|
||||
handleVideoClick = (e) => { |
||||
e.stopPropagation(); |
||||
|
||||
const node = this.video; |
||||
|
||||
if (node.paused) { |
||||
node.play(); |
||||
} else { |
||||
node.pause(); |
||||
} |
||||
} |
||||
|
||||
handleOpen = () => { |
||||
this.setState({ preview: !this.state.preview }); |
||||
} |
||||
|
||||
handleVisibility = () => { |
||||
this.setState({ |
||||
visible: !this.state.visible, |
||||
preview: true, |
||||
}); |
||||
} |
||||
|
||||
handleExpand = () => { |
||||
this.video.pause(); |
||||
this.props.onOpenVideo(this.props.media, this.video.currentTime); |
||||
} |
||||
|
||||
setRef = (c) => { |
||||
this.video = c; |
||||
} |
||||
|
||||
handleLoadedData = () => { |
||||
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { |
||||
this.setState({ hasAudio: false }); |
||||
} |
||||
} |
||||
|
||||
handleVideoError = () => { |
||||
this.setState({ videoError: true }); |
||||
} |
||||
|
||||
componentDidMount () { |
||||
if (!this.video) { |
||||
return; |
||||
} |
||||
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData); |
||||
this.video.addEventListener('error', this.handleVideoError); |
||||
} |
||||
|
||||
componentDidUpdate () { |
||||
if (!this.video) { |
||||
return; |
||||
} |
||||
|
||||
this.video.addEventListener('loadeddata', this.handleLoadedData); |
||||
this.video.addEventListener('error', this.handleVideoError); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
if (!this.video) { |
||||
return; |
||||
} |
||||
|
||||
this.video.removeEventListener('loadeddata', this.handleLoadedData); |
||||
this.video.removeEventListener('error', this.handleVideoError); |
||||
} |
||||
|
||||
render () { |
||||
const { media, intl, width, height, sensitive, autoplay } = this.props; |
||||
|
||||
let spoilerButton = ( |
||||
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> |
||||
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> |
||||
</div> |
||||
); |
||||
|
||||
let expandButton = ''; |
||||
|
||||
if (this.context.router) { |
||||
expandButton = ( |
||||
<div className='status__video-player-expand'> |
||||
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
let muteButton = ''; |
||||
|
||||
if (this.state.hasAudio) { |
||||
muteButton = ( |
||||
<div className='status__video-player-mute'> |
||||
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
if (!this.state.visible) { |
||||
if (sensitive) { |
||||
return ( |
||||
<button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> |
||||
{spoilerButton} |
||||
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> |
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |
||||
</button> |
||||
); |
||||
} else { |
||||
return ( |
||||
<button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}> |
||||
{spoilerButton} |
||||
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> |
||||
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> |
||||
</button> |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (this.state.preview && !autoplay) { |
||||
return ( |
||||
<button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> |
||||
{spoilerButton} |
||||
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
if (this.state.videoError) { |
||||
return ( |
||||
<div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' > |
||||
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}> |
||||
{spoilerButton} |
||||
{muteButton} |
||||
{expandButton} |
||||
|
||||
<video |
||||
className='status__video-player-video' |
||||
role='button' |
||||
tabIndex='0' |
||||
ref={this.setRef} |
||||
src={media.get('url')} |
||||
autoPlay={!isIOS()} |
||||
loop |
||||
muted={this.state.muted} |
||||
onClick={this.handleVideoClick} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,96 @@ |
||||
import React from 'react'; |
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
import PropTypes from 'prop-types'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
import Motion from 'react-motion/lib/Motion'; |
||||
import spring from 'react-motion/lib/spring'; |
||||
import ImmutablePureComponent from 'react-immutable-pure-component'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
import classNames from 'classnames'; |
||||
|
||||
const messages = defineMessages({ |
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, |
||||
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, |
||||
}); |
||||
|
||||
@injectIntl |
||||
export default class Upload extends ImmutablePureComponent { |
||||
|
||||
static propTypes = { |
||||
media: ImmutablePropTypes.map.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
onUndo: PropTypes.func.isRequired, |
||||
onDescriptionChange: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
state = { |
||||
hovered: false, |
||||
focused: false, |
||||
dirtyDescription: null, |
||||
}; |
||||
|
||||
handleUndoClick = () => { |
||||
this.props.onUndo(this.props.media.get('id')); |
||||
} |
||||
|
||||
handleInputChange = e => { |
||||
this.setState({ dirtyDescription: e.target.value }); |
||||
} |
||||
|
||||
handleMouseEnter = () => { |
||||
this.setState({ hovered: true }); |
||||
} |
||||
|
||||
handleMouseLeave = () => { |
||||
this.setState({ hovered: false }); |
||||
} |
||||
|
||||
handleInputFocus = () => { |
||||
this.setState({ focused: true }); |
||||
} |
||||
|
||||
handleInputBlur = () => { |
||||
const { dirtyDescription } = this.state; |
||||
|
||||
this.setState({ focused: false, dirtyDescription: null }); |
||||
|
||||
if (dirtyDescription !== null) { |
||||
this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); |
||||
} |
||||
} |
||||
|
||||
render () { |
||||
const { intl, media } = this.props; |
||||
const active = this.state.hovered || this.state.focused; |
||||
const description = this.state.dirtyDescription || media.get('description') || ''; |
||||
|
||||
return ( |
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> |
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> |
||||
{({ scale }) => ( |
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> |
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> |
||||
|
||||
<div className={classNames('compose-form__upload-description', { active })}> |
||||
<label> |
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> |
||||
|
||||
<input |
||||
placeholder={intl.formatMessage(messages.description)} |
||||
type='text' |
||||
value={description} |
||||
maxLength={140} |
||||
onFocus={this.handleInputFocus} |
||||
onChange={this.handleInputChange} |
||||
onBlur={this.handleInputBlur} |
||||
/> |
||||
</label> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</Motion> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,21 @@ |
||||
import { connect } from 'react-redux'; |
||||
import Upload from '../components/upload'; |
||||
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; |
||||
|
||||
const mapStateToProps = (state, { id }) => ({ |
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onUndo: id => { |
||||
dispatch(undoUploadCompose(id)); |
||||
}, |
||||
|
||||
onDescriptionChange: (id, description) => { |
||||
dispatch(changeUploadCompose(id, description)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Upload); |
@ -1,17 +1,8 @@ |
||||
import { connect } from 'react-redux'; |
||||
import UploadForm from '../components/upload_form'; |
||||
import { undoUploadCompose } from '../../../actions/compose'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
media: state.getIn(['compose', 'media_attachments']), |
||||
mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
|
||||
onRemoveFile (media_id) { |
||||
dispatch(undoUploadCompose(media_id)); |
||||
}, |
||||
|
||||
}); |
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); |
||||
export default connect(mapStateToProps)(UploadForm); |
||||
|
@ -0,0 +1,5 @@ |
||||
class AddDescriptionToMediaAttachments < ActiveRecord::Migration[5.1] |
||||
def change |
||||
add_column :media_attachments, :description, :text |
||||
end |
||||
end |
Loading…
Reference in new issue