Browse Source

Merge branches 'branding_cybre', 'dependency_whatinput', 'feature_512_char_toots', 'feature_disable_reply_counts', 'feature_hotlink_twitter_mentions', 'feature_cybrespace_locale', 'feature_doodlebox' and 'feature_longer_bios' into cybrespace

33 changed files with 2253 additions and 31 deletions
  1. 6
    1
      app/controllers/concerns/localized.rb
  2. 1
    0
      app/helpers/settings_helper.rb
  3. 9
    0
      app/javascript/mastodon/actions/compose.js
  4. 9
    3
      app/javascript/mastodon/components/icon_button.js
  5. 2
    0
      app/javascript/mastodon/components/relative_timestamp.js
  6. 1
    1
      app/javascript/mastodon/components/status_action_bar.js
  7. 133
    0
      app/javascript/mastodon/features/compose/components/attach_options.js
  8. 76
    0
      app/javascript/mastodon/features/compose/components/compose_dropdown.js
  9. 7
    6
      app/javascript/mastodon/features/compose/components/compose_form.js
  10. 614
    0
      app/javascript/mastodon/features/ui/components/doodle_modal.js
  11. 13
    1
      app/javascript/mastodon/features/ui/components/modal_root.js
  12. 293
    0
      app/javascript/mastodon/locales/en-CY.json
  13. 2
    0
      app/javascript/mastodon/locales/whitelist_en-CY.json
  14. 14
    0
      app/javascript/mastodon/reducers/compose.js
  15. 2
    0
      app/javascript/packs/application.js
  16. 34
    1
      app/javascript/packs/public.js
  17. 96
    0
      app/javascript/styles/doodle.scss
  18. 73
    1
      app/javascript/styles/mastodon/components.scss
  19. 10
    1
      app/lib/formatter.rb
  20. 8
    5
      app/models/account.rb
  21. 1
    1
      app/validators/status_length_validator.rb
  22. 1
    1
      app/views/settings/profiles/show.html.haml
  23. 0
    1
      app/views/stream_entries/_simple_status.html.haml
  24. 6
    3
      config/application.rb
  25. 1
    0
      config/i18n-tasks.yml
  26. 61
    0
      config/locales/devise.en-CY.yml
  27. 119
    0
      config/locales/doorkeeper.en-CY.yml
  28. 563
    0
      config/locales/en-CY.yml
  29. 66
    0
      config/locales/simple_form.en-CY.yml
  30. 17
    0
      db/migrate/20171026200500_en_to_en_cy.rb
  31. 3
    1
      package.json
  32. 4
    4
      spec/models/account_spec.rb
  33. 8
    0
      yarn.lock

+ 6
- 1
app/controllers/concerns/localized.rb View File

@@ -20,7 +20,12 @@ module Localized
20 20
     if ENV['DEFAULT_LOCALE'].present?
21 21
       I18n.default_locale
22 22
     else
23
-      request_locale || I18n.default_locale
23
+      case request_locale
24
+      when /en\b/, nil
25
+        I18n.default_locale
26
+      else
27
+        request_locale
28
+      end
24 29
     end
25 30
   end
26 31
 

+ 1
- 0
app/helpers/settings_helper.rb View File

@@ -3,6 +3,7 @@
3 3
 module SettingsHelper
