From 442fdbfc5309f46c23a073829e5fe16d10c7c6ca Mon Sep 17 00:00:00 2001
From: Kit Redgrave <qwertyitis@gmail.com>
Date: Sun, 5 Feb 2017 19:51:56 -0600
Subject: Mute button progress so far. WIP, doesn't entirely work correctly.

---
 .../javascripts/components/actions/accounts.jsx    | 78 ++++++++++++++++++++++
 1 file changed, 78 insertions(+)

(limited to 'app/assets/javascripts/components/actions/accounts.jsx')

diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 47c0d9f85..8af0b15d8 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
 export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
 export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
 
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
 export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
 export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
 export const ACCOUNT_TIMELINE_FETCH_FAIL    = 'ACCOUNT_TIMELINE_FETCH_FAIL';
@@ -328,6 +336,76 @@ export function unblockAccountFail(error) {
   };
 };
 
+
+export function muteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error
+  };
+};
+
+
 export function fetchFollowers(id) {
   return (dispatch, getState) => {
     dispatch(fetchFollowersRequest(id));
-- 
cgit 


From caf5b8e9757679b93b9a34b0c55f43cb47910201 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Sat, 4 Mar 2017 22:17:10 +0100
Subject: Fix #431 - convert gif to webm during upload. Web UI treats them like
 it did before. In the API, attachments now can be either image, video or
 gifv. Gifv is to be treated like images in terms of behaviour, but are videos
 by file type.

---
 .../javascripts/components/actions/accounts.jsx    |   7 +-
 .../components/extended_video_player.jsx           |  21 ++
 .../components/components/media_gallery.jsx        | 232 ++++++++++++++-------
 .../javascripts/components/components/status.jsx   |   4 +-
 .../components/components/video_player.jsx         |   4 +-
 .../features/status/components/detailed_status.jsx |   2 +-
 .../features/ui/containers/modal_container.jsx     |  22 +-
 app/assets/stylesheets/stream_entries.scss         |  31 ++-
 app/models/media_attachment.rb                     |  69 +++---
 app/views/api/v1/media/create.rabl                 |   4 +-
 .../stream_entries/_detailed_status.html.haml      |   6 +-
 app/views/stream_entries/_media.html.haml          |   4 +
 app/views/stream_entries/_simple_status.html.haml  |  15 +-
 config/application.rb                              |   5 +-
 ...20170304202101_add_type_to_media_attachments.rb |  12 ++
 db/schema.rb                                       |   3 +-
 lib/paperclip/gif_transcoder.rb                    |  21 ++
 17 files changed, 325 insertions(+), 137 deletions(-)
 create mode 100644 app/assets/javascripts/components/components/extended_video_player.jsx
 create mode 100644 app/views/stream_entries/_media.html.haml
 create mode 100644 db/migrate/20170304202101_add_type_to_media_attachments.rb
 create mode 100644 lib/paperclip/gif_transcoder.rb

(limited to 'app/assets/javascripts/components/actions/accounts.jsx')

diff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 8af0b15d8..05fa8e68d 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -75,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
 export function fetchAccount(id) {
   return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
     dispatch(fetchAccountRequest(id));
 
     api(getState).get(`/api/v1/accounts/${id}`).then(response => {
       dispatch(fetchAccountSuccess(response.data));
-      dispatch(fetchRelationships([id]));
     }).catch(error => {
       dispatch(fetchAccountFail(id, error));
     });
diff --git a/app/assets/javascripts/components/components/extended_video_player.jsx b/app/assets/javascripts/components/components/extended_video_player.jsx
new file mode 100644
index 000000000..66e5dee16
--- /dev/null
+++ b/app/assets/javascripts/components/components/extended_video_player.jsx
@@ -0,0 +1,21 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ExtendedVideoPlayer = React.createClass({
+
+  propTypes: {
+    src: React.PropTypes.string.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div>
+        <video src={this.props.src} autoPlay muted loop />
+      </div>
+    );
+  },
+
+});
+
+export default ExtendedVideoPlayer;
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index b0e397e80..cd2394023 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -43,6 +43,141 @@ const spoilerButtonStyle = {
   zIndex: '100'
 };
 
+const itemStyle = {
+  boxSizing: 'border-box',
+  position: 'relative',
+  float: 'left',
+  border: 'none',
+  display: 'block'
+};
+
+const thumbStyle = {
+  display: 'block',
+  width: '100%',
+  height: '100%',
+  textDecoration: 'none',
+  backgroundSize: 'cover',
+  cursor: 'zoom-in'
+};
+
+const gifvThumbStyle = {
+  position: 'relative',
+  zIndex: '1',
+  width: '100%',
+  height: '100%',
+  objectFit: 'cover',
+  top: '50%',
+  transform: 'translateY(-50%)',
+  cursor: 'zoom-in'
+};
+
+const Item = React.createClass({
+
+  propTypes: {
+    attachment: ImmutablePropTypes.map.isRequired,
+    index: React.PropTypes.number.isRequired,
+    size: React.PropTypes.number.isRequired,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    const { index, onClick } = this.props;
+
+    if (e.button === 0) {
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  },
+
+  render () {
+    const { attachment, index, size } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'image') {
+      thumbnail = (
+        <a
+          href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')}
+          onClick={this.handleClick}
+          target='_blank'
+          style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }}
+        />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      thumbnail = (
+        <video
+          src={attachment.get('url')}
+          onClick={this.handleClick}
+          autoPlay={true}
+          loop={true}
+          muted={true}
+          style={gifvThumbStyle}
+        />
+      );
+    }
+
+    return (
+      <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+});
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
@@ -61,17 +196,12 @@ const MediaGallery = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  handleClick (index, e) {
-    if (e.button === 0) {
-      e.preventDefault();
-      this.props.onOpenMedia(this.props.media, index);
-    }
-
-    e.stopPropagation();
+  handleOpen (e) {
+    this.setState({ visible: !this.state.visible });
   },
 
-  handleOpen () {
-    this.setState({ visible: !this.state.visible });
+  handleClick (index) {
+    this.props.onOpenMedia(this.props.media, index);
   },
 
   render () {
@@ -80,87 +210,31 @@ const MediaGallery = React.createClass({
     let children;
 
     if (!this.state.visible) {
+      let warning;
+
       if (sensitive) {
-        children = (
-          <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
       } else {
-        children = (
-          <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
       }
+
+      children = (
+        <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
+          <span style={spoilerSpanStyle}>{warning}</span>
+          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
     } else {
       const size = media.take(4).size;
-
-      children = media.take(4).map((attachment, i) => {
-        let width  = 50;
-        let height = 100;
-        let top    = 'auto';
-        let left   = 'auto';
-        let bottom = 'auto';
-        let right  = 'auto';
-
-        if (size === 1) {
-          width = 100;
-        }
-
-        if (size === 4 || (size === 3 && i > 0)) {
-          height = 50;
-        }
-
-        if (size === 2) {
-          if (i === 0) {
-            right = '2px';
-          } else {
-            left = '2px';
-          }
-        } else if (size === 3) {
-          if (i === 0) {
-            right = '2px';
-          } else if (i > 0) {
-            left = '2px';
-          }
-
-          if (i === 1) {
-            bottom = '2px';
-          } else if (i > 1) {
-            top = '2px';
-          }
-        } else if (size === 4) {
-          if (i === 0 || i === 2) {
-            right = '2px';
-          }
-
-          if (i === 1 || i === 3) {
-            left = '2px';
-          }
-
-          if (i < 2) {
-            bottom = '2px';
-          } else {
-            top = '2px';
-          }
-        }
-
-        return (
-          <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
-            <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
-          </div>
-        );
-      });
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
     }
 
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
-        <div style={spoilerButtonStyle} >
+        <div style={spoilerButtonStyle}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
         </div>
+
         {children}
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 110d26c6d..fb49db069 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -74,8 +74,8 @@ const Status = React.createClass({
     }
 
     if (status.get('media_attachments').size > 0 && !this.props.muted) {
-      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />;
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video' || (status.get('media_attachments').size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'gifv')) {
+        media = <VideoPlayer media={status.getIn(['media_attachments', 0])} autoplay={status.getIn(['media_attachments', 0, 'type']) === 'gifv'} sensitive={status.get('sensitive')} />;
       } else {
         media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
       }
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index 5c2cef21a..df09da912 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -175,7 +175,7 @@ const VideoPlayer = React.createClass({
         );
       } else {
         return (
-          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleOpen}>
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
             <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@@ -197,7 +197,7 @@ const VideoPlayer = React.createClass({
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
         {spoilerButton}
         {muteButton}
-        <video ref={this.setRef} src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
+        <video ref={this.setRef} src={media.get('url')} autoPlay={true} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index caa46ff3c..623872953 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -38,7 +38,7 @@ const DetailedStatus = React.createClass({
     let applicationLink = '';
 
     if (status.get('media_attachments').size > 0) {
-      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video' || (status.get('media_attachments').size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'gifv')) {
         media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />;
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index d8301b20f..e3c4281b9 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -9,6 +9,7 @@ import ImageLoader from 'react-imageloader';
 import LoadingIndicator from '../../../components/loading_indicator';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
 
 const mapStateToProps = state => ({
   media: state.getIn(['modal', 'media']),
@@ -131,27 +132,34 @@ const Modal = React.createClass({
       return null;
     }
 
-    const url = media.get(index).get('url');
+    const attachment = media.get(index);
+    const url = attachment.get('url');
 
-    let leftNav, rightNav;
+    let leftNav, rightNav, content;
 
-    leftNav = rightNav = '';
+    leftNav = rightNav = content = '';
 
     if (media.size > 1) {
       leftNav  = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
       rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
     }
 
-    return (
-      <Lightbox {...other}>
-        {leftNav}
-
+    if (attachment.get('type') === 'image') {
+      content = (
         <ImageLoader
           src={url}
           preloader={preloader}
           imgProps={{ style: imageStyle }}
         />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} />;
+    }
 
+    return (
+      <Lightbox {...other}>
+        {leftNav}
+        {content}
         {rightNav}
       </Lightbox>
     );
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index 3b2e88f6d..b9a9a1da3 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -104,8 +104,12 @@
       overflow: hidden;
       width: 100%;
       box-sizing: border-box;
-      height: 110px;
-      display: flex;
+      position: relative;
+
+      .status__attachments__inner {
+        display: flex;
+        height: 214px;
+      }
     }
   }
 
@@ -184,8 +188,12 @@
       overflow: hidden;
       width: 100%;
       box-sizing: border-box;
-      height: 300px;
-      display: flex;
+      position: relative;
+
+      .status__attachments__inner {
+        display: flex;
+        height: 360px;
+      }
     }
 
     .video-player {
@@ -231,11 +239,19 @@
       text-decoration: none;
       cursor: zoom-in;
     }
+
+    video {
+      position: relative;
+      z-index: 1;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      top: 50%;
+      transform: translateY(-50%);
+    }
   }
 
   .video-item {
-    max-width: 196px;
-
     a {
       cursor: pointer;
     }
@@ -258,6 +274,9 @@
     width: 100%;
     height: 100%;
     cursor: pointer;
+    position: absolute;
+    top: 0;
+    left: 0;
     display: flex;
     align-items: center;
     justify-content: center;
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6925f9b0d..620a92dbc 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -1,15 +1,32 @@
 # frozen_string_literal: true
 
 class MediaAttachment < ApplicationRecord
+  self.inheritance_column = nil
+
+  enum type: [:image, :gifv, :video]
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
 
+  IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  VIDEO_STYLES = {
+    small: {
+      convert_options: {
+        output: {
+          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+        },
+      },
+      format: 'png',
+      time: 0,
+    },
+  }.freeze
+
   belongs_to :account, inverse_of: :media_attachments
   belongs_to :status,  inverse_of: :media_attachments
 
   has_attached_file :file,
-                    styles: -> (f) { file_styles f },
-                    processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
+                    styles: ->(f) { file_styles f },
+                    processors: ->(f) { file_processors f },
                     convert_options: { all: '-quality 90 -strip' }
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
   validates_attachment_size :file, less_than: 8.megabytes
@@ -27,45 +44,45 @@ class MediaAttachment < ApplicationRecord
     self.file = URI.parse(url)
   end
 
-  def image?
-    IMAGE_MIME_TYPES.include? file_content_type
-  end
-
-  def video?
-    VIDEO_MIME_TYPES.include? file_content_type
-  end
-
-  def type
-    image? ? 'image' : 'video'
-  end
-
   def to_param
     shortcode
   end
 
   before_create :set_shortcode
+  before_post_process :set_type
 
   class << self
     private
 
     def file_styles(f)
-      if f.instance.image?
+      if f.instance.file_content_type == 'image/gif'
         {
-          original: '1280x1280>',
-          small: '400x400>',
-        }
-      else
-        {
-          small: {
+          small: IMAGE_STYLES[:small],
+          original: {
+            format: 'webm',
             convert_options: {
               output: {
-                vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+                'c:v' => 'libvpx',
+                'crf' => 6,
+                'b:v' => '500K',
               },
             },
-            format: 'png',
-            time: 1,
           },
         }
+      elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
+        IMAGE_STYLES
+      else
+        VIDEO_STYLES
+      end
+    end
+
+    def file_processors(f)
+      if f.file_content_type == 'image/gif'
+        [:gif_transcoder]
+      elsif VIDEO_MIME_TYPES.include? f.file_content_type
+        [:transcoder]
+      else
+        [:thumbnail]
       end
     end
   end
@@ -80,4 +97,8 @@ class MediaAttachment < ApplicationRecord
       break if MediaAttachment.find_by(shortcode: shortcode).nil?
     end
   end
+
+  def set_type
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
+  end
 end
diff --git a/app/views/api/v1/media/create.rabl b/app/views/api/v1/media/create.rabl
index 0b42e6e3d..916217cbd 100644
--- a/app/views/api/v1/media/create.rabl
+++ b/app/views/api/v1/media/create.rabl
@@ -1,5 +1,5 @@
 object @media
 attribute :id, :type
-node(:url) { |media| full_asset_url(media.file.url( :original)) }
-node(:preview_url) { |media| full_asset_url(media.file.url( :small)) }
+node(:url) { |media| full_asset_url(media.file.url(:original)) }
+node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
 node(:text_url) { |media| medium_url(media) }
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 6c1c1ce84..8c0456b1f 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -22,9 +22,9 @@
       .detailed-status__attachments
         - if status.sensitive?
           = render partial: 'stream_entries/content_spoiler'
-        - status.media_attachments.each do |media|
-          .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+        .status__attachments__inner
+          - status.media_attachments.each do |media|
+            = render partial: 'stream_entries/media', locals: { media: media }
 
   %div.detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml
new file mode 100644
index 000000000..cd7faa700
--- /dev/null
+++ b/app/views/stream_entries/_media.html.haml
@@ -0,0 +1,4 @@
+.media-item
+  = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
+    - unless media.image?
+      %video{ src: media.file.url(:original), autoplay: true, loop: true }/
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 52ad39220..cb2c976ce 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -22,11 +22,12 @@
       - if status.sensitive?
         = render partial: 'stream_entries/content_spoiler'
       - if status.media_attachments.first.video?
-        .video-item
-          = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
-            .video-item__play
-              = fa_icon('play')
+        .status__attachments__inner
+          .video-item
+            = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
+              .video-item__play
+                = fa_icon('play')
       - else
-        - status.media_attachments.each do |media|
-          .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+        .status__attachments__inner
+          - status.media_attachments.each do |media|
+            = render partial: 'stream_entries/media', locals: { media: media }
diff --git a/config/application.rb b/config/application.rb
index 1ea65619c..30ed608c5 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -2,12 +2,13 @@ require_relative 'boot'
 
 require 'rails/all'
 
-require_relative '../app/lib/exceptions'
-
 # Require the gems listed in Gemfile, including any gems
 # you've limited to :test, :development, or :production.
 Bundler.require(*Rails.groups)
 
+require_relative '../app/lib/exceptions'
+require_relative '../lib/paperclip/gif_transcoder'
+
 Dotenv::Railtie.load
 
 module Mastodon
diff --git a/db/migrate/20170304202101_add_type_to_media_attachments.rb b/db/migrate/20170304202101_add_type_to_media_attachments.rb
new file mode 100644
index 000000000..514079958
--- /dev/null
+++ b/db/migrate/20170304202101_add_type_to_media_attachments.rb
@@ -0,0 +1,12 @@
+class AddTypeToMediaAttachments < ActiveRecord::Migration[5.0]
+  def up
+    add_column :media_attachments, :type, :integer, default: 0, null: false
+
+    MediaAttachment.where(file_content_type: MediaAttachment::IMAGE_MIME_TYPES).update_all(type: MediaAttachment.types[:image])
+    MediaAttachment.where(file_content_type: MediaAttachment::VIDEO_MIME_TYPES).update_all(type: MediaAttachment.types[:video])
+  end
+
+  def down
+    remove_column :media_attachments, :type
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8cc3bd8e3..4ec85ef2b 100644
--- 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: 20170303212857) do
+ActiveRecord::Schema.define(version: 20170304202101) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170303212857) do
     t.datetime "created_at",                     null: false
     t.datetime "updated_at",                     null: false
     t.string   "shortcode"
+    t.integer  "type",              default: 0,  null: false
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
     t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
   end
diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb
new file mode 100644
index 000000000..33d2c4a01
--- /dev/null
+++ b/lib/paperclip/gif_transcoder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Paperclip
+  # This transcoder is only to be used for the MediaAttachment model
+  # to convert animated gifs to webm
+  class GifTranscoder < Paperclip::Processor
+    def make
+      num_frames = identify('-format %n :file', file: file.path).to_i
+
+      return file unless options[:style] == :original && num_frames > 1
+
+      final_file = Paperclip::Transcoder.make(file, options, attachment)
+
+      attachment.instance.file_file_name    = 'media.webm'
+      attachment.instance.file_content_type = 'video/webm'
+      attachment.instance.type              = MediaAttachment.types[:gifv]
+
+      final_file
+    end
+  end
+end
-- 
cgit 

