logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: 4ec1771165ab8dd40e52804fd087eacfab25290b
parent: 3d9b8847d21d886886baae483304288139669795
Author: Eugen Rochko <eugen@zeonfederated.com>
Date:   Thu, 28 Sep 2017 15:31:31 +0200

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 difference

Diffstat:

Mapp/controllers/api/v1/media_controller.rb10++++++++--
Mapp/javascript/mastodon/actions/compose.js38++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/components/extended_video_player.js14++++++++++----
Mapp/javascript/mastodon/components/media_gallery.js3++-
Dapp/javascript/mastodon/components/video_player.js204-------------------------------------------------------------------------------
Aapp/javascript/mastodon/features/compose/components/upload.js96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/features/compose/components/upload_form.js44+++++++++++---------------------------------
Aapp/javascript/mastodon/features/compose/containers/upload_container.js21+++++++++++++++++++++
Mapp/javascript/mastodon/features/compose/containers/upload_form_container.js13++-----------
Mapp/javascript/mastodon/features/ui/components/media_modal.js5+++--
Mapp/javascript/mastodon/features/ui/components/video_modal.js1+
Mapp/javascript/mastodon/features/ui/util/async-components.js4----
Mapp/javascript/mastodon/features/video/index.js4+++-
Mapp/javascript/mastodon/reducers/compose.js19++++++++++++++++---
Mapp/javascript/styles/components.scss47++++++++++++++++++++++++++++++++++++++++-------
Mapp/lib/activitypub/activity/create.rb2+-
Mapp/models/media_attachment.rb7+++++++
Mapp/serializers/activitypub/note_serializer.rb6+++++-
Mapp/serializers/rest/media_attachment_serializer.rb3++-
Mconfig/routes.rb2+-
Adb/migrate/20170927215609_add_description_to_media_attachments.rb5+++++
Mdb/schema.rb3++-
Mspec/controllers/api/v1/media_controller_spec.rb29+++++++++++++++++++++++++++++
Mspec/models/media_attachment_spec.rb9++++++++-
24 files changed, 311 insertions(+), 278 deletions(-)

diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb @@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController respond_to :json 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 rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 @@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController render json: processing_error, status: 500 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 def media_params - params.permit(:file) + params.permit(:file, :description) end def file_type_error diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js @@ -37,6 +37,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; 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) { return { 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() { return { type: COMPOSE_UPLOAD_REQUEST, diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js @@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, + alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, time: PropTypes.number, @@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent { } render () { + const { src, muted, controls, alt } = this.props; + return ( <div className='extended-video-player'> <video ref={this.setRef} - src={this.props.src} + src={src} 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> ); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js @@ -136,7 +136,7 @@ class Item extends React.PureComponent { onClick={this.handleClick} target='_blank' > - <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> + <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} /> </a> ); } else if (attachment.get('type') === 'gifv') { @@ -146,6 +146,7 @@ class Item extends React.PureComponent { <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <video className='media-gallery__item-gifv-thumbnail' + aria-label={attachment.get('description')} role='application' src={attachment.get('url')} onClick={this.handleClick} diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js @@ -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> - ); - } - -} diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -1,49 +1,27 @@ import React from 'react'; 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 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 = { - 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 () { - 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 ( <div className='compose-form__upload-wrapper'> <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> ); } diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js @@ -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); diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js @@ -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); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -76,9 +76,9 @@ export default class MediaModal extends ImmutablePureComponent { const height = image.getIn(['meta', 'original', 'height']) || null; 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') { - 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; @@ -90,6 +90,7 @@ export default class MediaModal extends ImmutablePureComponent { <div className='media-modal__content'> <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight> {content} </ReactSwipeableViews> diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent { src={media.get('url')} startTime={time} onCloseVideo={onClose} + description={media.get('description')} /> </div> </div> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js @@ -90,10 +90,6 @@ export function MediaGallery () { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } -export function VideoPlayer () { - return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); -} - export function Video () { return import(/* webpackChunkName: "features/video" */'../../video'); } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js @@ -104,6 +104,7 @@ export default class Video extends React.PureComponent { static propTypes = { preview: PropTypes.string, src: PropTypes.string.isRequired, + alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, @@ -247,7 +248,7 @@ export default class Video extends React.PureComponent { } 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; return ( @@ -260,6 +261,7 @@ export default class Video extends React.PureComponent { loop role='button' tabIndex='0' + aria-label={alt} width={width} height={height} onClick={this.togglePlay} diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js @@ -22,6 +22,9 @@ import { COMPOSE_VISIBILITY_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -220,15 +223,15 @@ export default function compose(state = initialState, action) { map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: + case COMPOSE_UPLOAD_CHANGE_REQUEST: return state.set('is_submitting', true); case COMPOSE_SUBMIT_SUCCESS: return clearAll(state); case COMPOSE_SUBMIT_FAIL: + case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_submitting', false); case COMPOSE_UPLOAD_REQUEST: - return state.withMutations(map => { - map.set('is_uploading', true); - }); + return state.set('is_uploading', true); case COMPOSE_UPLOAD_SUCCESS: return appendMedia(state, fromJS(action.media)); case COMPOSE_UPLOAD_FAIL: @@ -256,6 +259,16 @@ export default function compose(state = initialState, action) { } case COMPOSE_EMOJI_INSERT: 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: return state; } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss @@ -335,12 +335,52 @@ .compose-form__uploads-wrapper { display: flex; + flex-direction: row; padding: 5px; + flex-wrap: wrap; } .compose-form__upload { flex: 1 1 0; + min-width: 40%; 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 { @@ -352,13 +392,6 @@ width: 100%; } -.compose-form__upload-cancel { - background-size: cover; - border-radius: 4px; - height: 100px; - width: 100px; -} - .compose-form__label { display: block; line-height: 24px; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb @@ -105,7 +105,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? 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? diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb @@ -16,6 +16,7 @@ # shortcode :string # type :integer default("image"), not null # file_meta :json +# description :text # require 'mime/types' @@ -58,6 +59,7 @@ class MediaAttachment < ApplicationRecord validates_attachment_size :file, less_than: 8.megabytes validates :account, presence: true + validates :description, length: { maximum: 140 }, if: :local? scope :attached, -> { where.not(status_id: nil) } scope :unattached, -> { where(status_id: nil) } @@ -78,6 +80,7 @@ class MediaAttachment < ApplicationRecord shortcode end + before_create :prepare_description, unless: :local? before_create :set_shortcode before_post_process :set_type_and_extension before_save :set_meta @@ -136,6 +139,10 @@ class MediaAttachment < ApplicationRecord end end + def prepare_description + self.description = description.strip[0...140] unless description.nil? + end + def set_type_and_extension self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image extension = appropriate_extension diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb @@ -89,12 +89,16 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper - attributes :type, :media_type, :url + attributes :type, :media_type, :url, :name def type 'Document' end + def name + object.description + end + def media_type object.file_content_type end diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb @@ -4,7 +4,8 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper attributes :id, :type, :url, :preview_url, - :remote_url, :text_url, :meta + :remote_url, :text_url, :meta, + :description def id object.id.to_s diff --git a/config/routes.rb b/config/routes.rb @@ -193,7 +193,7 @@ Rails.application.routes.draw do get '/search', to: 'search#index', as: :search resources :follows, only: [:create] - resources :media, only: [:create] + resources :media, only: [:create, :update] resources :apps, only: [:create] resources :blocks, only: [:index] resources :mutes, only: [:index] diff --git a/db/migrate/20170927215609_add_description_to_media_attachments.rb b/db/migrate/20170927215609_add_description_to_media_attachments.rb @@ -0,0 +1,5 @@ +class AddDescriptionToMediaAttachments < ActiveRecord::Migration[5.1] + def change + add_column :media_attachments, :description, :text + end +end diff --git a/db/schema.rb b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "plpgsql" @@ -161,6 +161,7 @@ ActiveRecord::Schema.define(version: 20170924022025) do t.string "shortcode" t.integer "type", default: 0, null: false t.json "file_meta" + t.text "description" t.index ["account_id"], name: "index_media_attachments_on_account_id" t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true t.index ["status_id"], name: "index_media_attachments_on_status_id" diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb @@ -101,4 +101,33 @@ RSpec.describe Api::V1::MediaController, type: :controller do 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 diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb @@ -17,7 +17,6 @@ RSpec.describe MediaAttachment, type: :model do expect(media.file.meta["original"]["height"]).to eq 128 expect(media.file.meta["original"]["aspect"]).to eq 1.0 end - end 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 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