about summary refs log tree commit diff
diff options
context:
space:
mode:
authorbeatrix <beatrix.bitrot@gmail.com>2018-01-06 11:28:36 -0500
committerGitHub <noreply@github.com>2018-01-06 11:28:36 -0500
commita251c42192d0f8aa3481aae47429f4b732bfb623 (patch)
tree8b9d4b093b1e77561c9c063a2de0aa28b22abc04
parent2ec9a75a1de70e8b53b58c05e64a4772e5709fa3 (diff)
parentc71d84885511793a1a5ef613e0e4566f85c2ffe5 (diff)
Merge pull request #296 from glitch-soc/thread-mode
Threaded mode~
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js12
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js21
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/index.js63
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/icons/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/index.js7
-rw-r--r--app/javascript/flavours/glitch/locales/en.js8
-rw-r--r--app/javascript/flavours/glitch/locales/ja.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js64
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss21
-rw-r--r--app/javascript/flavours/glitch/util/js_helpers.js5
10 files changed, 217 insertions, 48 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 31866d223..a88dba1b1 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -61,7 +61,7 @@ export function replyCompose(status, router) {
       status: status,
     });
 
-    if (!getState().getIn(['compose', 'mounted'])) {
+    if (router && !getState().getIn(['compose', 'mounted'])) {
       router.push('/statuses/new');
     }
   };
