瀏覽代碼

Change design of compose form in web UI (#28119)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
Eugen Rochko 3 月之前
父節點
當前提交
6936e5aa69
共有 71 個文件被更改,包括 1407 次插入1522 次删除
  1. 1 1
      .eslintrc.js
  2. 25 0
      app/javascript/images/warning-stripes.svg
  3. 10 2
      app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap
  4. 4 4
      app/javascript/mastodon/components/account.jsx
  5. 1 1
      app/javascript/mastodon/components/autosuggest_emoji.jsx
  6. 13 24
      app/javascript/mastodon/components/autosuggest_hashtag.tsx
  7. 33 28
      app/javascript/mastodon/components/autosuggest_input.jsx
  8. 33 36
      app/javascript/mastodon/components/autosuggest_textarea.jsx
  9. 1 1
      app/javascript/mastodon/components/dropdown_menu.jsx
  10. 3 3
      app/javascript/mastodon/components/status.jsx
  11. 8 5
      app/javascript/mastodon/components/visibility_icon.tsx
  12. 18 22
      app/javascript/mastodon/containers/compose_container.jsx
  13. 50 49
      app/javascript/mastodon/features/compose/components/action_bar.jsx
  14. 1 1
      app/javascript/mastodon/features/compose/components/autosuggest_account.jsx
  15. 10 18
      app/javascript/mastodon/features/compose/components/character_counter.jsx
  16. 80 82
      app/javascript/mastodon/features/compose/components/compose_form.jsx
  17. 62 0
      app/javascript/mastodon/features/compose/components/edit_indicator.jsx
  18. 13 11
      app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx
  19. 21 16
      app/javascript/mastodon/features/compose/components/language_dropdown.jsx
  20. 27 41
      app/javascript/mastodon/features/compose/components/navigation_bar.jsx
  21. 3 9
      app/javascript/mastodon/features/compose/components/poll_button.jsx
  22. 143 170
      app/javascript/mastodon/features/compose/components/poll_form.jsx
  23. 30 46
      app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx
  24. 34 60
      app/javascript/mastodon/features/compose/components/reply_indicator.jsx
  25. 18 10
      app/javascript/mastodon/features/compose/components/upload.jsx
  26. 4 10
      app/javascript/mastodon/features/compose/components/upload_button.jsx
  27. 9 10
      app/javascript/mastodon/features/compose/components/upload_form.jsx
  28. 1 3
      app/javascript/mastodon/features/compose/components/upload_progress.jsx
  29. 0 36
      app/javascript/mastodon/features/compose/containers/navigation_container.js
  30. 1 1
      app/javascript/mastodon/features/compose/containers/poll_button_container.js
  31. 0 53
      app/javascript/mastodon/features/compose/containers/poll_form_container.js
  32. 0 36
      app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
  33. 0 73
      app/javascript/mastodon/features/compose/containers/sensitive_button_container.jsx
  34. 7 3
      app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
  35. 1 2
      app/javascript/mastodon/features/compose/containers/upload_button_container.js
  36. 1 0
      app/javascript/mastodon/features/compose/containers/upload_container.js
  37. 0 4
      app/javascript/mastodon/features/compose/index.jsx
  38. 2 2
      app/javascript/mastodon/features/getting_started/index.jsx
  39. 15 21
      app/javascript/mastodon/features/standalone/compose/index.jsx
  40. 1 5
      app/javascript/mastodon/features/ui/components/compose_panel.jsx
  41. 1 1
      app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
  42. 0 1
      app/javascript/mastodon/features/ui/components/mute_modal.jsx
  43. 0 1
      app/javascript/mastodon/features/ui/components/navigation_panel.jsx
  44. 20 21
      app/javascript/mastodon/locales/en.json
  45. 13 7
      app/javascript/mastodon/reducers/compose.js
  46. 1 0
      app/javascript/material-icons/400-24px/bar_chart_4_bars-fill.svg
  47. 1 0
      app/javascript/material-icons/400-24px/bar_chart_4_bars.svg
  48. 1 0
      app/javascript/material-icons/400-24px/mood-fill.svg
  49. 1 0
      app/javascript/material-icons/400-24px/mood.svg
  50. 1 0
      app/javascript/material-icons/400-24px/photo_library-fill.svg
  51. 1 0
      app/javascript/material-icons/400-24px/photo_library.svg
  52. 1 0
      app/javascript/material-icons/400-24px/quiet_time-fill.svg
  53. 1 0
      app/javascript/material-icons/400-24px/quiet_time.svg
  54. 1 0
      app/javascript/material-icons/400-24px/translate-fill.svg
  55. 1 0
      app/javascript/material-icons/400-24px/translate.svg
  56. 1 0
      app/javascript/material-icons/400-24px/warning-fill.svg
  57. 1 0
      app/javascript/material-icons/400-24px/warning.svg
  58. 3 1
      app/javascript/packs/share.jsx
  59. 6 31
      app/javascript/styles/contrast/diff.scss
  60. 28 125
      app/javascript/styles/mastodon-light/diff.scss
  61. 7 2
      app/javascript/styles/mastodon-light/variables.scss
  62. 3 2
      app/javascript/styles/mastodon/_mixins.scss
  63. 6 0
      app/javascript/styles/mastodon/admin.scss
  64. 1 1
      app/javascript/styles/mastodon/basics.scss
  65. 564 298
      app/javascript/styles/mastodon/components.scss
  66. 14 12
      app/javascript/styles/mastodon/containers.scss
  67. 14 16
      app/javascript/styles/mastodon/emoji_picker.scss
  68. 1 1
      app/javascript/styles/mastodon/modal.scss
  69. 26 99
      app/javascript/styles/mastodon/polls.scss
  70. 2 2
      spec/system/new_statuses_spec.rb
  71. 2 2
      spec/system/share_entrypoint_spec.rb

+ 1 - 1
.eslintrc.js

@@ -165,7 +165,7 @@ module.exports = defineConfig({
     //   },
     // ],
     'jsx-a11y/no-noninteractive-tabindex': 'off',
-    'jsx-a11y/no-onchange': 'warn',
+    'jsx-a11y/no-onchange': 'off',
     // recommended is full 'error'
     'jsx-a11y/no-static-element-interactions': [
       'warn',

+ 25 - 0
app/javascript/images/warning-stripes.svg

@@ -0,0 +1,25 @@
+<svg width="5" height="80" viewBox="0 0 5 80" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_253_1286)">
+<rect width="5" height="80" fill="url(#paint0_linear_253_1286)"/>
+<line x1="-0.860365" y1="6.80136" x2="10.6078" y2="-1.22871" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="14.8314" x2="10.6078" y2="6.80132" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="22.8615" x2="10.6078" y2="14.8314" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="30.8916" x2="10.6078" y2="22.8615" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="38.9216" x2="10.6078" y2="30.8915" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="46.9517" x2="10.6078" y2="38.9216" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="54.9818" x2="10.6078" y2="46.9517" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="63.0118" x2="10.6078" y2="54.9817" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="71.0419" x2="10.6078" y2="63.0118" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="79.072" x2="10.6078" y2="71.0419" stroke="black" stroke-width="3"/>
+<line x1="-0.860365" y1="87.102" x2="10.6078" y2="79.072" stroke="black" stroke-width="3"/>
+</g>
+<defs>
+<linearGradient id="paint0_linear_253_1286" x1="2.5" y1="0" x2="2.5" y2="80" gradientUnits="userSpaceOnUse">
+<stop stop-color="#FEC84B"/>
+<stop offset="1" stop-color="#F79009"/>
+</linearGradient>
+<clipPath id="clip0_253_1286">
+<rect width="5" height="80" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 10 - 2
app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap

@@ -9,7 +9,11 @@ exports[`<AutosuggestEmoji /> renders emoji with custom url 1`] = `
     className="emojione"
     src="http://example.com/emoji.png"
   />
-  :foobar:
+  <div
+    className="autosuggest-emoji__name"
+  >
+    :foobar:
+  </div>
 </div>
 `;
 
@@ -22,6 +26,10 @@ exports[`<AutosuggestEmoji /> renders native emoji 1`] = `
     className="emojione"
     src="/emoji/1f499.svg"
   />
-  :foobar:
+  <div
+    className="autosuggest-emoji__name"
+  >
+    :foobar:
+  </div>
 </div>
 `;

+ 4 - 4
app/javascript/mastodon/components/account.jsx

@@ -37,10 +37,10 @@ class Account extends ImmutablePureComponent {
   static propTypes = {
     size: PropTypes.number,
     account: ImmutablePropTypes.record,
-    onFollow: PropTypes.func.isRequired,
-    onBlock: PropTypes.func.isRequired,
-    onMute: PropTypes.func.isRequired,
-    onMuteNotifications: PropTypes.func.isRequired,
+    onFollow: PropTypes.func,
+    onBlock: PropTypes.func,
+    onMute: PropTypes.func,
+    onMuteNotifications: PropTypes.func,
     intl: PropTypes.object.isRequired,
     hidden: PropTypes.bool,
     minimal: PropTypes.bool,

+ 1 - 1
app/javascript/mastodon/components/autosuggest_emoji.jsx

@@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
           alt={emoji.native || emoji.colons}
         />
 
-        {emoji.colons}
+        <div className='autosuggest-emoji__name'>{emoji.colons}</div>
       </div>
     );
   }

+ 13 - 24
app/javascript/mastodon/components/autosuggest_hashtag.tsx

@@ -1,5 +1,3 @@
-import { FormattedMessage } from 'react-intl';
-
 import { ShortNumber } from 'mastodon/components/short_number';
 
 interface Props {
@@ -16,27 +14,18 @@ interface Props {
   };
 }
 
-export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
-  const weeklyUses = tag.history && (
-    <ShortNumber
-      value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
-    />
-  );
+export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
+  <div className='autosuggest-hashtag'>
+    <div className='autosuggest-hashtag__name'>
+      #<strong>{tag.name}</strong>
+    </div>
 
-  return (
-    <div className='autosuggest-hashtag'>
-      <div className='autosuggest-hashtag__name'>
-        #<strong>{tag.name}</strong>
+    {tag.history !== undefined && (
+      <div className='autosuggest-hashtag__uses'>
+        <ShortNumber
+          value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
+        />
       </div>
-      {tag.history !== undefined && (
-        <div className='autosuggest-hashtag__uses'>
-          <FormattedMessage
-            id='autosuggest_hashtag.per_week'
-            defaultMessage='{count} per week'
-            values={{ count: weeklyUses }}
-          />
-        </div>
-      )}
-    </div>
-  );
-};
+    )}
+  </div>
+);

+ 33 - 28
app/javascript/mastodon/components/autosuggest_input.jsx

@@ -5,6 +5,8 @@ import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
+import Overlay from 'react-overlays/Overlay';
+
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 
 import AutosuggestEmoji from './autosuggest_emoji';
@@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
 
     return (
       <div className='autosuggest-input'>
-        <label>
-          <span style={{ display: 'none' }}>{placeholder}</span>
-
-          <input
-            type='text'
-            ref={this.setInput}
-            disabled={disabled}
-            placeholder={placeholder}
-            autoFocus={autoFocus}
-            value={value}
-            onChange={this.onChange}
-            onKeyDown={this.onKeyDown}
-            onKeyUp={onKeyUp}
-            onFocus={this.onFocus}
-            onBlur={this.onBlur}
-            dir='auto'
-            aria-autocomplete='list'
-            id={id}
-            className={className}
-            maxLength={maxLength}
-            lang={lang}
-            spellCheck={spellCheck}
-          />
-        </label>
-
-        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
-          {suggestions.map(this.renderSuggestion)}
-        </div>
+        <input
+          type='text'
+          ref={this.setInput}
+          disabled={disabled}
+          placeholder={placeholder}
+          autoFocus={autoFocus}
+          value={value}
+          onChange={this.onChange}
+          onKeyDown={this.onKeyDown}
+          onKeyUp={onKeyUp}
+          onFocus={this.onFocus}
+          onBlur={this.onBlur}
+          dir='auto'
+          aria-autocomplete='list'
+          aria-label={placeholder}
+          id={id}
+          className={className}
+          maxLength={maxLength}
+          lang={lang}
+          spellCheck={spellCheck}
+        />
+
+        <Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
+          {({ props }) => (
+            <div {...props}>
+              <div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
+                {suggestions.map(this.renderSuggestion)}
+              </div>
+            </div>
+          )}
+        </Overlay>
       </div>
     );
   }

+ 33 - 36
app/javascript/mastodon/components/autosuggest_textarea.jsx

@@ -5,6 +5,7 @@ import classNames from 'classnames';
 
 import ImmutablePropTypes from 'react-immutable-proptypes';
 