4 4
   HUMAN_LOCALES = {
5 5
     en: 'English',
6
+    'en-CY': 'English (Cybre)',
6 7
     ar: 'العربية',
7 8
     ast: 'l\'asturianu',
8 9
     bg: 'Български',

+ 9
- 0
app/javascript/mastodon/actions/compose.js View File

@@ -49,6 +49,8 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST'
49 49
 export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
50 50
 export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL';
51 51
 
52
+export const COMPOSE_DOODLE_SET        = 'COMPOSE_DOODLE_SET';
53
+
52 54
 export function changeCompose(text) {
53 55
   return {
54 56
     type: COMPOSE_CHANGE,
@@ -178,6 +180,13 @@ export function submitComposeFail(error) {
178 180
   };
179 181
 };
180 182
 
183
+export function doodleSet(options) {
184
+  return {
185
+    type: COMPOSE_DOODLE_SET,
186
+    options: options,
187
+  };
188
+};
189
+
181 190
 export function uploadCompose(files) {
182 191
   return function (dispatch, getState) {
183 192
     if (getState().getIn(['compose', 'media_attachments']).size > 3) {

+ 9
- 3
app/javascript/mastodon/components/icon_button.js View File

@@ -22,6 +22,7 @@ export default class IconButton extends React.PureComponent {
22 22
     animate: PropTypes.bool,
23 23
     overlay: PropTypes.bool,
24 24
     tabIndex: PropTypes.string,
25
+    label: PropTypes.string,
25 26
   };
26 27
 
27 28
   static defaultProps = {
@@ -42,14 +43,18 @@ export default class IconButton extends React.PureComponent {
42 43
   }
43 44
 
44 45
   render () {
45
-    const style = {
46
+    let style = {
46 47
       fontSize: `${this.props.size}px`,
47
-      width: `${this.props.size * 1.28571429}px`,
48 48
       height: `${this.props.size * 1.28571429}px`,
49 49
       lineHeight: `${this.props.size}px`,
50 50
       ...this.props.style,
51 51
       ...(this.props.active ? this.props.activeStyle : {}),
52 52
     };
53
+    if (!this.props.label) {
54
+      style.width = `${this.props.size * 1.28571429}px`;
55
+    } else {
56
+      style.textAlign = 'left';
57
+    }
53 58
 
54 59
     const {
55 60
       active,
@@ -104,7 +109,8 @@ export default class IconButton extends React.PureComponent {
104 109
             style={style}
105 110
             tabIndex={tabIndex}
106 111
           >
107
-            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
112
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
113
+            {this.props.label}
108 114
           </button>
109 115
         )}
110 116
       </Motion>

+ 2
- 0
app/javascript/mastodon/components/relative_timestamp.js View File

@@ -60,6 +60,8 @@ const getUnitDelay = units => {
60 60
   }
61 61
 };
62 62
 
63
+const fallbackFormat = new Intl.DateTimeFormat('en', shortDateFormatOptions);
64
+
63 65
 export const timeAgoString = (intl, date, now, year) => {
64 66
   const delta = now - date.getTime();
65 67
 

+ 1
- 1
app/javascript/mastodon/components/status_action_bar.js View File

@@ -204,7 +204,7 @@ class StatusActionBar extends ImmutablePureComponent {
204 204
 
205 205
     return (
206 206
       <div className='status__action-bar'>
207
-        <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
207
+        <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /></div>
208 208
         <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
209 209
         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='floppy-o' onClick={this.handleFavouriteClick} />
210 210
         {shareButton}

+ 133
- 0
app/javascript/mastodon/features/compose/components/attach_options.js View File

@@ -0,0 +1,133 @@
1
+//  Package imports  // 
2
+import React from 'react'; 
3
+import PropTypes from 'prop-types'; 
4
+import { connect } from 'react-redux'; 
5
+import { injectIntl, defineMessages } from 'react-intl'; 
6
+ 
7
+//  Our imports  // 
8
+import ComposeDropdown from './compose_dropdown'; 
9
+import { uploadCompose } from '../../../actions/compose'; 
10
+import ImmutablePropTypes from 'react-immutable-proptypes'; 
11
+import ImmutablePureComponent from 'react-immutable-pure-component'; 
12
+import { openModal } from '../../../actions/modal'; 
13
+ 
14
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 
15
+ 
16
+const messages = defineMessages({ 
17
+  upload : 
18
+    { id: 'compose.attach.upload', defaultMessage: 'Upload a file' }, 
19
+  doodle : 
20
+    { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, 
21
+  attach : 
22
+    { id: 'compose.attach', defaultMessage: 'Attach...' }, 
23
+}); 
24
+ 
25
+const mapStateToProps = state => ({ 
26
+  // This horrible expression is copied from vanilla upload_button_container 
27
+  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), 
28
+  resetFileKey: state.getIn(['compose', 'resetFileKey']), 
29
+  acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), 
30
+}); 
31
+ 
32
+const mapDispatchToProps = dispatch => ({ 
33
+  onSelectFile (files) { 
34
+    dispatch(uploadCompose(files)); 
35
+  }, 
36
+  onOpenDoodle () { 
37
+    dispatch(openModal('DOODLE', { noEsc: true })); 
38
+  }, 
39
+}); 
40
+ 
41
+@injectIntl 
42
+@connect(mapStateToProps, mapDispatchToProps) 
43
+export default class ComposeAttachOptions extends ImmutablePureComponent { 
44
+ 
45
+  static propTypes = { 
46
+    intl     : PropTypes.object.isRequired, 
47
+    resetFileKey: PropTypes.number, 
48
+    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, 
49
+    disabled: PropTypes.bool, 
50
+    onSelectFile: PropTypes.func.isRequired, 
51
+    onOpenDoodle: PropTypes.func.isRequired, 
52
+  }; 
53
+ 
54
+  handleItemClick = bt => { 
55
+    if (bt === 'upload') { 
56
+      this.fileElement.click(); 
57
+    } 
58
+ 
59
+    if (bt === 'doodle') { 
60
+      this.props.onOpenDoodle(); 
61
+    } 
62
+ 
63
+    this.dropdown.setState({ open: false }); 
64
+  }; 
65
+ 
66
+  handleFileChange = (e) => { 
67
+    if (e.target.files.length > 0) { 
68
+      this.props.onSelectFile(e.target.files); 
69
+    } 
70
+  } 
71
+ 
72
+  setFileRef = (c) => { 
73
+    this.fileElement = c; 
74
+  } 
75
+ 
76
+  setDropdownRef = (c) => { 
77
+    this.dropdown = c; 
78
+  } 
79
+ 
80
+  render () { 
81
+    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; 
82
+ 
83
+    const options = [ 
84
+      { icon: 'cloud-upload', text: messages.upload, name: 'upload' }, 
85
+      { icon: 'paint-brush', text: messages.doodle, name: 'doodle' }, 
86
+    ]; 
87
+ 
88
+    const optionElems = options.map((item) => { 
89
+      const hdl = () => this.handleItemClick(item.name); 
90
+      return ( 
91
+        <div 
92
+          role='button' 
93
+          tabIndex='0' 
94
+          key={item.name} 
95
+          onClick={hdl} 
96
+          className='privacy-dropdown__option' 
97
+        > 
98
+          <div className='privacy-dropdown__option__icon'> 
99
+            <i className={`fa fa-fw fa-${item.icon}`} /> 
100
+          </div> 
101
+ 
102
+          <div className='privacy-dropdown__option__content'> 
103
+            <strong>{intl.formatMessage(item.text)}</strong> 
104
+          </div> 
105
+        </div> 
106
+      ); 
107
+    }); 
108
+ 
109
+    return ( 
110
+      <div> 
111
+        <ComposeDropdown 
112
+          title={intl.formatMessage(messages.attach)} 
113
+          icon='paperclip' 
114
+          disabled={disabled} 
115
+          ref={this.setDropdownRef} 
116
+        > 
117
+          {optionElems} 
118
+        </ComposeDropdown> 
119
+        <input 
120
+          key={resetFileKey} 
121
+          ref={this.setFileRef} 
122
+          type='file' 
123
+          multiple={false} 
124
+          accept={acceptContentTypes.toArray().join(',')} 
125
+          onChange={this.handleFileChange} 
126
+          disabled={disabled} 
127
+          style={{ display: 'none' }} 
128
+        /> 
129
+      </div> 
130
+    ); 
131
+  } 
132
+ 
133
+} 

+ 76
- 0
app/javascript/mastodon/features/compose/components/compose_dropdown.js View File

@@ -0,0 +1,76 @@
1
+//  Package imports  // 
2
+import React from 'react'; 
3
+import PropTypes from 'prop-types'; 
4
+ 
5
+//  Mastodon imports  // 
6
+import IconButton from '../../../components/icon_button'; 
7
+ 
8
+const iconStyle = { 
9
+  height     : null, 
10
+  lineHeight : '27px', 
11
+}; 
12
+ 
13
+export default class ComposeDropdown extends React.PureComponent {
14
+ 
15
+  static propTypes = { 
16
+    title: PropTypes.string.isRequired, 
17
+    icon: PropTypes.string, 
18
+    highlight: PropTypes.bool, 
19
+    disabled: PropTypes.bool, 
20
+    children: PropTypes.arrayOf(PropTypes.node).isRequired, 
21
+  }; 
22
+ 
23
+  state = { 
24
+    open: false, 
25
+  }; 
26
+ 
27
+  onGlobalClick = (e) => { 
28
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { 
29
+      this.setState({ open: false }); 
30
+    } 
31
+  }; 
32
+ 
33
+  componentDidMount () { 
34
+    window.addEventListener('click', this.onGlobalClick); 
35
+    window.addEventListener('touchstart', this.onGlobalClick); 
36
+  } 
37
+  componentWillUnmount () { 
38
+    window.removeEventListener('click', this.onGlobalClick); 
39
+    window.removeEventListener('touchstart', this.onGlobalClick); 
40
+  } 
41
+ 
42
+  onToggleDropdown = () => { 
43
+    if (this.props.disabled) return; 
44
+    this.setState({ open: !this.state.open }); 
45
+  }; 
46
+ 
47
+  setRef = (c) => { 
48
+    this.node = c; 
49
+  }; 
50
+ 
51
+  render () { 
52
+    const { open } = this.state; 
53
+    let { highlight, title, icon, disabled } = this.props; 
54
+ 
55
+    if (!icon) icon = 'ellipsis-h'; 
56
+ 
57
+    return ( 
58
+      <div ref={this.setRef} className={`advanced-options-dropdown ${open ?  'open' : ''} ${highlight ? 'active' : ''} `}> 
59
+        <div className='advanced-options-dropdown__value'> 
60
+          <IconButton 
61
+            className={'inverted'} 
62
+            title={title} 
63
+            icon={icon} active={open || highlight} 
64
+            size={18} 
65
+            style={iconStyle} 
66
+            disabled={disabled} 
67
+            onClick={this.onToggleDropdown} 
68
+          /> 
69
+        </div> 
70
+        <div className='advanced-options-dropdown__dropdown'> 
71
+          {this.props.children} 
72
+        </div> 
73
+      </div> 
74
+    ); 
75
+  }
76
+}

+ 7
- 6
app/javascript/mastodon/features/compose/components/compose_form.js View File

@@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
5 5
 import PropTypes from 'prop-types';
6 6
 import ReplyIndicatorContainer from '../containers/reply_indicator_container';
7 7
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
8
-import UploadButtonContainer from '../containers/upload_button_container';
9 8
 import { defineMessages, injectIntl } from 'react-intl';
10 9
 import SpoilerButtonContainer from '../containers/spoiler_button_container';
11 10
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
@@ -17,6 +16,7 @@ import { isMobile } from '../../../is_mobile';
17 16
 import ImmutablePureComponent from 'react-immutable-pure-component';
18 17
 import { length } from 'stringz';
19 18
 import { countableText } from '../util/counter';
19
+import ComposeAttachOptions from './attach_options';
20 20
 
21 21
 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';
22 22
 
@@ -84,7 +84,7 @@ class ComposeForm extends ImmutablePureComponent {
84 84
     const { is_submitting, is_uploading, anyMedia } = this.props;
85 85
     const fulltext = [this.props.spoiler_text, countableText(this.props.text)].join('');
86 86
 
87
-    if (is_submitting || is_uploading || length(fulltext) > 500 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
87
+    if (is_submitting || is_uploading || length(fulltext) > 512 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
88 88
       return;
89 89
     }
90 90
 
@@ -160,7 +160,7 @@ class ComposeForm extends ImmutablePureComponent {
160 160
     const { intl, onPaste, showSearch, anyMedia } = this.props;
161 161
     const disabled = this.props.is_submitting;
162 162
     const text     = [this.props.spoiler_text, countableText(this.props.text)].join('');
163
-    const disabledButton = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
163
+    const disabledButton = disabled || this.props.is_uploading || length(text) > 512 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
164 164
     let publishText = '';
165 165
 
166 166
     if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
@@ -207,12 +207,13 @@ class ComposeForm extends ImmutablePureComponent {
207 207
 
208 208
         <div className='compose-form__buttons-wrapper'>
209 209
           <div className='compose-form__buttons'>
210
-            <UploadButtonContainer />
211
-            <PrivacyDropdownContainer />
210
+            <ComposeAttachOptions />
212 211
             <SensitiveButtonContainer />
212
+            <div className='compose-form__buttons-separator' /> 
213
+            <PrivacyDropdownContainer />
213 214
             <SpoilerButtonContainer />
214 215
           </div>
215
-          <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
216
+          <div className='character-counter__wrapper'><CharacterCounter max={512} text={text} /></div>
216 217
         </div>
217 218
 
218 219
         <div className='compose-form__publish'>

+ 614
- 0
app/javascript/mastodon/features/ui/components/doodle_modal.js View File

@@ -0,0 +1,614 @@
1
+import React from 'react';
2
+import PropTypes from 'prop-types';
3
+import Button from '../../../components/button';
4
+import ImmutablePureComponent from 'react-immutable-pure-component';
5
+import Atrament from 'atrament'; // the doodling library
6
+import { connect } from 'react-redux';
7
+import ImmutablePropTypes from 'react-immutable-proptypes';
8
+import { doodleSet, uploadCompose } from '../../../actions/compose';
9
+import IconButton from '../../../components/icon_button';
10
+import { debounce, mapValues } from 'lodash';
11
+import classNames from 'classnames';
12
+
13
+// palette nicked from MyPaint, CC0
14
+const palette = [
15
+  ['rgb(  0,    0,    0)', 'Black'],
16
+  ['rgb( 38,   38,   38)', 'Gray 15'],
17
+  ['rgb( 77,   77,   77)', 'Grey 30'],
18
+  ['rgb(128,  128,  128)', 'Grey 50'],
19
+  ['rgb(171,  171,  171)', 'Grey 67'],
20
+  ['rgb(217,  217,  217)', 'Grey 85'],
21
+  ['rgb(255,  255,  255)', 'White'],
22
+  ['rgb(128,    0,    0)', 'Maroon'],
23
+  ['rgb(209,    0,    0)', 'English-red'],
24
+  ['rgb(255,   54,   34)', 'Tomato'],
25
+  ['rgb(252,   60,    3)', 'Orange-red'],
26
+  ['rgb(255,  140,  105)', 'Salmon'],
27
+  ['rgb(252,  232,   32)', 'Cadium-yellow'],
28
+  ['rgb(243,  253,   37)', 'Lemon yellow'],
29
+  ['rgb(121,    5,   35)', 'Dark crimson'],
30
+  ['rgb(169,   32,   62)', 'Deep carmine'],
31
+  ['rgb(255,  140,    0)', 'Orange'],
32
+  ['rgb(255,  168,   18)', 'Dark tangerine'],
33
+  ['rgb(217,  144,   88)', 'Persian orange'],
34
+  ['rgb(194,  178,  128)', 'Sand'],
35
+  ['rgb(255,  229,  180)', 'Peach'],
36
+  ['rgb(100,   54,   46)', 'Bole'],
37
+  ['rgb(108,   41,   52)', 'Dark cordovan'],
38
+  ['rgb(163,   65,   44)', 'Chestnut'],
39
+  ['rgb(228,  136,  100)', 'Dark salmon'],
40
+  ['rgb(255,  195,  143)', 'Apricot'],
41
+  ['rgb(255,  219,  188)', 'Unbleached silk'],
42
+  ['rgb(242,  227,  198)', 'Straw'],
43
+  ['rgb( 53,   19,   13)', 'Bistre'],
44
+  ['rgb( 84,   42,   14)', 'Dark chocolate'],
45
+  ['rgb(102,   51,   43)', 'Burnt sienna'],
46
+  ['rgb(184,   66,    0)', 'Sienna'],
47
+  ['rgb(216,  153,   12)', 'Yellow ochre'],
48
+  ['rgb(210,  180,  140)', 'Tan'],
49
+  ['rgb(232,  204,  144)', 'Dark wheat'],
50
+  ['rgb(  0,   49,   83)', 'Prussian blue'],
51
+  ['rgb( 48,   69,  119)', 'Dark grey blue'],
52
+  ['rgb(  0,   71,  171)', 'Cobalt blue'],
53
+  ['rgb( 31,  117,  254)', 'Blue'],
54
+  ['rgb(120,  180,  255)', 'Bright french blue'],
55
+  ['rgb(171,  200,  255)', 'Bright steel blue'],
56
+  ['rgb(208,  231,  255)', 'Ice blue'],
57
+  ['rgb( 30,   51,   58)', 'Medium jungle green'],
58
+  ['rgb( 47,   79,   79)', 'Dark slate grey'],
59
+  ['rgb( 74,  104,   93)', 'Dark grullo green'],
60
+  ['rgb(  0,  128,  128)', 'Teal'],
61
+  ['rgb( 67,  170,  176)', 'Turquoise'],
62
+  ['rgb(109,  174,  199)', 'Cerulean frost'],
63
+  ['rgb(173,  217,  186)', 'Tiffany green'],
64
+  ['rgb( 22,   34,   29)', 'Gray-asparagus'],
65
+  ['rgb( 36,   48,   45)', 'Medium dark teal'],
66
+  ['rgb( 74,  104,   93)', 'Xanadu'],
67
+  ['rgb(119,  198,  121)', 'Mint'],
68
+  ['rgb(175,  205,  182)', 'Timberwolf'],
69
+  ['rgb(185,  245,  246)', 'Celeste'],
70
+  ['rgb(193,  255,  234)', 'Aquamarine'],
71
+  ['rgb( 29,   52,   35)', 'Cal Poly Pomona'],
72
+  ['rgb(  1,   68,   33)', 'Forest green'],
73
+  ['rgb( 42,  128,    0)', 'Napier green'],
74
+  ['rgb(128,  128,    0)', 'Olive'],
75
+  ['rgb( 65,  156,  105)', 'Sea green'],
76
+  ['rgb(189,  246,   29)', 'Green-yellow'],
77
+  ['rgb(231,  244,  134)', 'Bright chartreuse'],
78
+  ['rgb(138,   23,  137)', 'Purple'],
79
+  ['rgb( 78,   39,  138)', 'Violet'],
80
+  ['rgb(193,   75,  110)', 'Dark thulian pink'],
81
+  ['rgb(222,   49,   99)', 'Cerise'],
82
+  ['rgb(255,   20,  147)', 'Deep pink'],
83
+  ['rgb(255,  102,  204)', 'Rose pink'],
84
+  ['rgb(255,  203,  219)', 'Pink'],
85
+  ['rgb(255,  255,  255)', 'White'],
86
+  ['rgb(229,   17,    1)', 'RGB Red'],
87
+  ['rgb(  0,  255,    0)', 'RGB Green'],
88
+  ['rgb(  0,    0,  255)', 'RGB Blue'],
89
+  ['rgb(  0,  255,  255)', 'CMYK Cyan'],
90
+  ['rgb(255,    0,  255)', 'CMYK Magenta'],
91
+  ['rgb(255,  255,    0)', 'CMYK Yellow'],
92
+];
93
+
94
+// re-arrange to the right order for display
95
+let palReordered = [];
96
+for (let row = 0; row < 7; row++) {
97
+  for (let col = 0; col < 11; col++) {
98
+    palReordered.push(palette[col * 7 + row]);
99
+  }
100
+  palReordered.push(null); // null indicates a <br />
101
+}
102
+
103
+// Utility for converting base64 image to binary for upload
104
+// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
105
+function dataURLtoFile(dataurl, filename) {
106
+  let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
107
+    bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
108
+  while(n--){
109
+    u8arr[n] = bstr.charCodeAt(n);
110
+  }
111
+  return new File([u8arr], filename, { type: mime });
112
+}
113
+
114
+const DOODLE_SIZES = {
115
+  normal: [500, 500, 'Square 500'],
116
+  tootbanner: [702, 330, 'Tootbanner'],
117
+  s640x480: [640, 480, '640×480 - 480p'],
118
+  s800x600: [800, 600, '800×600 - SVGA'],
119
+  s720x480: [720, 405, '720x405 - 16:9'],
120
+};
121
+
122
+
123
+const mapStateToProps = state => ({
124
+  options: state.getIn(['compose', 'doodle']),
125
+});
126
+
127
+const mapDispatchToProps = dispatch => ({
128
+  /** Set options in the redux store */
129
+  setOpt: (opts) => dispatch(doodleSet(opts)),
130
+  /** Submit doodle for upload */
131
+  submit: (file) => dispatch(uploadCompose([file])),
132
+});
133
+
134
+/**
135
+ * Doodling dialog with drawing canvas
136
+ *
137
+ * Keyboard shortcuts:
138
+ * - Delete: Clear screen, fill with background color
139
+ * - Backspace, Ctrl+Z: Undo one step
140
+ * - Ctrl held while drawing: Use background color
141
+ * - Shift held while clicking screen: Use fill tool
142
+ *
143
+ * Palette:
144
+ * - Left mouse button: pick foreground
145
+ * - Ctrl + left mouse button: pick background
146
+ * - Right mouse button: pick background
147
+ */
148
+@connect(mapStateToProps, mapDispatchToProps)
149
+export default class DoodleModal extends ImmutablePureComponent {
150
+
151
+  static propTypes = {
152
+    options: ImmutablePropTypes.map,
153
+    onClose: PropTypes.func.isRequired,
154
+    setOpt: PropTypes.func.isRequired,
155
+    submit: PropTypes.func.isRequired,
156
+  };
157
+
158
+  //region Option getters/setters
159
+
160
+  /** Foreground color */
161
+  get fg () {
162
+    return this.props.options.get('fg');
163
+  }
164
+  set fg (value) {
165
+    this.props.setOpt({ fg: value });
166
+  }
167
+
168
+  /** Background color */
169
+  get bg () {
170
+    return this.props.options.get('bg');
171
+  }
172
+  set bg (value) {
173
+    this.props.setOpt({ bg: value });
174
+  }
175
+
176
+  /** Swap Fg and Bg for drawing */
177
+  get swapped () {
178
+    return this.props.options.get('swapped');
179
+  }
180
+  set swapped (value) {
181
+    this.props.setOpt({ swapped: value });
182
+  }
183
+
184
+  /** Mode - 'draw' or 'fill' */
185
+  get mode () {
186
+    return this.props.options.get('mode');
187
+  }
188
+  set mode (value) {
189
+    this.props.setOpt({ mode: value });
190
+  }
191
+
192
+  /** Base line weight */
193
+  get weight () {
194
+    return this.props.options.get('weight');
195
+  }
196
+  set weight (value) {
197
+    this.props.setOpt({ weight: value });
198
+  }
199
+
200
+  /** Drawing opacity */
201
+  get opacity () {
202
+    return this.props.options.get('opacity');
203
+  }
204
+  set opacity (value) {
205
+    this.props.setOpt({ opacity: value });
206
+  }
207
+
208
+  /** Adaptive stroke - change width with speed */
209
+  get adaptiveStroke () {
210
+    return this.props.options.get('adaptiveStroke');
211
+  }
212
+  set adaptiveStroke (value) {
213
+    this.props.setOpt({ adaptiveStroke: value });
214
+  }
215
+
216
+  /** Smoothing (for mouse drawing) */
217
+  get smoothing () {
218
+    return this.props.options.get('smoothing');
219
+  }
220
+  set smoothing (value) {
221
+    this.props.setOpt({ smoothing: value });
222
+  }
223
+
224
+  /** Size preset */
225
+  get size () {
226
+    return this.props.options.get('size');
227
+  }
228
+  set size (value) {
229
+    this.props.setOpt({ size: value });
230
+  }
231
+
232
+  //endregion
233
+
234
+  /** Key up handler */
235
+  handleKeyUp = (e) => {
236
+    if (e.target.nodeName === 'INPUT') return;
237
+
238
+    if (e.key === 'Delete') {
239
+      e.preventDefault();
240
+      this.handleClearBtn();
241
+      return;
242
+    }
243
+
244
+    if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
245
+      e.preventDefault();
246
+      this.undo();
247
+    }
248
+
249
+    if (e.key === 'Control' || e.key === 'Meta') {
250
+      this.controlHeld = false;
251
+      this.swapped = false;
252
+    }
253
+
254
+    if (e.key === 'Shift') {
255
+      this.shiftHeld = false;
256
+      this.mode = 'draw';
257
+    }
258
+  };
259
+
260
+  /** Key down handler */
261
+  handleKeyDown = (e) => {
262
+    if (e.key === 'Control' || e.key === 'Meta') {
263
+      this.controlHeld = true;
264
+      this.swapped = true;
265
+    }
266
+
267
+    if (e.key === 'Shift') {
268
+      this.shiftHeld = true;
269
+      this.mode = 'fill';
270
+    }
271
+  };
272
+
273
+  /**
274
+   * Component installed in the DOM, do some initial set-up
275
+   */
276
+  componentDidMount () {
277
+    this.controlHeld = false;
278
+    this.shiftHeld = false;
279
+    this.swapped = false;
280
+    window.addEventListener('keyup', this.handleKeyUp, false);
281
+    window.addEventListener('keydown', this.handleKeyDown, false);
282
+  };
283
+
284
+  /**
285
+   * Tear component down
286
+   */
287
+  componentWillUnmount () {
288
+    window.removeEventListener('keyup', this.handleKeyUp, false);
289
+    window.removeEventListener('keydown', this.handleKeyDown, false);
290
+    if (this.sketcher) this.sketcher.destroy();
291
+  }
292
+
293
+  /**
294
+   * Set reference to the canvas element.
295
+   * This is called during component init
296
+   *
297
+   * @param elem - canvas element
298
+   */
299
+  setCanvasRef = (elem) => {
300
+    this.canvas = elem;
301
+    if (elem) {
302
+      elem.addEventListener('dirty', () => {
303
+        this.saveUndo();
304
+        this.sketcher._dirty = false;
305
+      });
306
+
307
+      elem.addEventListener('click', () => {
308
+        // sketcher bug - does not fire dirty on fill
309
+        if (this.mode === 'fill') {
310
+          this.saveUndo();
311
+        }
312
+      });
313
+
314
+      // prevent context menu
315
+      elem.addEventListener('contextmenu', (e) => {
316
+        e.preventDefault();
317
+      });
318
+
319
+      elem.addEventListener('mousedown', (e) => {
320
+        if (e.button === 2) {
321
+          this.swapped = true;
322
+        }
323
+      });
324
+
325
+      elem.addEventListener('mouseup', (e) => {
326
+        if (e.button === 2) {
327
+          this.swapped = this.controlHeld;
328
+        }
329
+      });
330
+
331
+      this.initSketcher(elem);
332
+      this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
333
+    }
334
+  };
335
+
336
+  /**
337
+   * Set up the sketcher instance
338
+   *
339
+   * @param canvas - canvas element. Null if we're just resizing
340
+   */
341
+  initSketcher (canvas = null) {
342
+    const sizepreset = DOODLE_SIZES[this.size];
343
+
344
+    if (this.sketcher) this.sketcher.destroy();
345
+    this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
346
+
347
+    if (canvas) {
348
+      this.ctx = this.sketcher.context;
349
+      this.updateSketcherSettings();
350
+    }
351
+
352
+    this.clearScreen();
353
+  }
354
+
355
+  /**
356
+   * Done button handler
357
+   */
358
+  onDoneButton = () => {
359
+    const dataUrl = this.sketcher.toImage();
360
+    const file = dataURLtoFile(dataUrl, 'doodle.png');
361
+    this.props.submit(file);
362
+    this.props.onClose(); // close dialog
363
+  };
364
+
365
+  /**
366
+   * Cancel button handler
367
+   */
368
+  onCancelButton = () => {
369
+    if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
370
+      return;
371
+    }
372
+
373
+    this.props.onClose(); // close dialog
374
+  };
375
+
376
+  /**
377
+   * Update sketcher options based on state
378
+   */
379
+  updateSketcherSettings () {
380
+    if (!this.sketcher) return;
381
+
382
+    if (this.oldSize !== this.size) this.initSketcher();
383
+
384
+    this.sketcher.color = (this.swapped ? this.bg : this.fg);
385
+    this.sketcher.opacity = this.opacity;
386
+    this.sketcher.weight = this.weight;
387
+    this.sketcher.mode = this.mode;
388
+    this.sketcher.smoothing = this.smoothing;
389
+    this.sketcher.adaptiveStroke = this.adaptiveStroke;
390
+
391
+    this.oldSize = this.size;
392
+  }
393
+
394
+  /**
395
+   * Fill screen with background color
396
+   */
397
+  clearScreen = () => {
398
+    this.ctx.fillStyle = this.bg;
399
+    this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
400
+    this.undos = [];
401
+
402
+    this.doSaveUndo();
403
+  };
404
+
405
+  /**
406
+   * Undo one step
407
+   */
408
+  undo = () => {
409
+    if (this.undos.length > 1) {
410
+      this.undos.pop();
411
+      const buf = this.undos.pop();
412
+
413
+      this.sketcher.clear();
414
+      this.ctx.putImageData(buf, 0, 0);
415
+      this.doSaveUndo();
416
+    }
417
+  };
418
+
419
+  /**
420
+   * Save canvas content into the undo buffer immediately
421
+   */
422
+  doSaveUndo = () => {
423
+    this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
424
+  };
425
+
426
+  /**
427
+   * Called on each canvas change.
428
+   * Saves canvas content to the undo buffer after some period of inactivity.
429
+   */
430
+  saveUndo = debounce(() => {
431
+    this.doSaveUndo();
432
+  }, 100);
433
+
434
+  /**
435
+   * Palette left click.
436
+   * Selects Fg color (or Bg, if Control/Meta is held)
437
+   *
438
+   * @param e - event
439
+   */
440
+  onPaletteClick = (e) => {
441
+    const c = e.target.dataset.color;
442
+
443
+    if (this.controlHeld) {
444
+      this.bg = c;
445
+    } else {
446
+      this.fg = c;
447
+    }
448
+
449
+    e.target.blur();
450
+    e.preventDefault();
451
+  };
452
+
453
+  /**
454
+   * Palette right click.
455
+   * Selects Bg color
456
+   *
457
+   * @param e - event
458
+   */
459
+  onPaletteRClick = (e) => {
460
+    this.bg = e.target.dataset.color;
461
+    e.target.blur();
462
+    e.preventDefault();
463
+  };
464
+
465
+  /**
466
+   * Handle click on the Draw mode button
467
+   *
468
+   * @param e - event
469
+   */
470
+  setModeDraw = (e) => {
471
+    this.mode = 'draw';
472
+    e.target.blur();
473
+  };
474
+
475
+  /**
476
+   * Handle click on the Fill mode button
477
+   *
478
+   * @param e - event
479
+   */
480
+  setModeFill = (e) => {
481
+    this.mode = 'fill';
482
+    e.target.blur();
483
+  };
484
+
485
+  /**
486
+   * Handle click on Smooth checkbox
487
+   *
488
+   * @param e - event
489
+   */
490
+  tglSmooth = (e) => {
491
+    this.smoothing = !this.smoothing;
492
+    e.target.blur();
493
+  };
494
+
495
+  /**
496
+   * Handle click on Adaptive checkbox
497
+   *
498
+   * @param e - event
499
+   */
500
+  tglAdaptive = (e) => {
501
+    this.adaptiveStroke = !this.adaptiveStroke;
502
+    e.target.blur();
503
+  };
504
+
505
+  /**
506
+   * Handle change of the Weight input field
507
+   *
508
+   * @param e - event
509
+   */
510
+  setWeight = (e) => {
511
+    this.weight = +e.target.value || 1;
512
+  };
513
+
514
+  /**
515
+   * Set size - clalback from the select box
516
+   *
517
+   * @param e - event
518
+   */
519
+  changeSize = (e) => {
520
+    let newSize = e.target.value;
521
+    if (newSize === this.oldSize) return;
522
+
523
+    if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
524
+      return;
525
+    }
526
+
527
+    this.size = newSize;
528
+  };
529
+
530
+  handleClearBtn = () => {
531
+    if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
532
+      return;
533
+    }
534
+
535
+    this.clearScreen();
536
+  };
537
+
538
+  /**
539
+   * Render the component
540
+   */
541
+  render () {
542
+    this.updateSketcherSettings();
543
+
544
+    return (
545
+      <div className='modal-root__modal doodle-modal'>
546
+        <div className='doodle-modal__container'>
547
+          <canvas ref={this.setCanvasRef} />
548
+        </div>
549
+
550
+        <div className='doodle-modal__action-bar'>
551
+          <div className='doodle-toolbar'>
552
+            <Button text='Done' onClick={this.onDoneButton} />
553
+            <Button text='Cancel' onClick={this.onCancelButton} />
554
+          </div>
555
+          <div className='filler' />
556
+          <div className='doodle-toolbar with-inputs'>
557
+            <div>
558
+              <label htmlFor='dd_smoothing'>Smoothing</label>
559
+              <span className='val'>
560
+                <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
561
+              </span>
562
+            </div>
563
+            <div>
564
+              <label htmlFor='dd_adaptive'>Adaptive</label>
565
+              <span className='val'>
566
+                <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
567
+              </span>
568
+            </div>
569
+            <div>
570
+              <label htmlFor='dd_weight'>Weight</label>
571
+              <span className='val'>
572
+                <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
573
+              </span>
574
+            </div>
575
+            <div>
576
+              <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
577
+                { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
578
+                  <option key={k} value={k}>{val[2]}</option>
579
+                )) }
580
+              </select>
581
+            </div>
582
+          </div>
583
+          <div className='doodle-toolbar'>
584
+            <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
585
+            <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
586
+            <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
587
+            <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
588
+          </div>
589
+          <div className='doodle-palette'>
590
+            {
591
+              palReordered.map((c, i) =>
592
+                c === null ?
593
+                  <br key={i} /> :
594
+                  <button
595
+                    key={i}
596
+                    style={{ backgroundColor: c[0] }}
597
+                    onClick={this.onPaletteClick}
598
+                    onContextMenu={this.onPaletteRClick}
599
+                    data-color={c[0]}
600
+                    title={c[1]}
601
+                    className={classNames({
602
+                      'foreground': this.fg === c[0],
603
+                      'background': this.bg === c[0],
604
+                    })}
605
+                  />
606
+              )
607
+            }
608
+          </div>
609
+        </div>
610
+      </div>
611
+    );
612
+  }
613
+
614
+}

+ 13
- 1
app/javascript/mastodon/features/ui/components/modal_root.js View File

@@ -8,6 +8,7 @@ import ActionsModal from './actions_modal';
8 8
 import MediaModal from './media_modal';
9 9
 import VideoModal from './video_modal';
10 10
 import BoostModal from './boost_modal';
11
+import DoodleModal from './doodle_modal';
11 12
 import ConfirmationModal from './confirmation_modal';
12 13
 import FocalPointModal from './focal_point_modal';
13 14
 import {
@@ -23,6 +24,7 @@ const MODAL_COMPONENTS = {
23 24
   'ONBOARDING': OnboardingModal,
24 25
   'VIDEO': () => Promise.resolve({ default: VideoModal }),
25 26
   'BOOST': () => Promise.resolve({ default: BoostModal }),
27
+  'DOODLE': () => Promise.resolve({ default: DoodleModal }),
26 28
   'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
27 29
   'MUTE': MuteModal,
28 30
   'REPORT': ReportModal,
@@ -43,6 +45,16 @@ export default class ModalRoot extends React.PureComponent {
43 45
   getSnapshotBeforeUpdate () {
44 46
     return { visible: !!this.props.type };
45 47
   }
48
+  state = {
49
+    revealed: false,
50
+  };
51
+
52
+  handleKeyUp = (e) => {
53
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
54
+         && !!this.props.type && !this.props.props.noEsc) {
55
+      this.props.onClose();
56
+    }
57
+  }
46 58
 
47 59
   componentDidUpdate (prevProps, prevState, { visible }) {
48 60
     if (visible) {
@@ -53,7 +65,7 @@ export default class ModalRoot extends React.PureComponent {
53 65
   }
54 66
 
55 67
   renderLoading = modalId => () => {
56
-    return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
68
+    return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
57 69
   }
58 70
 
59 71
   renderError = (props) => {

+ 293
- 0
app/javascript/mastodon/locales/en-CY.json View File

@@ -0,0 +1,293 @@
1
+{
2
+  "account.block": "Block @{name}",
3
+  "account.block_domain": "Hide everything from {domain}",
4
+  "account.blocked": "Blocked",
5
+  "account.disclaimer_full": "THESE NUMBERS ARE THE STUFF WHAT YOUR SERVER KNOWS ABOUT AND THERE MIGHT BE MORE THAT IT DONT KNOW ABOUT.",
6
+  "account.domain_blocked": "Domain hidden",
7
+  "account.edit_profile": "edit ~/.profile",
8
+  "account.follow": "Follow",
9
+  "account.followers": "Followers",
10
+  "account.follows": "Follows",
11
+  "account.follows_you": "Follows you",
12
+  "account.hide_reblogs": "Hide boosts from @{name}",
13
+  "account.media": "Media",
14
+  "account.mention": "Mention @{name}",
15
+  "account.moved_to": "{name} has moved to:",
16
+  "account.mute": "Mute @{name}",
17
+  "account.mute_notifications": "Mute notifications from @{name}",
18
+  "account.muted": "Muted",
19
+  "account.posts": "Pings",
20
+  "account.posts_with_replies": "Pings with replies",
21
+  "account.report": "Report @{name}",
22
+  "account.requested": "Awaiting approval. Click to cancel follow request",
23
+  "account.share": "Share @{name}'s profile",
24
+  "account.show_reblogs": "Show boosts from @{name}",
25
+  "account.unblock": "Unblock @{name}",
26
+  "account.unblock_domain": "Unhide {domain}",
27
+  "account.unfollow": "Unfollow",
28
+  "account.unmute": "Unmute @{name}",
29
+  "account.unmute_notifications": "Unmute notifications from @{name}",
30
+  "account.view_full_profile": "View full profile",
31
+  "boost_modal.combo": "You can press {combo} to skip this next time",
32
+  "bundle_column_error.body": "Something went wrong while loading this component.",
33
+  "bundle_column_error.retry": "Try again",
34
+  "bundle_column_error.title": "Network error",
35
+  "bundle_modal_error.close": "Close",
36
+  "bundle_modal_error.message": "Something went wrong while loading this component.",
37
+  "bundle_modal_error.retry": "Try again",
38
+  "column.blocks": "~/.blocked",
39
+  "column.community": "/timelines/local",
40
+  "column.direct": "~/.dms",
41
+  "column.favourites": "~/.florps",
42
+  "column.follow_requests": "~/.follow-requests",
43
+  "column.home": "/timelines/home",
44
+  "column.lists": "Lists",
45
+  "column.mutes": "~/.muted",
46
+  "column.notifications": "~/.notifications",
47
+  "column.pins": "~/.pinned",
48
+  "column.public": "/timelines/federated",
49
+  "column_back_button.label": "Back",
50
+  "column_header.hide_settings": "Hide settings",
51
+  "column_header.moveLeft_settings": "Move column to the left",
52
+  "column_header.moveRight_settings": "Move column to the right",
53
+  "column_header.pin": "Pin",
54
+  "column_header.show_settings": "Show settings",
55
+  "column_header.unpin": "Unpin",
56
+  "column_subheading.navigation": "Navigation",
57
+  "column_subheading.settings": "Settings",
58
+  "compose.attach": "Attach...",
59
+  "compose.attach.doodle": "Draw something",
60
+  "compose.attach.upload": "Upload a file",
61
+  "compose_form.hashtag_warning": "This ping won't be listed under any hashtag as it is unlisted. Only public pings can be searched by hashtag.",
62
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
63
+  "compose_form.lock_disclaimer.lock": "locked",
64
+  "compose_form.placeholder": "What is in your databanks?",
65
+  "compose_form.publish": "Ping",
66
+  "compose_form.publish_loud": "{publish}!",
67
+  "compose_form.sensitive.marked": "Media is marked as sensitive",
68
+  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
69
+  "compose_form.spoiler.marked": "Text is hidden behind warning",
70
+  "compose_form.spoiler.unmarked": "Text is not hidden",
71
+  "compose_form.spoiler_placeholder": "Write your warning here",
72
+  "confirmation_modal.cancel": "Cancel",
73
+  "confirmations.block.confirm": "Block",
74
+  "confirmations.block.message": "Are you sure you want to block {name}?",
75
+  "confirmations.delete.confirm": "Delete",
76
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
77
+  "confirmations.delete_list.confirm": "Delete",
78
+  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
79
+  "confirmations.domain_block.confirm": "Hide entire domain",
80
+  "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
81
+  "confirmations.mute.confirm": "Mute",
82
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
83
+  "confirmations.unfollow.confirm": "Unfollow",
84
+  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
85
+  "doodle_button.label": "Add a drawing",
86
+  "embed.instructions": "Embed this status on your website by copying the code below.",
87
+  "embed.preview": "Here is what it will look like:",
88
+  "emoji_button.activity": "Activity",
89
+  "emoji_button.custom": "Custom",
90
+  "emoji_button.flags": "Flags",
91
+  "emoji_button.food": "Food & Drink",
92
+  "emoji_button.label": "Insert emoji",
93
+  "emoji_button.nature": "Nature",
94
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
95
+  "emoji_button.objects": "Objects",
96
+  "emoji_button.people": "People",
97
+  "emoji_button.recent": "Frequently used",
98
+  "emoji_button.search": "Search...",
99
+  "emoji_button.search_results": "Search results",
100
+  "emoji_button.symbols": "Symbols",
101
+  "emoji_button.travel": "Travel & Places",
102
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
103
+  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
104
+  "empty_column.hashtag": "There is nothing in this hashtag yet.",
105
+  "empty_column.home": "Your home timeline is empty! Visit {public} or use query to get started and meet other users.",
106
+  "empty_column.home.public_timeline": "the public timeline",
107
+  "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
108
+  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
109
+  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
110
+  "follow_request.authorize": "Authorize",
111
+  "follow_request.reject": "Reject",
112
+  "getting_started.appsshort": "Apps",
113
+  "getting_started.faq": "FAQ",
114
+  "getting_started.heading": "Getting started",
115
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
116
+  "getting_started.userguide": "User Guide",
117
+  "home.column_settings.advanced": "Advanced",
118
+  "home.column_settings.basic": "Basic",
119
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
120
+  "home.column_settings.show_reblogs": "Show relays",
121
+  "home.column_settings.show_replies": "Show replies",
122
+  "home.settings": "Column settings",
123
+  "keyboard_shortcuts.back": "to navigate back",
124
+  "keyboard_shortcuts.boost": "to boost",
125
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
126
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
127
+  "keyboard_shortcuts.description": "Description",
128
+  "keyboard_shortcuts.down": "to move down in the list",
129
+  "keyboard_shortcuts.enter": "to open status",
130
+  "keyboard_shortcuts.favourite": "to favourite",
131
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
132
+  "keyboard_shortcuts.hotkey": "Hotkey",
133
+  "keyboard_shortcuts.legend": "to display this legend",
134
+  "keyboard_shortcuts.mention": "to mention author",
135
+  "keyboard_shortcuts.reply": "to reply",
136
+  "keyboard_shortcuts.search": "to focus search",
137
+  "keyboard_shortcuts.toot": "to start a brand new ping",
138
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
139
+  "keyboard_shortcuts.up": "to move up in the list",
140
+  "lightbox.close": "Close",
141
+  "lightbox.next": "Next",
142
+  "lightbox.previous": "Previous",
143
+  "lists.account.add": "Add to list",
144
+  "lists.account.remove": "Remove from list",
145
+  "lists.delete": "Delete list",
146
+  "lists.edit": "Edit list",
147
+  "lists.new.create": "Add list",
148
+  "lists.new.title_placeholder": "New list title",
149
+  "lists.search": "Search among people you follow",
150
+  "lists.subheading": "Your lists",
151
+  "loading_indicator.label": "Loading...",
152
+  "media_gallery.toggle_visible": "Toggle visibility",
153
+  "missing_indicator.label": "Not found",
154
+  "missing_indicator.sublabel": "This resource could not be found",
155
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
156
+  "navigation_bar.blocks": "~/.blocks",
157
+  "navigation_bar.community_timeline": "/timelines/local",
158
+  "navigation_bar.direct": "~/.dms",
159
+  "navigation_bar.edit_profile": "edit ~/.profile",
160
+  "navigation_bar.favourites": "~/.florps",
161
+  "navigation_bar.follow_requests": "~/.follow-requests",
162
+  "navigation_bar.info": "/about/more",
163
+  "navigation_bar.keyboard_shortcuts": "~/.kbd/shortcuts.conf",
164
+  "navigation_bar.lists": "~/.lists",
165
+  "navigation_bar.logout": "Jack out",
166
+  "navigation_bar.mutes": "~/.muted",
167
+  "navigation_bar.pins": "~/.pinned",
168
+  "navigation_bar.preferences": "edit ~/.config",
169
+  "navigation_bar.public_timeline": "/timelines/federated",
170
+  "notification.favourite": "{name} florped your ping",
171
+  "notification.follow": "{name} followed you",
172
+  "notification.mention": "{name} mentioned you",
173
+  "notification.reblog": "{name} relayed your ping",
174
+  "notifications.clear": "Clear notifications",
175
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
176
+  "notifications.column_settings.alert": "Desktop notifications",
177
+  "notifications.column_settings.favourite": "Favourites:",
178
+  "notifications.column_settings.follow": "New followers:",
179
+  "notifications.column_settings.mention": "Mentions:",
180
+  "notifications.column_settings.push": "Push notifications",
181
+  "notifications.column_settings.push_meta": "This device",
182
+  "notifications.column_settings.reblog": "Boosts:",
183
+  "notifications.column_settings.show": "Show in column",
184
+  "notifications.column_settings.sound": "Play sound",
185
+  "onboarding.done": "Done",
186
+  "onboarding.next": "Next",
187
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
188
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
189
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
190
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
191
+  "onboarding.page_one.full_handle": "Your full handle",
192
+  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
193
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
194
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
195
+  "onboarding.page_six.almost_done": "Almost done...",
196
+  "onboarding.page_six.appetoot": "Hang ten on the cybrewaves!",
197
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
198
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
199
+  "onboarding.page_six.guidelines": "community guidelines",
200
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
201
+  "onboarding.page_six.various_app": "mobile apps",
202
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
203
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
204
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
205
+  "onboarding.skip": "Skip",
206
+  "privacy.change": "Adjust status privacy",
207
+  "privacy.direct.long": "Post to mentioned users only",
208
+  "privacy.direct.short": "Direct",
209
+  "privacy.private.long": "Post to followers only",
210
+  "privacy.private.short": "Followers-only",
211
+  "privacy.public.long": "Post to public timelines",
212
+  "privacy.public.short": "Public",
213
+  "privacy.unlisted.long": "Do not post to public timelines",
214
+  "privacy.unlisted.short": "Unlisted",
215
+  "regeneration_indicator.label": "Loading…",
216
+  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
217
+  "relative_time.days": "{number}d",
218
+  "relative_time.hours": "{number}h",
219
+  "relative_time.just_now": "now",
220
+  "relative_time.minutes": "{number}m",
221
+  "relative_time.seconds": "{number}s",
222
+  "reply_indicator.cancel": "Cancel",
223
+  "report.forward": "Forward to {target}",
224
+  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
225
+  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
226
+  "report.placeholder": "Additional comments",
227
+  "report.submit": "Submit",
228
+  "report.target": "Reporting {target}",
229
+  "search.placeholder": "Query...",
230
+  "search_popout.search_format": "Advanced search format",
231
+  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
232
+  "search_popout.tips.hashtag": "hashtag",
233
+  "search_popout.tips.status": "status",
234
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
235
+  "search_popout.tips.user": "user",
236
+  "search_results.accounts": "People",
237
+  "search_results.hashtags": "Hashtags",
238
+  "search_results.statuses": "Pings",
239
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
240
+  "standalone.public_title": "Peer into the data grid...",
241
+  "status.block": "Block @{name}",
242
+  "status.cannot_reblog": "This ping cannot be relayed",
243
+  "status.delete": "Delete",
244
+  "status.embed": "Embed",
245
+  "status.favourite": "Florp",
246
+  "status.load_more": "Load more",
247
+  "status.media_hidden": "Media hidden",
248
+  "status.mention": "Mention @{name}",
249
+  "status.more": "More",
250
+  "status.mute": "Mute @{name}",
251
+  "status.mute_conversation": "Mute conversation",
252
+  "status.open": "Expand this status",
253
+  "status.pin": "Pin on profile",
254
+  "status.pinned": "Pinned ping",
255
+  "status.reblog": "Relay",
256
+  "status.reblogged_by": "{name} relayed",
257
+  "status.reply": "Reply",
258
+  "status.replyAll": "Reply to thread",
259
+  "status.report": "Report @{name}",
260
+  "status.sensitive_toggle": "Click to view",
261
+  "status.sensitive_warning": "Sensitive content",
262
+  "status.share": "Share",
263
+  "status.show_less": "Show less",
264
+  "status.show_less_all": "Show less for all",
265
+  "status.show_more": "Show more",
266
+  "status.show_more_all": "Show more for all",
267
+  "status.unmute_conversation": "Unmute conversation",
268
+  "status.unpin": "Unpin from profile",
269
+  "tabs_bar.federated_timeline": "/timelines/federated",
270
+  "tabs_bar.home": "/timelines/home",
271
+  "tabs_bar.local_timeline": "/timelines/local",
272
+  "tabs_bar.notifications": "~/.notifications",
273
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
274
+  "upload_area.title": "Drag & drop to upload",
275
+  "upload_button.label": "Add media",
276
+  "upload_form.description": "Describe for the visually impaired",
277
+  "upload_form.focus": "Crop",
278
+  "upload_form.undo": "Undo",
279
+  "upload_progress.label": "Uploading...",
280
+  "video.close": "Close video",
281
+  "video.exit_fullscreen": "Exit full screen",
282
+  "video.expand": "Expand video",
283
+  "video.fullscreen": "Full screen",
284
+  "video.hide": "Hide video",
285
+  "video.mute": "Mute sound",
286
+  "video.pause": "Pause",
287
+  "video.play": "Play",
288
+  "video.unmute": "Unmute sound",
289
+  "video_player.expand": "Expand video",
290
+  "video_player.toggle_sound": "Toggle sound",
291
+  "video_player.toggle_visible": "Toggle visibility",
292
+  "video_player.video_error": "Video could not be played"
293
+}

+ 2
- 0
app/javascript/mastodon/locales/whitelist_en-CY.json View File

@@ -0,0 +1,2 @@
1
+[
2
+]

+ 14
- 0
app/javascript/mastodon/reducers/compose.js View File

@@ -28,6 +28,7 @@ import {
28 28
   COMPOSE_UPLOAD_CHANGE_REQUEST,
29 29
   COMPOSE_UPLOAD_CHANGE_SUCCESS,
30 30
   COMPOSE_UPLOAD_CHANGE_FAIL,
31
+  COMPOSE_DOODLE_SET,
31 32
   COMPOSE_RESET,
32 33
 } from '../actions/compose';
33 34
 import { TIMELINE_DELETE } from '../actions/timelines';
@@ -61,6 +62,17 @@ const initialState = ImmutableMap({
61 62
   resetFileKey: Math.floor((Math.random() * 0x10000)),
62 63
   idempotencyKey: null,
63 64
   tagHistory: ImmutableList(),
65
+  doodle: ImmutableMap({
66
+    fg: 'rgb(  0,    0,    0)',
67
+    bg: 'rgb(255,  255,  255)',
68
+    swapped: false,
69
+    mode: 'draw',
70
+    size: 'normal',
71
+    weight: 2,
72
+    opacity: 1,
73
+    adaptiveStroke: true,
74
+    smoothing: false,
75
+  }),
64 76
 });
65 77
 
66 78
 function statusToTextMentions(state, status) {
@@ -326,6 +338,8 @@ export default function compose(state = initialState, action) {
326 338
         map.set('spoiler_text', '');
327 339
       }
328 340
     });
341
+  case COMPOSE_DOODLE_SET:
342
+    return state.mergeIn(['doodle'], action.options);
329 343
   default:
330 344
     return state;
331 345
   }

+ 2
- 0
app/javascript/packs/application.js View File

@@ -8,3 +8,5 @@ loadPolyfills().then(() => {
8 8
 }).catch(e => {
9 9
   console.error(e);
10 10
 });
11
+
12
+require('what-input');

+ 34
- 1
app/javascript/packs/public.js View File

@@ -1,4 +1,4 @@
1
-import loadPolyfills from '../mastodon/load_polyfills';
1
+                    import loadPolyfills from '../mastodon/load_polyfills';
2 2
 import ready from '../mastodon/ready';
3 3
 import { start } from '../mastodon/common';
4 4
 
@@ -17,6 +17,12 @@ window.addEventListener('message', e => {
17 17
       id: data.id,
18 18
       height: document.getElementsByTagName('html')[0].scrollHeight,
19 19
     }, '*');
20
+
21
+    if (document.fonts && document.fonts.ready) {
22
+      document.fonts.ready.then(sizeBioText);
23
+    } else {
24
+      sizeBioText();
25
+    }
20 26
   });
21 27
 });
22 28
 
@@ -93,6 +99,17 @@ function main() {
93 99
       detailedStatuses[0].scrollIntoView();
94 100
       history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
95 101
     }
102
+
103
+    [].forEach.call(document.querySelectorAll('[data-component="Card"]'), (content) => {
104
+      const props = JSON.parse(content.getAttribute('data-props'));
105
+      ReactDOM.render(<CardContainer locale={locale} {...props} />, content);
106
+    });
107
+
108
+    if (document.fonts && document.fonts.ready) {
109
+      document.fonts.ready.then(sizeBioText);
110
+    } else {
111
+      sizeBioText();
112
+    }
96 113
   });
97 114
 
98 115
   delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
@@ -188,6 +205,22 @@ function main() {
188 205
       console.error(err);
189 206
     }
190 207
   });
208
+
209
+  delegate(document, '#account_note', 'input', sizeBioText);
210
+
211
+  function sizeBioText() {
212
+    const noteCounter = document.querySelector('.note-counter');
213
+    const bioTextArea = document.querySelector('#account_note');
214
+
215
+    if (noteCounter) {
216
+      noteCounter.textContent = 413 - length(bioTextArea.value);
217
+    }
218
+
219
+    if (bioTextArea) {
220
+      bioTextArea.style.height = 'auto';
221
+      bioTextArea.style.height = (bioTextArea.scrollHeight+3) + 'px';
222
+    }
223
+  }
191 224
 }
192 225
 
193 226
 loadPolyfills().then(main).catch(error => {

+ 96
- 0
app/javascript/styles/doodle.scss View File

@@ -0,0 +1,96 @@
1
+$doodleBg: #d9e1e8;
2
+.doodle-modal {
3
+  @extend .boost-modal;
4
+  width: unset;
5
+}
6
+
7
+.doodle-modal__container {
8
+  background: $doodleBg;
9
+  text-align: center;
10
+  line-height: 0; // remove weird gap under canvas
11
+  canvas {
12
+    border: 5px solid $doodleBg;
13
+  }
14
+}
15
+
16
+.doodle-modal__action-bar {
17
+  @extend .boost-modal__action-bar;
18
+
19
+  .filler {
20
+    flex-grow: 1;
21
+    margin: 0;
22
+    padding: 0;
23
+  }
24
+
25
+  .doodle-toolbar {
26
+    line-height: 1;
27
+
28
+    display: flex;
29
+    flex-direction: column;
30
+    flex-grow: 0;
31
+    justify-content: space-around;
32
+
33
+    &.with-inputs {
34
+      label {
35
+        display: inline-block;
36
+        width: 70px;
37
+        text-align: right;
38
+        margin-right: 2px;
39
+      }
40
+
41
+      input[type="number"],input[type="text"] {
42
+        width: 40px;
43
+      }
44
+      span.val {
45
+        display: inline-block;
46
+        text-align: left;
47
+        width: 50px;
48
+      }
49
+    }
50
+  }
51
+
52
+  .doodle-palette {
53
+    padding-right: 0 !important;
54
+    border: 1px solid black;
55
+    line-height: .2rem;
56
+    flex-grow: 0;
57
+    background: white;
58
+
59
+    button {
60
+      appearance: none;
61
+      width: 1rem;
62
+      height: 1rem;
63
+      margin: 0; padding: 0;
64
+      text-align: center;
65
+      color: black;
66
+      text-shadow: 0 0 1px white;
67
+      cursor: pointer;
68
+      box-shadow: inset 0 0 1px rgba(white, .5);
69
+      border: 1px solid black;
70
+      outline-offset:-1px;
71
+
72
+      &.foreground {
73
+        outline: 1px dashed white;
74
+      }
75
+
76
+      &.background {
77
+        outline: 1px dashed red;
78
+      }
79
+
80
+      &.foreground.background {
81
+        outline: 1px dashed red;
82
+        border-color: white;
83
+      }
84
+    }
85
+  }
86
+}
87
+
88
+.compose-form__buttons-separator { 
89
+  border-left: 1px solid #c3c3c3; 
90
+  margin: 0 3px; 
91
+} 
92
+
93
+.compose-form__upload-button-icon {
94
+  line-height: 27px;
95
+}
96
+

+ 73
- 1
app/javascript/styles/mastodon/components.scss View File

@@ -383,7 +383,6 @@
383 383
     padding: 10px;
384 384
     cursor: pointer;
385 385
     border-radius: 4px;
386
-
387 386
     &:hover,
388 387
     &:focus,
389 388
     &:active,
@@ -3468,6 +3467,78 @@ a.status-card.compact:hover {
3468 3467
   }
3469 3468
 }
3470 3469
 
3470
+.advanced-options-dropdown {
3471
+  position: relative;
3472
+}
3473
+
3474
+.advanced-options-dropdown__dropdown {
3475
+  display: none;
3476
+  position: absolute;
3477
+  left: 0;
3478
+  top: 27px;
3479
+  width: 210px;
3480
+  background: $simple-background-color;
3481
+  border-radius: 0 4px 4px;
3482
+  z-index: 2;
3483
+  overflow: hidden;
3484
+}
3485
+
3486
+.advanced-options-dropdown__option {
3487
+  color: $ui-base-color;
3488
+  padding: 10px;
3489
+  cursor: pointer;
3490
+  display: flex;
3491
+
3492
+  &:hover,
3493
+  &.active {
3494
+    background: $ui-highlight-color;
3495
+    color: $primary-text-color;
3496
+
3497
+    .advanced-options-dropdown__option__content {
3498
+      color: $primary-text-color;
3499
+
3500
+      strong {
3501
+        color: $primary-text-color;
3502
+      }
3503
+    }
3504
+  }
3505
+
3506
+  &.active:hover {
3507
+    background: lighten($ui-highlight-color, 4%);
3508
+  }
3509
+}
3510
+
3511
+.advanced-options-dropdown__option__toggle {
3512
+  display: flex;
3513
+  align-items: center;
3514
+  justify-content: center;
3515
+  margin-right: 10px;
3516
+}
3517
+
3518
+.advanced-options-dropdown__option__content {
3519
+  flex: 1 1 auto;
3520
+  color: darken($ui-primary-color, 24%);
3521
+
3522
+  strong {
3523
+    font-weight: 500;
3524
+    display: block;
3525
+    color: $ui-base-color;
3526
+  }
3527
+}
3528
+
3529
+.advanced-options-dropdown.open {
3530
+  .advanced-options-dropdown__value {
3531
+    background: $simple-background-color;
3532
+    border-radius: 4px 4px 0 0;
3533
+    box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
3534
+  }
3535
+
3536
+  .advanced-options-dropdown__dropdown {
3537
+    display: block;
3538
+    box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
3539
+  }
3540
+}
3541
+
3471 3542
 .search {
3472 3543
   position: relative;
3473 3544
 }
@@ -5538,3 +5609,4 @@ noscript {
5538 5609
     }
5539 5610
   }
5540 5611
 }
