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 differencelocal
parent
3d9b8847d2
commit
4ec1771165
24 changed files with 311 additions and 278 deletions
@ -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 { connect } from 'react-redux'; |
||||||
import UploadForm from '../components/upload_form'; |
import UploadForm from '../components/upload_form'; |
||||||
import { undoUploadCompose } from '../../../actions/compose'; |
|
||||||
|
|
||||||
const mapStateToProps = state => ({ |
const mapStateToProps = state => ({ |
||||||
media: state.getIn(['compose', 'media_attachments']), |
mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')), |
||||||
}); |
}); |
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({ |
export default connect(mapStateToProps)(UploadForm); |
||||||
|
|
||||||
onRemoveFile (media_id) { |
|
||||||
dispatch(undoUploadCompose(media_id)); |
|
||||||
}, |
|
||||||
|
|
||||||
}); |
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(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