about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/components')
-rw-r--r--app/javascript/flavours/glitch/components/account.js26
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js42
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js223
-rw-r--r--app/javascript/flavours/glitch/components/avatar.js18
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js12
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js12
-rw-r--r--app/javascript/flavours/glitch/components/icon.js26
-rw-r--r--app/javascript/flavours/glitch/components/link.js97
-rw-r--r--app/javascript/flavours/glitch/components/text_icon_button.js29
9 files changed, 200 insertions, 285 deletions
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index a1f075491..ced18b348 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent {
     onMuteNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     hidden: PropTypes.bool,
+    small: PropTypes.bool,
   };
 
   handleFollow = () => {
@@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, hidden } = this.props;
+    const {
+      account,
+      hidden,
+      intl,
+      small,
+    } = this.props;
 
     if (!account) {
       return <div />;
@@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent {
 
     let buttons;
 
-    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+    if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
       const requested = account.getIn(['relationship', 'requested']);
       const blocking  = account.getIn(['relationship', 'blocking']);
@@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent {
       }
     }
 
-    return (
+    return small ? (
+      <div className='account small'>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    ) : (
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             <DisplayName account={account} />
           </Permalink>
-
-          <div className='account__relationship'>
-            {buttons}
-          </div>
+          {buttons ?
+            <div className='account__relationship'>
+              {buttons}
+            </div>
+          : null}
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
deleted file mode 100644
index 79e113d9c..000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
-
-const assetHost = process.env.CDN_HOST || '';
-
-export default class AutosuggestEmoji extends React.PureComponent {
-
-  static propTypes = {
-    emoji: PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { emoji } = this.props;
-    let url;
-
-    if (emoji.custom) {
-      url = emoji.imageUrl;
-    } else {
-      const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
-
-      if (!mapping) {
-        return null;
-      }
-
-      url = `${assetHost}/emoji/${mapping.filename}.svg`;
-    }
-
-    return (
-      <div className='autosuggest-emoji'>
-        <img
-          className='emojione'
-          src={url}
-          alt={emoji.native || emoji.colons}
-        />
-
-        {emoji.colons}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
deleted file mode 100644
index a29b2c9c5..000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import React from 'react';
-import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
-import AutosuggestEmoji from './autosuggest_emoji';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { isRtl } from 'flavours/glitch/util/rtl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Textarea from 'react-textarea-autosize';
-import classNames from 'classnames';
-
-const textAtCursorMatchesToken = (str, caretPosition) => {
-  let word;
-
-  let left  = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
-  let right = str.slice(caretPosition).search(/[\s\u200B]/);
-
-  if (right < 0) {
-    word = str.slice(left);
-  } else {
-    word = str.slice(left, right + caretPosition);
-  }
-
-  if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
-    return [null, null];
-  }
-
-  word = word.trim().toLowerCase();
-
-  if (word.length > 0) {
-    return [left + 1, word];
-  } else {
-    return [null, null];
-  }
-};
-
-export default class AutosuggestTextarea extends ImmutablePureComponent {
-
-  static propTypes = {
-    value: PropTypes.string,
-    suggestions: ImmutablePropTypes.list,
-    disabled: PropTypes.bool,
-    placeholder: PropTypes.string,
-    onSuggestionSelected: PropTypes.func.isRequired,
-    onSuggestionsClearRequested: PropTypes.func.isRequired,
-    onSuggestionsFetchRequested: PropTypes.func.isRequired,
-    onChange: PropTypes.func.isRequired,
-    onKeyUp: PropTypes.func,
-    onKeyDown: PropTypes.func,
-    onPaste: PropTypes.func.isRequired,
-    autoFocus: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    autoFocus: true,
-  };
-
-  state = {
-    suggestionsHidden: false,
-    selectedSuggestion: 0,
-    lastToken: null,
-    tokenStart: 0,
-  };
-
-  onChange = (e) => {
-    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
-
-    if (token !== null && this.state.lastToken !== token) {
-      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
-      this.props.onSuggestionsFetchRequested(token);
-    } else if (token === null) {
-      this.setState({ lastToken: null });
-      this.props.onSuggestionsClearRequested();
-    }
-
-    this.props.onChange(e);
-  }
-
-  onKeyDown = (e) => {
-    const { suggestions, disabled } = this.props;
-    const { selectedSuggestion, suggestionsHidden } = this.state;
-
-    if (disabled) {
-      e.preventDefault();
-      return;
-    }
-
-    switch(e.key) {
-    case 'Escape':
-      if (!suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ suggestionsHidden: true });
-      }
-
-      break;
-    case 'ArrowDown':
-      if (suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
-      }
-
-      break;
-    case 'ArrowUp':
-      if (suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
-      }
-
-      break;
-    case 'Enter':
-    case 'Tab':
-      // Select suggestion
-      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
-      }
-
-      break;
-    }
-
-    if (e.defaultPrevented || !this.props.onKeyDown) {
-      return;
-    }
-
-    this.props.onKeyDown(e);
-  }
-
-  onKeyUp = e => {
-    if (e.key === 'Escape' && this.state.suggestionsHidden) {
-      document.querySelector('.ui').parentElement.focus();
-    }
-
-    if (this.props.onKeyUp) {
-      this.props.onKeyUp(e);
-    }
-  }
-
-  onBlur = () => {
-    this.setState({ suggestionsHidden: true });
-  }
-
-  onSuggestionClick = (e) => {
-    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
-    e.preventDefault();
-    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
-    this.textarea.focus();
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
-      this.setState({ suggestionsHidden: false });
-    }
-  }
-
-  setTextarea = (c) => {
-    this.textarea = c;
-  }
-
-  onPaste = (e) => {
-    if (e.clipboardData && e.clipboardData.files.length === 1) {
-      this.props.onPaste(e.clipboardData.files);
-      e.preventDefault();
-    }
-  }
-
-  renderSuggestion = (suggestion, i) => {
-    const { selectedSuggestion } = this.state;
-    let inner, key;
-
-    if (typeof suggestion === 'object') {
-      inner = <AutosuggestEmoji emoji={suggestion} />;
-      key   = suggestion.id;
-    } else {
-      inner = <AutosuggestAccountContainer id={suggestion} />;
-      key   = suggestion;
-    }
-
-    return (
-      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
-        {inner}
-      </div>
-    );
-  }
-
-  render () {
-    const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
-    const { suggestionsHidden } = this.state;
-    const style = { direction: 'ltr' };
-
-    if (isRtl(value)) {
-      style.direction = 'rtl';
-    }
-
-    return (
-      <div className='autosuggest-textarea'>
-        <label>
-          <span style={{ display: 'none' }}>{placeholder}</span>
-
-          <Textarea
-            inputRef={this.setTextarea}
-            className='autosuggest-textarea__textarea'
-            disabled={disabled}
-            placeholder={placeholder}
-            autoFocus={autoFocus}
-            value={value}
-            onChange={this.onChange}
-            onKeyDown={this.onKeyDown}
-            onKeyUp={this.onKeyUp}
-            onBlur={this.onBlur}
-            onPaste={this.onPaste}
-            style={style}
-            aria-autocomplete='list'
-          />
-        </label>
-
-        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
-          {suggestions.map(this.renderSuggestion)}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/components/avatar.js b/app/javascript/flavours/glitch/components/avatar.js
index 82ab0f45a..c5e9072c4 100644
--- a/app/javascript/flavours/glitch/components/avatar.js
+++ b/app/javascript/flavours/glitch/components/avatar.js
@@ -1,3 +1,4 @@
+import classNames from 'classnames';
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
@@ -7,6 +8,7 @@ export default class Avatar extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    className: PropTypes.string,
     size: PropTypes.number.isRequired,
     style: PropTypes.object,
     inline: PropTypes.bool,
@@ -34,17 +36,19 @@ export default class Avatar extends React.PureComponent {
   }
 
   render () {
-    const { account, size, animate, inline } = this.props;
+    const {
+      account,
+      animate,
+      className,
+      inline,
+      size,
+    } = this.props;
     const { hovering } = this.state;
 
     const src = account.get('avatar');
     const staticSrc = account.get('avatar_static');
 
-    let className = 'account__avatar';
-
-    if (inline) {
-      className = className + ' account__avatar-inline';
-    }
+    const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
 
     const style = {
       ...this.props.style,
@@ -61,7 +65,7 @@ export default class Avatar extends React.PureComponent {
 
     return (
       <div
-        className={className}
+        className={computedClass}
         onMouseEnter={this.handleMouseEnter}
         onMouseLeave={this.handleMouseLeave}
         style={style}
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index 2cf84f8f4..ad1c3a534 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -1,3 +1,5 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 
@@ -5,13 +7,19 @@ export default class DisplayName extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    className: PropTypes.string,
   };
 
   render () {
-    const displayNameHtml = { __html: this.props.account.get('display_name_html') };
+    const {
+      account,
+      className,
+    } = this.props;
+    const computedClass = classNames('display-name', className);
+    const displayNameHtml = { __html: account.get('display_name_html') };
 
     return (
-      <span className='display-name'>
+      <span className={computedClass}>
         <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
       </span>
     );
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index d4a886a8b..706390c92 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -133,8 +133,13 @@ export default class Dropdown extends React.PureComponent {
 
       this.props.onModalOpen({
         status,
-        actions: items,
-        onClick: this.handleItemClick,
+        actions: items.map(
+          (item, i) => item ? {
+            ...item,
+            name: `${item.text}-${i}`,
+            onClick: this.handleItemClick.bind(i),
+          } : null
+        ),
       });
 
       return;
@@ -162,8 +167,7 @@ export default class Dropdown extends React.PureComponent {
     }
   }
 
-  handleItemClick = e => {
-    const i = Number(e.currentTarget.getAttribute('data-index'));
+  handleItemClick = (i, e) => {
     const { action, to } = this.props.items[i];
 
     this.handleClose();
diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js
new file mode 100644
index 000000000..8f55a0115
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon.js
@@ -0,0 +1,26 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  This just renders a FontAwesome icon.
+export default function Icon ({
+  className,
+  fullwidth,
+  icon,
+}) {
+  const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
+  return icon ? (
+    <span
+      aria-hidden='true'
+      className={computedClass}
+    />
+  ) : null;
+}
+
+//  Props.
+Icon.propTypes = {
+  className: PropTypes.string,
+  fullwidth: PropTypes.bool,
+  icon: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/components/link.js b/app/javascript/flavours/glitch/components/link.js
new file mode 100644
index 000000000..de99f7d42
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/link.js
@@ -0,0 +1,97 @@
+//  Inspired by <CommonLink> from Mastodon GO!
+//  ~ 😘 kibi!
+
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  We don't handle clicks that are made with modifiers, since these
+  //  often have special browser meanings (eg, "open in new tab").
+  click (e) {
+    const { onClick } = this.props;
+    if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
+      return;
+    }
+    onClick(e);
+    e.preventDefault();  //  Prevents following of the link
+  },
+};
+
+//  The component.
+export default class Link extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { click } = this.handlers;
+    const {
+      children,
+      className,
+      href,
+      onClick,
+      role,
+      title,
+      ...rest
+    } = this.props;
+    const computedClass = classNames('link', className, `role-${role}`);
+
+    //  We assume that our `onClick` is a routing function and give it
+    //  the qualities of a link even if no `href` is provided. However,
+    //  if we have neither an `onClick` or an `href`, our link is
+    //  purely presentational.
+    const conditionalProps = {};
+    if (href) {
+      conditionalProps.href = href;
+      conditionalProps.onClick = click;
+    } else if (onClick) {
+      conditionalProps.onClick = click;
+      conditionalProps.role = 'link';
+      conditionalProps.tabIndex = 0;
+    } else {
+      conditionalProps.role = 'presentation';
+    }
+
+    //  If we were provided a `role` it overwrites any that we may have
+    //  set above.  This can be used for "links" which are actually
+    //  buttons.
+    if (role) {
+      conditionalProps.role = role;
+    }
+
+    //  Rendering.  We set `rel='noopener'` for user privacy, and our
+    //  `target` as `'_blank'`.
+    return (
+      <a
+        className={computedClass}
+        {...conditionalProps}
+        rel='noopener'
+        target='_blank'
+        title={title}
+        {...rest}
+      >{children}</a>
+    );
+  }
+
+}
+
+//  Props.
+Link.propTypes = {
+  children: PropTypes.node,
+  className: PropTypes.string,
+  href: PropTypes.string,  //  The link destination
+  onClick: PropTypes.func,  //  A function to call instead of opening the link
+  role: PropTypes.string,  //  An ARIA role for the link
+  title: PropTypes.string,  //  A title for the link
+};
diff --git a/app/javascript/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js
new file mode 100644
index 000000000..9c8ffab1f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/text_icon_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class TextIconButton extends React.PureComponent {
+
+  static propTypes = {
+    label: PropTypes.string.isRequired,
+    title: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    ariaControls: PropTypes.string,
+  };
+
+  handleClick = (e) => {
+    e.preventDefault();
+    this.props.onClick();
+  }
+
+  render () {
+    const { label, title, active, ariaControls } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
+        {label}
+      </button>
+    );
+  }
+
+}