about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/assets/images/void.pngbin0 -> 180 bytes
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx76
-rw-r--r--app/assets/javascripts/components/components/button.jsx10
-rw-r--r--app/assets/javascripts/components/components/compose_form.jsx3
-rw-r--r--app/assets/javascripts/components/components/frontend.jsx2
-rw-r--r--app/assets/javascripts/components/components/upload_button.jsx37
-rw-r--r--app/assets/javascripts/components/components/upload_form.jsx41
-rw-r--r--app/assets/javascripts/components/containers/upload_form_container.jsx25
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx26
-rw-r--r--app/assets/stylesheets/components.scss17
-rw-r--r--app/services/post_status_service.rb2
-rw-r--r--app/views/api/media/create.rabl1
12 files changed, 222 insertions, 18 deletions
diff --git a/app/assets/images/void.png b/app/assets/images/void.png
new file mode 100644
index 000000000..7eeee75af
--- /dev/null
+++ b/app/assets/images/void.png
Binary files differdiff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 4954f0f5c..a107b3352 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -1,12 +1,18 @@
 import api from '../api'
 
-export const COMPOSE_CHANGE         = 'COMPOSE_CHANGE';
-export const COMPOSE_SUBMIT         = 'COMPOSE_SUBMIT';
-export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
-export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
-export const COMPOSE_SUBMIT_FAIL    = 'COMPOSE_SUBMIT_FAIL';
-export const COMPOSE_REPLY          = 'COMPOSE_REPLY';
-export const COMPOSE_REPLY_CANCEL   = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
+export const COMPOSE_SUBMIT          = 'COMPOSE_SUBMIT';
+export const COMPOSE_SUBMIT_REQUEST  = 'COMPOSE_SUBMIT_REQUEST';
+export const COMPOSE_SUBMIT_SUCCESS  = 'COMPOSE_SUBMIT_SUCCESS';
+export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
+export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
+export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
+export const COMPOSE_UPLOAD          = 'COMPOSE_UPLOAD';
+export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
+export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS';
+export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL';
+export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
+export const COMPOSE_UPLOAD_UNDO     = 'COMPOSE_UPLOAD_UNDO';
 
 export function changeCompose(text) {
   return {
@@ -34,7 +40,8 @@ export function submitCompose() {
 
     api(getState).post('/api/statuses', {
       status: getState().getIn(['compose', 'text'], ''),
-      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null)
+      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
+      media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id'))
     }).then(function (response) {
       dispatch(submitComposeSuccess(response.data));
     }).catch(function (error) {
@@ -63,3 +70,56 @@ export function submitComposeFail(error) {
   };
 }
 
+export function uploadCompose(files) {
+  return function (dispatch, getState) {
+    dispatch(uploadComposeRequest());
+
+    let data = new FormData();
+    data.append('file', files[0]);
+
+    api(getState).post('/api/media', data, {
+      onUploadProgress: function (e) {
+        dispatch(uploadComposeProgress(e.loaded, e.total));
+      }
+    }).then(function (response) {
+      dispatch(uploadComposeSuccess(response.data));
+    }).catch(function (error) {
+      dispatch(uploadComposeFail(error));
+    });
+  };
+}
+
+export function uploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_REQUEST
+  };
+}
+
+export function uploadComposeProgress(loaded, total) {
+  return {
+    type: COMPOSE_UPLOAD_PROGRESS,
+    loaded: loaded,
+    total: total
+  };
+}
+
+export function uploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_SUCCESS,
+    media: media
+  };
+}
+
+export function uploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_FAIL,
+    error: error
+  };
+}
+
+export function undoUploadCompose(media_id) {
+  return {
+    type: COMPOSE_UPLOAD_UNDO,
+    media_id: media_id
+  };
+}
diff --git a/app/assets/javascripts/components/components/button.jsx b/app/assets/javascripts/components/components/button.jsx
index 1a55dd879..6f7ace323 100644
--- a/app/assets/javascripts/components/components/button.jsx
+++ b/app/assets/javascripts/components/components/button.jsx
@@ -3,9 +3,11 @@ import PureRenderMixin from 'react-addons-pure-render-mixin';
 const Button = React.createClass({
 
   propTypes: {
-    text: React.PropTypes.string.isRequired,
+    text: React.PropTypes.string,
     onClick: React.PropTypes.func,
-    disabled: React.PropTypes.bool
+    disabled: React.PropTypes.bool,
+    block: React.PropTypes.bool,
+    secondary: React.PropTypes.bool
   },
 
   mixins: [PureRenderMixin],
@@ -18,8 +20,8 @@ const Button = React.createClass({
 
   render () {
     return (
-      <button className='button' disabled={this.props.disabled} onClick={this.handleClick} style={{ fontFamily: 'Roboto', display: 'inline-block', position: 'relative', boxSizing: 'border-box', textAlign: 'center', border: '10px none', color: '#fff', fontSize: '14px', fontWeight: '500', letterSpacing: '0', textTransform: 'uppercase', padding: '0 16px', height: '36px', cursor: 'pointer', lineHeight: '36px', borderRadius: '4px', textDecoration: 'none' }}>
-        {this.props.text}
+      <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ fontFamily: 'Roboto', display: this.props.block ? 'block' : 'inline-block', width: this.props.block ? '100%' : 'auto', position: 'relative', boxSizing: 'border-box', textAlign: 'center', border: '10px none', color: '#fff', fontSize: '14px', fontWeight: '500', letterSpacing: '0', textTransform: 'uppercase', padding: '0 16px', height: '36px', cursor: 'pointer', lineHeight: '36px', borderRadius: '4px', textDecoration: 'none' }}>
+        {this.props.text || this.props.children}
       </button>
     );
   }
diff --git a/app/assets/javascripts/components/components/compose_form.jsx b/app/assets/javascripts/components/components/compose_form.jsx
index 641412012..40d9b7773 100644
--- a/app/assets/javascripts/components/components/compose_form.jsx
+++ b/app/assets/javascripts/components/components/compose_form.jsx
@@ -3,6 +3,7 @@ import Button             from './button';
 import PureRenderMixin    from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ReplyIndicator     from './reply_indicator';
+import UploadButton       from './upload_button';
 
 const ComposeForm = React.createClass({
 
@@ -39,7 +40,7 @@ const ComposeForm = React.createClass({
     }
 
     return (
-      <div style={{ marginBottom: '30px', padding: '10px' }}>
+      <div style={{ padding: '10px' }}>
         {replyArea}
 
         <textarea disabled={this.props.is_submitting} placeholder='What is on your mind?' value={this.props.text} onKeyUp={this.handleKeyUp} onChange={this.handleChange} className='compose-form__textarea' style={{ display: 'block', boxSizing: 'border-box', width: '100%', height: '100px', resize: 'none', border: 'none', color: '#282c37', padding: '10px', fontFamily: 'Roboto', fontSize: '14px', margin: '0' }} />
diff --git a/app/assets/javascripts/components/components/frontend.jsx b/app/assets/javascripts/components/components/frontend.jsx
index 3532b6674..c227bcc1f 100644
--- a/app/assets/javascripts/components/components/frontend.jsx
+++ b/app/assets/javascripts/components/components/frontend.jsx
@@ -2,6 +2,7 @@ import ColumnsArea          from './columns_area';
 import Drawer               from './drawer';
 import ComposeFormContainer from '../containers/compose_form_container';
 import FollowFormContainer  from '../containers/follow_form_container';
+import UploadFormContainer  from '../containers/upload_form_container';
 import PureRenderMixin      from 'react-addons-pure-render-mixin';
 
 const Frontend = React.createClass({
@@ -14,6 +15,7 @@ const Frontend = React.createClass({
         <Drawer>
           <div style={{ flex: '1 1 auto' }}>
             <ComposeFormContainer />
+            <UploadFormContainer />
           </div>
 
           <FollowFormContainer />
diff --git a/app/assets/javascripts/components/components/upload_button.jsx b/app/assets/javascripts/components/components/upload_button.jsx
new file mode 100644
index 000000000..295c3b855
--- /dev/null
+++ b/app/assets/javascripts/components/components/upload_button.jsx
@@ -0,0 +1,37 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import Button          from './button';
+
+const UploadButton = React.createClass({
+
+  propTypes: {
+    disabled: React.PropTypes.bool,
+    onSelectFile: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleChange (e) {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  },
+
+  handleClick () {
+    this.refs.fileElement.click();
+  },
+
+  render () {
+    return (
+      <div>
+        <Button disabled={this.props.disabled} onClick={this.handleClick} block={true}>
+          <i className='fa fa-fw fa-photo' /> Add images
+        </Button>
+
+        <input ref='fileElement' type='file' onChange={this.handleChange} disabled={this.props.disabled} style={{ display: 'none' }} />
+      </div>
+    );
+  }
+
+});
+
+export default UploadButton;
diff --git a/app/assets/javascripts/components/components/upload_form.jsx b/app/assets/javascripts/components/components/upload_form.jsx
new file mode 100644
index 000000000..429e672c0
--- /dev/null
+++ b/app/assets/javascripts/components/components/upload_form.jsx
@@ -0,0 +1,41 @@
+import PureRenderMixin    from 'react-addons-pure-render-mixin';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import UploadButton       from './upload_button';
+import IconButton         from './icon_button';
+
+const UploadForm = React.createClass({
+
+  propTypes: {
+    media: ImmutablePropTypes.list.isRequired,
+    is_uploading: React.PropTypes.bool,
+    onSelectFile: React.PropTypes.func.isRequired,
+    onRemoveFile: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    let uploads = this.props.media.map(function (attachment) {
+      return (
+        <div key={attachment.get('id')} style={{ borderRadius: '4px', marginBottom: '10px' }} className='transparent-background'>
+          <div style={{ width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
+            <IconButton icon='times' title='Undo' size={36} onClick={() => this.props.onRemoveFile(attachment.get('id'))} />
+          </div>
+        </div>
+      );
+    }.bind(this));
+
+    return (
+      <div style={{ marginBottom: '20px', padding: '10px', paddingTop: '0' }}>
+        <UploadButton onSelectFile={this.props.onSelectFile} disabled={this.props.is_uploading || this.props.media.size > 3} />
+
+        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
+          {uploads}
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default UploadForm;
diff --git a/app/assets/javascripts/components/containers/upload_form_container.jsx b/app/assets/javascripts/components/containers/upload_form_container.jsx
new file mode 100644
index 000000000..b168e8187
--- /dev/null
+++ b/app/assets/javascripts/components/containers/upload_form_container.jsx
@@ -0,0 +1,25 @@
+import { connect }                          from 'react-redux';
+import UploadForm                           from '../components/upload_form';
+import { uploadCompose, undoUploadCompose } from '../actions/compose';
+
+const mapStateToProps = function (state, props) {
+  return {
+    media: state.getIn(['compose', 'media_attachments']),
+    progress: state.getIn(['compose', 'progress']),
+    is_uploading: state.getIn(['compose', 'is_uploading'])
+  };
+};
+
+const mapDispatchToProps = function (dispatch) {
+  return {
+    onSelectFile: function (files) {
+      dispatch(uploadCompose(files));
+    },
+
+    onRemoveFile: function (media_id) {
+      dispatch(undoUploadCompose(media_id));
+    }
+  }
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index 5dd093e9a..b4a21b874 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -5,7 +5,10 @@ import Immutable           from 'immutable';
 const initialState = Immutable.Map({
   text: '',
   in_reply_to: null,
-  is_submitting: false
+  is_submitting: false,
+  is_uploading: false,
+  progress: 0,
+  media_attachments: Immutable.List([])
 });
 
 export default function compose(state = initialState, action) {
@@ -19,16 +22,33 @@ export default function compose(state = initialState, action) {
       });
     case constants.COMPOSE_REPLY_CANCEL:
       return state.withMutations(map => {
-        map.set('in_reply_to', null).set('text', '');
+        map.set('in_reply_to', null);
+        map.set('text', '');
       });
     case constants.COMPOSE_SUBMIT_REQUEST:
       return state.set('is_submitting', true);
     case constants.COMPOSE_SUBMIT_SUCCESS:
       return state.withMutations(map => {
-        map.set('text', '').set('is_submitting', false).set('in_reply_to', null);
+        map.set('text', '');
+        map.set('is_submitting', false);
+        map.set('in_reply_to', null);
+        map.update('media_attachments', list => list.clear());
       });
     case constants.COMPOSE_SUBMIT_FAIL:
       return state.set('is_submitting', false);
+    case constants.COMPOSE_UPLOAD_REQUEST:
+      return state.set('is_uploading', true);
+    case constants.COMPOSE_UPLOAD_SUCCESS:
+      return state.withMutations(map => {
+        map.update('media_attachments', list => list.push(Immutable.fromJS(action.media)));
+        map.set('is_uploading', false);
+      });
+    case constants.COMPOSE_UPLOAD_FAIL:
+      return state.set('is_uploading', false);
+    case constants.COMPOSE_UPLOAD_UNDO:
+      return state.update('media_attachments', list => list.filterNot(item => item.get('id') === action.media_id));
+    case constants.COMPOSE_UPLOAD_PROGRESS:
+      return state.set('progress', Math.round((action.loaded / action.total) * 100));
     case TIMELINE_DELETE:
       if (action.id === state.get('in_reply_to')) {
         return state.set('in_reply_to', null);
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 39b571183..f1308b11d 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -8,6 +8,18 @@
   &:disabled {
     background-color: #9baec8;
   }
+
+  &.button-secondary {
+    background-color: #282c37;
+
+    &:hover {
+      background-color: #282c37;
+    }
+
+    &:disabled {
+      background-color: #9baec8;
+    }
+  }
 }
 
 .icon-button {
@@ -39,7 +51,6 @@
 .status__content, .reply-indicator__content {
   font-size: 15px;
   line-height: 20px;
-  white-space: pre-wrap;
   word-wrap: break-word;
   font-weight: 300;
 
@@ -95,3 +106,7 @@
     text-decoration: underline;
   }
 }
+
+.transparent-background {
+  background: image-url('void.png');
+}
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index ee2136e3f..930e7ec9e 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -19,7 +19,7 @@ class PostStatusService < BaseService
   def attach_media(status, media_ids)
     return if media_ids.nil? || !media_ids.is_a?(Enumerable)
 
-    media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(2).map { |id| id.to_i })
+    media = MediaAttachment.where(status_id: nil).where(id: media_ids.take(4).map { |id| id.to_i })
     media.update(status_id: status.id)
   end
 
diff --git a/app/views/api/media/create.rabl b/app/views/api/media/create.rabl
index 23909066d..0239aa585 100644
--- a/app/views/api/media/create.rabl
+++ b/app/views/api/media/create.rabl
@@ -1,3 +1,4 @@
 object @media
 attribute :id
 node(:url) { |media| full_asset_url(media.file.url) }
+node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }