about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch')
-rw-r--r--app/javascript/flavours/glitch/actions/modal.js3
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js56
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.js27
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js23
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js75
-rw-r--r--app/javascript/flavours/glitch/containers/dropdown_menu_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js192
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js254
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.js (renamed from app/javascript/flavours/glitch/components/text_icon_button.js)16
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js2
-rw-r--r--app/javascript/flavours/glitch/features/lists/components/new_list_form.js2
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js9
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js10
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/modal.js2
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss36
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss5
-rw-r--r--app/javascript/flavours/glitch/util/idna.js10
-rw-r--r--app/javascript/flavours/glitch/util/resize_image.js8
22 files changed, 502 insertions, 235 deletions
diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js
index 80e15c28e..3d0299db5 100644
--- a/app/javascript/flavours/glitch/actions/modal.js
+++ b/app/javascript/flavours/glitch/actions/modal.js
@@ -9,8 +9,9 @@ export function openModal(type, props) {
   };
 };
 
-export function closeModal() {
+export function closeModal(type) {
   return {
     type: MODAL_CLOSE,
+    modalType: type,
   };
 };
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index 05611c135..39d7ba50c 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('keydown', this.handleKeyDown, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
-    if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
+    if (this.focusedItem && this.props.openedViaKeyboard) {
+      this.focusedItem.focus();
+    }
     this.setState({ mounted: true });
   }
 
@@ -81,6 +83,18 @@ class DropdownMenu extends React.PureComponent {
         element.focus();
       }
       break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = items[index-1] || items[items.length-1];
+      } else {
+        element = items[index+1] || items[0];
+      }
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      break;
     case 'Home':
       element = items[0];
       if (element) {
@@ -93,11 +107,14 @@ class DropdownMenu extends React.PureComponent {
         element.focus();
       }
       break;
+    case 'Escape':
+      this.props.onClose();
+      break;
     }
   }
 
