about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/components/components/account.jsx2
-rw-r--r--app/assets/javascripts/components/components/avatar.jsx135
-rw-r--r--app/assets/javascripts/components/components/status.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/navigation_bar.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/reply_indicator.jsx2
-rw-r--r--app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx2
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx2
-rw-r--r--app/assets/stylesheets/components.scss10
-rw-r--r--app/models/account.rb32
-rw-r--r--app/views/api/v1/accounts/show.rabl11
-rw-r--r--lib/tasks/mastodon.rake12
-rw-r--r--spec/fixtures/files/avatar.gifbin0 -> 85810 bytes
-rw-r--r--spec/javascript/components/avatar.test.jsx12
-rw-r--r--spec/models/account_spec.rb20
15 files changed, 108 insertions, 138 deletions
diff --git a/app/assets/javascripts/components/components/account.jsx b/app/assets/javascripts/components/components/account.jsx
index 7a1c9f5ce..782cf382d 100644
--- a/app/assets/javascripts/components/components/account.jsx
+++ b/app/assets/javascripts/components/components/account.jsx
@@ -65,7 +65,7 @@ const Account = React.createClass({
       <div className='account'>
         <div style={{ display: 'flex' }}>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
-            <div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={36} /></div>
+            <div style={{ float: 'left', marginLeft: '12px', marginRight: '10px' }}><Avatar src={account.get('avatar')} staticSrc={status.getIn(['account', 'avatar_static'])} size={36} /></div>
             <DisplayName account={account} />
           </Permalink>
 
diff --git a/app/assets/javascripts/components/components/avatar.jsx b/app/assets/javascripts/components/components/avatar.jsx
index 0237a1904..673b1a247 100644
--- a/app/assets/javascripts/components/components/avatar.jsx
+++ b/app/assets/javascripts/components/components/avatar.jsx
@@ -1,103 +1,18 @@
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 
-// From: http://stackoverflow.com/a/18320662
-const resample = (canvas, width, height, resize_canvas) => {
-  let width_source  = canvas.width;
-  let height_source = canvas.height;
-  width  = Math.round(width);
-  height = Math.round(height);
-
-  let ratio_w      = width_source / width;
-  let ratio_h      = height_source / height;
-  let ratio_w_half = Math.ceil(ratio_w / 2);
-  let ratio_h_half = Math.ceil(ratio_h / 2);
-
-  let ctx   = canvas.getContext("2d");
-  let img   = ctx.getImageData(0, 0, width_source, height_source);
-  let img2  = ctx.createImageData(width, height);
-  let data  = img.data;
-  let data2 = img2.data;
-
-  for (let j = 0; j < height; j++) {
-    for (let i = 0; i < width; i++) {
-      let x2            = (i + j * width) * 4;
-      let weight        = 0;
-      let weights       = 0;
-      let weights_alpha = 0;
-      let gx_r          = 0;
-      let gx_g          = 0;
-      let gx_b          = 0;
-      let gx_a          = 0;
-      let center_y      = (j + 0.5) * ratio_h;
-      let yy_start      = Math.floor(j * ratio_h);
-      let yy_stop       = Math.ceil((j + 1) * ratio_h);
-
-      for (let yy = yy_start; yy < yy_stop; yy++) {
-        let dy       = Math.abs(center_y - (yy + 0.5)) / ratio_h_half;
-        let center_x = (i + 0.5) * ratio_w;
-        let w0       = dy * dy; //pre-calc part of w
-        let xx_start = Math.floor(i * ratio_w);
-        let xx_stop  = Math.ceil((i + 1) * ratio_w);
-
-        for (let xx = xx_start; xx < xx_stop; xx++) {
-          let dx = Math.abs(center_x - (xx + 0.5)) / ratio_w_half;
-          let w  = Math.sqrt(w0 + dx * dx);
-
-          if (w >= 1) {
-            // pixel too far
-            continue;
-          }
-
-          // hermite filter
-          weight    = 2 * w * w * w - 3 * w * w + 1;
-          let pos_x = 4 * (xx + yy * width_source);
-
-          // alpha
-          gx_a          += weight * data[pos_x + 3];
-          weights_alpha += weight;
-
-          // colors
-          if (data[pos_x + 3] < 255)
-            weight = weight * data[pos_x + 3] / 250;
-
-          gx_r    += weight * data[pos_x];
-          gx_g    += weight * data[pos_x + 1];
-          gx_b    += weight * data[pos_x + 2];
-          weights += weight;
-        }
-      }
-
-      data2[x2]     = gx_r / weights;
-      data2[x2 + 1] = gx_g / weights;
-      data2[x2 + 2] = gx_b / weights;
-      data2[x2 + 3] = gx_a / weights_alpha;
-    }
-  }
-
-  // clear and resize canvas
-  if (resize_canvas === true) {
-    canvas.width  = width;
-    canvas.height = height;
-  } else {
-    ctx.clearRect(0, 0, width_source, height_source);
-  }
-
-  // draw
-  ctx.putImageData(img2, 0, 0);
-};
-
 const Avatar = React.createClass({
 
   propTypes: {
     src: React.PropTypes.string.isRequired,
+    staticSrc: React.PropTypes.string,
     size: React.PropTypes.number.isRequired,
     style: React.PropTypes.object,
-    animated: React.PropTypes.bool
+    animate: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
-      animated: true
+      animate: false
     };
   },
 
@@ -117,38 +32,30 @@ const Avatar = React.createClass({
     this.setState({ hovering: false });
   },
 
-  handleLoad () {
-    this.canvas.width  = this.image.naturalWidth;
-    this.canvas.height = this.image.naturalHeight;
-    this.canvas.getContext('2d').drawImage(this.image, 0, 0);
-
-    resample(this.canvas, this.props.size * window.devicePixelRatio, this.props.size * window.devicePixelRatio, true);
-  },
-
-  setImageRef (c) {
-    this.image = c;
-  },
-
-  setCanvasRef (c) {
-    this.canvas = c;
-  },
-
   render () {
+    const { src, size, staticSrc, animate } = this.props;
     const { hovering } = this.state;
 
-    if (this.props.animated) {
-      return (
-        <div style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px` }}>
-          <img src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ borderRadius: '4px' }} />
-        </div>
-      );
+    const style = {
+      ...this.props.style,
+      width: `${size}px`,
+      height: `${size}px`,
+      backgroundSize: `${size}px ${size}px`
+    };
+
+    if (hovering || animate) {
+      style.backgroundImage = `url(${src})`;
+    } else {
+      style.backgroundImage = `url(${staticSrc})`;
     }
 
     return (
-      <div onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} style={{ ...this.props.style, width: `${this.props.size}px`, height: `${this.props.size}px`, position: 'relative' }}>
-        <img ref={this.setImageRef} onLoad={this.handleLoad} src={this.props.src} width={this.props.size} height={this.props.size} alt='' style={{ position: 'absolute', top: '0', left: '0', opacity: hovering ? '1' : '0', borderRadius: '4px' }} />
-        <canvas ref={this.setCanvasRef} style={{ borderRadius: '4px', width: this.props.size, height: this.props.size, opacity: hovering ? '0' : '1' }} />
-      </div>
+      <div
+        className='avatar'
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        style={style}
+      />
     );
   }
 
diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx
index 110d26c6d..65db8f79b 100644
--- a/app/assets/javascripts/components/components/status.jsx
+++ b/app/assets/javascripts/components/components/status.jsx
@@ -90,7 +90,7 @@ const Status = React.createClass({
 
           <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px' }}>
             <div className='status__avatar' style={{ position: 'absolute', left: '10px', top: '10px', width: '48px', height: '48px' }}>
-              <Avatar src={status.getIn(['account', 'avatar'])} size={48} />
+              <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
             </div>
 
             <DisplayName account={status.get('account')} />
diff --git a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
index 5591b45cf..9e05193fb 100644
--- a/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
+++ b/app/assets/javascripts/components/features/compose/components/autosuggest_account.jsx
@@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 
 const AutosuggestAccount = ({ account }) => (
   <div style={{ overflow: 'hidden' }} className='autosuggest-account'>
-    <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} size={18} /></div>
+    <div style={{ float: 'left', marginRight: '5px' }}><Avatar src={account.get('avatar')} staticSrc={status.getIn(['account', 'avatar_static'])} size={18} /></div>
     <DisplayName account={account} />
   </div>
 );
diff --git a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
index 076ac7cbb..1a748a23c 100644
--- a/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
+++ b/app/assets/javascripts/components/features/compose/components/navigation_bar.jsx
@@ -17,7 +17,7 @@ const NavigationBar = React.createClass({
   render () {
     return (
       <div className='navigation-bar'>
-        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} size={40} /></Permalink>
+        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`} style={{ textDecoration: 'none' }}><Avatar src={this.props.account.get('avatar')} animate size={40} /></Permalink>
 
         <div style={{ flex: '1 1 auto', marginLeft: '8px' }}>
           <strong style={{ fontWeight: '500', display: 'block' }}>{this.props.account.get('acct')}</strong>
diff --git a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
index a72bd32c2..11a89449e 100644
--- a/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
+++ b/app/assets/javascripts/components/features/compose/components/reply_indicator.jsx
@@ -50,7 +50,7 @@ const ReplyIndicator = React.createClass({
           <div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
 
           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
-            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} /></div>
+            <div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
             <DisplayName account={status.get('account')} />
           </a>
         </div>
diff --git a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx
index 1766655c2..9c713287c 100644
--- a/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx
+++ b/app/assets/javascripts/components/features/follow_requests/components/account_authorize.jsx
@@ -33,7 +33,7 @@ const AccountAuthorize = ({ intl, account, onAuthorize, onReject }) => {
     <div>
       <div style={outerStyle}>
         <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
-          <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} size={48} /></div>
+          <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={account.get('avatar')} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
           <DisplayName account={account} />
         </Permalink>
 
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..2da57252e 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -54,7 +54,7 @@ const DetailedStatus = React.createClass({
     return (
       <div style={{ padding: '14px 10px' }} className='detailed-status'>
         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name' style={{ display: 'block', overflow: 'hidden', marginBottom: '15px' }}>
-          <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} size={48} /></div>
+          <div style={{ float: 'left', marginRight: '10px' }}><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
           <DisplayName account={status.get('account')} />
         </a>
 
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 95e432cb6..8c76ddf99 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,7 +1,7 @@
 @import 'variables';
 
 .app-body{
- -ms-overflow-style: -ms-autohiding-scrollbar; 
+ -ms-overflow-style: -ms-autohiding-scrollbar;
 }
 
 .button {
@@ -165,6 +165,14 @@
   }
 }
 
+.avatar {
+  border-radius: 4px;
+  background: transparent no-repeat;
+  background-position: 50%;
+  background-clip: padding-box;
+  position: relative;
+}
+
 .lightbox .icon-button {
   color: $color1;
 }
diff --git a/app/models/account.rb b/app/models/account.rb
index a482fc8e6..8ceda7f97 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -12,12 +12,12 @@ class Account < ApplicationRecord
   validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?'
 
   # Avatar upload
-  has_attached_file :avatar, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-quality 80 -strip' }
   validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :avatar, less_than: 2.megabytes
 
   # Header upload
-  has_attached_file :header, styles: { original: '700x335#' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-quality 80 -strip' }
   validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :header, less_than: 2.megabytes
 
@@ -158,6 +158,22 @@ class Account < ApplicationRecord
     save!
   end
 
+  def avatar_original_url
+    avatar.url(:original)
+  end
+
+  def avatar_static_url
+    avatar_content_type == 'image/gif' ? avatar.url(:static) : avatar_original_url
+  end
+
+  def header_original_url
+    header.url(:original)
+  end
+
+  def header_static_url
+    header_content_type == 'image/gif' ? header.url(:static) : header_original_url
+  end
+
   def avatar_remote_url=(url)
     parsed_url = URI.parse(url)
 
@@ -292,6 +308,18 @@ class Account < ApplicationRecord
     def follow_mapping(query, field)
       query.pluck(field).inject({}) { |mapping, id| mapping[id] = true; mapping }
     end
+
+    def avatar_styles(file)
+      styles = { original: '120x120#' }
+      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles
+    end
+
+    def header_styles(file)
+      styles = { original: '700x335#' }
+      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles
+    end
   end
 
   before_create do
diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl
index 32df0457a..8826aa22d 100644
--- a/app/views/api/v1/accounts/show.rabl
+++ b/app/views/api/v1/accounts/show.rabl
@@ -4,8 +4,9 @@ attributes :id, :username, :acct, :display_name, :locked, :created_at
 
 node(:note)            { |account| Formatter.instance.simplified_format(account) }
 node(:url)             { |account| TagManager.instance.url_for(account) }
-node(:avatar)          { |account| full_asset_url(account.avatar.url(:original)) }
-node(:header)          { |account| full_asset_url(account.header.url(:original)) }
-node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : account.followers_count }
-node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : account.following_count }
-node(:statuses_count)  { |account| defined?(@statuses_counts_map)  ? (@statuses_counts_map[account.id]  || 0) : account.statuses_count }
+node(:avatar)          { |account| full_asset_url(account.avatar_original_url) }
+node(:avatar_static)   { |account| full_asset_url(account.avatar_static_url) }
+node(:header)          { |account| full_asset_url(account.header_original_url) }
+node(:header_static)   { |account| full_asset_url(account.header_static_url) }
+
+attributes :followers_count, :following_count, :statuses_count
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 037a13398..a8fb58b7f 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -92,5 +92,17 @@ namespace :mastodon do
 
       Rails.logger.debug 'Done!'
     end
+
+    desc 'Generate static versions of GIF avatars/headers'
+    task add_static_avatars: :environment do
+      Rails.logger.debug 'Generating static avatars/headers for GIF ones...'
+
+      Account.unscoped.where(avatar_content_type: 'image/gif').or(Account.unscoped.where(header_content_type: 'image/gif')).find_each do |account|
+        account.avatar.reprocess!
+        account.header.reprocess!
+      end
+
+      Rails.logger.debug 'Done!'
+    end
   end
 end
diff --git a/spec/fixtures/files/avatar.gif b/spec/fixtures/files/avatar.gif
new file mode 100644
index 000000000..d929801e5
--- /dev/null
+++ b/spec/fixtures/files/avatar.gif
Binary files differdiff --git a/spec/javascript/components/avatar.test.jsx b/spec/javascript/components/avatar.test.jsx
index 852e13a89..7131bbec7 100644
--- a/spec/javascript/components/avatar.test.jsx
+++ b/spec/javascript/components/avatar.test.jsx
@@ -6,16 +6,10 @@ import Avatar from '../../../app/assets/javascripts/components/components/avatar
 describe('<Avatar />', () => {
   const src = '/path/to/image.jpg';
   const size = 100;
-  const wrapper = render(<Avatar src={src} size={size} />);
+  const wrapper = render(<Avatar src={src} animate size={size} />);
 
-  it('renders an img element with the given src', () => {
-    expect(wrapper.find('img')).to.have.attr('src', `${src}`);
-  });
-
-  it('renders an img element of the given size', () => {
-    ['width', 'height'].map((attr) => {
-      expect(wrapper.find('img')).to.have.attr(attr, `${size}`);
-    });
+  it('renders a div element with the given src as background', () => {
+    expect(wrapper.find('div')).to.have.style('background-image', `url(${src})`);
   });
 
   it('renders a div element of the given size', () => {
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 0906bb0ae..fb367ab7a 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -421,4 +421,24 @@ RSpec.describe Account, type: :model do
       end
     end
   end
+
+  describe 'static avatars' do
+    describe 'when GIF' do
+      it 'creates a png static style' do
+        subject.avatar = attachment_fixture('avatar.gif')
+        subject.save
+
+        expect(subject.avatar_static_url).to_not eq subject.avatar_original_url
+      end
+    end
+
+    describe 'when non-GIF' do
+      it 'does not create extra static style' do
+        subject.avatar = attachment_fixture('attachment.jpg')
+        subject.save
+
+        expect(subject.avatar_static_url).to eq subject.avatar_original_url
+      end
+    end
+  end
 end