+import Overlay from 'react-overlays/Overlay';
 import Textarea from 'react-textarea-autosize';
 
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
@@ -52,7 +53,6 @@ const AutosuggestTextarea = forwardRef(({
   onFocus,
   autoFocus = true,
   lang,
-  children,
 }, textareaRef) => {
 
   const [suggestionsHidden, setSuggestionsHidden] = useState(true);
@@ -183,40 +183,38 @@ const AutosuggestTextarea = forwardRef(({
     );
   };
 
-  return [
-    <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
-      <div className='autosuggest-textarea'>
-        <label>
-          <span style={{ display: 'none' }}>{placeholder}</span>
-
-          <Textarea
-            ref={textareaRef}
-            className='autosuggest-textarea__textarea'
-            disabled={disabled}
-            placeholder={placeholder}
-            autoFocus={autoFocus}
-            value={value}
-            onChange={handleChange}
-            onKeyDown={handleKeyDown}
-            onKeyUp={onKeyUp}
-            onFocus={handleFocus}
-            onBlur={handleBlur}
-            onPaste={handlePaste}
-            dir='auto'
-            aria-autocomplete='list'
-            lang={lang}
-          />
-        </label>
-      </div>
-      {children}
-    </div>,
-
-    <div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
-      <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
-        {suggestions.map(renderSuggestion)}
-      </div>
-    </div>,
-  ];
+  return (
+    <div className='autosuggest-textarea'>
+      <Textarea
+        ref={textareaRef}
+        className='autosuggest-textarea__textarea'
+        disabled={disabled}
+        placeholder={placeholder}
+        autoFocus={autoFocus}
+        value={value}
+        onChange={handleChange}
+        onKeyDown={handleKeyDown}
+        onKeyUp={onKeyUp}
+        onFocus={handleFocus}
+        onBlur={handleBlur}
+        onPaste={handlePaste}
+        dir='auto'
+        aria-autocomplete='list'
+        aria-label={placeholder}
+        lang={lang}
+      />
+
+      <Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
+        {({ props }) => (
+          <div {...props}>
+            <div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
+              {suggestions.map(renderSuggestion)}
+            </div>
+          </div>
+        )}
+      </Overlay>
+    </div>
+  );
 });
 
 AutosuggestTextarea.propTypes = {
@@ -232,7 +230,6 @@ AutosuggestTextarea.propTypes = {
   onKeyDown: PropTypes.func,
   onPaste: PropTypes.func.isRequired,
   onFocus:PropTypes.func,
-  children: PropTypes.node,
   autoFocus: PropTypes.bool,
   lang: PropTypes.string,
 };

+ 1 - 1
app/javascript/mastodon/components/dropdown_menu.jsx

@@ -165,7 +165,7 @@ class Dropdown extends PureComponent {
     children: PropTypes.node,
     icon: PropTypes.string,
     iconComponent: PropTypes.func,
-    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
+    items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]),
     loading: PropTypes.bool,
     size: PropTypes.number,
     title: PropTypes.string,

+ 3 - 3
app/javascript/mastodon/components/status.jsx

@@ -70,9 +70,9 @@ export const defaultMediaVisibility = (status) => {
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
-  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
-  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
-  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
   edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
 });
 

+ 8 - 5
app/javascript/mastodon/components/visibility_icon.tsx

@@ -2,8 +2,8 @@ import { defineMessages, useIntl } from 'react-intl';
 
 import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
 import LockIcon from '@/material-icons/400-24px/lock.svg?react';
-import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
 import PublicIcon from '@/material-icons/400-24px/public.svg?react';
+import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
 
 import { Icon } from './icon';
 
@@ -11,14 +11,17 @@ type Visibility = 'public' | 'unlisted' | 'private' | 'direct';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
-  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_short: {
+    id: 'privacy.unlisted.short',
+    defaultMessage: 'Quiet public',
+  },
   private_short: {
     id: 'privacy.private.short',
-    defaultMessage: 'Followers only',
+    defaultMessage: 'Followers',
   },
   direct_short: {
     id: 'privacy.direct.short',
-    defaultMessage: 'Mentioned people only',
+    defaultMessage: 'Specific people',
   },
 });
 