@@ -118,6 +118,11 @@ export function submitCompose() {
     }).then(function (response) {
       dispatch(submitComposeSuccess({ ...response.data }));
 
+      //  If the response has no data then we can't do anything else.
+      if (!response.data) {
+        return;
+      }
+
       // To make the app more responsive, immediately get the status into the columns
 
       const insertOrRefresh = (timelineId, refreshAction) => {
@@ -341,10 +346,11 @@ export function unmountCompose() {
   };
 };
 
-export function toggleComposeAdvancedOption(option) {
+export function changeComposeAdvancedOption(option, value) {
   return {
+    option,
     type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
-    option: option,
+    value,
   };
 }
 
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
index 3582dedfe..cae9bf9f2 100644
--- a/app/javascript/flavours/glitch/features/composer/index.js
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -7,6 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import {
   cancelReplyCompose,
   changeCompose,
+  changeComposeAdvancedOption,
   changeComposeSensitivity,
   changeComposeSpoilerText,
   changeComposeSpoilerness,
@@ -18,7 +19,6 @@ import {
   mountCompose,
   selectComposeSuggestion,
   submitCompose,
-  toggleComposeAdvancedOption,
   undoUploadCompose,
   unmountCompose,
   uploadCompose,
@@ -49,8 +49,8 @@ function mapStateToProps (state) {
   const inReplyTo = state.getIn(['compose', 'in_reply_to']);
   return {
     acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    advancedOptions: state.getIn(['compose', 'advanced_options']),
     amUnlocked: !state.getIn(['accounts', me, 'locked']),
-    doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
     focusDate: state.getIn(['compose', 'focusDate']),
     isSubmitting: state.getIn(['compose', 'is_submitting']),
     isUploading: state.getIn(['compose', 'is_uploading']),
@@ -76,6 +76,7 @@ function mapStateToProps (state) {
 //  Dispatch mapping.
 const mapDispatchToProps = {
   onCancelReply: cancelReplyCompose,
+  onChangeAdvancedOption: changeComposeAdvancedOption,
   onChangeDescription: changeUploadCompose,
   onChangeSensitivity: changeComposeSensitivity,
   onChangeSpoilerText: changeComposeSpoilerText,
@@ -91,7 +92,6 @@ const mapDispatchToProps = {
   onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
   onSelectSuggestion: selectComposeSuggestion,
   onSubmit: submitCompose,
-  onToggleAdvancedOption: toggleComposeAdvancedOption,
   onUndoUpload: undoUploadCompose,
   onUnmount: unmountCompose,
   onUpload: uploadCompose,
@@ -267,14 +267,15 @@ class Composer extends React.Component {
     } = this.handlers;
     const {
       acceptContentTypes,
+      advancedOptions,
       amUnlocked,
-      doNotFederate,
       intl,
       isSubmitting,
       isUploading,
       layout,
       media,
       onCancelReply,
+      onChangeAdvancedOption,
       onChangeDescription,
       onChangeSensitivity,
       onChangeSpoilerness,
@@ -285,7 +286,6 @@ class Composer extends React.Component {
       onFetchSuggestions,
       onOpenActionsModal,
       onOpenDoodleModal,
-      onToggleAdvancedOption,
       onUndoUpload,
       onUpload,
       privacy,
@@ -321,6 +321,7 @@ class Composer extends React.Component {
           />
         ) : null}
         <ComposerTextarea
+          advancedOptions={advancedOptions}
           autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
           disabled={isSubmitting}
           intl={intl}
@@ -347,19 +348,19 @@ class Composer extends React.Component {
         ) : null}
         <ComposerOptions
           acceptContentTypes={acceptContentTypes}
+          advancedOptions={advancedOptions}
           disabled={isSubmitting}
-          doNotFederate={doNotFederate}
           full={media.size >= 4 || media.some(
             item => item.get('type') === 'video'
           )}
           hasMedia={!!media.size}
           intl={intl}
+          onChangeAdvancedOption={onChangeAdvancedOption}
           onChangeSensitivity={onChangeSensitivity}
           onChangeVisibility={onChangeVisibility}
           onDoodleOpen={onOpenDoodleModal}
           onModalClose={onCloseModal}
           onModalOpen={onOpenActionsModal}
-          onToggleAdvancedOption={onToggleAdvancedOption}
           onToggleSpoiler={onChangeSpoilerness}
           onUpload={onUpload}
           privacy={privacy}
@@ -368,7 +369,7 @@ class Composer extends React.Component {
           spoiler={spoiler}
         />
         <ComposerPublisher
-          countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`}
+          countText={`${spoilerText}${countableText(text)}${advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
           disabled={isSubmitting || isUploading || !!text.length && !text.trim().length}
           intl={intl}
           onSecondarySubmit={handleSecondarySubmit}
@@ -388,8 +389,8 @@ Composer.propTypes = {
 
   //  State props.
   acceptContentTypes: PropTypes.string,
+  advancedOptions: ImmutablePropTypes.map,
   amUnlocked: PropTypes.bool,
-  doNotFederate: PropTypes.bool,
   focusDate: PropTypes.instanceOf(Date),
   isSubmitting: PropTypes.bool,
   isUploading: PropTypes.bool,
@@ -412,6 +413,7 @@ Composer.propTypes = {
 
   //  Dispatch props.
   onCancelReply: PropTypes.func,
+  onChangeAdvancedOption: PropTypes.func,
   onChangeDescription: PropTypes.func,
   onChangeSensitivity: PropTypes.func,
   onChangeSpoilerText: PropTypes.func,
@@ -427,7 +429,6 @@ Composer.propTypes = {
   onOpenDoodleModal: PropTypes.func,
   onSelectSuggestion: PropTypes.func,
   onSubmit: PropTypes.func,
-  onToggleAdvancedOption: PropTypes.func,
   onUndoUpload: PropTypes.func,
   onUnmount: PropTypes.func,
   onUpload: PropTypes.func,
diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
index e805372ab..954508c11 100644
--- a/app/javascript/flavours/glitch/features/composer/options/index.js
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -1,6 +1,7 @@
 //  Package imports.
 import PropTypes from 'prop-types';
 import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import {
   FormattedMessage,
   defineMessages,
@@ -47,11 +48,11 @@ const messages = defineMessages({
   },
   local_only_long: {
     defaultMessage: 'Do not post to other instances',
-    id: 'advanced-options.local-only.long',
+    id: 'advanced_options.local-only.long',
   },
   local_only_short: {
     defaultMessage: 'Local-only',
-    id: 'advanced-options.local-only.short',
+    id: 'advanced_options.local-only.short',
   },
   private_long: {
     defaultMessage: 'Post to followers only',
@@ -77,6 +78,14 @@ const messages = defineMessages({
     defaultMessage: 'Hide text behind warning',
     id: 'compose_form.spoiler',
   },
+  threaded_mode_long: {
+    defaultMessage: 'Automatically opens a reply on posting',
+    id: 'advanced_options.threaded_mode.long',
+  },
+  threaded_mode_short: {
+    defaultMessage: 'Threaded mode',
+    id: 'advanced_options.threaded_mode.short',
+  },
   unlisted_long: {
     defaultMessage: 'Do not show in public timelines',
     id: 'privacy.unlisted.long',
@@ -149,16 +158,16 @@ export default class ComposerOptions extends React.PureComponent {
     } = this.handlers;
     const {
       acceptContentTypes,
+      advancedOptions,
       disabled,
-      doNotFederate,
       full,
       hasMedia,
       intl,
+      onChangeAdvancedOption,
       onChangeSensitivity,
       onChangeVisibility,
       onModalClose,
       onModalOpen,
-      onToggleAdvancedOption,
       onToggleSpoiler,
       privacy,
       resetFileKey,
@@ -283,23 +292,31 @@ export default class ComposerOptions extends React.PureComponent {
           onClick={onToggleSpoiler}
           title={intl.formatMessage(messages.spoiler)}
         />
-        <Dropdown
-          active={doNotFederate}
-          disabled={disabled}
-          icon='home'
-          items={[
-            {
-              meta: <FormattedMessage {...messages.local_only_long} />,
-              name: 'do_not_federate',
-              on: doNotFederate,
-              text: <FormattedMessage {...messages.local_only_short} />,
-            },
-          ]}
-          onChange={onToggleAdvancedOption}
-          onModalClose={onModalClose}
-          onModalOpen={onModalOpen}
-          title={intl.formatMessage(messages.advanced_options_icon_title)}
-        />
+        {advancedOptions ? (
+          <Dropdown
+            active={advancedOptions.some(value => !!value)}
+            disabled={disabled}
+            icon='ellipsis-h'
+            items={[
+              {
+                meta: <FormattedMessage {...messages.local_only_long} />,
+                name: 'do_not_federate',
+                on: advancedOptions.get('do_not_federate'),
+                text: <FormattedMessage {...messages.local_only_short} />,
+              },
+              {
+                meta: <FormattedMessage {...messages.threaded_mode_long} />,
+                name: 'threaded_mode',
+                on: advancedOptions.get('threaded_mode'),
+                text: <FormattedMessage {...messages.threaded_mode_short} />,
+              },
+            ]}
+            onChange={onChangeAdvancedOption}
+            onModalClose={onModalClose}
+            onModalOpen={onModalOpen}
+            title={intl.formatMessage(messages.advanced_options_icon_title)}
+          />
+        ) : null}
       </div>
     );
   }
@@ -309,17 +326,17 @@ export default class ComposerOptions extends React.PureComponent {
 //  Props.
 ComposerOptions.propTypes = {
   acceptContentTypes: PropTypes.string,
+  advancedOptions: ImmutablePropTypes.map,
   disabled: PropTypes.bool,
-  doNotFederate: PropTypes.bool,
   full: PropTypes.bool,
   hasMedia: PropTypes.bool,
   intl: PropTypes.object.isRequired,
+  onChangeAdvancedOption: PropTypes.func,
   onChangeSensitivity: PropTypes.func,
   onChangeVisibility: PropTypes.func,
   onDoodleOpen: PropTypes.func,
   onModalClose: PropTypes.func,
   onModalOpen: PropTypes.func,
-  onToggleAdvancedOption: PropTypes.func,
   onToggleSpoiler: PropTypes.func,
   onUpload: PropTypes.func,
   privacy: PropTypes.string,
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js
new file mode 100644
index 000000000..049cdd5cd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js
@@ -0,0 +1,60 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Messages.
+const messages = defineMessages({
+  localOnly: {
+    defaultMessage: 'This post is local-only',
+    id: 'advanced_options.local-only.tooltip',
+  },
+  threadedMode: {
+    defaultMessage: 'Threaded mode enabled',
+    id: 'advanced_options.threaded_mode.tooltip',
+  },
+});
+
+//  We use an array of tuples here instead of an object because it
+//  preserves order.
+const iconMap = [
+  ['do_not_federate', 'home', messages.localOnly],
+  ['threaded_mode', 'comments', messages.threadedMode],
+];
+
+//  The component.
+export default function ComposerTextareaIcons ({
+  advancedOptions,
+  intl,
+}) {
+
+  //  The result. We just map every active option to its icon.
+  return (
+    <div className='composer--textarea--icons'>
+      {advancedOptions ? iconMap.map(
+        ([key, icon, message]) => advancedOptions.get(key) ? (
+          <span
+            className='textarea_icon'
+            key={key}
+            title={intl.formatMessage(message)}
+          >
+            <Icon
+              fullwidth
+              icon={icon}
+            />
+          </span>
+        ) : null
+      ) : null}
+    </div>
+  );
+}
+
+//  Props.
+ComposerTextareaIcons.propTypes = {
+  advancedOptions: ImmutablePropTypes.map,
+  intl: PropTypes.object.isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
index 2e0b3e3d7..0f5fd4d4d 100644
--- a/app/javascript/flavours/glitch/features/composer/textarea/index.js
+++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js
@@ -10,6 +10,7 @@ import Textarea from 'react-textarea-autosize';
 
 //  Components.
 import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import ComposerTextareaIcons from './icons';
 import ComposerTextareaSuggestions from './suggestions';
 
 //  Utils.
@@ -232,6 +233,7 @@ export default class ComposerTextarea extends React.Component {
       handleRefTextarea,
     } = this.handlers;
     const {
+      advancedOptions,
       autoFocus,
       disabled,
       intl,
@@ -249,6 +251,10 @@ export default class ComposerTextarea extends React.Component {
       <div className='composer--textarea'>
         <label>
           <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
+          <ComposerTextareaIcons
+            advancedOptions={advancedOptions}
+            intl={intl}
+          />
           <Textarea
             aria-autocomplete='list'
             autoFocus={autoFocus}
@@ -280,6 +286,7 @@ export default class ComposerTextarea extends React.Component {
 
 //  Props.
 ComposerTextarea.propTypes = {
+  advancedOptions: ImmutablePropTypes.map,
   autoFocus: PropTypes.bool,
   disabled: PropTypes.bool,
   intl: PropTypes.object.isRequired,
diff --git a/app/javascript/flavours/glitch/locales/en.js b/app/javascript/flavours/glitch/locales/en.js
index a2a2b4a9b..de6af0990 100644
--- a/app/javascript/flavours/glitch/locales/en.js
+++ b/app/javascript/flavours/glitch/locales/en.js
@@ -52,9 +52,13 @@ const messages = {
   'compose.attach.doodle': 'Draw something',
   'compose.attach': 'Attach...',
 
-  'advanced-options.local-only.short': 'Local-only',
-  'advanced-options.local-only.long': 'Do not post to other instances',
+  'advanced_options.local-only.short': 'Local-only',
+  'advanced_options.local-only.long': 'Do not post to other instances',
+  'advanced_options.local-only.tooltip': 'This post is local-only',
   'advanced_options.icon_title': 'Advanced options',
+  'advanced_options.threaded_mode.short': 'Threaded mode',
+  'advanced_options.threaded_mode.long': 'Automatically opens a reply on posting',
+  'advanced_options.threaded_mode.tooltip': 'Threaded mode enabled',
 };
 
 export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js
index 96e6e0b2c..0c365319e 100644
--- a/app/javascript/flavours/glitch/locales/ja.js
+++ b/app/javascript/flavours/glitch/locales/ja.js
@@ -55,8 +55,8 @@ const messages = {
   'compose.attach.doodle': '落書きをする',
   'compose.attach': 'アタッチ...',
 
-  'advanced-options.local-only.short': 'ローカル限定',
-  'advanced-options.local-only.long': '他のインスタンスには投稿されません',
+  'advanced_options.local-only.short': 'ローカル限定',
+  'advanced_options.local-only.long': '他のインスタンスには投稿されません',
   'advanced_options.icon_title': '高度な設定',
 };
 
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index e1f811f6f..610cc9446 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -33,11 +33,13 @@ import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
 import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
 import uuid from 'flavours/glitch/util/uuid';
 import { me } from 'flavours/glitch/util/initial_state';
+import { overwrite } from 'flavours/glitch/util/js_helpers';
 
 const initialState = ImmutableMap({
   mounted: false,
   advanced_options: ImmutableMap({
     do_not_federate: false,
+    threaded_mode: false,
   }),
   sensitive: false,
   spoiler: false,
@@ -55,6 +57,7 @@ const initialState = ImmutableMap({
   suggestions: ImmutableList(),
   default_advanced_options: ImmutableMap({
     do_not_federate: false,
+    threaded_mode: null,  //  Do not reset
   }),
   default_privacy: 'public',
   default_sensitive: false,
@@ -83,6 +86,20 @@ function statusToTextMentions(state, status) {
   return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
 };
 
+function apiStatusToTextMentions (state, status) {
+  let set = ImmutableOrderedSet([]);
+
+  if (status.account.id !== me) {
+    set = set.add(`@${status.account.acct} `);
+  }
+
+  return set.union(status.mentions.filter(
+    mention => mention.id !== me
+  ).map(
+    mention => `@${mention.acct} `
+  )).join('');
+}
+
 function clearAll(state) {
   return state.withMutations(map => {
     map.set('text', '');
@@ -90,7 +107,10 @@ function clearAll(state) {
     map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
-    map.set('advanced_options', state.get('default_advanced_options'));
+    map.update(
+      'advanced_options',
+      map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+    );
     map.set('privacy', state.get('default_privacy'));
     map.set('sensitive', false);
     map.update('media_attachments', list => list.clear());
@@ -98,6 +118,31 @@ function clearAll(state) {
   });
 };
 
+function continueThread (state, status) {
+  return state.withMutations(function (map) {
+    map.set('text', apiStatusToTextMentions(state, status));
+    if (status.spoiler_text) {
+      map.set('spoiler', true);
+      map.set('spoiler_text', status.spoiler_text);
+    } else {
+      map.set('spoiler', false);
+      map.set('spoiler_text', '');
+    }
+    map.set('is_submitting', false);
+    map.set('in_reply_to', status.id);
+    map.update(
+      'advanced_options',
+      map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(status.content) }))
+    );
+    map.set('privacy', privacyPreference(status.visibility, state.get('default_privacy')));
+    map.set('sensitive', false);
+    map.update('media_attachments', list => list.clear());
+    map.set('idempotencyKey', uuid());
+    map.set('focusDate', new Date());
+    map.set('preselectDate', new Date());
+  });
+}
+
 function appendMedia(state, media) {
   const prevSize = state.get('media_attachments').size;
 
@@ -182,8 +227,7 @@ export default function compose(state = initialState, action) {
     return state.set('mounted', false);
   case COMPOSE_ADVANCED_OPTIONS_CHANGE:
     return state
-      .set('advanced_options',
-        state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option])))
+      .set('advanced_options', state.get('advanced_options').set(action.option, !!overwrite(!state.getIn(['advanced_options', action.option]), action.value)))
       .set('idempotencyKey', uuid());
   case COMPOSE_SENSITIVITY_CHANGE:
     return state.withMutations(map => {
@@ -220,9 +264,10 @@ export default function compose(state = initialState, action) {
       map.set('in_reply_to', action.status.get('id'));
       map.set('text', statusToTextMentions(state, action.status));
       map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
-      map.set('advanced_options', new ImmutableMap({
-        do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')),
-      }));
+      map.update(
+        'advanced_options',
+        map => map.merge(new ImmutableMap({ do_not_federate: /👁\ufe0f?\u200b?(?:<\/p>)?$/.test(action.status.get('content')) }))
+      );
       map.set('focusDate', new Date());
       map.set('preselectDate', new Date());
       map.set('idempotencyKey', uuid());
@@ -243,14 +288,17 @@ export default function compose(state = initialState, action) {
       map.set('spoiler', false);
       map.set('spoiler_text', '');
       map.set('privacy', state.get('default_privacy'));
-      map.set('advanced_options', state.get('default_advanced_options'));
+      map.update(
+        'advanced_options',
+        map => map.mergeWith(overwrite, state.get('default_advanced_options'))
+      );
       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);
+    return action.status && state.get('advanced_options', 'threaded_mode') ? continueThread(state, action.status) : clearAll(state);
   case COMPOSE_SUBMIT_FAIL:
   case COMPOSE_UPLOAD_CHANGE_FAIL:
     return state.set('is_submitting', false);
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index ab5fa4712..52d9ed105 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -134,6 +134,27 @@
   }
 }
 
+.composer--textarea--icons {
+  display: block;
+  position: absolute;
+  top: 29px;
+  right: 5px;
+  bottom: 5px;
+  overflow: hidden;
+
+  & > .textarea_icon {
+    display: block;
+    margin: 2px 0 0 2px;
+    width: 24px;
+    height: 24px;
+    color: darken($ui-primary-color, 24%);
+    font-size: 18px;
+    line-height: 24px;
+    text-align: center;
+    opacity: .8;
+  }
+}
+
 .composer--textarea--suggestions {
   display: block;
   position: absolute;
diff --git a/app/javascript/flavours/glitch/util/js_helpers.js b/app/javascript/flavours/glitch/util/js_helpers.js
new file mode 100644
index 000000000..2ebd5b6c5
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/js_helpers.js
@@ -0,0 +1,5 @@
+//  This function returns the new value unless it is `null` or
+//  `undefined`, in which case it returns the old one.
+export function overwrite (oldVal, newVal) {
+  return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal;
+}