5612
+@import 'doodle';

+ 10
- 1
app/lib/formatter.rb View File

@@ -212,8 +212,9 @@ class Formatter
212 212
 
213 213
   def link_to_mention(entity, linkable_accounts)
214 214
     acct = entity[:screen_name]
215
+    username, domain = acct.split('@')
215 216
 
216
-    return link_to_account(acct) unless linkable_accounts
217
+    return link_to_account(acct) unless linkable_accounts and domain != "twitter.com"
217 218
 
218 219
     account = linkable_accounts.find { |item| TagManager.instance.same_acct?(item.acct, acct) }
219 220
     account ? mention_html(account) : "@#{encode(acct)}"
@@ -222,6 +223,10 @@ class Formatter
222 223
   def link_to_account(acct)
223 224
     username, domain = acct.split('@')
224 225
 
226
+    if domain == "twitter.com"
227
+      return mention_twitter_html(username)
228
+    end
229
+
225 230
     domain  = nil if TagManager.instance.local_domain?(domain)
226 231
     account = EntityCache.instance.mention(username, domain)
227 232
 
@@ -249,4 +254,8 @@ class Formatter
249 254
   def mention_html(account)
250 255
     "<span class=\"h-card\"><a href=\"#{encode(TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>"
251 256
   end
257
+
258
+  def mention_twitter_html(username)
259
+      "<span class=\"h-card\"><a href=\"https://twitter.com/#{username}\" class=\"u-url mention\">@<span>#{username}@twitter.com</span></a></span>"
260
+  end
252 261
 end