@@ -35,7 +38,7 @@ export const VisibilityIcon: React.FC<{ visibility: Visibility }> = ({
     },
     unlisted: {
       icon: 'unlock',
-      iconComponent: LockOpenIcon,
+      iconComponent: QuietTimeIcon,
       text: intl.formatMessage(messages.unlisted_short),
     },
     private: {

+ 18 - 22
app/javascript/mastodon/containers/compose_container.jsx

@@ -1,14 +1,12 @@
-import { PureComponent } from 'react';
-
 import { Provider } from 'react-redux';
 
-import { fetchCustomEmojis } from '../actions/custom_emojis';
-import { hydrateStore } from '../actions/store';
-import Compose from '../features/standalone/compose';
-import initialState from '../initial_state';
-import { IntlProvider } from '../locales';
-import { store } from '../store';
-
+import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
+import { hydrateStore } from 'mastodon/actions/store';
+import { Router } from 'mastodon/components/router';
+import Compose from 'mastodon/features/standalone/compose';
+import initialState from 'mastodon/initial_state';
+import { IntlProvider } from 'mastodon/locales';
+import { store } from 'mastodon/store';
 
 if (initialState) {
   store.dispatch(hydrateStore(initialState));
@@ -16,16 +14,14 @@ if (initialState) {
 
 store.dispatch(fetchCustomEmojis());
 
-export default class ComposeContainer extends PureComponent {
-
-  render () {
-    return (
-      <IntlProvider>
-        <Provider store={store}>
-          <Compose />
-        </Provider>
-      </IntlProvider>
-    );
-  }
-
-}
+const ComposeContainer = () => (
+  <IntlProvider>
+    <Provider store={store}>
+      <Router>
+        <Compose />
+      </Router>
+    </Provider>
+  </IntlProvider>
+);
+
+export default ComposeContainer;

+ 50 - 49
app/javascript/mastodon/features/compose/components/action_bar.jsx

@@ -1,13 +1,13 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
+import { useCallback } from 'react';
 
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, useIntl } from 'react-intl';
 
-import ImmutablePropTypes from 'react-immutable-proptypes';
+import { useDispatch } from 'react-redux';
 
-import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
-
-import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
+import { openModal } from 'mastodon/actions/modal';
+import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
+import { logOut } from 'mastodon/utils/log_out';
 
 const messages = defineMessages({
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@@ -23,51 +23,52 @@ const messages = defineMessages({
   filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
   logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
   bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
 });
 
-class ActionBar extends PureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.record.isRequired,
-    onLogout: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleLogout = () => {
-    this.props.onLogout();
-  };
-
-  render () {
-    const { intl } = this.props;
-
-    let menu = [];
+export const ActionBar = () => {
+  const dispatch = useDispatch();
+  const intl = useIntl();
 
-    menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
-    menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
-    menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
-    menu.push(null);
-    menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
-    menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
-    menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
-    menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
-    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
-    menu.push(null);
-    menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
-    menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
-    menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
-    menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
-    menu.push(null);
-    menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
+  const handleLogoutClick = useCallback(() => {
+    dispatch(openModal({
+      modalType: 'CONFIRM',
+      modalProps: {
+        message: intl.formatMessage(messages.logoutMessage),
+        confirm: intl.formatMessage(messages.logoutConfirm),
+        closeWhenConfirm: false,
+        onConfirm: () => logOut(),
+      },
+    }));
+  }, [dispatch, intl]);
 
-    return (
-      <div className='compose__action-bar'>
-        <div className='compose__action-bar-dropdown'>
-          <DropdownMenuContainer items={menu} icon='bars' iconComponent={MenuIcon} size={24} direction='right' />
-        </div>
-      </div>
-    );
-  }
+  let menu = [];
 
-}
+  menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+  menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
+  menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+  menu.push(null);
+  menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+  menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+  menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
+  menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+  menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
+  menu.push(null);
+  menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+  menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+  menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+  menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
+  menu.push(null);
+  menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick });
 
-export default injectIntl(ActionBar);
+  return (
+    <DropdownMenuContainer
+      items={menu}
+      icon='bars'
+      iconComponent={MoreHorizIcon}
+      size={24}
+      direction='right'
+    />
+  );
+};

+ 1 - 1
app/javascript/mastodon/features/compose/components/autosuggest_account.jsx

@@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent {
 
     return (
       <div className='autosuggest-account' title={account.get('acct')}>
-        <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
+        <Avatar account={account} size={24} />
         <DisplayName account={account} />
       </div>
     );

+ 10 - 18
app/javascript/mastodon/features/compose/components/character_counter.jsx

@@ -1,26 +1,18 @@
 import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
 
 import { length } from 'stringz';
 
-export default class CharacterCounter extends PureComponent {
+export const CharacterCounter = ({ text, max }) => {
+  const diff = max - length(text);
 
-  static propTypes = {
-    text: PropTypes.string.isRequired,
-    max: PropTypes.number.isRequired,
-  };
-
-  checkRemainingText (diff) {
-    if (diff < 0) {
-      return <span className='character-counter character-counter--over'>{diff}</span>;
-    }
-
-    return <span className='character-counter'>{diff}</span>;
+  if (diff < 0) {
+    return <span className='character-counter character-counter--over'>{diff}</span>;
   }
 
-  render () {
-    const diff = this.props.max - length(this.props.text);
-    return this.checkRemainingText(diff);
-  }
+  return <span className='character-counter'>{diff}</span>;
+};
 
-}
+CharacterCounter.propTypes = {
+  text: PropTypes.string.isRequired,
+  max: PropTypes.number.isRequired,
+};

+ 80 - 82
app/javascript/mastodon/features/compose/components/compose_form.jsx

@@ -10,8 +10,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 
 import { length } from 'stringz';
 
-import LockIcon from '@/material-icons/400-24px/lock.svg?react';
-import { Icon }  from 'mastodon/components/icon';
 import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
 
 import AutosuggestInput from '../../../components/autosuggest_input';
@@ -20,25 +18,27 @@ import { Button } from '../../../components/button';
 import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 import LanguageDropdown from '../containers/language_dropdown_container';
 import PollButtonContainer from '../containers/poll_button_container';
-import PollFormContainer from '../containers/poll_form_container';
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
-import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import SpoilerButtonContainer from '../containers/spoiler_button_container';
 import UploadButtonContainer from '../containers/upload_button_container';
 import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
 import { countableText } from '../util/counter';
 
-import CharacterCounter from './character_counter';
+import { CharacterCounter } from './character_counter';
+import { EditIndicator } from './edit_indicator';
+import { NavigationBar } from './navigation_bar';
+import { PollForm } from "./poll_form";
+import { ReplyIndicator } from './reply_indicator';
 
 const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
-  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
-  publish: { id: 'compose_form.publish', defaultMessage: 'Publish' },
-  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
-  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning (optional)' },
+  publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
+  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Update' },
+  reply: { id: 'compose_form.reply', defaultMessage: 'Reply' },
 });
 
 class ComposeForm extends ImmutablePureComponent {
@@ -65,6 +65,7 @@ class ComposeForm extends ImmutablePureComponent {
     onPaste: PropTypes.func.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
     autoFocus: PropTypes.bool,
+    withoutNavigation: PropTypes.bool,
     anyMedia: PropTypes.bool,
     isInReply: PropTypes.bool,
     singleColumn: PropTypes.bool,
@@ -223,95 +224,92 @@ class ComposeForm extends ImmutablePureComponent {
   };
 
   render () {
-    const { intl, onPaste, autoFocus } = this.props;
+    const { intl, onPaste, autoFocus, withoutNavigation } = this.props;
     const { highlighted } = this.state;
     const disabled = this.props.isSubmitting;
 
-    let publishText = '';
-
-    if (this.props.isEditing) {
-      publishText = intl.formatMessage(messages.saveChanges);
-    } else if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
-      publishText = <><Icon id='lock' icon={LockIcon} /> {intl.formatMessage(messages.publish)}</>;
-    } else {
-      publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
-    }
-
     return (
       <form className='compose-form' onSubmit={this.handleSubmit}>
+        <ReplyIndicator />
+        {!withoutNavigation && <NavigationBar />}
         <WarningContainer />
 
-        <ReplyIndicatorContainer />
-
-        <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
-          <AutosuggestInput
-            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
-            value={this.props.spoilerText}
-            onChange={this.handleChangeSpoilerText}
-            onKeyDown={this.handleKeyDown}
-            disabled={!this.props.spoiler}
-            ref={this.setSpoilerText}
-            suggestions={this.props.suggestions}
-            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-            onSuggestionSelected={this.onSpoilerSuggestionSelected}
-            searchTokens={[':']}
-            id='cw-spoiler-input'
-            className='spoiler-input__input'
-            lang={this.props.lang}
-            spellCheck
-          />
-        </div>
+        <div className={classNames('compose-form__highlightable', { active: highlighted })} ref={this.setRef}>
+          <div className='compose-form__scrollable'>
+            <EditIndicator />
+
+            {this.props.spoiler && (
+              <div className='spoiler-input'>
+                <div className='spoiler-input__border' />
+
+                <AutosuggestInput
+                  placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+                  value={this.props.spoilerText}
+                  disabled={disabled}
+                  onChange={this.handleChangeSpoilerText}
+                  onKeyDown={this.handleKeyDown}
+                  ref={this.setSpoilerText}
+                  suggestions={this.props.suggestions}
+                  onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+                  onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+                  onSuggestionSelected={this.onSpoilerSuggestionSelected}
+                  searchTokens={[':']}
+                  id='cw-spoiler-input'
+                  className='spoiler-input__input'
+                  lang={this.props.lang}
+                  spellCheck
+                />
+
+                <div className='spoiler-input__border' />
+              </div>
+            )}
+
+            <AutosuggestTextarea
+              ref={this.textareaRef}
+              placeholder={intl.formatMessage(messages.placeholder)}
+              disabled={disabled}
+              value={this.props.text}
+              onChange={this.handleChange}
+              suggestions={this.props.suggestions}
+              onFocus={this.handleFocus}
+              onKeyDown={this.handleKeyDown}
+              onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+              onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+              onSuggestionSelected={this.onSuggestionSelected}
+              onPaste={onPaste}
+              autoFocus={autoFocus}
+              lang={this.props.lang}
+            />
+          </div>
 
-        <div className={classNames('compose-form__highlightable', { active: highlighted })}>
-          <AutosuggestTextarea
-            ref={this.textareaRef}
-            placeholder={intl.formatMessage(messages.placeholder)}
-            disabled={disabled}
-            value={this.props.text}
-            onChange={this.handleChange}
-            suggestions={this.props.suggestions}
-            onFocus={this.handleFocus}
-            onKeyDown={this.handleKeyDown}
-            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-            onSuggestionSelected={this.onSuggestionSelected}
-            onPaste={onPaste}
-            autoFocus={autoFocus}
-            lang={this.props.lang}
-          >
-            <div className='compose-form__modifiers'>
-              <UploadFormContainer />
-              <PollFormContainer />
-            </div>
-          </AutosuggestTextarea>
-          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+          <UploadFormContainer />
+          <PollForm />
 
-          <div className='compose-form__buttons-wrapper'>
-            <div className='compose-form__buttons'>
-              <UploadButtonContainer />
-              <PollButtonContainer />
+          <div className='compose-form__footer'>
+            <div className='compose-form__dropdowns'>
               <PrivacyDropdownContainer disabled={this.props.isEditing} />
-              <SpoilerButtonContainer />
               <LanguageDropdown />
             </div>
 
-            <div className='character-counter__wrapper'>
-              <CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} />
+            <div className='compose-form__actions'>
+              <div className='compose-form__buttons'>
+                <UploadButtonContainer />
+                <PollButtonContainer />
+                <SpoilerButtonContainer />
+                <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+                <CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} />
+              </div>
+
+              <div className='compose-form__submit'>
+                <Button
+                  type='submit'
+                  text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
+                  disabled={!this.canSubmit()}
+                />
+              </div>
             </div>
           </div>
         </div>
-
-        <div className='compose-form__publish'>
-          <div className='compose-form__publish-button-wrapper'>
-            <Button
-              type='submit'
-              text={publishText}
-              disabled={!this.canSubmit()}
-              block
-            />
-          </div>
-        </div>
       </form>
     );
   }

+ 62 - 0
app/javascript/mastodon/features/compose/components/edit_indicator.jsx

@@ -0,0 +1,62 @@
+import { useCallback } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import { Link } from 'react-router-dom';
+
+import { useDispatch, useSelector } from 'react-redux';
+
+import BarChart4BarsIcon from 'mastodon/../material-icons/400-24px/bar_chart_4_bars.svg?react';
+import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
+import PhotoLibraryIcon from 'mastodon/../material-icons/400-24px/photo_library.svg?react';
+import { cancelReplyCompose } from 'mastodon/actions/compose';
+import { Icon } from 'mastodon/components/icon';
+import { IconButton } from 'mastodon/components/icon_button';
+import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
+
+const messages = defineMessages({
+  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+export const EditIndicator = () => {
+  const intl = useIntl();
+  const dispatch = useDispatch();
+  const id = useSelector(state => state.getIn(['compose', 'id']));
+  const status = useSelector(state => state.getIn(['statuses', id]));
+  const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
+
+  const handleCancelClick = useCallback(() => {
+    dispatch(cancelReplyCompose());
+  }, [dispatch]);
+
+  if (!status) {
+    return null;
+  }
+
+  const content = { __html: status.get('contentHtml') };
+
+  return (
+    <div className='edit-indicator'>
+      <div className='edit-indicator__header'>
+        <div className='edit-indicator__display-name'>
+          <Link to={`/@${account.get('acct')}`}>@{account.get('acct')}</Link>
+          ·
+          <Link to={`/@${account.get('acct')}/${status.get('id')}`}><RelativeTimestamp timestamp={status.get('created_at')} /></Link>
+        </div>
+
+        <div className='edit-indicator__cancel'>
+          <IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={handleCancelClick} inverted />
+        </div>
+      </div>
+
+      <div className='edit-indicator__content translate' dangerouslySetInnerHTML={content} />
+
+      {(status.get('poll') || status.get('media_attachments').size > 0) && (
+        <div className='edit-indicator__attachments'>
+          {status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
+          {status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
+        </div>
+      )}
+    </div>
+  );
+};

+ 13 - 11
app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx

@@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { supportsPassiveEvents } from 'detect-passive-events';
 import Overlay from 'react-overlays/Overlay';
 
+import MoodIcon from 'mastodon/../material-icons/400-24px/mood.svg?react';
+import { IconButton } from 'mastodon/components/icon_button';
 import { assetHost } from 'mastodon/utils/config';
 
 import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
@@ -321,7 +323,6 @@ class EmojiPickerDropdown extends PureComponent {
     onPickEmoji: PropTypes.func.isRequired,
     onSkinTone: PropTypes.func.isRequired,
     skinTone: PropTypes.number.isRequired,
-    button: PropTypes.node,
   };
 
   state = {
@@ -379,23 +380,24 @@ class EmojiPickerDropdown extends PureComponent {
   };
 
   render () {
-    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
     const title = intl.formatMessage(messages.emoji);
     const { active, loading } = this.state;
 
     return (
-      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
-        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
-          {button || <img
-            className={classNames('emojione', { 'pulse-loading': active && loading })}
-            alt='🙂'
-            src={`${assetHost}/emoji/1f642.svg`}
-          />}
-        </div>
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown} ref={this.setTargetRef}>
+        <IconButton
+          title={title}
+          aria-expanded={active}
+          active={active}
+          iconComponent={MoodIcon}
+          onClick={this.onToggle}
+          inverted
+        />
 
         <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
           {({ props, placement })=> (
-            <div {...props} style={{ ...props.style, width: 299 }}>
+            <div {...props} style={{ ...props.style }}>
               <div className={`dropdown-animation ${placement}`}>
                 <EmojiPickerMenu
                   custom_emojis={this.props.custom_emojis}

+ 21 - 16
app/javascript/mastodon/features/compose/components/language_dropdown.jsx

@@ -9,10 +9,11 @@ import { supportsPassiveEvents } from 'detect-passive-events';
 import fuzzysort from 'fuzzysort';
 import Overlay from 'react-overlays/Overlay';
 
+import CancelIcon from 'mastodon/../material-icons/400-24px/cancel-fill.svg?react';
+import SearchIcon from 'mastodon/../material-icons/400-24px/search.svg?react';
+import TranslateIcon from 'mastodon/../material-icons/400-24px/translate.svg?react';
+import { Icon } from 'mastodon/components/icon';
 import { languages as preloadedLanguages } from 'mastodon/initial_state';
-import { loupeIcon, deleteIcon } from 'mastodon/utils/icons';
-
-import TextIconButton from './text_icon_button';
 
 const messages = defineMessages({
   changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
@@ -231,7 +232,7 @@ class LanguageDropdownMenu extends PureComponent {
       <div ref={this.setRef}>
         <div className='emoji-mart-search'>
           <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
-          <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
+          <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}><Icon icon={!isSearching ? SearchIcon : CancelIcon} /></button>
         </div>
 
         <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
@@ -297,20 +298,24 @@ class LanguageDropdown extends PureComponent {
   render () {
     const { value, intl, frequentlyUsedLanguages } = this.props;
     const { open, placement } = this.state;
+    const current = preloadedLanguages.find(lang => lang[0] === value) ?? [];
 
     return (
-      <div className={classNames('privacy-dropdown', placement, { active: open })}>
-        <div className='privacy-dropdown__value' ref={this.setTargetRef} >
-          <TextIconButton
-            className='privacy-dropdown__value-icon'
-            label={value && value.toUpperCase()}
-            title={intl.formatMessage(messages.changeLanguage)}
-            active={open}
-            onClick={this.handleToggle}
-          />
-        </div>
-
-        <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
+      <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
+        <button
+          type='button'
+          title={intl.formatMessage(messages.changeLanguage)}
+          aria-expanded={open}
+          onClick={this.handleToggle}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleButtonKeyDown}
+          className={classNames('dropdown-button', { active: open })}
+        >
+          <Icon icon={TranslateIcon} />
+          <span className='dropdown-button__label'>{current[2] ?? value}</span>
+        </button>
+
+        <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
           {({ props, placement }) => (
             <div {...props}>
               <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >

+ 27 - 41
app/javascript/mastodon/features/compose/components/navigation_bar.jsx

@@ -1,50 +1,36 @@
-import PropTypes from 'prop-types';
+import { useCallback } from 'react';
 
-import { FormattedMessage } from 'react-intl';
+import { useIntl, defineMessages } from 'react-intl';
 
-import { Link } from 'react-router-dom';
+import { useSelector, useDispatch } from 'react-redux';
 
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import CloseIcon from 'mastodon/../material-icons/400-24px/close.svg?react';
+import { cancelReplyCompose } from 'mastodon/actions/compose';
+import Account from 'mastodon/components/account';
+import { IconButton } from 'mastodon/components/icon_button';
+import { me } from 'mastodon/initial_state';
 
-import { Avatar } from '../../../components/avatar';
+import { ActionBar } from './action_bar';
 
-import ActionBar from './action_bar';
 
-export default class NavigationBar extends ImmutablePureComponent {
+const messages = defineMessages({
+  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
 
-  static propTypes = {
-    account: ImmutablePropTypes.record.isRequired,
-    onLogout: PropTypes.func.isRequired,
-    onClose: PropTypes.func,
-  };
+export const NavigationBar = () => {
+  const dispatch = useDispatch();
+  const intl = useIntl();
+  const account = useSelector(state => state.getIn(['accounts', me]));
+  const isReplying = useSelector(state => !!state.getIn(['compose', 'in_reply_to']));
 
-  render () {
-    const username = this.props.account.get('acct');
-    return (
-      <div className='navigation-bar'>
-        <Link to={`/@${username}`}>
-          <span style={{ display: 'none' }}>{username}</span>
-          <Avatar account={this.props.account} size={46} />
-        </Link>
+  const handleCancelClick = useCallback(() => {
+    dispatch(cancelReplyCompose());
+  }, [dispatch]);
 
-        <div className='navigation-bar__profile'>
-          <span>
-            <Link to={`/@${username}`}>
-              <strong className='navigation-bar__profile-account'>@{username}</strong>
-            </Link>
-          </span>
-
-          <span>
-            <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
-          </span>
-        </div>
-
-        <div className='navigation-bar__actions'>
-          <ActionBar account={this.props.account} onLogout={this.props.onLogout} />
-        </div>
-      </div>
-    );
-  }
-
-}
+  return (
+    <div className='navigation-bar'>
+      <Account account={account} minimal />
+      {isReplying ? <IconButton title={intl.formatMessage(messages.cancel)} iconComponent={CloseIcon} onClick={handleCancelClick} /> : <ActionBar />}
+    </div>
+  );
+};

+ 3 - 9
app/javascript/mastodon/features/compose/components/poll_button.jsx

@@ -3,11 +3,10 @@ import { PureComponent } from 'react';
 
 import { defineMessages, injectIntl } from 'react-intl';
 
-import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
+import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
 
 import { IconButton } from '../../../components/icon_button';
 
-
 const messages = defineMessages({
   add_poll: { id: 'poll_button.add_poll', defaultMessage: 'Add a poll' },
   remove_poll: { id: 'poll_button.remove_poll', defaultMessage: 'Remove poll' },
@@ -22,7 +21,6 @@ class PollButton extends PureComponent {
 
   static propTypes = {
     disabled: PropTypes.bool,
-    unavailable: PropTypes.bool,
     active: PropTypes.bool,
     onClick: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -33,17 +31,13 @@ class PollButton extends PureComponent {
   };
 
   render () {
-    const { intl, active, unavailable, disabled } = this.props;
-
-    if (unavailable) {
-      return null;
-    }
+    const { intl, active, disabled } = this.props;
 
     return (
       <div className='compose-form__poll-button'>
         <IconButton
           icon='tasks'
-          iconComponent={InsertChartIcon}
+          iconComponent={BarChart4BarsIcon}
           title={intl.formatMessage(active ? messages.remove_poll : messages.add_poll)}
           disabled={disabled}
           onClick={this.handleClick}

+ 143 - 170
app/javascript/mastodon/features/compose/components/poll_form.jsx

@@ -1,189 +1,162 @@
 import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
+import { useCallback } from 'react';
 
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, useIntl } from 'react-intl';
 
 import classNames from 'classnames';
 
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import { useDispatch, useSelector } from 'react-redux';
 
-import AddIcon from '@/material-icons/400-24px/add.svg?react';
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import {
+  changePollSettings,
+  changePollOption,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+} from 'mastodon/actions/compose';
 import AutosuggestInput from 'mastodon/components/autosuggest_input';
-import { Icon }  from 'mastodon/components/icon';
-import { IconButton } from 'mastodon/components/icon_button';
 
 const messages = defineMessages({
-  option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
-  add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
-  remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
-  poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
+  option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Option {number}' },
+  add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add option' },
+  remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this option' },
+  duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll length' },
+  type: { id: 'compose_form.poll.type', defaultMessage: 'Style' },
   switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
   switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
   minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
   hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
   days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  singleChoice: { id: 'compose_form.poll.single', defaultMessage: 'Pick one' },
+  multipleChoice: { id: 'compose_form.poll.multiple', defaultMessage: 'Multiple choice' },
 });
 
-class OptionIntl extends PureComponent {
-
-  static propTypes = {
-    title: PropTypes.string.isRequired,
-    lang: PropTypes.string,
-    index: PropTypes.number.isRequired,
-    isPollMultiple: PropTypes.bool,
-    autoFocus: PropTypes.bool,
-    onChange: PropTypes.func.isRequired,
-    onRemove: PropTypes.func.isRequired,
-    onToggleMultiple: PropTypes.func.isRequired,
-    suggestions: ImmutablePropTypes.list,
-    onClearSuggestions: PropTypes.func.isRequired,
-    onFetchSuggestions: PropTypes.func.isRequired,
-    onSuggestionSelected: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleOptionTitleChange = e => {
-    this.props.onChange(this.props.index, e.target.value);
-  };
-
-  handleOptionRemove = () => {
-    this.props.onRemove(this.props.index);
-  };
-
-
-  handleToggleMultiple = e => {
-    this.props.onToggleMultiple();
-    e.preventDefault();
-    e.stopPropagation();
-  };
-
-  handleCheckboxKeypress = e => {
-    if (e.key === 'Enter' || e.key === ' ') {
-      this.handleToggleMultiple(e);
-    }
-  };
-
-  onSuggestionsClearRequested = () => {
-    this.props.onClearSuggestions();
-  };
-
-  onSuggestionsFetchRequested = (token) => {
-    this.props.onFetchSuggestions(token);
-  };
-
-  onSuggestionSelected = (tokenStart, token, value) => {
-    this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
-  };
-
-  render () {
-    const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
-
-    return (
-      <li>
-        <label className='poll__option editable'>
-          <span
-            className={classNames('poll__input', { checkbox: isPollMultiple })}
-            onClick={this.handleToggleMultiple}
-            onKeyPress={this.handleCheckboxKeypress}
-            role='button'
-            tabIndex={0}
-            title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
-            aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
-          />
-
-          <AutosuggestInput
-            placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
-            maxLength={50}
-            value={title}
-            lang={lang}
-            spellCheck
-            onChange={this.handleOptionTitleChange}
-            suggestions={this.props.suggestions}
-            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-            onSuggestionSelected={this.onSuggestionSelected}
-            searchTokens={[':']}
-            autoFocus={autoFocus}
-          />
-        </label>
-
-        <div className='poll__cancel'>
-          <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' iconComponent={CloseIcon} onClick={this.handleOptionRemove} />
-        </div>
-      </li>
-    );
+const Select = ({ label, options, value, onChange }) => {
+  return (
+    <label className='compose-form__poll__select'>
+      <span className='compose-form__poll__select__label'>{label}</span>
+
+      <select className='compose-form__poll__select__value' value={value} onChange={onChange}>
+        {options.map((option, i) => (
+          <option key={i} value={option.value}>{option.label}</option>
+        ))}
+      </select>
+    </label>
+  );
+};
+
+Select.propTypes = {
+  label: PropTypes.node,
+  value: PropTypes.any,
+  onChange: PropTypes.func,
+  options: PropTypes.arrayOf(PropTypes.shape({
+    label: PropTypes.node,
+    value: PropTypes.any,
+  })),
+};
+
+const Option = ({ multipleChoice, index, title, autoFocus }) => {
+  const intl = useIntl();
+  const dispatch = useDispatch();
+  const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
+  const lang = useSelector(state => state.getIn(['compose', 'language']));
+
+  const handleChange = useCallback(({ target: { value } }) => {
+    dispatch(changePollOption(index, value));
+  }, [dispatch, index]);
+
+  const handleSuggestionsFetchRequested = useCallback(token => {
+    dispatch(fetchComposeSuggestions(token));
+  }, [dispatch]);
+
+  const handleSuggestionsClearRequested = useCallback(() => {
+    dispatch(clearComposeSuggestions());
+  }, [dispatch]);
+
+  const handleSuggestionSelected = useCallback((tokenStart, token, value) => {
+    dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index]));
+  }, [dispatch, index]);
+
+  return (
+    <label className={classNames('poll__option editable', { empty: index > 1 && title.length === 0 })}>
+      <span className={classNames('poll__input', { checkbox: multipleChoice })} />
+
+      <AutosuggestInput
+        placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
+        maxLength={50}
+        value={title}
+        lang={lang}
+        spellCheck
+        onChange={handleChange}
+        suggestions={suggestions}
+        onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
+        onSuggestionsClearRequested={handleSuggestionsClearRequested}
+        onSuggestionSelected={handleSuggestionSelected}
+        searchTokens={[':']}
+        autoFocus={autoFocus}
+      />
+    </label>
+  );
+};
+
+Option.propTypes = {
+  title: PropTypes.string.isRequired,
+  index: PropTypes.number.isRequired,
+  multipleChoice: PropTypes.bool,
+  autoFocus: PropTypes.bool,
+};
+
+export const PollForm = () => {
+  const intl = useIntl();
+  const dispatch = useDispatch();
+  const poll = useSelector(state => state.getIn(['compose', 'poll']));
+  const options = poll?.get('options');
+  const expiresIn = poll?.get('expires_in');
+  const isMultiple = poll?.get('multiple');
+
+  const handleDurationChange = useCallback(({ target: { value } }) => {
+    dispatch(changePollSettings(value, isMultiple));
+  }, [dispatch, isMultiple]);
+
+  const handleTypeChange = useCallback(({ target: { value } }) => {
+    dispatch(changePollSettings(expiresIn, value === 'true'));
+  }, [dispatch, expiresIn]);
+
+  if (poll === null) {
+    return null;
   }
 
-}
-
-const Option = injectIntl(OptionIntl);
-
-class PollForm extends ImmutablePureComponent {
-
-  static propTypes = {
-    options: ImmutablePropTypes.list,
-    lang: PropTypes.string,
-    expiresIn: PropTypes.number,
-    isMultiple: PropTypes.bool,
-    onChangeOption: PropTypes.func.isRequired,
-    onAddOption: PropTypes.func.isRequired,
-    onRemoveOption: PropTypes.func.isRequired,
-    onChangeSettings: PropTypes.func.isRequired,
-    suggestions: ImmutablePropTypes.list,
-    onClearSuggestions: PropTypes.func.isRequired,
-    onFetchSuggestions: PropTypes.func.isRequired,
-    onSuggestionSelected: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleAddOption = () => {
-    this.props.onAddOption('');
-  };
-
-  handleSelectDuration = e => {
-    this.props.onChangeSettings(e.target.value, this.props.isMultiple);
-  };
-
-  handleToggleMultiple = () => {
-    this.props.onChangeSettings(this.props.expiresIn, !this.props.isMultiple);
-  };
-
-  render () {
-    const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
-
-    if (!options) {
-      return null;
-    }
-
-    const autoFocusIndex = options.indexOf('');
-
-    return (
-      <div className='compose-form__poll-wrapper'>
-        <ul>
-          {options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
-        </ul>
-
-        <div className='poll__footer'>
-          <button type='button' disabled={options.size >= 4} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' icon={AddIcon} /> <FormattedMessage {...messages.add_option} /></button>
-
-          {/* eslint-disable-next-line jsx-a11y/no-onchange */}
-          <select value={expiresIn} onChange={this.handleSelectDuration}>
-            <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
-            <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
-            <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
-            <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
-            <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
-            <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
-            <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
-            <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
-          </select>
-        </div>
+  return (
+    <div className='compose-form__poll'>
+      {options.map((title, i) => (
+        <Option
+          title={title}
+          key={i}
+          index={i}
+          multipleChoice={isMultiple}
+          autoFocus={i === 0}
+        />
+      ))}
+
+      <div className='compose-form__poll__footer'>
+        <Select label={intl.formatMessage(messages.duration)} options={[
+          { value: 300, label: intl.formatMessage(messages.minutes, { number: 5 })},
+          { value: 1800, label: intl.formatMessage(messages.minutes, { number: 30 })},
+          { value: 3600, label: intl.formatMessage(messages.hours, { number: 1 })},
+          { value: 21600, label: intl.formatMessage(messages.hours, { number: 6 })},
+          { value: 43200, label: intl.formatMessage(messages.hours, { number: 12 })},
+          { value: 86400, label: intl.formatMessage(messages.days, { number: 1 })},
+          { value: 259200, label: intl.formatMessage(messages.days, { number: 3 })},
+          { value: 604800, label: intl.formatMessage(messages.days, { number: 7 })},
+        ]} value={expiresIn} onChange={handleDurationChange} />
+
+        <div className='compose-form__poll__footer__sep' />
+
+        <Select label={intl.formatMessage(messages.type)} options={[
+          { value: false, label: intl.formatMessage(messages.singleChoice) },
+          { value: true, label: intl.formatMessage(messages.multipleChoice) },
+        ]} value={isMultiple} onChange={handleTypeChange} />
       </div>
-    );
-  }
-
-}
-
-export default injectIntl(PollForm);
+    </div>
+  );
+};

+ 30 - 46
app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx

@@ -5,28 +5,27 @@ import { injectIntl, defineMessages } from 'react-intl';
 
 import classNames from 'classnames';
 
-
 import { supportsPassiveEvents } from 'detect-passive-events';
 import Overlay from 'react-overlays/Overlay';
 
 import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
+import InfoIcon from '@/material-icons/400-24px/info.svg?react';
 import LockIcon from '@/material-icons/400-24px/lock.svg?react';
-import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
 import PublicIcon from '@/material-icons/400-24px/public.svg?react';
+import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
 import { Icon }  from 'mastodon/components/icon';
 
-import { IconButton } from '../../../components/icon_button';
-
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
-  public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
-  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
-  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
-  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
-  private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
-  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
-  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
-  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Everyone mentioned in the post' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Change post privacy' },
+  unlisted_extra: { id: 'privacy.unlisted.additional', defaultMessage: 'This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.' },
 });
 
 const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
@@ -135,6 +134,12 @@ class PrivacyDropdownMenu extends PureComponent {
               <strong>{item.text}</strong>
               {item.meta}
             </div>
+
+            {item.extra && (
+              <div className='privacy-dropdown__option__additional' title={item.extra}>
+                <Icon id='info-circle' icon={InfoIcon} />
+              </div>
+            )}
           </div>
         ))}
       </div>
@@ -163,30 +168,11 @@ class PrivacyDropdown extends PureComponent {
   };
 
   handleToggle = () => {
-    if (this.props.isUserTouching && this.props.isUserTouching()) {
-      if (this.state.open) {
-        this.props.onModalClose();
-      } else {
-        this.props.onModalOpen({
-          actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
-          onClick: this.handleModalActionClick,
-        });
-      }
-    } else {
-      if (this.state.open && this.activeElement) {
-        this.activeElement.focus({ preventScroll: true });
-      }
-      this.setState({ open: !this.state.open });
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
     }
-  };
 
-  handleModalActionClick = (e) => {
-    e.preventDefault();
-
-    const { value } = this.options[e.currentTarget.getAttribute('data-index')];
-
-    this.props.onModalClose();
-    this.props.onChange(value);
+    this.setState({ open: !this.state.open });
   };
 
   handleKeyDown = e => {
@@ -228,7 +214,7 @@ class PrivacyDropdown extends PureComponent {
 
     this.options = [
       { icon: 'globe', iconComponent: PublicIcon, value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
-      { icon: 'unlock', iconComponent: LockOpenIcon,  value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+      { icon: 'unlock', iconComponent: QuietTimeIcon,  value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long), extra: formatMessage(messages.unlisted_extra) },
       { icon: 'lock', iconComponent: LockIcon, value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
     ];
 
@@ -259,23 +245,21 @@ class PrivacyDropdown extends PureComponent {
 
     return (
       <div ref={this.setTargetRef} onKeyDown={this.handleKeyDown}>
-        <IconButton
-          className='privacy-dropdown__value-icon'
-          icon={valueOption.icon}
-          iconComponent={valueOption.iconComponent}
+        <button
+          type='button'
           title={intl.formatMessage(messages.change_privacy)}
-          size={18}
-          expanded={open}
-          active={open}
-          inverted
+          aria-expanded={open}
           onClick={this.handleToggle}
           onMouseDown={this.handleMouseDown}
           onKeyDown={this.handleButtonKeyDown}
-          style={{ height: null, lineHeight: '27px' }}
           disabled={disabled}
-        />
+          className={classNames('dropdown-button', { active: open })}
+        >
+          <Icon id={valueOption.icon} icon={valueOption.iconComponent} />
+          <span className='dropdown-button__label'>{valueOption.text}</span>
+        </button>
 
-        <Overlay show={open} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
+        <Overlay show={open} offset={[5, 5]} placement={placement} flip target={this.findTarget} container={container} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
           {({ props, placement }) => (
             <div {...props}>
               <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>

+ 34 - 60
app/javascript/mastodon/features/compose/components/reply_indicator.jsx

@@ -1,74 +1,48 @@
-import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
 
-import { defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
 
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
+import { useSelector } from 'react-redux';
 
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import AttachmentList from 'mastodon/components/attachment_list';
-import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
+import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
+import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
+import { Avatar } from 'mastodon/components/avatar';
+import { DisplayName } from 'mastodon/components/display_name';
+import { Icon } from 'mastodon/components/icon';
 
-import { Avatar } from '../../../components/avatar';
-import { DisplayName } from '../../../components/display_name';
-import { IconButton } from '../../../components/icon_button';
+export const ReplyIndicator = () => {
+  const inReplyToId = useSelector(state => state.getIn(['compose', 'in_reply_to']));
+  const status = useSelector(state => state.getIn(['statuses', inReplyToId]));
+  const account = useSelector(state => state.getIn(['accounts', status?.get('account')]));
 
-const messages = defineMessages({
-  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
-});
-
-class ReplyIndicator extends ImmutablePureComponent {
-
-  static propTypes = {
-    status: ImmutablePropTypes.map,
-    onCancel: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-    ...WithOptionalRouterPropTypes,
-  };
-
-  handleClick = () => {
-    this.props.onCancel();
-  };
-
-  handleAccountClick = (e) => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
-      e.preventDefault();
-      this.props.history?.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
-    }
-  };
-
-  render () {
-    const { status, intl } = this.props;
+  if (!status) {
+    return null;
+  }
 
-    if (!status) {
-      return null;
-    }
+  const content = { __html: status.get('contentHtml') };
 
-    const content = { __html: status.get('contentHtml') };
+  return (
+    <div className='reply-indicator'>
+      <div className='reply-indicator__line' />
 
-    return (
-      <div className='reply-indicator'>
-        <div className='reply-indicator__header'>
-          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' iconComponent={CloseIcon} onClick={this.handleClick} inverted /></div>
+      <Link to={`/@${account.get('acct')}`} className='detailed-status__display-avatar'>
+        <Avatar account={account} size={46} />
+      </Link>
 
-          <a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
-            <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
-            <DisplayName account={status.get('account')} />
-          </a>
-        </div>
+      <div className='reply-indicator__main'>
+        <Link to={`/@${account.get('acct')}`} className='detailed-status__display-name'>
+          <DisplayName account={account} />
+        </Link>
 
         <div className='reply-indicator__content translate' dangerouslySetInnerHTML={content} />
 
-        {status.get('media_attachments').size > 0 && (
-          <AttachmentList
-            compact
-            media={status.get('media_attachments')}
-          />
+        {(status.get('poll') || status.get('media_attachments').size > 0) && (
+          <div className='reply-indicator__attachments'>
+            {status.get('poll') && <><Icon icon={BarChart4BarsIcon} /><FormattedMessage id='reply_indicator.poll' defaultMessage='Poll' /></>}
+            {status.get('media_attachments').size > 0 && <><Icon icon={PhotoLibraryIcon} /><FormattedMessage id='reply_indicator.attachments' defaultMessage='{count, plural, one {# attachment} other {# attachments}}' values={{ count: status.get('media_attachments').size }} /></>}
+          </div>
         )}
       </div>
-    );
-  }
-
-}
-
-export default withOptionalRouter(injectIntl(ReplyIndicator));
+    </div>
+  );
+};

+ 18 - 10
app/javascript/mastodon/features/compose/components/upload.jsx

@@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
 
 import { FormattedMessage } from 'react-intl';
 
+import classNames from 'classnames';
+
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -9,7 +11,8 @@ import spring from 'react-motion/lib/spring';
 
 import CloseIcon from '@/material-icons/400-24px/close.svg?react';
 import EditIcon from '@/material-icons/400-24px/edit.svg?react';
-import InfoIcon from '@/material-icons/400-24px/info.svg?react';
+import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
+import { Blurhash } from 'mastodon/components/blurhash';
 import { Icon }  from 'mastodon/components/icon';
 
 import Motion from '../../ui/util/optional_motion';
@@ -18,6 +21,7 @@ export default class Upload extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
+    sensitive: PropTypes.bool,
     onUndo: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
   };
@@ -33,7 +37,7 @@ export default class Upload extends ImmutablePureComponent {
   };
 
   render () {
-    const { media } = this.props;
+    const { media, sensitive } = this.props;
 
     if (!media) {
       return null;
@@ -43,22 +47,26 @@ export default class Upload extends ImmutablePureComponent {
     const focusY = media.getIn(['meta', 'focus', 'y']);
     const x = ((focusX /  2) + .5) * 100;
     const y = ((focusY / -2) + .5) * 100;
+    const missingDescription = (media.get('description') || '').length === 0;
 
     return (
       <div className='compose-form__upload'>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
           {({ scale }) => (
-            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+            <div className='compose-form__upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: !sensitive ? `url(${media.get('preview_url')})` : null, backgroundPosition: `${x}% ${y}%` }}>
+              {sensitive && <Blurhash
+                hash={media.get('blurhash')}
+                className='compose-form__upload__preview'
+              />}
+
               <div className='compose-form__upload__actions'>
-                <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+                <button type='button' className='icon-button compose-form__upload__delete' onClick={this.handleUndoClick}><Icon icon={CloseIcon} /></button>
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon icon={EditIcon} /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
 
-              {(media.get('description') || '').length === 0 && (
-                <div className='compose-form__upload__warning'>
-                  <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' icon={InfoIcon} /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
-                </div>
-              )}
+              <div className='compose-form__upload__warning'>
+                <button type='button' className={classNames('icon-button', { active: missingDescription })} onClick={this.handleFocalPointClick}>{missingDescription && <Icon icon={WarningIcon} />} ALT</button>
+              </div>
             </div>
           )}
         </Motion>

+ 4 - 10
app/javascript/mastodon/features/compose/components/upload_button.jsx

@@ -6,9 +6,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
 
-import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react';
-
-import { IconButton } from '../../../components/icon_button';
+import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
+import { IconButton } from 'mastodon/components/icon_button';
 
 const messages = defineMessages({
   upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
@@ -31,7 +30,6 @@ class UploadButton extends ImmutablePureComponent {
 
   static propTypes = {
     disabled: PropTypes.bool,
-    unavailable: PropTypes.bool,
     onSelectFile: PropTypes.func.isRequired,
     style: PropTypes.object,
     resetFileKey: PropTypes.number,
@@ -54,17 +52,13 @@ class UploadButton extends ImmutablePureComponent {
   };
 
   render () {
-    const { intl, resetFileKey, unavailable, disabled, acceptContentTypes } = this.props;
-
-    if (unavailable) {
-      return null;
-    }
+    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
 
     const message = intl.formatMessage(messages.upload);
 
     return (
       <div className='compose-form__upload-button'>
-        <IconButton icon='paperclip' iconComponent={AddPhotoAlternateIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
+        <IconButton icon='paperclip' iconComponent={PhotoLibraryIcon} title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
         <label>
           <span style={{ display: 'none' }}>{message}</span>
           <input

+ 9 - 10
app/javascript/mastodon/features/compose/components/upload_form.jsx

@@ -1,7 +1,6 @@
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
 import UploadContainer from '../containers/upload_container';
 import UploadProgressContainer from '../containers/upload_progress_container';
 
@@ -15,17 +14,17 @@ export default class UploadForm extends ImmutablePureComponent {
     const { mediaIds } = this.props;
 
     return (
-      <div className='compose-form__upload-wrapper'>
+      <>
         <UploadProgressContainer />
 
-        <div className='compose-form__uploads-wrapper'>
-          {mediaIds.map(id => (
-            <UploadContainer id={id} key={id} />
-          ))}
-        </div>
-
-        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
-      </div>
+        {mediaIds.size > 0 && (
+          <div className='compose-form__uploads'>
+            {mediaIds.map(id => (
+              <UploadContainer id={id} key={id} />
+            ))}
+          </div>
+        )}
+      </>
     );
   }
 

+ 1 - 3
app/javascript/mastodon/features/compose/components/upload_progress.jsx

@@ -35,9 +35,7 @@ export default class UploadProgress extends PureComponent {
 
     return (
       <div className='upload-progress'>
-        <div className='upload-progress__icon'>
-          <Icon id='upload' icon={UploadFileIcon} />
-        </div>
+        <Icon id='upload' icon={UploadFileIcon} />
 
         <div className='upload-progress__message'>
           {message}

+ 0 - 36
app/javascript/mastodon/features/compose/containers/navigation_container.js

@@ -1,36 +0,0 @@
-import { defineMessages, injectIntl } from 'react-intl';
-
-import { connect }   from 'react-redux';
-
-import { openModal } from 'mastodon/actions/modal';
-import { logOut } from 'mastodon/utils/log_out';
-
-import { me } from '../../../initial_state';
-import NavigationBar from '../components/navigation_bar';
-
-const messages = defineMessages({
-  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
-  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
-});
-
-const mapStateToProps = state => {
-  return {
-    account: state.getIn(['accounts', me]),
-  };
-};
-
-const mapDispatchToProps = (dispatch, { intl }) => ({
-  onLogout () {
-    dispatch(openModal({
-      modalType: 'CONFIRM',
-      modalProps: {
-        message: intl.formatMessage(messages.logoutMessage),
-        confirm: intl.formatMessage(messages.logoutConfirm),
-        closeWhenConfirm: false,
-        onConfirm: () => logOut(),
-      },
-    }));
-  },
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));

+ 1 - 1
app/javascript/mastodon/features/compose/containers/poll_button_container.js

@@ -4,7 +4,7 @@ import { addPoll, removePoll } from '../../../actions/compose';
 import PollButton from '../components/poll_button';
 
 const mapStateToProps = state => ({
-  unavailable: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
+  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 0),
   active: state.getIn(['compose', 'poll']) !== null,
 });
 

+ 0 - 53
app/javascript/mastodon/features/compose/containers/poll_form_container.js

@@ -1,53 +0,0 @@
-import { connect } from 'react-redux';
-
-import {
-  addPollOption,
-  removePollOption,
-  changePollOption,
-  changePollSettings,
-  clearComposeSuggestions,
-  fetchComposeSuggestions,
-  selectComposeSuggestion,
-} from '../../../actions/compose';
-import PollForm from '../components/poll_form';
-
-const mapStateToProps = state => ({
-  suggestions: state.getIn(['compose', 'suggestions']),
-  options: state.getIn(['compose', 'poll', 'options']),
-  lang: state.getIn(['compose', 'language']),
-  expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
-  isMultiple: state.getIn(['compose', 'poll', 'multiple']),
-});
-
-const mapDispatchToProps = dispatch => ({
-  onAddOption(title) {
-    dispatch(addPollOption(title));
-  },
-
-  onRemoveOption(index) {
-    dispatch(removePollOption(index));
-  },
-
-  onChangeOption(index, title) {
-    dispatch(changePollOption(index, title));
-  },
-
-  onChangeSettings(expiresIn, isMultiple) {
-    dispatch(changePollSettings(expiresIn, isMultiple));
-  },
-
-  onClearSuggestions () {
-    dispatch(clearComposeSuggestions());
-  },
-
-  onFetchSuggestions (token) {
-    dispatch(fetchComposeSuggestions(token));
-  },
-
-  onSuggestionSelected (position, token, accountId, path) {
-    dispatch(selectComposeSuggestion(position, token, accountId, path));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(PollForm);

+ 0 - 36
app/javascript/mastodon/features/compose/containers/reply_indicator_container.js

@@ -1,36 +0,0 @@
-import { connect } from 'react-redux';
-
-import { cancelReplyCompose } from '../../../actions/compose';
-import { makeGetStatus } from '../../../selectors';
-import ReplyIndicator from '../components/reply_indicator';
-
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
-
-  const mapStateToProps = state => {
-    let statusId = state.getIn(['compose', 'id'], null);
-    let editing  = true;
-
-    if (statusId === null) {
-      statusId = state.getIn(['compose', 'in_reply_to']);
-      editing  = false;
-    }
-
-    return {
-      status: getStatus(state, { id: statusId }),
-      editing,
-    };
-  };
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => ({
-
-  onCancel () {
-    dispatch(cancelReplyCompose());
-  },
-
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

+ 0 - 73
app/javascript/mastodon/features/compose/containers/sensitive_button_container.jsx

@@ -1,73 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-
-import classNames from 'classnames';
-
-import { connect } from 'react-redux';
-
-import { changeComposeSensitivity } from 'mastodon/actions/compose';
-
-const messages = defineMessages({
-  marked: {
-    id: 'compose_form.sensitive.marked',
-    defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
-  },
-  unmarked: {
-    id: 'compose_form.sensitive.unmarked',
-    defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
-  },
-});
-
-const mapStateToProps = state => ({
-  active: state.getIn(['compose', 'sensitive']),
-  disabled: state.getIn(['compose', 'spoiler']),
-  mediaCount: state.getIn(['compose', 'media_attachments']).size,
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onClick () {
-    dispatch(changeComposeSensitivity());
-  },
-
-});
-
-class SensitiveButton extends PureComponent {
-
-  static propTypes = {
-    active: PropTypes.bool,
-    disabled: PropTypes.bool,
-    mediaCount: PropTypes.number,
-    onClick: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { active, disabled, mediaCount, onClick, intl } = this.props;
-
-    return (
-      <div className='compose-form__sensitive-button'>
-        <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
-          <input
-            name='mark-sensitive'
-            type='checkbox'
-            checked={active}
-            onChange={onClick}
-            disabled={disabled}
-          />
-
-          <FormattedMessage
-            id='compose_form.sensitive.hide'
-            defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
-            values={{ count: mediaCount }}
-          />
-        </label>
-      </div>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));

+ 7 - 3
app/javascript/mastodon/features/compose/containers/spoiler_button_container.js

@@ -2,8 +2,10 @@ import { injectIntl, defineMessages } from 'react-intl';
 
 import { connect } from 'react-redux';
 
+import WarningIcon from 'mastodon/../material-icons/400-24px/warning.svg?react';
+import { IconButton } from 'mastodon/components/icon_button';
+
 import { changeComposeSpoilerness } from '../../../actions/compose';
-import TextIconButton from '../components/text_icon_button';
 
 const messages = defineMessages({
   marked: { id: 'compose_form.spoiler.marked', defaultMessage: 'Text is hidden behind warning' },
@@ -11,10 +13,12 @@ const messages = defineMessages({
 });
 
 const mapStateToProps = (state, { intl }) => ({
-  label: 'CW',
+  iconComponent: WarningIcon,
   title: intl.formatMessage(state.getIn(['compose', 'spoiler']) ? messages.marked : messages.unmarked),
   active: state.getIn(['compose', 'spoiler']),
   ariaControls: 'cw-spoiler-input',
+  size: 18,
+  inverted: true,
 });
 
 const mapDispatchToProps = dispatch => ({
@@ -25,4 +29,4 @@ const mapDispatchToProps = dispatch => ({
 
 });
 
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(IconButton));

+ 1 - 2
app/javascript/mastodon/features/compose/containers/upload_button_container.js

@@ -4,8 +4,7 @@ import { uploadCompose } from '../../../actions/compose';
 import UploadButton from '../components/upload_button';
 
 const mapStateToProps = state => ({
-  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
-  unavailable: state.getIn(['compose', 'poll']) !== null,
+  disabled: state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
   resetFileKey: state.getIn(['compose', 'resetFileKey']),
 });
 

+ 1 - 0
app/javascript/mastodon/features/compose/containers/upload_container.js

@@ -5,6 +5,7 @@ import Upload from '../components/upload';
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+  sensitive: state.getIn(['compose', 'spoiler']),
 });
 
 const mapDispatchToProps = dispatch => ({

+ 0 - 4
app/javascript/mastodon/features/compose/index.jsx

@@ -30,7 +30,6 @@ import { isMobile } from '../../is_mobile';
 import Motion from '../ui/util/optional_motion';
 
 import ComposeFormContainer from './containers/compose_form_container';
-import NavigationContainer from './containers/navigation_container';
 import SearchContainer from './containers/search_container';
 import SearchResultsContainer from './containers/search_results_container';
 
@@ -129,8 +128,6 @@ class Compose extends PureComponent {
 
           <div className='drawer__pager'>
             <div className='drawer__inner' onFocus={this.onFocus}>
-              <NavigationContainer onClose={this.onBlur} />
-
               <ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
 
               <div className='drawer__inner__mastodon'>
@@ -152,7 +149,6 @@ class Compose extends PureComponent {
 
     return (
       <Column onFocus={this.onFocus}>
-        <NavigationContainer onClose={this.onBlur} />
         <ComposeFormContainer />
 
         <Helmet>

+ 2 - 2
app/javascript/mastodon/features/getting_started/index.jsx

@@ -26,7 +26,7 @@ import ColumnHeader from 'mastodon/components/column_header';
 import LinkFooter from 'mastodon/features/ui/components/link_footer';
 
 import { me, showTrends } from '../../initial_state';
-import NavigationContainer from '../compose/containers/navigation_container';
+import { NavigationBar } from '../compose/components/navigation_bar';
 import ColumnLink from '../ui/components/column_link';
 import ColumnSubheading from '../ui/components/column_subheading';
 
@@ -143,7 +143,7 @@ class GettingStarted extends ImmutablePureComponent {
 
     return (
       <Column>
-        {(signedIn && !multiColumn) ? <NavigationContainer /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' iconComponent={MenuIcon} multiColumn={multiColumn} />}
+        {(signedIn && !multiColumn) ? <NavigationBar /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' iconComponent={MenuIcon} multiColumn={multiColumn} />}
 
         <div className='getting-started scrollable scrollable--flex'>
           <div className='getting-started__wrapper'>

+ 15 - 21
app/javascript/mastodon/features/standalone/compose/index.jsx

@@ -1,21 +1,15 @@
-import { PureComponent } from 'react';
-
-import ComposeFormContainer from '../../compose/containers/compose_form_container';
-import LoadingBarContainer from '../../ui/containers/loading_bar_container';
-import ModalContainer from '../../ui/containers/modal_container';
-import NotificationsContainer from '../../ui/containers/notifications_container';
-
-export default class Compose extends PureComponent {
-
-  render () {
-    return (
-      <div>
-        <ComposeFormContainer autoFocus />
-        <NotificationsContainer />
-        <ModalContainer />
-        <LoadingBarContainer className='loading-bar' />
-      </div>
-    );
-  }
-
-}
+import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
+import LoadingBarContainer from 'mastodon/features/ui/containers/loading_bar_container';
+import ModalContainer from 'mastodon/features/ui/containers/modal_container';
+import NotificationsContainer from 'mastodon/features/ui/containers/notifications_container';
+
+const Compose = () => (
+  <>
+    <ComposeFormContainer autoFocus withoutNavigation />
+    <NotificationsContainer />
+    <ModalContainer />
+    <LoadingBarContainer className='loading-bar' />
+  </>
+);
+
+export default Compose;

+ 1 - 5
app/javascript/mastodon/features/ui/components/compose_panel.jsx

@@ -6,7 +6,6 @@ import { connect } from 'react-redux';
 import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
 import ServerBanner from 'mastodon/components/server_banner';
 import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
-import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
 import SearchContainer from 'mastodon/features/compose/containers/search_container';
 
 import LinkFooter from './link_footer';
@@ -56,10 +55,7 @@ class ComposePanel extends PureComponent {
         )}
 
         {signedIn && (
-          <>
-            <NavigationContainer onClose={this.onBlur} />
-            <ComposeFormContainer singleColumn />
-          </>
+          <ComposeFormContainer singleColumn />
         )}
 
         <LinkFooter />

+ 1 - 1
app/javascript/mastodon/features/ui/components/focal_point_modal.jsx

@@ -21,7 +21,7 @@ import { Button } from 'mastodon/components/button';
 import { GIFV } from 'mastodon/components/gifv';
 import { IconButton } from 'mastodon/components/icon_button';
 import Audio from 'mastodon/features/audio';
-import CharacterCounter from 'mastodon/features/compose/components/character_counter';
+import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
 import UploadProgress from 'mastodon/features/compose/components/upload_progress';
 import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
 import { me } from 'mastodon/initial_state';

+ 0 - 1
app/javascript/mastodon/features/ui/components/mute_modal.jsx

@@ -108,7 +108,6 @@ class MuteModal extends PureComponent {
           <div>
             <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
 
-            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
             <select value={muteDuration} onChange={this.changeMuteDuration}>
               <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
               <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>

+ 0 - 1
app/javascript/mastodon/features/ui/components/navigation_panel.jsx

@@ -77,7 +77,6 @@ class NavigationPanel extends Component {
       <div className='navigation-panel'>
         <div className='navigation-panel__logo'>
           <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
-          {!banner && <hr />}
         </div>
 
         {banner &&

+ 20 - 21
app/javascript/mastodon/locales/en.json

@@ -89,7 +89,6 @@
   "announcement.announcement": "Announcement",
   "attachments_list.unprocessed": "(unprocessed)",
   "audio.hide": "Hide audio",
-  "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.copy_stacktrace": "Copy error report",
   "bundle_column_error.error.body": "The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.",
@@ -146,22 +145,22 @@
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "What's on your mind?",
-  "compose_form.poll.add_option": "Add a choice",
+  "compose_form.poll.add_option": "Add option",
   "compose_form.poll.duration": "Poll duration",
-  "compose_form.poll.option_placeholder": "Choice {number}",
-  "compose_form.poll.remove_option": "Remove this choice",
+  "compose_form.poll.multiple": "Multiple choice",
+  "compose_form.poll.option_placeholder": "Option {number}",
+  "compose_form.poll.remove_option": "Remove this option",
+  "compose_form.poll.single": "Pick one",
   "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
   "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
-  "compose_form.publish": "Publish",
+  "compose_form.poll.type": "Style",
+  "compose_form.publish": "Post",
   "compose_form.publish_form": "New post",
-  "compose_form.publish_loud": "{publish}!",
-  "compose_form.save_changes": "Save changes",
-  "compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
-  "compose_form.sensitive.marked": "{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}",
-  "compose_form.sensitive.unmarked": "{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}",
+  "compose_form.reply": "Reply",
+  "compose_form.save_changes": "Update",
   "compose_form.spoiler.marked": "Remove content warning",
   "compose_form.spoiler.unmarked": "Add content warning",
-  "compose_form.spoiler_placeholder": "Write your warning here",
+  "compose_form.spoiler_placeholder": "Content warning (optional)",
   "confirmation_modal.cancel": "Cancel",
   "confirmations.block.block_and_report": "Block & Report",
   "confirmations.block.confirm": "Block",
@@ -408,7 +407,6 @@
   "navigation_bar.direct": "Private mentions",
   "navigation_bar.discover": "Discover",
   "navigation_bar.domain_blocks": "Blocked domains",
-  "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.explore": "Explore",
   "navigation_bar.favourites": "Favorites",
   "navigation_bar.filters": "Muted words",
@@ -526,14 +524,15 @@
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Change post privacy",
-  "privacy.direct.long": "Visible for mentioned users only",
-  "privacy.direct.short": "Mentioned people only",
-  "privacy.private.long": "Visible for followers only",
-  "privacy.private.short": "Followers only",
-  "privacy.public.long": "Visible for all",
+  "privacy.direct.long": "Everyone mentioned in the post",
+  "privacy.direct.short": "Specific people",
+  "privacy.private.long": "Only your followers",
+  "privacy.private.short": "Followers",
+  "privacy.public.long": "Anyone on and off Mastodon",
   "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Visible for all, but opted-out of discovery features",
-  "privacy.unlisted.short": "Unlisted",
+  "privacy.unlisted.additional": "This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.",
+  "privacy.unlisted.long": "Fewer algorithmic fanfares",
+  "privacy.unlisted.short": "Quiet public",
   "privacy_policy.last_updated": "Last updated {date}",
   "privacy_policy.title": "Privacy Policy",
   "recommended": "Recommended",
@@ -551,7 +550,9 @@
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "relative_time.today": "today",
+  "reply_indicator.attachments": "{count, plural, one {# attachment} other {# attachments}}",
   "reply_indicator.cancel": "Cancel",
+  "reply_indicator.poll": "Poll",
   "report.block": "Block",
   "report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
   "report.categories.legal": "Legal",
@@ -715,10 +716,8 @@
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
   "upload_form.description": "Describe for people who are blind or have low vision",
-  "upload_form.description_missing": "No description added",
   "upload_form.edit": "Edit",
   "upload_form.thumbnail": "Change thumbnail",
-  "upload_form.undo": "Delete",
   "upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",

+ 13 - 7
app/javascript/mastodon/reducers/compose.js

@@ -40,9 +40,7 @@ import {
   COMPOSE_RESET,
   COMPOSE_POLL_ADD,
   COMPOSE_POLL_REMOVE,
-  COMPOSE_POLL_OPTION_ADD,
   COMPOSE_POLL_OPTION_CHANGE,
-  COMPOSE_POLL_OPTION_REMOVE,
   COMPOSE_POLL_SETTINGS_CHANGE,
   INIT_MEDIA_EDIT_MODAL,
   COMPOSE_CHANGE_MEDIA_DESCRIPTION,
@@ -282,6 +280,18 @@ const updateSuggestionTags = (state, token) => {
   });
 };
 
+const updatePoll = (state, index, value) => state.updateIn(['poll', 'options'], options => {
+  const tmp = options.set(index, value).filterNot(x => x.trim().length === 0);
+
+  if (tmp.size === 0) {
+    return tmp.push('').push('');
+  } else if (tmp.size < 4) {
+    return tmp.push('');
+  }
+
+  return tmp;
+});
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -518,12 +528,8 @@ export default function compose(state = initialState, action) {
     return state.set('poll', initialPoll);
   case COMPOSE_POLL_REMOVE:
     return state.set('poll', null);
-  case COMPOSE_POLL_OPTION_ADD:
-    return state.updateIn(['poll', 'options'], options => options.push(action.title));
   case COMPOSE_POLL_OPTION_CHANGE:
-    return state.setIn(['poll', 'options', action.index], action.title);
-  case COMPOSE_POLL_OPTION_REMOVE:
-    return state.updateIn(['poll', 'options'], options => options.delete(action.index));
+    return updatePoll(state, action.index, action.title);
   case COMPOSE_POLL_SETTINGS_CHANGE:
     return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
   case COMPOSE_LANGUAGE_CHANGE:

+ 1 - 0
app/javascript/material-icons/400-24px/bar_chart_4_bars-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M80-120v-80h800v80H80Zm40-120v-280h120v280H120Zm200 0v-480h120v480H320Zm200 0v-360h120v360H520Zm200 0v-600h120v600H720Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/bar_chart_4_bars.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M80-120v-80h800v80H80Zm40-120v-280h120v280H120Zm200 0v-480h120v480H320Zm200 0v-360h120v360H520Zm200 0v-600h120v600H720Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/mood-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Zm0 180q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/mood.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Zm0 180q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 320q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/photo_library-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-400h400L622-580l-92 120-62-80-108 140Zm-40 160q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320ZM160-80q-33 0-56.5-23.5T80-160v-560h80v560h560v80H160Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/photo_library.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M360-400h400L622-580l-92 120-62-80-108 140Zm-40 160q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320Zm0-80h480v-480H320v480ZM160-80q-33 0-56.5-23.5T80-160v-560h80v560h560v80H160Zm160-720v480-480Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/quiet_time-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M524-40q-84 0-157.5-32t-128-86.5Q184-213 152-286.5T120-444q0-146 93-257.5T450-840q-18 99 11 193.5T561-481q71 71 165.5 100T920-370q-26 144-138 237T524-40Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/quiet_time.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M524-40q-84 0-157.5-32t-128-86.5Q184-213 152-286.5T120-444q0-146 93-257.5T450-840q-18 99 11 193.5T561-481q71 71 165.5 100T920-370q-26 144-138 237T524-40Zm0-80q88 0 163-44t118-121q-86-8-163-43.5T504-425q-61-61-97-138t-43-163q-77 43-120.5 118.5T200-444q0 135 94.5 229.5T524-120Zm-20-305Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/translate-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m476-80 182-480h84L924-80h-84l-43-122H603L560-80h-84ZM160-200l-56-56 202-202q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H40v-80h280v-80h80v80h280v80H564q-21 72-63 148t-83 116l96 98-30 82-122-125-202 201Zm468-72h144l-72-204-72 204Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/translate.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m476-80 182-480h84L924-80h-84l-43-122H603L560-80h-84ZM160-200l-56-56 202-202q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H40v-80h280v-80h80v80h280v80H564q-21 72-63 148t-83 116l96 98-30 82-122-125-202 201Zm468-72h144l-72-204-72 204Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/warning-fill.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m40-120 440-760 440 760H40Zm440-120q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Z"/></svg>

+ 1 - 0
app/javascript/material-icons/400-24px/warning.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>

+ 3 - 1
app/javascript/packs/share.jsx

@@ -13,10 +13,12 @@ function loaded() {
 
   if (mountNode) {
     const attr = mountNode.getAttribute('data-props');
-    if(!attr) return;
+
+    if (!attr) return;
 
     const props = JSON.parse(attr);
     const root = createRoot(mountNode);
+
     root.render(<ComposeContainer {...props} />);
   }
 }

+ 6 - 31
app/javascript/styles/contrast/diff.scss

@@ -1,20 +1,7 @@
-.compose-form {
-  .compose-form__modifiers {
-    .compose-form__upload {
-      &-description {
-        input {
-          &::placeholder {
-            opacity: 1;
-          }
-        }
-      }
-    }
-  }
-}
-
 .status__content a,
-.link-footer a,
 .reply-indicator__content a,
+.edit-indicator__content a,
+.link-footer a,
 .status__content__read-more-button,
 .status__content__translate-button {
   text-decoration: underline;
@@ -42,7 +29,9 @@
   }
 }
 
-.status__content a {
+.status__content a,
+.reply-indicator__content a,
+.edit-indicator__content a {
   color: $highlight-text-color;
 }
 
@@ -50,24 +39,10 @@
   color: $darker-text-color;
 }
 
-.compose-form__poll-wrapper .button.button-secondary,
-.compose-form .autosuggest-textarea__textarea::placeholder,
-.compose-form .spoiler-input__input::placeholder,
-.report-dialog-modal__textarea::placeholder,
-.language-dropdown__dropdown__results__item__common-name,
-.compose-form .icon-button {
+.report-dialog-modal__textarea::placeholder {
   color: $inverted-text-color;
 }
 
-.text-icon-button.active {
-  color: $ui-highlight-color;
-}
-
-.language-dropdown__dropdown__results__item.active {
-  background: $ui-highlight-color;
-  font-weight: 500;
-}
-
 .link-button:disabled {
   cursor: not-allowed;
 

+ 28 - 125
app/javascript/styles/mastodon-light/diff.scss

@@ -145,10 +145,6 @@ html {
   }
 }
 
-.compose-form__autosuggest-wrapper,
-.poll__option input[type='text'],
-.compose-form .spoiler-input__input,
-.compose-form__poll-wrapper select,
 .search__input,
 .setting-text,
 .report-dialog-modal__textarea,
@@ -172,28 +168,11 @@ html {
   border-bottom: 0;
 }
 
-.compose-form__poll-wrapper select {
-  background: $simple-background-color
-    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>")
-    no-repeat right 8px center / auto 16px;
-}
-
-.compose-form__poll-wrapper,
-.compose-form__poll-wrapper .poll__footer {
-  border-top-color: lighten($ui-base-color, 8%);
-}
-
 .notification__filter-bar {
   border: 1px solid lighten($ui-base-color, 8%);
   border-top: 0;
 }
 
-.compose-form .compose-form__buttons-wrapper {
-  background: $ui-base-color;
-  border: 1px solid lighten($ui-base-color, 8%);
-  border-top: 0;
-}
-
 .drawer__header,
 .drawer__inner {
   background: $white;
@@ -206,52 +185,6 @@ html {
     no-repeat bottom / 100% auto;
 }
 
-// Change the colors used in compose-form
-.compose-form {
-  .compose-form__modifiers {
-    .compose-form__upload__actions .icon-button,
-    .compose-form__upload__warning .icon-button {
-      color: lighten($white, 7%);
-
-      &:active,
-      &:focus,
-      &:hover {
-        color: $white;
-      }
-    }
-  }
-
-  .compose-form__buttons-wrapper {
-    background: darken($ui-base-color, 6%);
-  }
-
-  .autosuggest-textarea__suggestions {
-    background: darken($ui-base-color, 6%);
-  }
-
-  .autosuggest-textarea__suggestions__item {
-    &:hover,
-    &:focus,
-    &:active,
-    &.selected {
-      background: lighten($ui-base-color, 4%);
-    }
-  }
-}
-
-.emoji-mart-bar {
-  border-color: lighten($ui-base-color, 4%);
-
-  &:first-child {
-    background: darken($ui-base-color, 6%);
-  }
-}
-
-.emoji-mart-search input {
-  background: rgba($ui-base-color, 0.3);
-  border-color: $ui-base-color;
-}
-
 .upload-progress__backdrop {
   background: $ui-base-color;
 }
@@ -283,46 +216,11 @@ html {
   background: $ui-base-color;
 }
 
-.privacy-dropdown.active .privacy-dropdown__value.active .icon-button {
-  color: $white;
-}
-
 .account-gallery__item a {
   background-color: $ui-base-color;
 }
 
-// Change the colors used in the dropdown menu
-.dropdown-menu {
-  background: $white;
-
-  &__arrow::before {
-    background-color: $white;
-  }
-
-  &__item {
-    color: $darker-text-color;
-
-    &--dangerous {
-      color: $error-value-color;
-    }
-
-    a,
-    button {
-      background: $white;
-    }
-  }
-}
-
 // Change the text colors on inverted background
-.privacy-dropdown__option.active,
-.privacy-dropdown__option:hover,
-.privacy-dropdown__option.active .privacy-dropdown__option__content,
-.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
-.privacy-dropdown__option:hover .privacy-dropdown__option__content,
-.privacy-dropdown__option:hover .privacy-dropdown__option__content strong,
-.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:active,
-.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:focus,
-.dropdown-menu__item:not(.dropdown-menu__item--dangerous) a:hover,
 .actions-modal ul li:not(:empty) a.active,
 .actions-modal ul li:not(:empty) a.active button,
 .actions-modal ul li:not(:empty) a:active,
@@ -331,7 +229,6 @@ html {
 .actions-modal ul li:not(:empty) a:focus button,
 .actions-modal ul li:not(:empty) a:hover,
 .actions-modal ul li:not(:empty) a:hover button,
-.language-dropdown__dropdown__results__item.active,
 .admin-wrapper .sidebar ul .simple-navigation-active-leaf a,
 .simple_form .block-button,
 .simple_form .button,
@@ -339,19 +236,6 @@ html {
   color: $white;
 }
 
-.language-dropdown__dropdown__results__item
-  .language-dropdown__dropdown__results__item__common-name {
-  color: lighten($ui-base-color, 8%);
-}
-
-.language-dropdown__dropdown__results__item.active
-  .language-dropdown__dropdown__results__item__common-name {
-  color: darken($ui-base-color, 12%);
-}
-
-.dropdown-menu__separator,
-.dropdown-menu__item.edited-timestamp__history__item,
-.dropdown-menu__container__header,
 .compare-history-modal .report-modal__target,
 .report-dialog-modal .poll__option.dialog-option {
   border-bottom-color: lighten($ui-base-color, 4%);
@@ -385,10 +269,7 @@ html {
 
 .reactions-bar__item:hover,
 .reactions-bar__item:focus,
-.reactions-bar__item:active,
-.language-dropdown__dropdown__results__item:hover,
-.language-dropdown__dropdown__results__item:focus,
-.language-dropdown__dropdown__results__item:active {
+.reactions-bar__item:active {
   background-color: $ui-base-color;
 }
 
@@ -631,11 +512,6 @@ html {
   }
 }
 
-.reply-indicator {
-  background: transparent;
-  border: 1px solid lighten($ui-base-color, 8%);
-}
-
 .status__content,
 .reply-indicator__content {
   a {
@@ -675,3 +551,30 @@ html {
     background-color: rgba($ui-highlight-color, 0.15);
   }
 }
+
+.compose-form__actions .icon-button.active,
+.dropdown-button.active,
+.privacy-dropdown__option.active,
+.privacy-dropdown__option:focus,
+.language-dropdown__dropdown__results__item:focus,
+.language-dropdown__dropdown__results__item.active,
+.privacy-dropdown__option:focus .privacy-dropdown__option__content,
+.privacy-dropdown__option:focus .privacy-dropdown__option__content strong,
+.privacy-dropdown__option.active .privacy-dropdown__option__content,
+.privacy-dropdown__option.active .privacy-dropdown__option__content strong,
+.language-dropdown__dropdown__results__item:focus
+  .language-dropdown__dropdown__results__item__common-name,
+.language-dropdown__dropdown__results__item.active
+  .language-dropdown__dropdown__results__item__common-name {
+  color: $white;
+}
+
+.compose-form .spoiler-input__input {
+  color: lighten($ui-highlight-color, 8%);
+}
+
+.compose-form .autosuggest-textarea__textarea,
+.compose-form__highlightable,
+.poll__option input[type='text'] {
+  background: darken($ui-base-color, 10%);
+}

+ 7 - 2
app/javascript/styles/mastodon-light/variables.scss

@@ -5,7 +5,7 @@ $white: #ffffff;
 $classic-base-color: #282c37;
 $classic-primary-color: #9baec8;
 $classic-secondary-color: #d9e1e8;
-$classic-highlight-color: #858afa;
+$classic-highlight-color: #6364ff;
 
 $blurple-600: #563acc; // Iris
 $blurple-500: #6364ff; // Brand purple
@@ -34,7 +34,7 @@ $ui-button-tertiary-border-color: $blurple-500 !default;
 
 $primary-text-color: $black !default;
 $darker-text-color: $classic-base-color !default;
-$highlight-text-color: darken($ui-highlight-color, 8%) !default;
+$highlight-text-color: $ui-highlight-color !default;
 $dark-text-color: #444b5d;
 $action-button-color: #606984;
 
@@ -55,3 +55,8 @@ $account-background-color: $white !default;
 }
 
 $emojis-requiring-inversion: 'chains';
+
+.theme-mastodon-light {
+  --dropdown-border-color: #d9e1e8;
+  --dropdown-background-color: #fff;
+}

+ 3 - 2
app/javascript/styles/mastodon/_mixins.scss

@@ -15,13 +15,14 @@
   outline: 0;
   box-sizing: border-box;
   width: 100%;
-  border: 0;
   box-shadow: none;
   font-family: inherit;
   background: $ui-base-color;
   color: $darker-text-color;
   border-radius: 4px;
-  font-size: 14px;
+  border: 1px solid lighten($ui-base-color, 8%);
+  font-size: 17px;
+  line-height: normal;
   margin: 0;
 }
 

+ 6 - 0
app/javascript/styles/mastodon/admin.scss

@@ -1314,6 +1314,9 @@ a.sparkline {
 
     &__label {
       padding: 15px;
+      display: flex;
+      gap: 8px;
+      align-items: center;
     }
 
     &__rules {
@@ -1324,6 +1327,9 @@ a.sparkline {
   &__rule {
     cursor: pointer;
     padding: 15px;
+    display: flex;
+    gap: 8px;
+    align-items: center;
   }
 }
 

+ 1 - 1
app/javascript/styles/mastodon/basics.scss

@@ -8,7 +8,7 @@
 
 body {
   font-family: $font-sans-serif, sans-serif;
-  background: darken($ui-base-color, 7%);
+  background: darken($ui-base-color, 8%);
   font-size: 13px;
   line-height: 18px;
   font-weight: 400;

文件差異過大導致無法顯示
+ 564 - 298
app/javascript/styles/mastodon/components.scss


+ 14 - 12
app/javascript/styles/mastodon/containers.scss

@@ -40,13 +40,12 @@
   .compose-form {
     width: 400px;
     margin: 0 auto;
-    padding: 20px 0;
-    margin-top: 40px;
+    padding: 10px 0;
+    padding-bottom: 20px;
     box-sizing: border-box;
 
     @media screen and (width <= 400px) {
       width: 100%;
-      margin-top: 0;
       padding: 20px;
     }
   }
@@ -56,13 +55,15 @@
   width: 400px;
   margin: 0 auto;
   display: flex;
-  font-size: 13px;
-  line-height: 18px;
+  align-items: center;
+  gap: 10px;
+  font-size: 14px;
+  line-height: 20px;
   box-sizing: border-box;
   padding: 20px 0;
   margin-top: 40px;
   margin-bottom: 10px;
-  border-bottom: 1px solid $ui-base-color;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
 
   @media screen and (width <= 440px) {
     width: 100%;
@@ -71,9 +72,9 @@
   }
 
   .avatar {
-    width: 40px;
-    height: 40px;
-    margin-inline-end: 10px;
+    width: 48px;
+    height: 48px;
+    flex: 0 0 auto;
 
     img {
       width: 100%;
@@ -87,13 +88,14 @@
   .name {
     flex: 1 1 auto;
     color: $secondary-text-color;
-    width: calc(100% - 90px);
 
     .username {
       display: block;
-      font-weight: 500;
+      font-size: 16px;
+      line-height: 24px;
       text-overflow: ellipsis;
       overflow: hidden;
+      color: $primary-text-color;
     }
   }
 
@@ -101,7 +103,7 @@
     display: block;
     font-size: 32px;
     line-height: 40px;
-    margin-inline-start: 10px;
+    flex: 0 0 auto;
   }
 }
 

+ 14 - 16
app/javascript/styles/mastodon/emoji_picker.scss

@@ -1,7 +1,6 @@
 .emoji-mart {
   font-size: 13px;
   display: inline-block;
-  color: $inverted-text-color;
 
   &,
   * {
@@ -15,13 +14,13 @@
 }
 
 .emoji-mart-bar {
-  border: 0 solid darken($ui-secondary-color, 8%);
+  border: 0 solid var(--dropdown-border-color);
 
   &:first-child {
     border-bottom-width: 1px;
     border-top-left-radius: 5px;
     border-top-right-radius: 5px;
-    background: $ui-secondary-color;
+    background: var(--dropdown-border-color);
   }
 
   &:last-child {
@@ -36,7 +35,6 @@
   display: flex;
   justify-content: space-between;
   padding: 0 6px;
-  color: $lighter-text-color;
   line-height: 0;
 }
 
@@ -50,9 +48,10 @@
   cursor: pointer;
   background: transparent;
   border: 0;
+  color: $darker-text-color;
 
   &:hover {
-    color: darken($lighter-text-color, 4%);
+    color: lighten($darker-text-color, 4%);
   }
 }
 
@@ -60,7 +59,7 @@
   color: $highlight-text-color;
 
   &:hover {
-    color: darken($highlight-text-color, 4%);
+    color: lighten($highlight-text-color, 4%);
   }
 
   .emoji-mart-anchor-bar {
@@ -95,7 +94,7 @@
   height: 270px;
   max-height: 35vh;
   padding: 0 6px 6px;
-  background: $simple-background-color;
+  background: var(--dropdown-background-color);
   will-change: transform;
 
   &::-webkit-scrollbar-track:hover,
@@ -107,7 +106,7 @@
 .emoji-mart-search {
   padding: 10px;
   padding-inline-end: 45px;
-  background: $simple-background-color;
+  background: var(--dropdown-background-color);
   position: relative;
 
   input {
@@ -118,9 +117,9 @@
     font-family: inherit;
     display: block;
     width: 100%;
-    background: rgba($ui-secondary-color, 0.3);
-    color: $inverted-text-color;
-    border: 1px solid $ui-secondary-color;
+    background: $ui-base-color;
+    color: $darker-text-color;
+    border: 1px solid lighten($ui-base-color, 8%);
     border-radius: 4px;
 
     &::-moz-focus-inner {
@@ -155,11 +154,10 @@
   &:disabled {
     cursor: default;
     pointer-events: none;
-    opacity: 0.3;
   }
 
   svg {
-    fill: $action-button-color;
+    fill: $darker-text-color;
   }
 }
 
@@ -180,7 +178,7 @@
     inset-inline-start: 0;
     width: 100%;
     height: 100%;
-    background-color: rgba($ui-secondary-color, 0.7);
+    background-color: var(--dropdown-border-color);
     border-radius: 100%;
   }
 }
@@ -197,7 +195,7 @@
     width: 100%;
     font-weight: 500;
     padding: 5px 6px;
-    background: $simple-background-color;
+    background: var(--dropdown-background-color);
   }
 }
 
@@ -241,7 +239,7 @@
 
 .emoji-mart-no-results {
   font-size: 14px;
-  color: $light-text-color;
+  color: $dark-text-color;
   text-align: center;
   padding: 5px 6px;
   padding-top: 70px;

+ 1 - 1
app/javascript/styles/mastodon/modal.scss

@@ -1,5 +1,5 @@
 .modal-layout {
-  background: $ui-base-color
+  background: darken($ui-base-color, 4%)
     url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>')
     repeat-x bottom fixed;
   display: flex;

+ 26 - 99
app/javascript/styles/mastodon/polls.scss

@@ -52,6 +52,8 @@
   &__option {
     position: relative;
     display: flex;
+    align-items: flex-start;
+    gap: 8px;
     padding: 6px 0;
     line-height: 18px;
     cursor: default;
@@ -78,16 +80,22 @@
       box-sizing: border-box;
       width: 100%;
       font-size: 14px;
-      color: $inverted-text-color;
+      color: $secondary-text-color;
       outline: 0;
       font-family: inherit;
-      background: $simple-background-color;
-      border: 1px solid darken($simple-background-color, 14%);
+      background: $ui-base-color;
+      border: 1px solid $darker-text-color;
       border-radius: 4px;
-      padding: 6px 10px;
+      padding: 8px 12px;
 
       &:focus {
-        border-color: $highlight-text-color;
+        border-color: $ui-highlight-color;
+      }
+
+      @media screen and (width <= 600px) {
+        font-size: 16px;
+        line-height: 24px;
+        letter-spacing: 0.5px;
       }
     }
 
@@ -96,26 +104,20 @@
     }
 
     &.editable {
-      display: flex;
       align-items: center;
       overflow: visible;
     }
   }
 
   &__input {
-    display: inline-block;
+    display: block;
     position: relative;
     border: 1px solid $ui-primary-color;
     box-sizing: border-box;
-    width: 18px;
-    height: 18px;
-    margin-inline-end: 10px;
-    top: -1px;
+    width: 17px;
+    height: 17px;
     border-radius: 50%;
-    vertical-align: middle;
-    margin-top: auto;
-    margin-bottom: auto;
-    flex: 0 0 18px;
+    flex: 0 0 auto;
 
     &.checkbox {
       border-radius: 4px;
@@ -159,6 +161,15 @@
     }
   }
 
+  &__option.editable &__input {
+    &:active,
+    &:focus,
+    &:hover {
+      border-color: $ui-primary-color;
+      border-width: 1px;
+    }
+  }
+
   &__number {
     display: inline-block;
     width: 45px;
@@ -209,90 +220,6 @@
   }
 }
 
-.compose-form__poll-wrapper {
-  border-top: 1px solid darken($simple-background-color, 8%);
-
-  ul {
-    padding: 10px;
-  }
-
-  .poll__input {
-    &:active,
-    &:focus,
-    &:hover {
-      border-color: $ui-button-focus-background-color;
-    }
-  }
-
-  .poll__footer {
-    border-top: 1px solid darken($simple-background-color, 8%);
-    padding: 10px;
-    display: flex;
-    align-items: center;
-
-    button,
-    select {
-      flex: 1 1 50%;
-
-      &:focus {
-        border-color: $highlight-text-color;
-      }
-    }
-  }
-
-  .button.button-secondary {
-    font-size: 14px;
-    font-weight: 400;
-    padding: 6px 10px;
-    height: auto;
-    line-height: inherit;
-    color: $action-button-color;
-    border-color: $action-button-color;
-    margin-inline-end: 5px;
-
-    &:hover,
-    &:focus,
-    &.active {
-      border-color: $action-button-color;
-      background-color: $action-button-color;
-      color: $ui-button-color;
-    }
-  }
-
-  li {
-    display: flex;
-    align-items: center;
-
-    .poll__option {
-      flex: 0 0 auto;
-      width: calc(100% - (23px + 6px));
-      margin-inline-end: 6px;
-    }
-  }
-
-  select {
-    appearance: none;
-    box-sizing: border-box;
-    font-size: 14px;
-    color: $inverted-text-color;
-    display: inline-block;
-    width: auto;
-    outline: 0;
-    font-family: inherit;
-    background: $simple-background-color
-      url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>")
-      no-repeat right 8px center / auto 16px;
-    border: 1px solid darken($simple-background-color, 14%);
-    border-radius: 4px;
-    padding: 6px 10px;
-    padding-inline-end: 30px;
-  }
-
-  .icon-button.disabled {
-    color: darken($simple-background-color, 14%);
-  }
-}
-
 .muted .poll {
   color: $dark-text-color;
 

+ 2 - 2
spec/system/new_statuses_spec.rb

@@ -24,7 +24,7 @@ describe 'NewStatuses', :sidekiq_inline do
 
     within('.compose-form') do
       fill_in "What's on your mind?", with: status_text
-      click_on 'Publish!'
+      click_on 'Post'
     end
 
     expect(subject).to have_css('.status__content__text', text: status_text)
@@ -37,7 +37,7 @@ describe 'NewStatuses', :sidekiq_inline do
 
     within('.compose-form') do
       fill_in "What's on your mind?", with: status_text
-      click_on 'Publish!'
+      click_on 'Post'
     end
 
     expect(subject).to have_css('.status__content__text', text: status_text)

+ 2 - 2
spec/system/share_entrypoint_spec.rb

@@ -19,13 +19,13 @@ describe 'ShareEntrypoint' do
 
   it 'can be used to post a new status' do
     expect(subject).to have_css('div#mastodon-compose')
-    expect(subject).to have_css('.compose-form__publish-button-wrapper > button')
+    expect(subject).to have_css('.compose-form__submit')
 
     status_text = 'This is a new status!'
 
     within('.compose-form') do
       fill_in "What's on your mind?", with: status_text
-      click_on 'Publish!'
+      click_on 'Post'
     end
 
     expect(subject).to have_css('.notification-bar-message', text: 'Post published.')

部分文件因文件數量過多而無法顯示