-  handleItemKeyDown = e => {
-    if (e.key === 'Enter') {
+  handleItemKeyPress = e => {
+    if (e.key === 'Enter' || e.key === ' ') {
       this.handleClick(e);
     }
   }
@@ -126,7 +143,7 @@ class DropdownMenu extends React.PureComponent {
 
     return (
       <li className='dropdown-menu__item' key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}>
+        <a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
           {text}
         </a>
       </li>
@@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent {
     } else {
       const { top } = target.getBoundingClientRect();
       const placement = top * 2 < innerHeight ? 'bottom' : 'top';
-
       this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
     }
   }
 
   handleClose = () => {
+    if (this.activeElement) {
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
     this.props.onClose(this.state.id);
   }
 
-  handleKeyDown = e => {
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  }
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  }
+
+  handleKeyPress = (e) => {
     switch(e.key) {
     case ' ':
     case 'Enter':
       this.handleClick(e);
+      e.stopPropagation();
       e.preventDefault();
       break;
-    case 'Escape':
-      this.handleClose();
-      break;
     }
   }
 
@@ -248,7 +281,7 @@ export default class Dropdown extends React.PureComponent {
     const open = this.state.id === openDropdownId;
 
     return (
-      <div onKeyDown={this.handleKeyDown}>
+      <div>
         <IconButton
           icon={icon}
           title={ariaLabel}
@@ -257,6 +290,9 @@ export default class Dropdown extends React.PureComponent {
           size={size}
           ref={this.setTargetRef}
           onClick={this.handleClick}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleButtonKeyDown}
+          onKeyPress={this.handleKeyPress}
         />
 
         <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
index 6a25794d3..521353238 100644
--- a/app/javascript/flavours/glitch/components/icon_button.js
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -11,6 +11,9 @@ export default class IconButton extends React.PureComponent {
     title: PropTypes.string.isRequired,
     icon: PropTypes.string.isRequired,
     onClick: PropTypes.func,
+    onMouseDown: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    onKeyPress: PropTypes.func,
     size: PropTypes.number,
     active: PropTypes.bool,
     pressed: PropTypes.bool,
@@ -43,6 +46,24 @@ export default class IconButton extends React.PureComponent {
     }
   }
 
+  handleKeyPress = (e) => {
+    if (this.props.onKeyPress && !this.props.disabled) {
+      this.props.onKeyPress(e);
+    }
+  }
+
+  handleMouseDown = (e) => {
+    if (!this.props.disabled && this.props.onMouseDown) {
+      this.props.onMouseDown(e);
+    }
+  }
+
+  handleKeyDown = (e) => {
+    if (!this.props.disabled && this.props.onKeyDown) {
+      this.props.onKeyDown(e);
+    }
+  }
+
   render () {
     let style = {
       fontSize: `${this.props.size}px`,
@@ -105,6 +126,9 @@ export default class IconButton extends React.PureComponent {
           title={title}
           className={classes}
           onClick={this.handleClick}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleKeyDown}
+          onKeyPress={this.handleKeyPress}
           style={style}
           tabIndex={tabIndex}
           disabled={disabled}
@@ -124,6 +148,9 @@ export default class IconButton extends React.PureComponent {
             title={title}
             className={classes}
             onClick={this.handleClick}
+            onMouseDown={this.handleMouseDown}
+            onKeyDown={this.handleKeyDown}
+            onKeyPress={this.handleKeyPress}
             style={style}
             tabIndex={tabIndex}
             disabled={disabled}
diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 4e8648b49..fd0af9f6e 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -26,8 +26,30 @@ export default class ModalRoot extends React.PureComponent {
     }
   }
 
+  handleKeyDown = (e) => {
+    if (e.key === 'Tab') {
+      const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
+      const index = focusable.indexOf(e.target);
+
+      let element;
+
+      if (e.shiftKey) {
+        element = focusable[index - 1] || focusable[focusable.length - 1];
+      } else {
+        element = focusable[index + 1] || focusable[0];
+      }
+
+      if (element) {
+        element.focus();
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    }
+  }
+
   componentDidMount () {
     window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
     this.history = this.context.router ? this.context.router.history : createHistory();
   }
 
@@ -60,6 +82,7 @@ export default class ModalRoot extends React.PureComponent {
 
   componentWillUnmount () {
     window.removeEventListener('keyup', this.handleKeyUp);
+    window.removeEventListener('keydown', this.handleKeyDown);
   }
 
   handleModalClose () {
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 7c08ae4e8..170efad04 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -699,6 +699,7 @@ class Status extends ImmutablePureComponent {
             onExpandedToggle={this.handleExpandedToggle}
             parseClick={parseClick}
             disabled={!router}
+            tagLinks={settings.get('tag_misleading_links')}
           />
           {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
             <StatusActionBar
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 602a28064..95a4fe3fa 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -6,6 +6,53 @@ import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 import classnames from 'classnames';
 import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
+
+const textMatchesTarget = (text, origin, host) => {
+  return (text === origin || text === host
+          || text.startsWith(origin + '/') || text.startsWith(host + '/')
+          || 'www.' + text === host || ('www.' + text).startsWith(host + '/'));
+}
+
+const isLinkMisleading = (link) => {
+  let linkTextParts = [];
+
+  // Reconstruct visible text, as we do not have much control over how links
+  // from remote software look, and we can't rely on `innerText` because the
+  // `invisible` class does not set `display` to `none`.
+
+  const walk = (node) => {
+    switch (node.nodeType) {
+    case Node.TEXT_NODE:
+      linkTextParts.push(node.textContent);
+      break;
+    case Node.ELEMENT_NODE:
+      if (node.classList.contains('invisible')) return;
+      const children = node.childNodes;
+      for (let i = 0; i < children.length; i++) {
+        walk(children[i]);
+      }
+      break;
+    }
+  };
+
+  walk(link);
+
+  const linkText = linkTextParts.join('');
+  const targetURL = new URL(link.href);
+
+  // The following may not work with international domain names
+  if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) {
+    return false;
+  }
+
+  // The link hasn't been recognized, maybe it features an international domain name
+  const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
+  const host = targetURL.host.replace(targetURL.hostname, hostname);
+  const origin = targetURL.origin.replace(targetURL.host, host);
+  const text = linkText.normalize('NFKC');
+  return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
+};
 
 export default class StatusContent extends React.PureComponent {
 
@@ -19,6 +66,11 @@ export default class StatusContent extends React.PureComponent {
     parseClick: PropTypes.func,
     disabled: PropTypes.bool,
     onUpdate: PropTypes.func,
+    tagLinks: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    tagLinks: true,
   };
 
   state = {
@@ -27,6 +79,7 @@ export default class StatusContent extends React.PureComponent {
 
   _updateStatusLinks () {
     const node = this.contentsNode;
+    const { tagLinks } = this.props;
 
     if (!node) {
       return;
@@ -52,6 +105,21 @@ export default class StatusContent extends React.PureComponent {
         link.addEventListener('click', this.onLinkClick.bind(this), false);
         link.setAttribute('title', link.href);
         link.classList.add('unhandled-link');
+
+        try {
+          if (tagLinks && isLinkMisleading(link)) {
+            // Add a tag besides the link to display its origin
+
+            const tag = document.createElement('span');
+            tag.classList.add('link-origin-tag');
+            tag.textContent = `[${new URL(link.href).host}]`;
+            link.insertAdjacentText('beforeend', ' ');
+            link.insertAdjacentElement('beforeend', tag);
+          }
+        } catch (e) {
+          // The URL is invalid, remove the href just to be safe
+          if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
+        }
       }
 
       link.setAttribute('target', '_blank');
@@ -104,7 +172,7 @@ export default class StatusContent extends React.PureComponent {
   }
 
   onHashtagClick = (hashtag, e) => {
-    hashtag = hashtag.replace(/^#/, '').toLowerCase();
+    hashtag = hashtag.replace(/^#/, '');
 
     if (this.props.parseClick) {
       this.props.parseClick(e, `/timelines/tag/${hashtag}`);
@@ -173,6 +241,7 @@ export default class StatusContent extends React.PureComponent {
       mediaIcon,
       parseClick,
       disabled,
+      tagLinks,
     } = this.props;
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
@@ -247,6 +316,7 @@ export default class StatusContent extends React.PureComponent {
           <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
             <div
               ref={this.setContentsRef}
+              key={`contents-${tagLinks}`}
               style={directionStyle}
               tabIndex={!hidden ? 0 : null}
               dangerouslySetInnerHTML={content}
@@ -270,6 +340,7 @@ export default class StatusContent extends React.PureComponent {
         >
           <div
             ref={this.setContentsRef}
+            key={`contents-${tagLinks}`}
             dangerouslySetInnerHTML={content}
             lang={status.get('language')}
             className='status__content__text'
@@ -286,7 +357,7 @@ export default class StatusContent extends React.PureComponent {
           tabIndex='0'
           ref={this.setRef}
         >
-          <div ref={this.setContentsRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
+          <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
           {media}
         </div>
       );
diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
index b2419a0fd..1378e75fe 100644
--- a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
+++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js
@@ -25,7 +25,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
     }) : openDropdownMenu(id, dropdownPlacement, keyboard));
   },
   onClose(id) {
-    dispatch(closeModal());
+    dispatch(closeModal('ACTIONS'));
     dispatch(closeDropdownMenu(id));
   },
 });
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
index 8d982208f..60035b705 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -12,33 +12,101 @@ import DropdownMenu from './dropdown_menu';
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
 import { assignHandlers } from 'flavours/glitch/util/react_helpers';
 
-//  Handlers.
-const handlers = {
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
 
-  //  Closes the dropdown.
-  handleClose () {
-    this.setState({ open: false });
-  },
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    icon: PropTypes.string,
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })).isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    title: PropTypes.string,
+    value: PropTypes.string,
+    onChange: PropTypes.func,
+  };
+
+  state = {
+    needsModalUpdate: false,
+    open: false,
+    openedViaKeyboard: undefined,
+    placement: 'bottom',
+  };
 
-  //  The enter key toggles the dropdown's open state, and the escape
-  //  key closes it.
-  handleKeyDown ({ key }) {
-    const {
-      handleClose,
-      handleToggle,
-    } = this.handlers;
-    switch (key) {
+  //  Toggles opening and closing the dropdown.
+  handleToggle = ({ target, type }) => {
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    if (isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        const modal = this.handleMakeModal();
+        if (modal && onModalOpen) {
+          onModalOpen(modal);
+        }
+      }
+    } else {
+      const { top } = target.getBoundingClientRect();
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus();
+      }
+      this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+      this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
+    }
+  }
+
+  handleKeyDown = (e) => {
+    switch (e.key) {
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  }
+
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  }
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
     case 'Enter':
-      handleToggle(key);
+      this.handleMouseDown();
       break;
-    case 'Escape':
-      handleClose();
+    }
+  }
+
+  handleKeyPress = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleToggle(e);
+      e.stopPropagation();
+      e.preventDefault();
       break;
     }
-  },
+  }
+
+  handleClose = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus();
+    }
+    this.setState({ open: false });
+  }
 
   //  Creates an action modal object.
-  handleMakeModal () {
+  handleMakeModal = () => {
     const component = this;
     const {
       items,
@@ -76,74 +144,31 @@ const handlers = {
         })
       ),
     };
-  },
-
-  //  Toggles opening and closing the dropdown.
-  handleToggle ({ target }) {
-    const { handleMakeModal } = this.handlers;
-    const { onModalOpen } = this.props;
-    const { open } = this.state;
-
-    //  If this is a touch device, we open a modal instead of the
-    //  dropdown.
-    if (isUserTouching()) {
-
-      //  This gets the modal to open.
-      const modal = handleMakeModal();
-
-      //  If we can, we then open the modal.
-      if (modal && onModalOpen) {
-        onModalOpen(modal);
-        return;
-      }
-    }
-
-    const { top } = target.getBoundingClientRect();
-    this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
-    //  Otherwise, we just set our state to open.
-    this.setState({ open: !open });
-  },
+  }
 
   //  If our modal is open and our props update, we need to also update
   //  the modal.
-  handleUpdate () {
-    const { handleMakeModal } = this.handlers;
+  handleUpdate = () => {
     const { onModalOpen } = this.props;
     const { needsModalUpdate } = this.state;
 
     //  Gets our modal object.
-    const modal = handleMakeModal();
+    const modal = this.handleMakeModal();
 
     //  Reopens the modal with the new object.
     if (needsModalUpdate && modal && onModalOpen) {
       onModalOpen(modal);
     }
-  },
-};
-
-//  The component.
-export default class ComposerOptionsDropdown extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-    this.state = {
-      needsModalUpdate: false,
-      open: false,
-      placement: 'bottom',
-    };
   }
 
   //  Updates our modal as necessary.
   componentDidUpdate (prevProps) {
-    const { handleUpdate } = this.handlers;
     const { items } = this.props;
     const { needsModalUpdate } = this.state;
     if (needsModalUpdate && items.find(
       (item, i) => item.on !== prevProps.items[i].on
     )) {
-      handleUpdate();
+      this.handleUpdate();
       this.setState({ needsModalUpdate: false });
     }
   }
@@ -151,11 +176,6 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
   //  Rendering.
   render () {
     const {
-      handleClose,
-      handleKeyDown,
-      handleToggle,
-    } = this.handlers;
-    const {
       active,
       disabled,
       title,
@@ -175,14 +195,18 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
     return (
       <div
         className={computedClass}
-        onKeyDown={handleKeyDown}
+        onKeyDown={this.handleKeyDown}
       >
         <IconButton
           active={open || active}
           className='value'
           disabled={disabled}
           icon={icon}
-          onClick={handleToggle}
+          inverted
+          onClick={this.handleToggle}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleButtonKeyDown}
+          onKeyPress={this.handleKeyPress}
           size={18}
           style={{
             height: null,
@@ -199,8 +223,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
           <DropdownMenu
             items={items}
             onChange={onChange}
-            onClose={handleClose}
+            onClose={this.handleClose}
             value={value}
+            openedViaKeyboard={this.state.openedViaKeyboard}
           />
         </Overlay>
       </div>
@@ -208,22 +233,3 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
   }
 
 }
-
-//  Props.
-ComposerOptionsDropdown.propTypes = {
-  active: PropTypes.bool,
-  disabled: PropTypes.bool,
-  icon: PropTypes.string,
-  items: PropTypes.arrayOf(PropTypes.shape({
-    icon: PropTypes.string,
-    meta: PropTypes.node,
-    name: PropTypes.string.isRequired,
-    on: PropTypes.bool,
-    text: PropTypes.node,
-  })).isRequired,
-  onChange: PropTypes.func,
-  onModalClose: PropTypes.func,
-  onModalOpen: PropTypes.func,
-  title: PropTypes.string,
-  value: PropTypes.string,
-};
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
index 19d35a8f4..f812be7a9 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers';
 import Motion from 'flavours/glitch/util/optional_motion';
 import { assignHandlers } from 'flavours/glitch/util/react_helpers';
 
-class ComposerOptionsDropdownContentItem extends ImmutablePureComponent {
-
-  static propTypes = {
-    active: PropTypes.bool,
-    name: PropTypes.string,
-    onChange: PropTypes.func,
-    onClose: PropTypes.func,
-    options: PropTypes.shape({
-      icon: PropTypes.string,
-      meta: PropTypes.node,
-      on: PropTypes.bool,
-      text: PropTypes.node,
-    }),
-  };
-
-  handleActivate = (e) => {
-    const {
-      name,
-      onChange,
-      onClose,
-      options: { on },
-    } = this.props;
-
-    //  If the escape key was pressed, we close the dropdown.
-    if (e.key === 'Escape' && onClose) {
-      onClose();
-
-    //  Otherwise, we both close the dropdown and change the value.
-    } else if (onChange && (!e.key || e.key === 'Enter')) {
-      e.preventDefault();  //  Prevents change in focus on click
-      if ((on === null || typeof on === 'undefined') && onClose) {
-        onClose();
-      }
-      onChange(name);
-    }
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      active,
-      options: {
-        icon,
-        meta,
-        on,
-        text,
-      },
-    } = this.props;
-    const computedClass = classNames('composer--options--dropdown--content--item', {
-      active,
-      lengthy: meta,
-      'toggled-off': !on && on !== null && typeof on !== 'undefined',
-      'toggled-on': on,
-      'with-icon': icon,
-    });
-
-    let prefix = null;
-
-    if (on !== null && typeof on !== 'undefined') {
-      prefix = <Toggle checked={on} onChange={this.handleActivate} />;
-    } else if (icon) {
-      prefix = <Icon className='icon' fullwidth icon={icon} />
-    }
-
-    //  The result.
-    return (
-      <div
-        className={computedClass}
-        onClick={this.handleActivate}
-        onKeyDown={this.handleActivate}
-        role='button'
-        tabIndex='0'
-      >
-        {prefix}
-
-        <div className='content'>
-          <strong>{text}</strong>
-          {meta}
-        </div>
-      </div>
-    );
-  }
-
-};
-
 //  The spring to use with our motion.
 const springMotion = spring(1, {
   damping: 35,
@@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
       on: PropTypes.bool,
       text: PropTypes.node,
     })),
-    onChange: PropTypes.func,
-    onClose: PropTypes.func,
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
     style: PropTypes.object,
     value: PropTypes.string,
+    openedViaKeyboard: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
 
   state = {
     mounted: false,
+    value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
   };
 
   //  When the document is clicked elsewhere, we close the dropdown.
-  handleDocumentClick = ({ target }) => {
-    const { node } = this;
-    const { onClose } = this.props;
-    if (onClose && node && !node.contains(target)) {
-      onClose();
+  handleDocumentClick = (e) => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
     }
   }
 
@@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, withPassive);
+    if (this.focusedItem) {
+      this.focusedItem.focus();
+    } else {
+      this.node.firstChild.focus();
+    }
     this.setState({ mounted: true });
   }
 
@@ -157,6 +77,138 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
   }
 