+ 8
- 5
app/models/account.rb View File

@@ -75,7 +75,8 @@ class Account < ApplicationRecord
75 75
   validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
76 76
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
77 77
   validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
78
-  validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
78
+  validates :note, length: { maximum: 413 }, if: -> { local? && will_save_change_to_note? }
79
+  validate :note_has_eight_newlines?, if: -> { local? && will_save_change_to_note? }
79 80
   validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
80 81
 
81 82
   # Timelines
@@ -279,10 +280,8 @@ class Account < ApplicationRecord
279 280
   def save_with_optional_media!
280 281
     save!
281 282
   rescue ActiveRecord::RecordInvalid
282
-    self.avatar              = nil
283
-    self.header              = nil
284
-    self[:avatar_remote_url] = ''
285
-    self[:header_remote_url] = ''
283
+    self.avatar = nil if errors[:avatar].present?
284
+    self.header = nil if errors[:header].present?
286 285
     save!
287 286
   end
288 287
 
@@ -306,6 +305,10 @@ class Account < ApplicationRecord
306 305
     shared_inbox_url.presence || inbox_url
307 306
   end
308 307
 
308
+  def note_has_eight_newlines?
309
+    errors.add(:note, 'Bio can\'t have more then 8 newlines') unless note.count("\n") <= 8
310
+  end
311
+
309 312
   class Field < ActiveModelSerializers::Model
