* 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
@@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController | |||||
respond_to :json | respond_to :json | ||||
def create | def create | ||||
@media = current_account.media_attachments.create!(file: media_params[:file]) | |||||
@media = current_account.media_attachments.create!(media_params) | |||||
render json: @media, serializer: REST::MediaAttachmentSerializer | render json: @media, serializer: REST::MediaAttachmentSerializer | ||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError | rescue Paperclip::Errors::NotIdentifiedByImageMagickError | ||||
render json: file_type_error, status: 422 | render json: file_type_error, status: 422 | ||||
@@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController | |||||
render json: processing_error, status: 500 | render json: processing_error, status: 500 | ||||
end | end | ||||
def update | |||||
@media = current_account.media_attachments.where(status_id: nil).find(params[:id]) | |||||
@media.update!(media_params) | |||||
render json: @media, serializer: REST::MediaAttachmentSerializer | |||||
end | |||||
private | private | ||||
def media_params | def media_params | ||||
params.permit(:file) | |||||
params.permit(:file, :description) | |||||
end | end | ||||
def file_type_error | def file_type_error | ||||
@@ -37,6 +37,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; | |||||
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; | export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; | ||||
export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; | |||||
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; | |||||
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; | |||||
export function changeCompose(text) { | export function changeCompose(text) { | ||||
return { | return { | ||||
type: COMPOSE_CHANGE, | type: COMPOSE_CHANGE, | ||||
@@ -165,6 +169,40 @@ export function uploadCompose(files) { | |||||
}; | }; | ||||
}; | }; | ||||
export function changeUploadCompose(id, description) { | |||||
return (dispatch, getState) => { | |||||
dispatch(changeUploadComposeRequest()); | |||||
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { | |||||
dispatch(changeUploadComposeSuccess(response.data)); | |||||
}).catch(error => { | |||||
dispatch(changeUploadComposeFail(id, error)); | |||||
}); | |||||
}; | |||||
}; | |||||
export function changeUploadComposeRequest() { | |||||
return { | |||||
type: COMPOSE_UPLOAD_CHANGE_REQUEST, | |||||
skipLoading: true, | |||||
}; | |||||
}; | |||||
export function changeUploadComposeSuccess(media) { | |||||
return { | |||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, | |||||
media: media, | |||||
skipLoading: true, | |||||
}; | |||||
}; | |||||
export function changeUploadComposeFail(error) { | |||||
return { | |||||
type: COMPOSE_UPLOAD_CHANGE_FAIL, | |||||
error: error, | |||||
skipLoading: true, | |||||
}; | |||||
}; | |||||
export function uploadComposeRequest() { | export function uploadComposeRequest() { | ||||
return { | return { | ||||
type: COMPOSE_UPLOAD_REQUEST, | type: COMPOSE_UPLOAD_REQUEST, | ||||
@@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { | |||||
static propTypes = { | static propTypes = { | ||||
src: PropTypes.string.isRequired, | src: PropTypes.string.isRequired, | ||||
alt: PropTypes.string, | |||||
width: PropTypes.number, | width: PropTypes.number, | ||||
height: PropTypes.number, | height: PropTypes.number, | ||||
time: PropTypes.number, | time: PropTypes.number, | ||||
@@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent { | |||||
} | } | ||||
render () { | render () { | ||||
const { src, muted, controls, alt } = this.props; | |||||
return ( | return ( | ||||
<div className='extended-video-player'> | <div className='extended-video-player'> | ||||
<video | <video | ||||
ref={this.setRef} | ref={this.setRef} | ||||
src={this.props.src} | |||||
src={src} | |||||
autoPlay | autoPlay | ||||
muted={this.props.muted} | |||||
controls={this.props.controls} | |||||
loop={!this.props.controls} | |||||
role='button' | |||||
tabIndex='0' | |||||
aria-label={alt} | |||||
muted={muted} | |||||
controls={controls} | |||||
loop={!controls} | |||||
/> | /> | ||||
</div> | </div> | ||||
); | ); | ||||
@@ -136,7 +136,7 @@ class Item extends React.PureComponent { | |||||
onClick={this.handleClick} | onClick={this.handleClick} | ||||
target='_blank' | target='_blank' | ||||
> | > | ||||
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> | |||||
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} /> | |||||
</a> | </a> | ||||
); | ); | ||||
} else if (attachment.get('type') === 'gifv') { | } else if (attachment.get('type') === 'gifv') { | ||||
@@ -146,6 +146,7 @@ class Item extends React.PureComponent { | |||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> | <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> | ||||
<video | <video | ||||
className='media-gallery__item-gifv-thumbnail' | className='media-gallery__item-gifv-thumbnail' | ||||
aria-label={attachment.get('description')} | |||||
role='application' | role='application' | ||||
src={attachment.get('url')} | src={attachment.get('url')} | ||||
onClick={this.handleClick} | onClick={this.handleClick} | ||||
@@ -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> | |||||
); | |||||
} | |||||
} |
@@ -1,49 +1,27 @@ | |||||
import React from 'react'; | import React from 'react'; | ||||
import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
import PropTypes from 'prop-types'; | |||||
import IconButton from '../../../components/icon_button'; | |||||
import { defineMessages, injectIntl } from 'react-intl'; | |||||
import UploadProgressContainer from '../containers/upload_progress_container'; | import UploadProgressContainer from '../containers/upload_progress_container'; | ||||
import Motion from 'react-motion/lib/Motion'; | |||||
import spring from 'react-motion/lib/spring'; | |||||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||||
import UploadContainer from '../containers/upload_container'; | |||||
const messages = defineMessages({ | |||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, | |||||
}); | |||||
@injectIntl | |||||
export default class UploadForm extends React.PureComponent { | |||||
export default class UploadForm extends ImmutablePureComponent { | |||||
static propTypes = { | static propTypes = { | ||||
media: ImmutablePropTypes.list.isRequired, | |||||
onRemoveFile: PropTypes.func.isRequired, | |||||
intl: PropTypes.object.isRequired, | |||||
mediaIds: ImmutablePropTypes.list.isRequired, | |||||
}; | }; | ||||
onRemoveFile = (e) => { | |||||
const id = e.currentTarget.parentElement.getAttribute('data-id'); | |||||
this.props.onRemoveFile(id); | |||||
} | |||||
render () { | render () { | ||||
const { intl, media } = this.props; | |||||
const uploads = media.map(attachment => | |||||
<div className='compose-form__upload' key={attachment.get('id')}> | |||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> | |||||
{({ scale }) => | |||||
<div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}> | |||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} /> | |||||
</div> | |||||
} | |||||
</Motion> | |||||
</div> | |||||
); | |||||
const { mediaIds } = this.props; | |||||
return ( | return ( | ||||
<div className='compose-form__upload-wrapper'> | <div className='compose-form__upload-wrapper'> | ||||
<UploadProgressContainer /> | <UploadProgressContainer /> | ||||
<div className='compose-form__uploads-wrapper'>{uploads}</div> | |||||
<div className='compose-form__uploads-wrapper'> | |||||
{mediaIds.map(id => ( | |||||
<UploadContainer id={id} key={id} /> | |||||
))} | |||||
</div> | |||||
</div> | </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 => ({ | |||||
onRemoveFile (media_id) { | |||||
dispatch(undoUploadCompose(media_id)); | |||||
}, | |||||
}); | |||||
export default connect(mapStateToProps, mapDispatchToProps)(UploadForm); | |||||
export default connect(mapStateToProps)(UploadForm); |
@@ -76,9 +76,9 @@ export default class MediaModal extends ImmutablePureComponent { | |||||
const height = image.getIn(['meta', 'original', 'height']) || null; | const height = image.getIn(['meta', 'original', 'height']) || null; | ||||
if (image.get('type') === 'image') { | if (image.get('type') === 'image') { | ||||
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />; | |||||
return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />; | |||||
} else if (image.get('type') === 'gifv') { | } else if (image.get('type') === 'gifv') { | ||||
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />; | |||||
return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />; | |||||
} | } | ||||
return null; | return null; | ||||
@@ -90,6 +90,7 @@ export default class MediaModal extends ImmutablePureComponent { | |||||
<div className='media-modal__content'> | <div className='media-modal__content'> | ||||
<IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> | <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> | ||||
<ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight> | <ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight> | ||||
{content} | {content} | ||||
</ReactSwipeableViews> | </ReactSwipeableViews> | ||||
@@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent { | |||||
src={media.get('url')} | src={media.get('url')} | ||||
startTime={time} | startTime={time} | ||||
onCloseVideo={onClose} | onCloseVideo={onClose} | ||||
description={media.get('description')} | |||||
/> | /> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -90,10 +90,6 @@ export function MediaGallery () { | |||||
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); | return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); | ||||
} | } | ||||
export function VideoPlayer () { | |||||
return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); | |||||
} | |||||
export function Video () { | export function Video () { | ||||
return import(/* webpackChunkName: "features/video" */'../../video'); | return import(/* webpackChunkName: "features/video" */'../../video'); | ||||
} | } | ||||
@@ -104,6 +104,7 @@ export default class Video extends React.PureComponent { | |||||
static propTypes = { | static propTypes = { | ||||
preview: PropTypes.string, | preview: PropTypes.string, | ||||
src: PropTypes.string.isRequired, | src: PropTypes.string.isRequired, | ||||
alt: PropTypes.string, | |||||
width: PropTypes.number, | width: PropTypes.number, | ||||
height: PropTypes.number, | height: PropTypes.number, | ||||
sensitive: PropTypes.bool, | sensitive: PropTypes.bool, | ||||
@@ -247,7 +248,7 @@ export default class Video extends React.PureComponent { | |||||
} | } | ||||
render () { | render () { | ||||
const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props; | |||||
const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props; | |||||
const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; | const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; | ||||
return ( | return ( | ||||
@@ -260,6 +261,7 @@ export default class Video extends React.PureComponent { | |||||
loop | loop | ||||
role='button' | role='button' | ||||
tabIndex='0' | tabIndex='0' | ||||
aria-label={alt} | |||||
width={width} | width={width} | ||||
height={height} | height={height} | ||||
onClick={this.togglePlay} | onClick={this.togglePlay} | ||||
@@ -22,6 +22,9 @@ import { | |||||
COMPOSE_VISIBILITY_CHANGE, | COMPOSE_VISIBILITY_CHANGE, | ||||
COMPOSE_COMPOSING_CHANGE, | COMPOSE_COMPOSING_CHANGE, | ||||
COMPOSE_EMOJI_INSERT, | COMPOSE_EMOJI_INSERT, | ||||
COMPOSE_UPLOAD_CHANGE_REQUEST, | |||||
COMPOSE_UPLOAD_CHANGE_SUCCESS, | |||||
COMPOSE_UPLOAD_CHANGE_FAIL, | |||||
} from '../actions/compose'; | } from '../actions/compose'; | ||||
import { TIMELINE_DELETE } from '../actions/timelines'; | import { TIMELINE_DELETE } from '../actions/timelines'; | ||||
import { STORE_HYDRATE } from '../actions/store'; | import { STORE_HYDRATE } from '../actions/store'; | ||||
@@ -220,15 +223,15 @@ export default function compose(state = initialState, action) { | |||||
map.set('idempotencyKey', uuid()); | map.set('idempotencyKey', uuid()); | ||||
}); | }); | ||||
case COMPOSE_SUBMIT_REQUEST: | case COMPOSE_SUBMIT_REQUEST: | ||||
case COMPOSE_UPLOAD_CHANGE_REQUEST: | |||||
return state.set('is_submitting', true); | return state.set('is_submitting', true); | ||||
case COMPOSE_SUBMIT_SUCCESS: | case COMPOSE_SUBMIT_SUCCESS: | ||||
return clearAll(state); | return clearAll(state); | ||||
case COMPOSE_SUBMIT_FAIL: | case COMPOSE_SUBMIT_FAIL: | ||||
case COMPOSE_UPLOAD_CHANGE_FAIL: | |||||
return state.set('is_submitting', false); | return state.set('is_submitting', false); | ||||
case COMPOSE_UPLOAD_REQUEST: | case COMPOSE_UPLOAD_REQUEST: | ||||
return state.withMutations(map => { | |||||
map.set('is_uploading', true); | |||||
}); | |||||
return state.set('is_uploading', true); | |||||
case COMPOSE_UPLOAD_SUCCESS: | case COMPOSE_UPLOAD_SUCCESS: | ||||
return appendMedia(state, fromJS(action.media)); | return appendMedia(state, fromJS(action.media)); | ||||
case COMPOSE_UPLOAD_FAIL: | case COMPOSE_UPLOAD_FAIL: | ||||
@@ -256,6 +259,16 @@ export default function compose(state = initialState, action) { | |||||
} | } | ||||
case COMPOSE_EMOJI_INSERT: | case COMPOSE_EMOJI_INSERT: | ||||
return insertEmoji(state, action.position, action.emoji); | return insertEmoji(state, action.position, action.emoji); | ||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS: | |||||
return state | |||||
.set('is_submitting', false) | |||||
.update('media_attachments', list => list.map(item => { | |||||
if (item.get('id') === action.media.id) { | |||||
return item.set('description', action.media.description); | |||||
} | |||||
return item; | |||||
})); | |||||
default: | default: | ||||
return state; | return state; | ||||
} | } | ||||
@@ -335,12 +335,52 @@ | |||||
.compose-form__uploads-wrapper { | .compose-form__uploads-wrapper { | ||||
display: flex; | display: flex; | ||||
flex-direction: row; | |||||
padding: 5px; | padding: 5px; | ||||
flex-wrap: wrap; | |||||
} | } | ||||
.compose-form__upload { | .compose-form__upload { | ||||
flex: 1 1 0; | flex: 1 1 0; | ||||
min-width: 40%; | |||||
margin: 5px; | margin: 5px; | ||||
&-description { | |||||
position: absolute; | |||||
z-index: 2; | |||||
bottom: 0; | |||||
left: 0; | |||||
right: 0; | |||||
box-sizing: border-box; | |||||
background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); | |||||
padding: 10px; | |||||
opacity: 0; | |||||
transition: opacity .1s ease; | |||||
input { | |||||
background: transparent; | |||||
color: $ui-secondary-color; | |||||
border: 0; | |||||
padding: 0; | |||||
margin: 0; | |||||
width: 100%; | |||||
font-family: inherit; | |||||
font-size: 14px; | |||||
font-weight: 500; | |||||
&:focus { | |||||
color: $white; | |||||
} | |||||
} | |||||
&.active { | |||||
opacity: 1; | |||||
} | |||||
} | |||||
.icon-button { | |||||
mix-blend-mode: difference; | |||||
} | |||||
} | } | ||||
.compose-form__upload-thumbnail { | .compose-form__upload-thumbnail { | ||||
@@ -352,13 +392,6 @@ | |||||
width: 100%; | width: 100%; | ||||
} | } | ||||
.compose-form__upload-cancel { | |||||
background-size: cover; | |||||
border-radius: 4px; | |||||
height: 100px; | |||||
width: 100px; | |||||
} | |||||
.compose-form__label { | .compose-form__label { | ||||
display: block; | display: block; | ||||
line-height: 24px; | line-height: 24px; | ||||
@@ -105,7 +105,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? | next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? | ||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s | href = Addressable::URI.parse(attachment['url']).normalize.to_s | ||||
media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href) | |||||
media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href, description: attachment['name'].presence) | |||||
next if skip_download? | next if skip_download? | ||||
@@ -16,6 +16,7 @@ | |||||
# shortcode :string | # shortcode :string | ||||
# type :integer default("image"), not null | # type :integer default("image"), not null | ||||
# file_meta :json | # file_meta :json | ||||
# description :text | |||||
# | # | ||||
require 'mime/types' | require 'mime/types' | ||||
@@ -58,6 +59,7 @@ class MediaAttachment < ApplicationRecord | |||||
validates_attachment_size :file, less_than: 8.megabytes | validates_attachment_size :file, less_than: 8.megabytes | ||||
validates :account, presence: true | validates :account, presence: true | ||||
validates :description, length: { maximum: 140 }, if: :local? | |||||
scope :attached, -> { where.not(status_id: nil) } | scope :attached, -> { where.not(status_id: nil) } | ||||
scope :unattached, -> { where(status_id: nil) } | scope :unattached, -> { where(status_id: nil) } | ||||
@@ -78,6 +80,7 @@ class MediaAttachment < ApplicationRecord | |||||
shortcode | shortcode | ||||
end | end | ||||
before_create :prepare_description, unless: :local? | |||||
before_create :set_shortcode | before_create :set_shortcode | ||||
before_post_process :set_type_and_extension | before_post_process :set_type_and_extension | ||||
before_save :set_meta | before_save :set_meta | ||||
@@ -136,6 +139,10 @@ class MediaAttachment < ApplicationRecord | |||||
end | end | ||||
end | end | ||||
def prepare_description | |||||
self.description = description.strip[0...140] unless description.nil? | |||||
end | |||||
def set_type_and_extension | def set_type_and_extension | ||||
self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image | self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image | ||||
extension = appropriate_extension | extension = appropriate_extension | ||||
@@ -89,12 +89,16 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer | |||||
class MediaAttachmentSerializer < ActiveModel::Serializer | class MediaAttachmentSerializer < ActiveModel::Serializer | ||||
include RoutingHelper | include RoutingHelper | ||||
attributes :type, :media_type, :url | |||||
attributes :type, :media_type, :url, :name | |||||
def type | def type | ||||
'Document' | 'Document' | ||||
end | end | ||||
def name | |||||
object.description | |||||
end | |||||
def media_type | def media_type | ||||
object.file_content_type | object.file_content_type | ||||
end | end | ||||
@@ -4,7 +4,8 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer | |||||
include RoutingHelper | include RoutingHelper | ||||
attributes :id, :type, :url, :preview_url, | attributes :id, :type, :url, :preview_url, | ||||
:remote_url, :text_url, :meta | |||||
:remote_url, :text_url, :meta, | |||||
:description | |||||
def id | def id | ||||
object.id.to_s | object.id.to_s | ||||
@@ -193,7 +193,7 @@ Rails.application.routes.draw do | |||||
get '/search', to: 'search#index', as: :search | get '/search', to: 'search#index', as: :search | ||||
resources :follows, only: [:create] | resources :follows, only: [:create] | ||||
resources :media, only: [:create] | |||||
resources :media, only: [:create, :update] | |||||
resources :apps, only: [:create] | resources :apps, only: [:create] | ||||
resources :blocks, only: [:index] | resources :blocks, only: [:index] | ||||
resources :mutes, only: [:index] | resources :mutes, only: [:index] | ||||
@@ -0,0 +1,5 @@ | |||||
class AddDescriptionToMediaAttachments < ActiveRecord::Migration[5.1] | |||||
def change | |||||
add_column :media_attachments, :description, :text | |||||
end | |||||
end |
@@ -10,7 +10,7 @@ | |||||
# | # | ||||
# It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||
ActiveRecord::Schema.define(version: 20170924022025) do | |||||
ActiveRecord::Schema.define(version: 20170927215609) do | |||||
# These are extensions that must be enabled in order to support this database | # These are extensions that must be enabled in order to support this database | ||||
enable_extension "plpgsql" | enable_extension "plpgsql" | ||||
@@ -161,6 +161,7 @@ ActiveRecord::Schema.define(version: 20170924022025) do | |||||
t.string "shortcode" | t.string "shortcode" | ||||
t.integer "type", default: 0, null: false | t.integer "type", default: 0, null: false | ||||
t.json "file_meta" | t.json "file_meta" | ||||
t.text "description" | |||||
t.index ["account_id"], name: "index_media_attachments_on_account_id" | t.index ["account_id"], name: "index_media_attachments_on_account_id" | ||||
t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true | t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true | ||||
t.index ["status_id"], name: "index_media_attachments_on_status_id" | t.index ["status_id"], name: "index_media_attachments_on_status_id" | ||||
@@ -101,4 +101,33 @@ RSpec.describe Api::V1::MediaController, type: :controller do | |||||
end | end | ||||
end | end | ||||
end | end | ||||
describe 'PUT #update' do | |||||
context 'when somebody else\'s' do | |||||
let(:media) { Fabricate(:media_attachment, status: nil) } | |||||
it 'returns http not found' do | |||||
put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } | |||||
expect(response).to have_http_status(:not_found) | |||||
end | |||||
end | |||||
context 'when not attached to a status' do | |||||
let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) } | |||||
it 'updates the description' do | |||||
put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } | |||||
expect(media.reload.description).to eq 'Lorem ipsum!!!' | |||||
end | |||||
end | |||||
context 'when attached to a status' do | |||||
let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) } | |||||
it 'returns http not found' do | |||||
put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } | |||||
expect(response).to have_http_status(:not_found) | |||||
end | |||||
end | |||||
end | |||||
end | end |
@@ -17,7 +17,6 @@ RSpec.describe MediaAttachment, type: :model do | |||||
expect(media.file.meta["original"]["height"]).to eq 128 | expect(media.file.meta["original"]["height"]).to eq 128 | ||||
expect(media.file.meta["original"]["aspect"]).to eq 1.0 | expect(media.file.meta["original"]["aspect"]).to eq 1.0 | ||||
end | end | ||||
end | end | ||||
describe 'non-animated gif non-conversion' do | describe 'non-animated gif non-conversion' do | ||||
@@ -50,4 +49,12 @@ RSpec.describe MediaAttachment, type: :model do | |||||
expect(media.file.meta["small"]["aspect"]).to eq 400.0/267 | expect(media.file.meta["small"]["aspect"]).to eq 400.0/267 | ||||
end | end | ||||
end | end | ||||
describe 'descriptions for remote attachments' do | |||||
it 'are cut off at 140 characters' do | |||||
media = Fabricate(:media_attachment, description: 'foo' * 100, remote_url: 'http://example.com/blah.jpg') | |||||
expect(media.description.size).to be <= 140 | |||||
end | |||||
end | |||||
end | end |