+  handleClick = (e) => {
+    const name = e.currentTarget.getAttribute('data-index');
+
+    const {
+      onChange,
+      onClose,
+      items,
+    } = this.props;
+
+    const { on } = this.props.items.find(item => item.name === name);
+    e.preventDefault();  //  Prevents change in focus on click
+    if ((on === null || typeof on === 'undefined')) {
+      onClose();
+    }
+    onChange(name);
+  }
+
+  // Handle changes differently whether the dropdown is a list of options or actions
+  handleChange = (name) => {
+    if (this.props.value) {
+      this.props.onChange(name);
+    } else {
+      this.setState({ value: name });
+    }
+  }
+
+  handleKeyDown = e => {
+    const { items } = this.props;
+    const name = e.currentTarget.getAttribute('data-index');
+    const index = items.findIndex(item => {
+      return (item.name === name);
+    });
+    let element;
+
+    switch(e.key) {
+    case 'Escape':
+      this.props.onClose();
+      break;
+    case 'Enter':
+    case ' ':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1];
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1];
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.node.childNodes[index - 1] || this.node.lastChild;
+      } else {
+        element = this.node.childNodes[index + 1] || this.node.firstChild;
+      }
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    }
+  }
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
+  renderItem = (item) => {
+    const { name, icon, meta, on, text } = item;
+
+    const active = (name === (this.props.value || this.state.value));
+
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    let prefix = null;
+
+    if (on !== null && typeof on !== 'undefined') {
+      prefix = <Toggle checked={on} onChange={this.handleClick} />;
+    } else if (icon) {
+      prefix = <Icon className='icon' fullwidth icon={icon} />
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleClick}
+        onKeyDown={this.handleKeyDown}
+        role='option'
+        tabIndex='0'
+        key={name}
+        data-index={name}
+        ref={active ? this.setFocusRef : null}
+      >
+        {prefix}
+
+        <div className='content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </div>
+    );
+  }
+
   //  Rendering.
   render () {
     const { mounted } = this.state;
@@ -165,7 +217,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
       onChange,
       onClose,
       style,
-      value,
     } = this.props;
 
     //  The result.