310 313
     attributes :name, :value, :verified_at, :account, :errors
311 314
 

+ 1
- 1
app/validators/status_length_validator.rb View File

@@ -1,7 +1,7 @@
1 1
 # frozen_string_literal: true
2 2
 
3 3
 class StatusLengthValidator < ActiveModel::Validator
4
-  MAX_CHARS = 500
4
+  MAX_CHARS = 512
5 5
 
6 6
   def validate(status)
7 7
     return unless status.local? && !status.reblog?

+ 1
- 1
app/views/settings/profiles/show.html.haml View File

@@ -7,7 +7,7 @@
7 7
   .fields-row
8 8
     .fields-row__column.fields-group.fields-row__column-6
9 9
       = f.input :display_name, wrapper: :with_label, input_html: { maxlength: 30 }, hint: false
10
-      = f.input :note, wrapper: :with_label, input_html: { maxlength: 160 }, hint: false
10
+      = f.input :note, wrapper: :with_label, input_html: { maxlength: 413 }, hint: false
11 11
 
12 12
   .fields-row
13 13
     .fields-row__column.fields-row__column-6

+ 0
- 1
app/views/stream_entries/_simple_status.html.haml View File

@@ -37,7 +37,6 @@
37 37
     .status__action-bar__counter
38 38
       = link_to remote_interaction_path(status), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do
39 39
         = fa_icon 'reply fw'
40
-      .status__action-bar__counter__label= obscured_counter status.replies_count
41 40
     = link_to remote_interaction_path(status), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do
42 41
       - if status.public_visibility? || status.unlisted_visibility?
43 42
         = fa_icon 'retweet fw'

+ 6
- 3
config/application.rb View File

@@ -37,6 +37,7 @@ module Mastodon
37 37
     # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
38 38
     config.i18n.available_locales = [
39 39
       :en,
40
+      :'en-CY',
40 41
       :ar,
41 42
       :ast,
42 43
       :bg,
@@ -88,9 +89,11 @@ module Mastodon
88 89
     ]
89 90
 
90 91
     config.i18n.default_locale = ENV['DEFAULT_LOCALE']&.to_sym
91
-
92
-    unless config.i18n.available_locales.include?(config.i18n.default_locale)
93
-      config.i18n.default_locale = :en
92
+    if config.i18n.available_locales.include?(config.i18n.default_locale)
93
+      config.i18n.fallbacks = [:en]
94
+    else
95
+      config.i18n.default_locale = :'en-CY'
96
+      config.i18n.fallbacks = [:en]
94 97
     end
95 98
 
96 99
     # config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')

+ 1
- 0
config/i18n-tasks.yml View File

@@ -30,6 +30,7 @@ search:
30 30
     - app/assets/images
31 31
     - app/assets/fonts
32 32
     - app/assets/videos
33
+    - app/javascript/images
33 34
 
34 35
 ignore_missing:
35 36
   - 'activemodel.errors.*'

+ 61
- 0
config/locales/devise.en-CY.yml View File

@@ -0,0 +1,61 @@
1
+---
2
+en-CY:
3
+  devise:
4
+    confirmations:
5
+      confirmed: Your email address has been successfully confirmed.
6
+      send_instructions: You will receive an email with instructions for how to confirm your email address in a few minutes. Please check your spam folder if you didn't receive this email.
7
+      send_paranoid_instructions: If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes. Please check your spam folder if you didn't receive this email.
8
+    failure:
9
+      already_authenticated: You are already signed in.
10
+      inactive: Your account is not activated yet.
11
+      invalid: Invalid %{authentication_keys} or password.
12
+      last_attempt: You have one more attempt before your account is locked.
13
+      locked: Your account is locked.
14
+      not_found_in_database: Invalid %{authentication_keys} or password.
15
+      timeout: Your session expired. Please sign in again to continue.
16
+      unauthenticated: You need to sign in or sign up before continuing.
17
+      unconfirmed: You have to confirm your email address before continuing.
18
+    mailer:
19
+      confirmation_instructions:
20
+        subject: 'Mastodon: Confirmation instructions for %{instance}'
21
+      password_change:
22
+        subject: 'Mastodon: Password changed'
23
+      reset_password_instructions:
24
+        subject: 'Mastodon: Reset password instructions'
25
+      unlock_instructions:
26
+        subject: 'Mastodon: Unlock instructions'
27
+    omniauth_callbacks:
28
+      failure: Could not authenticate you from %{kind} because "%{reason}".
29
+      success: Successfully authenticated from %{kind} account.
30
+    passwords:
31
+      no_token: You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided.
32
+      send_instructions: If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes. Please check your spam folder if you didn't receive this email.
33
+      send_paranoid_instructions: If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes. Please check your spam folder if you didn't receive this email.
34
+      updated: Your password has been changed successfully. You are now signed in.
35
+      updated_not_active: Your password has been changed successfully.
36
+    registrations:
37
+      destroyed: Bye! Your account has been successfully cancelled. We hope to see you again soon.
38
+      signed_up: Welcome! You have signed up successfully.
39
+      signed_up_but_inactive: You have signed up successfully. However, we could not sign you in because your account is not yet activated.
40
+      signed_up_but_locked: You have signed up successfully. However, we could not sign you in because your account is locked.
41
+      signed_up_but_unconfirmed: A message with a confirmation link has been sent to your email address. Please follow the link to activate your account. Please check your spam folder if you didn't receive this email.
42
+      update_needs_confirmation: You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address. Please check your spam folder if you didn't receive this email.
43
+      updated: Your account has been updated successfully.
44
+    sessions:
45
+      already_signed_out: Signed out successfully.
46
+      signed_in: Signed in successfully.
47
+      signed_out: Signed out successfully.
48
+    unlocks:
49
+      send_instructions: You will receive an email with instructions for how to unlock your account in a few minutes. Please check your spam folder if you didn't receive this email.
50
+      send_paranoid_instructions: If your account exists, you will receive an email with instructions for how to unlock it in a few minutes. Please check your spam folder if you didn't receive this email.
51
+      unlocked: Your account has been unlocked successfully. Please sign in to continue.
52
+  errors:
53
+    messages:
54
+      already_confirmed: was already confirmed, please try signing in
55
+      confirmation_period_expired: needs to be confirmed within %{period}, please request a new one
56
+      expired: has expired, please request a new one
57
+      not_found: not found
58
+      not_locked: was not locked
59
+      not_saved:
60
+        one: '1 error prohibited this %{resource} from being saved:'
61
+        other: "%{count} errors prohibited this %{resource} from being saved:"

+ 119
- 0
config/locales/doorkeeper.en-CY.yml View File

@@ -0,0 +1,119 @@
1
+---
2
+en-CY:
3
+  activerecord:
4
+    attributes:
5
+      doorkeeper/application:
6
+        name: Application name
7
+        redirect_uri: Redirect URI
8
+        scopes: Scopes
9
+        website: Application website
10
+    errors:
11
+      models:
12
+        doorkeeper/application:
13
+          attributes:
14
+            redirect_uri:
15
+              fragment_present: cannot contain a fragment.
16
+              invalid_uri: must be a valid URI.
17
+              relative_uri: must be an absolute URI.
18
+              secured_uri: must be an HTTPS/SSL URI.
19
+  doorkeeper:
20
+    applications:
21
+      buttons:
22
+        authorize: Authorize
23
+        cancel: Cancel
24
+        destroy: Destroy
25
+        edit: Edit
26
+        submit: Submit
27
+      confirmations:
28
+        destroy: Are you sure?
29
+      edit:
30
+        title: Edit application
31
+      form:
32
+        error: Whoops! Check your form for possible errors
33
+      help:
34
+        native_redirect_uri: Use %{native_redirect_uri} for local tests
35
+        redirect_uri: Use one line per URI
36
+        scopes: Separate scopes with spaces. Leave blank to use the default scopes.
37
+      index:
38
+        application: Application
39
+        callback_url: Callback URL
40
+        delete: Delete
41
+        name: Name
42
+        new: New application
43
+        scopes: Scopes
44
+        show: Show
45
+        title: Your applications
46
+      new:
47
+        title: New application
48
+      show:
49
+        actions: Actions
50
+        application_id: Client key
51
+        callback_urls: Callback URLs
52
+        scopes: Scopes
53
+        secret: Client secret
54
+        title: 'Application: %{name}'
55
+    authorizations:
56
+      buttons:
57
+        authorize: Authorize
58
+        deny: Deny
59
+      error:
60
+        title: An error has occurred
61
+      new:
62
+        able_to: It will be able to
63
+        prompt: Application %{client_name} requests access to your account
64
+        title: Authorization required
65
+      show:
66
+        title: Authorization code
67
+    authorized_applications:
68
+      buttons:
69
+        revoke: Revoke
70
+      confirmations:
71
+        revoke: Are you sure?
72
+      index:
73
+        application: Application
74
+        created_at: Authorized
75
+        date_format: "%Y-%m-%d %H:%M:%S"
76
+        scopes: Scopes
77
+        title: Your authorized applications
78
+    errors:
79
+      messages:
80
+        access_denied: The resource owner or authorization server denied the request.
81
+        credential_flow_not_configured: Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.
82
+        invalid_client: Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.
83
+        invalid_grant: The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.
84
+        invalid_redirect_uri: The redirect uri included is not valid.
85
+        invalid_request: The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.
86
+        invalid_resource_owner: The provided resource owner credentials are not valid, or resource owner cannot be found
87
+        invalid_scope: The requested scope is invalid, unknown, or malformed.
88
+        invalid_token:
89
+          expired: The access token expired
90
+          revoked: The access token was revoked
91
+          unknown: The access token is invalid
92
+        resource_owner_authenticator_not_configured: Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfiged.
93
+        server_error: The authorization server encountered an unexpected condition which prevented it from fulfilling the request.
94
+        temporarily_unavailable: The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.
95
+        unauthorized_client: The client is not authorized to perform this request using this method.
96
+        unsupported_grant_type: The authorization grant type is not supported by the authorization server.
97
+        unsupported_response_type: The authorization server does not support this response type.
98
+    flash:
99
+      applications:
100
+        create:
101
+          notice: Application created.
102
+        destroy:
103
+          notice: Application deleted.
104
+        update:
105
+          notice: Application updated.
106
+      authorized_applications:
107
+        destroy:
108
+          notice: Application revoked.
109
+    layouts:
110
+      admin:
111
+        nav:
112
+          applications: Applications
113
+          oauth2_provider: OAuth2 Provider
114
+      application:
115
+        title: OAuth authorization required
116
+    scopes:
117
+      follow: follow, block, unblock and unfollow accounts
118
+      read: read your account's data
119
+      write: post on your behalf

+ 563
- 0
config/locales/en-CY.yml View File