@@ -189,27 +240,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
           <div
             className='composer--options--dropdown--content'
             ref={this.handleRef}
+            role='listbox'
             style={{
               ...style,
               opacity: opacity,
               transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
             }}
           >
-            {items ? items.map(
-              ({
-                name,
-                ...rest
-              }) => (
-                <ComposerOptionsDropdownContentItem
-                  active={name === value}
-                  key={name}
-                  name={name}
-                  onChange={onChange}
-                  onClose={onClose}
-                  options={rest}
-                />
-              )
-            ) : null}
+            {!!items && items.map(item => this.renderItem(item))}
           </div>
         )}
       </Motion>
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
index ed52b1997..92348b000 100644
--- a/app/javascript/flavours/glitch/features/compose/components/options.js
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -7,7 +7,7 @@ import spring from 'react-motion/lib/spring';
 
 //  Components.
 import IconButton from 'flavours/glitch/components/icon_button';
-import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import TextIconButton from './text_icon_button';
 import Dropdown from './dropdown';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
diff --git a/app/javascript/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
index 9c8ffab1f..7f2005060 100644
--- a/app/javascript/flavours/glitch/components/text_icon_button.js
+++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
@@ -1,6 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+  width: `${18 * 1.28571429}px`,
+};
+
 export default class TextIconButton extends React.PureComponent {
 
   static propTypes = {
@@ -20,7 +26,15 @@ export default class TextIconButton extends React.PureComponent {
     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}>
+      <button
+        title={title}
+        aria-label={title}
+        className={`text-icon-button ${active ? 'active' : ''}`}
+        aria-expanded={active}
+        onClick={this.handleClick}
+        aria-controls={ariaControls}
+        style={iconStyle}
+      >
         {label}
       </button>
     );
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js
index 24aaf82ac..bf5a8de35 100644
--- a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js
+++ b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js
@@ -11,7 +11,7 @@ const messages = defineMessages({
 
 const mapStateToProps = state => ({
   value: state.getIn(['listEditor', 'title']),
-  disabled: !state.getIn(['listEditor', 'isChanged']),
+  disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js
index 61fcbeaf9..eb5b6188a 100644
--- a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js
+++ b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js
@@ -66,7 +66,7 @@ export default class NewListForm extends React.PureComponent {
         </label>
 
         <IconButton
-          disabled={disabled}
+          disabled={disabled || !value}
           icon='plus'
           title={title}
           onClick={this.handleClick}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 910cb5346..bd92a81c2 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -66,6 +66,15 @@ export default class LocalSettingsPage extends React.PureComponent {
         >
           <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' />
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['tag_misleading_links']}
+          id='mastodon-settings--tag_misleading_links'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' />
+          <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage="Add a visual indication with the link target host to every link not mentioning it explicitly" /></span>
+        </LocalSettingsPageItem>
         <section>
           <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2>
           <LocalSettingsPageItem
diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js
index f974a87a1..108b6e3b2 100644
--- a/app/javascript/flavours/glitch/features/status/components/card.js
+++ b/app/javascript/flavours/glitch/features/status/components/card.js
@@ -4,15 +4,7 @@ import Immutable from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import punycode from 'punycode';
 import classnames from 'classnames';
-
-const IDNA_PREFIX = 'xn--';
-
-const decodeIDNA = domain => {
-  return domain
-    .split('.')
-    .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
-    .join('.');
-};
+import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
 
 const getHostname = url => {
   const parser = document.createElement('a');
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 1c2258256..fa4ed2fd5 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -241,6 +241,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             onExpandedToggle={onToggleHidden}
             parseClick={this.parseClick}
             onUpdate={this.handleChildUpdate}
+            tagLinks={settings.get('tag_misleading_links')}
             disabled
           />
 
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 6fd3d901b..7477c5584 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -22,6 +22,7 @@ const initialState = ImmutableMap({
   hicolor_privacy_icons: false,
   show_content_type_choice: false,
   filtering_behavior: 'hide',
+  tag_misleading_links: true,
   content_warnings : ImmutableMap({
     auto_unfold : false,
     filter      : null,
diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js
index 80bc11dda..7bd9d4b32 100644
--- a/app/javascript/flavours/glitch/reducers/modal.js
+++ b/app/javascript/flavours/glitch/reducers/modal.js
@@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
   case MODAL_OPEN:
     return { modalType: action.modalType, modalProps: action.modalProps };
   case MODAL_CLOSE:
-    return initialState;
+    return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 9f96a3154..6942170f2 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -118,20 +118,29 @@
   display: inline-block;
   padding: 0;
   color: $action-button-color;
-  border: none;
+  border: 0;
+  border-radius: 4px;
   background: transparent;
   cursor: pointer;
-  transition: color 100ms ease-in;
+  transition: all 100ms ease-in;
+  transition-property: background-color, color;
 
   &:hover,
   &:active,
   &:focus {
     color: lighten($action-button-color, 7%);
-    transition: color 200ms ease-out;
+    background-color: rgba($action-button-color, 0.15);
+    transition: all 200ms ease-out;
+    transition-property: background-color, color;
+  }
+
+  &:focus {
+    background-color: rgba($action-button-color, 0.3);
   }
 
   &.disabled {
     color: darken($action-button-color, 13%);
+    background-color: transparent;
     cursor: default;
   }
 
@@ -156,10 +165,16 @@
     &:active,
     &:focus {
       color: darken($lighter-text-color, 7%);
+      background-color: rgba($lighter-text-color, 0.15);
+    }
+
+    &:focus {
+      background-color: rgba($lighter-text-color, 0.3);
     }
 
     &.disabled {
       color: lighten($lighter-text-color, 7%);
+      background-color: transparent;
     }
 
     &.active {
@@ -186,7 +201,8 @@
 
 .text-icon-button {
   color: $lighter-text-color;
-  border: none;
+  border: 0;
+  border-radius: 4px;
   background: transparent;
   cursor: pointer;
   font-weight: 600;
@@ -194,17 +210,25 @@
   padding: 0 3px;
   line-height: 27px;
   outline: 0;
-  transition: color 100ms ease-in;
+  transition: all 100ms ease-in;
+  transition-property: background-color, color;
 
   &:hover,
   &:active,
   &:focus {
     color: darken($lighter-text-color, 7%);
-    transition: color 200ms ease-out;
+    background-color: rgba($lighter-text-color, 0.15);
+    transition: all 200ms ease-out;
+    transition-property: background-color, color;
+  }
+
+  &:focus {
+    background-color: rgba($lighter-text-color, 0.3);
   }
 
   &.disabled {
     color: lighten($lighter-text-color, 20%);
+    background-color: transparent;
     cursor: default;
   }
 
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 803494df6..d0183c2de 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -135,6 +135,11 @@
 
   a.unhandled-link {
     color: lighten($ui-highlight-color, 8%);
+
+    .link-origin-tag {
+      color: $gold-star;
+      font-size: 0.8em;
+    }
   }
 
   .status__content__spoiler-link {
diff --git a/app/javascript/flavours/glitch/util/idna.js b/app/javascript/flavours/glitch/util/idna.js
new file mode 100644
index 000000000..efab5bacf
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/idna.js
@@ -0,0 +1,10 @@
+import punycode from 'punycode';
+
+const IDNA_PREFIX = 'xn--';
+
+export const decode = domain => {
+  return domain
+    .split('.')
+    .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+    .join('.');
+};
diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js
index bbdbc865e..a8ec5f3fa 100644
--- a/app/javascript/flavours/glitch/util/resize_image.js
+++ b/app/javascript/flavours/glitch/util/resize_image.js
@@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
 
   context.drawImage(img, 0, 0, width, height);
 
+  // The Tor Browser and maybe other browsers may prevent reading from canvas
+  // and return an all-white image instead. Assume reading failed if the resized
+  // image is perfectly white.
+  const imageData = context.getImageData(0, 0, width, height);
+  if (imageData.every(value => value === 255)) {
+    throw 'Failed to read from canvas';
+  }
+
   canvas.toBlob(resolve, type);
 });