@@ -0,0 +1,563 @@
1
+---
2
+en-CY:
3
+  about:
4
+    about_hashtag_html: These are public pings tagged with <strong>#%{hashtag}</strong>. You can interact with them if you have an account anywhere in the fediverse
5
+    about_mastodon_html: Cybrespace is an instance of Mastodon, a social network based on open web protocols and free, open-source software. It is decentralized like e-mail.
6
+    about_this: About
7
+    closed_registrations: Registrations are currently closed on this instance. However! You can find a different instance to make an account on and get access to the very same network from there.
8
+    contact: Contact
9
+    contact_missing: Not set
10
+    contact_unavailable: N/A
11
+    description_headline: What is %{domain}?
12
+    domain_count_after: other instances
13
+    domain_count_before: Connected to
14
+    extended_description_html: |
15
+      <h3>A good place for rules</h3>
16
+      <p>The extended description has not been set up yet.</p>
17
+    features:
18
+      humane_approach_body: Learning from failures of other networks, Mastodon aims to make ethical design choices to combat the misuse of social media.
19
+      humane_approach_title: A more humane approach
20
+      not_a_product_body: Mastodon is not a commercial network. No advertising, no data mining, no walled gardens. There is no central authority.
21
+      not_a_product_title: You’re a person, not a product
22
+      real_conversation_body: With 512 characters at your disposal and support for granular content and media warnings, you can express yourself the way you want to.
23
+      real_conversation_title: Built for real conversation
24
+      within_reach_body: Multiple apps for iOS, Android, and other platforms thanks to a developer-friendly API ecosystem allow you to keep up with your friends anywhere.
25
+      within_reach_title: Always within reach
26
+    find_another_instance: Find another instance
27
+    generic_description: "%{domain} is one server in the network"
28
+    hosted_on: Mastodon hosted on %{domain}
29
+    learn_more: Learn more
30
+    other_instances: Instance list
31
+    source_code: Source code
32
+    status_count_after: pings
33
+    status_count_before: Who authored
34
+    user_count_after: users
35
+    user_count_before: Home to
36
+    what_is_mastodon: What is Mastodon?
37
+  accounts:
38
+    follow: Follow
39
+    followers: Followers
40
+    following: Following
41
+    media: Media
42
+    nothing_here: There is nothing here!
43
+    people_followed_by: People whom %{name} follows
44
+    people_who_follow: People who follow %{name}
45
+    posts: Pings
46
+    posts_with_replies: Pings with replies
47
+    remote_follow: Remote follow
48
+    reserved_username: The username is reserved
49
+    roles:
50
+      admin: Admin
51
+    unfollow: Unfollow
52
+  admin:
53
+    accounts:
54
+      are_you_sure: Are you sure?
55
+      confirm: Confirm
56
+      confirmed: Confirmed
57
+      disable_two_factor_authentication: Disable 2FA
58
+      display_name: Display name
59
+      domain: Domain
60
+      edit: Edit
61
+      email: E-mail
62
+      feed_url: Feed URL
63
+      followers: Followers
64
+      follows: Follows
65
+      inbox_url: Inbox URL
66
+      ip: IP
67
+      location:
68
+        all: All
69
+        local: Local
70
+        remote: Remote
71
+        title: Location
72
+      media_attachments: Media attachments
73
+      moderation:
74
+        all: All
75
+        silenced: Silenced
76
+        suspended: Suspended
77
+        title: Moderation
78
+      most_recent_activity: Most recent activity
79
+      most_recent_ip: Most recent IP
80
+      not_subscribed: Not subscribed
81
+      order:
82
+        alphabetic: Alphabetic
83
+        most_recent: Most recent
84
+        title: Order
85
+      outbox_url: Outbox URL
86
+      perform_full_suspension: Perform full suspension
87
+      profile_url: Profile URL
88
+      protocol: Protocol
89
+      public: Public
90
+      push_subscription_expires: PuSH subscription expires
91
+      redownload: Refresh avatar
92
+      reset: Reset
93
+      reset_password: Reset password
94
+      resubscribe: Resubscribe
95
+      salmon_url: Salmon URL
96
+      search: Search
97
+      show:
98
+        created_reports: Reports created by this account
99
+        report: report
100
+        targeted_reports: Reports made about this account
101
+      silence: Silence
102
+      statuses: Statuses
103
+      subscribe: Subscribe
104
+      title: Accounts
105
+      undo_silenced: Undo silence
106
+      undo_suspension: Undo suspension
107
+      unsubscribe: Unsubscribe
108
+      username: Username
109
+      web: Web
110
+    domain_blocks:
111
+      add_new: Add new
112
+      created_msg: Domain block is now being processed
113
+      destroyed_msg: Domain block has been undone
114
+      domain: Domain
115
+      new:
116
+        create: Create block
117
+        hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
118
+        severity:
119
+          desc_html: "<strong>Silence</strong> will make the account's posts invisible to anyone who isn't following them. <strong>Suspend</strong> will remove all of the account's content, media, and profile data. Use <strong>None</strong> if you just want to reject media files."
120
+          noop: None
121
+          silence: Silence
122
+          suspend: Suspend
123
+        title: New domain block
124
+      reject_media: Reject media files
125
+      reject_media_hint: Removes locally stored media files and refuses to download any in the future. Irrelevant for suspensions
126
+      severities:
127
+        noop: None
128
+        silence: Silence
129
+        suspend: Suspend
130
+      severity: Severity
131
+      show:
132
+        affected_accounts:
133
+          one: One account in the database affected
134
+          other: "%{count} accounts in the database affected"
135
+        retroactive:
136
+          silence: Unsilence all existing accounts from this domain
137
+          suspend: Unsuspend all existing accounts from this domain
138
+        title: Undo domain block for %{domain}
139
+        undo: Undo
140
+      title: Domain Blocks
141
+      undo: Undo
142
+    instances:
143
+      account_count: Known accounts
144
+      domain_name: Domain
145
+      title: Known Instances
146
+    reports:
147
+      action_taken_by: Action taken by
148
+      are_you_sure: Are you sure?
149
+      comment:
150
+        label: Comment
151
+        none: None
152
+      delete: Delete
153
+      id: ID
154
+      mark_as_resolved: Mark as resolved
155
+      nsfw:
156
+        'false': Unhide media attachments
157
+        'true': Hide media attachments
158
+      report: 'Report #%{id}'
159
+      report_contents: Contents
160
+      reported_account: Reported account
161
+      reported_by: Reported by
162
+      resolved: Resolved
163
+      silence_account: Silence account
164
+      status: Status
165
+      suspend_account: Suspend account
166
+      target: Target
167
+      title: Reports
168
+      unresolved: Unresolved
169
+      view: View
170
+    settings:
171
+      bootstrap_timeline_accounts:
172
+        desc_html: Separate multiple usernames by comma. Only local and unlocked accounts will work. Default when empty is all local admins.
173
+        title: Default follows for new users
174
+      contact_information:
175
+        email: Business e-mail
176
+        username: Contact username
177
+      registrations:
178
+        closed_message:
179
+          desc_html: Displayed on frontpage when registrations are closed. You can use HTML tags
180
+          title: Closed registration message
181
+        deletion:
182
+          desc_html: Allow anyone to delete their account
183
+          title: Open account deletion
184
+        open:
185
+          desc_html: Allow anyone to create an account
186
+          title: Open registration
187
+      site_description:
188
+        desc_html: Introductory paragraph on the frontpage and in meta tags. You can use HTML tags, in particular <code>&lt;a&gt;</code> and <code>&lt;em&gt;</code>.
189
+        title: Instance description
190
+      site_description_extended:
191
+        desc_html: A good place for your code of conduct, rules, guidelines and other things that set your instance apart. You can use HTML tags
192
+        title: Custom extended information
193
+      site_terms:
194
+        desc_html: You can write your own privacy policy, terms of service or other legalese. You can use HTML tags
195
+        title: Custom terms of service
196
+      site_title: Instance name
197
+      timeline_preview:
198
+        desc_html: Display public timeline on landing page
199
+        title: Timeline preview
200
+      title: Site Settings
201
+    statuses:
202
+      back_to_account: Back to account page
203
+      batch:
204
+        delete: Delete
205
+        nsfw_off: NSFW OFF
206
+        nsfw_on: NSFW ON
207
+      execute: Execute
208
+      failed_to_execute: Failed to execute
209
+      media:
210
+        hide: Hide media
211
+        show: Show media
212
+        title: Media
213
+      no_media: No media
214
+      title: Account statuses
215
+      with_media: With media
216
+    subscriptions:
217
+      callback_url: Callback URL
218
+      confirmed: Confirmed
219
+      expires_in: Expires in
220
+      last_delivery: Last delivery
221
+      title: WebSub
222
+      topic: Topic
223
+    title: Administration
224
+  admin_mailer:
225
+    new_report:
226
+      body: "%{reporter} has reported %{target}"
227
+      subject: New report for %{instance} (#%{id})
228
+  application_mailer:
229
+    salutation: "%{name},"
230
+    settings: 'Change e-mail preferences: %{link}'
231
+    signature: Mastodon notifications from %{instance}
232
+    view: 'View:'
233
+  applications:
234
+    created: Application successfully created
235
+    destroyed: Application successfully deleted
236
+    invalid_url: The provided URL is invalid
237
+    regenerate_token: Regenerate access token
238
+    token_regenerated: Access token successfully regenerated
239
+    warning: Be very careful with this data. Never share it with anyone!
240
+    your_token: Your access token
241
+  auth:
242
+    agreement_html: By signing up you agree to <a href="%{rules_path}">our terms of service</a> and <a href="%{terms_path}">privacy policy</a>.
243
+    change_password: Security
244
+    delete_account: Delete account
245
+    delete_account_html: If you wish to delete your account, you can <a href="%{path}">proceed here</a>. You will be asked for confirmation.
246
+    didnt_get_confirmation: Didn't receive confirmation instructions?
247
+    forgot_password: Forgot your password?
248
+    invalid_reset_password_token: Password reset token is invalid or expired. Please try again.
249
+    login: Jack in
250
+    logout: Jack out
251
+    register: Apply for upload
252
+    resend_confirmation: Resend confirmation instructions
253
+    reset_password: Reset password
254
+    set_new_password: Set new password
255
+  authorize_follow:
256
+    error: Unfortunately, there was an error looking up the remote account
257
+    follow: Follow
258
+    follow_request: 'You have sent a follow request to:'
259
+    following: 'Success! You are now following:'
260
+    post_follow:
261
+      close: Or, you can just close this window.
262
+      return: Return to the user's profile
263
+      web: Go to web
264
+    title: Follow %{acct}
265
+  datetime:
266
+    distance_in_words:
267
+      about_x_hours: "%{count}h"
268
+      about_x_months: "%{count}mo"
269
+      about_x_years: "%{count}y"
270
+      almost_x_years: "%{count}y"
271
+      half_a_minute: Just now
272
+      less_than_x_minutes: "%{count}m"
273
+      less_than_x_seconds: Just now
274
+      over_x_years: "%{count}y"
275
+      x_days: "%{count}d"
276
+      x_minutes: "%{count}m"
277
+      x_months: "%{count}mo"
278
+      x_seconds: "%{count}s"
279
+  deletes:
280
+    bad_password_msg: Nice try, hackers! Incorrect password
281
+    confirm_password: Enter your current password to verify your identity
282
+    description_html: This will <strong>permanently, irreversibly</strong> remove content from your account and deactivate it. Your username will remain reserved to prevent future impersonations.
283
+    proceed: Delete account
284
+    success_msg: Your account was successfully deleted
285
+    warning_html: Only deletion of content from this particular instance is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases.
286
+    warning_title: Disseminated content availability
287
+  errors:
288
+    '403': You don't have permission to view this page.
289
+    '404': The page you were looking for doesn't exist.
290
+    '410': The page you were looking for doesn't exist anymore.
291
+    '422':
292
+      content: Security verification failed. Are you blocking cookies?
293
+      title: Security verification failed
294
+    '429': Throttled
295
+    noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">native apps</a> for Mastodon for your platform.
296
+  exports:
297
+    blocks: You block
298
+    csv: CSV
299
+    follows: You follow
300
+    mutes: You mute
301
+    storage: Media storage
302
+  followers:
303
+    domain: Domain
304
+    explanation_html: If you want to ensure the privacy of your pings , you must be aware of who is following you. <strong>Your private pings are delivered to all instances where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances.
305
+    followers_count: Number of followers
306
+    lock_link: Lock your account
307
+    purge: Remove from followers
308
+    success:
309
+      one: In the process of soft-blocking followers from one domain...
310
+      other: In the process of soft-blocking followers from %{count} domains...
311
+    true_privacy_html: Please mind that <strong>true privacy can only be achieved with end-to-end encryption</strong>.
312
+    unlocked_warning_html: Anyone can follow you to immediately view your private pings. %{lock_link} to be able to review and reject followers.
313
+    unlocked_warning_title: Your account is not locked
314
+  generic:
315
+    changes_saved_msg: Changes successfully saved!
316
+    powered_by: powered by %{link}
317
+    save_changes: Save changes
318
+    validation_errors:
319
+      one: Something isn't quite right yet! Please review the error below
320
+      other: Something isn't quite right yet! Please review %{count} errors below
321
+  imports:
322
+    preface: You can import data that you have exported from another instance, such as a list of the people you are following or blocking.
323
+    success: Your data was successfully uploaded and will now be processed in due time
324
+    types:
325
+      blocking: Blocking list
326
+      following: Following list
327
+      muting: Muting list
328
+    upload: Upload
329
+  landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
330
+  landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
331
+  media_attachments:
332
+    validations:
333
+      images_and_video: Cannot attach a video to a ping that already contains images
334
+      too_many: Cannot attach more than 4 files
335
+  notification_mailer:
336
+    digest:
337
+      body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
338
+      mention: "%{name} mentioned you in:"
339
+      new_followers_summary:
340
+        one: You have acquired one new follower! Yay!
341
+        other: You have gotten %{count} new followers! Amazing!
342
+      subject:
343
+        one: "1 new notification since your last visit \U0001F418"
344
+        other: "%{count} new notifications since your last visit \U0001F418"
345
+    favourite:
346
+      body: 'Your ping was florped by %{name}:'
347
+      subject: "%{name} florped your ping"
348
+    follow:
349
+      body: "%{name} is now following you!"
350
+      subject: "%{name} is now following you"
351
+    follow_request:
352
+      body: "%{name} has requested to follow you"
353
+      subject: 'Pending follower: %{name}'
354
+    mention:
355
+      body: 'You were mentioned by %{name} in:'
356
+      subject: You were mentioned by %{name}
357
+    reblog:
358
+      body: 'Your ping was relayed by %{name}:'
359
+      subject: "%{name} relayed your ping"
360
+  number:
361
+    human:
362
+      decimal_units:
363
+        format: "%n%u"
364
+        units:
365
+          billion: B
366
+          million: M
367
+          quadrillion: Q
368
+          thousand: K
369
+          trillion: T
370
+          unit: ''
371
+  pagination:
372
+    next: Next
373
+    prev: Prev
374
+    truncate: "&hellip;"
375
+  push_notifications:
376
+    favourite:
377
+      title: "%{name} favourited your status"
378
+    follow:
379
+      title: "%{name} is now following you"
380
+    group:
381
+      title: "%{count} notifications"
382
+    mention:
383
+      action_boost: Boost
384
+      action_expand: Show more
385
+      action_favourite: Favourite
386
+      title: "%{name} mentioned you"
387
+    reblog:
388
+      title: "%{name} boosted your status"
389
+  remote_follow:
390
+    acct: Enter your username@domain you want to follow from
391
+    missing_resource: Could not find the required redirect URL for your account
392
+    proceed: Proceed to follow
393
+    prompt: 'You are going to follow:'
394
+  sessions:
395
+    activity: Last activity
396
+    browser: Browser
397
+    browsers:
398
+      alipay: Alipay
399
+      blackberry: Blackberry
400
+      chrome: Chrome
401
+      edge: Microsoft Edge
402
+      firefox: Firefox
403
+      generic: Unknown browser
404
+      ie: Internet Explorer
405
+      micro_messenger: MicroMessenger
406
+      nokia: Nokia S40 Ovi Browser
407
+      opera: Opera
408
+      phantom_js: PhantomJS
409
+      qq: QQ Browser
410
+      safari: Safari
411
+      uc_browser: UCBrowser
412
+      weibo: Weibo
413
+    current_session: Current session
414
+    description: "%{browser} on %{platform}"
415
+    explanation: These are the web browsers currently logged in to your cybre.space account.
416
+    ip: IP
417
+    platforms:
418
+      adobe_air: Adobe Air
419
+      android: Android
420
+      blackberry: Blackberry
421
+      chrome_os: ChromeOS
422
+      firefox_os: Firefox OS
423
+      ios: iOS
424
+      linux: Linux
425
+      mac: Mac
426
+      other: unknown platform
427
+      windows: Windows
428
+      windows_mobile: Windows Mobile
429
+      windows_phone: Windows Phone
430
+    revoke: Revoke
431
+    revoke_success: Session successfully revoked
432
+    title: Sessions
433
+  settings:
434
+    authorized_apps: Authorized apps
435
+    back: Back to Mastodon
436
+    delete: Account deletion
437
+    development: Development
438
+    edit_profile: edit ~/.profile
439
+    export: Data export
440
+    followers: Authorized followers
441
+    import: Import
442
+    preferences: Preferences
443
+    settings: Settings
444
+    two_factor_authentication: Two-factor Authentication
445
+    your_apps: Your applications
446
+  statuses:
447
+    open_in_web: Open in web
448
+    over_character_limit: character limit of %{max} exceeded
449
+    pin_errors:
450
+      limit: You have already pinned the maximum number of pings
451
+      ownership: Someone else's ping cannot be pinned
452
+      private: Non-public pings cannot be pinned
453
+      reblog: A boost cannot be pinned
454
+    show_more: Show more
455
+    visibilities:
456
+      private: Followers-only
457
+      private_long: Only show to followers
458
+      public: Public
459
+      public_long: Everyone can see
460
+      unlisted: Unlisted
461
+      unlisted_long: Everyone can see, but not listed on public timelines
462
+  stream_entries:
463
+    click_to_show: Click to show
464
+    pinned: Pinned ping
465
+    reblogged: relayed
466
+    sensitive_content: Sensitive content
467
+  terms:
468
+    body_html: |
469
+      <h2>Privacy Policy</h2>
470
+
471
+      <h3 id="collect">What information do we collect?</h3>
472
+
473
+      <p>We collect information from you when you register on our site and gather data when you participate in the forum by reading, writing, and evaluating the content shared here.</p>
474
+
475
+      <p>When registering on our site, you may be asked to enter your name and e-mail address. You may, however, visit our site without registering. Your e-mail address will be verified by an email containing a unique link. If that link is visited, we know that you control the e-mail address.</p>
476
+
477
+      <p>When registered and posting, we record the IP address that the post originated from. We also may retain server logs which include the IP address of every request to our server.</p>
478
+
479
+      <h3 id="use">What do we use your information for?</h3>
480
+
481
+      <p>Any of the information we collect from you may be used in one of the following ways:</p>
482
+
483
+      <ul>
484
+        <li>To personalize your experience &mdash; your information helps us to better respond to your individual needs.</li>
485
+        <li>To improve our site &mdash; we continually strive to improve our site offerings based on the information and feedback we receive from you.</li>
486
+        <li>To improve customer service &mdash; your information helps us to more effectively respond to your customer service requests and support needs.</li>
487
+        <li>To send periodic emails &mdash; The email address you provide may be used to send you information, notifications that you request about changes to topics or in response to your user name, respond to inquiries, and/or other requests or questions.</li>
488
+      </ul>
489
+
490
+      <h3 id="protect">How do we protect your information?</h3>
491
+
492
+      <p>We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information.</p>
493
+
494
+      <h3 id="data-retention">What is your data retention policy?</h3>
495
+
496
+      <p>We will make a good faith effort to:</p>
497
+
498
+      <ul>
499
+        <li>Retain server logs containing the IP address of all requests to this server no more than 90 days.</li>
500
+        <li>Retain the IP addresses associated with registered users and their posts no more than 5 years.</li>
501
+      </ul>
502
+
503
+      <h3 id="cookies">Do we use cookies?</h3>
504
+
505
+      <p>Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.</p>
506
+
507
+      <p>We use cookies to understand and save your preferences for future visits and compile aggregate data about site traffic and site interaction so that we can offer better site experiences and tools in the future. We may contract with third-party service providers to assist us in better understanding our site visitors. These service providers are not permitted to use the information collected on our behalf except to help us conduct and improve our business.</p>
508
+
509
+      <h3 id="disclose">Do we disclose any information to outside parties?</h3>
510
+
511
+      <p>We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety. However, non-personally identifiable visitor information may be provided to other parties for marketing, advertising, or other uses.</p>
512
+
513
+      <h3 id="third-party">Third party links</h3>
514
+
515
+      <p>Occasionally, at our discretion, we may include or offer third party products or services on our site. These third party sites have separate and independent privacy policies. We therefore have no responsibility or liability for the content and activities of these linked sites. Nonetheless, we seek to protect the integrity of our site and welcome any feedback about these sites.</p>
516
+
517
+      <h3 id="coppa">Children's Online Privacy Protection Act Compliance</h3>
518
+
519
+      <p>Our site, products and services are all directed to people who are at least 13 years old. If this server is in the USA, and you are under the age of 13, per the requirements of COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) do not use this site.</p>
520
+
521
+      <h3 id="online">Online Privacy Policy Only</h3>
522
+
523
+      <p>This online privacy policy applies only to information collected through our site and not to information collected offline.</p>
524
+
525
+      <h3 id="consent">Your Consent</h3>
526
+
527
+      <p>By using our site, you consent to our web site privacy policy.</p>
528
+
529
+      <h3 id="changes">Changes to our Privacy Policy</h3>
530
+
531
+      <p>If we decide to change our privacy policy, we will post those changes on this page.</p>
532
+
533
+      <p>This document is CC-BY-SA. It was last updated May 31, 2013.</p>
534
+
535
+      <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
536
+    title: "%{instance} Terms of Service and Privacy Policy"
537
+  themes:
538
+    default: Cybrespace
539
+    mastodon: Mastodon
540
+    win95: Masto 95
541
+  time:
542
+    formats:
543
+      default: "%b %d, %Y, %H:%M"
544
+  two_factor_authentication:
545
+    code_hint: Enter the code generated by your authenticator app to confirm
546
+    description_html: If you enable <strong>two-factor authentication</strong>, logging in will require you to be in possession of your phone, which will generate tokens for you to enter.
547
+    disable: Disable
548
+    enable: Enable
549
+    enabled: Two-factor authentication is enabled
550
+    enabled_success: Two-factor authentication successfully enabled
551
+    generate_recovery_codes: Generate recovery codes
552
+    instructions_html: "<strong>Scan this QR code into Google Authenticator or a similiar TOTP app on your phone</strong>. From now on, that app will generate tokens that you will have to enter when logging in."
553
+    lost_recovery_codes: Recovery codes allow you to regain access to your account if you lose your phone. If you've lost your recovery codes, you can regenerate them here. Your old recovery codes will be invalidated.
554
+    manual_instructions: 'If you can''t scan the QR code and need to enter it manually, here is the plain-text secret:'
555
+    recovery_codes: Backup recovery codes
556
+    recovery_codes_regenerated: Recovery codes successfully regenerated
557
+    recovery_instructions_html: If you ever lose access to your phone, you can use one of the recovery codes below to regain access to your account. <strong>Keep the recovery codes safe</strong>. For example, you may print them and store them with other important documents.
558
+    setup: Set up
559
+    wrong_code: The entered code was invalid! Are server time and device time correct?
560
+  users:
561
+    invalid_email: The e-mail address is invalid
562
+    invalid_otp_token: Invalid two-factor code
563
+    signed_in_as: 'Signed in as:'

+ 66
- 0
config/locales/simple_form.en-CY.yml View File

@@ -0,0 +1,66 @@
1
+---
2
+en-CY:
3
+  simple_form:
4
+    hints:
5
+      defaults:
6
+        avatar: PNG, GIF or JPG. At most 2MB. Will be downscaled to 120x120px
7
+        display_name:
8
+          one: <span class="name-counter">1</span> character left
9
+          other: <span class="name-counter">%{count}</span> characters left
10
+        header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px
11
+        locked: Requires you to manually approve followers and defaults ping privacy to followers-only
12
+        note:
13
+          one: <span class="note-counter">1</span> character left
14
+          other: <span class="note-counter">%{count}</span> characters left
15
+        setting_noindex: Affects your public profile and status pages
16
+        setting_theme: Affects how Mastodon looks when you're logged in from any device.
17
+      imports:
18
+        data: CSV file exported from another Mastodon instance
19
+      sessions:
20
+        otp: Enter the Two-factor code from your phone or use one of your recovery codes.
21
+      user:
22
+        filtered_languages: Selected languages will be removed from your public timelines.
23
+    labels:
24
+      defaults:
25
+        avatar: Avatar
26
+        confirm_new_password: Confirm new password
27
+        confirm_password: Confirm password
28
+        current_password: Current password
29
+        data: Data
30
+        display_name: Display name
31
+        email: E-mail address
32
+        filtered_languages: Filtered languages
33
+        header: Header
34
+        locale: Language
35
+        locked: Lock account
36
+        new_password: New password
37
+        note: Bio
38
+        otp_attempt: Two-factor code
39
+        password: Password
40
+        setting_auto_play_gif: Auto-play animated GIFs
41
+        setting_boost_modal: Show confirmation dialog before boosting
42
+        setting_default_privacy: Post privacy
43
+        setting_default_sensitive: Always mark media as sensitive
44
+        setting_delete_modal: Show confirmation dialog before deleting a ping
45
+        setting_noindex: Opt-out of search engine indexing
46
+        setting_system_font_ui: Use a heuristic-based approximation of what Mastodon thinks your system font might be
47
+        setting_theme: Site theme
48
+        setting_unfollow_modal: Show confirmation dialog before unfollowing someone
49
+        severity: Severity
50
+        type: Import type
51
+        username: Username
52
+      interactions:
53
+        must_be_follower: Block notifications from non-followers
54
+        must_be_following: Block notifications from people you don't follow
55
+      notification_emails:
56
+        digest: Send digest e-mails
57
+        favourite: Send e-mail when someone florps your ping
58
+        follow: Send e-mail when someone follows you
59
+        follow_request: Send e-mail when someone requests to follow you
60
+        mention: Send e-mail when someone mentions you
61
+        reblog: Send e-mail when someone boosts your ping
62
+    'no': 'No'
63
+    required:
64
+      mark: "*"
65
+      text: required
66
+    'yes': 'Yes'