From 49a28e69a520d5994352e231e9879a5093af5916 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 14:35:17 +0200 Subject: Move decodeIDNA to app/javascript/flavours/glitch/util --- .../flavours/glitch/features/status/components/card.js | 10 +--------- app/javascript/flavours/glitch/util/idna.js | 10 ++++++++++ 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 app/javascript/flavours/glitch/util/idna.js (limited to 'app/javascript/flavours') 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/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('.'); +}; -- cgit From df866a464d43ad718602a18b86c57b716f7bcf27 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 15:01:09 +0200 Subject: Add options to highlight misleading links in statuses Fixes #1162 --- .../flavours/glitch/components/status.js | 1 + .../flavours/glitch/components/status_content.js | 107 +++++++++++++++++++++ .../glitch/features/local_settings/page/index.js | 16 +++ .../features/status/components/detailed_status.js | 1 + .../flavours/glitch/reducers/local_settings.js | 1 + .../flavours/glitch/styles/components/status.scss | 4 + 6 files changed, 130 insertions(+) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 7c08ae4e8..06280d8d5 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} + linkRewriting={settings.get('link_rewriting')} /> {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( { + 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 (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/')) { + return false; + } + + // The link hasn't been recognized, maybe it features an international domain name + const hostname = decodeIDNA(targetURL.hostname); + const host = targetURL.host.replace(targetURL.hostname, hostname); + const origin = targetURL.origin.replace(targetURL.host, host); + if (linkText === origin || linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/')) { + return false; + } + + // If the link text looks like an URL or auto-generated link, it is misleading + return !checkUrlLike || linkRegex.test(linkText); +}; export default class StatusContent extends React.PureComponent { @@ -19,6 +82,11 @@ export default class StatusContent extends React.PureComponent { parseClick: PropTypes.func, disabled: PropTypes.bool, onUpdate: PropTypes.func, + linkRewriting: PropTypes.string, + }; + + static defaultProps = { + linkRewriting: 'tag', }; state = { @@ -27,6 +95,7 @@ export default class StatusContent extends React.PureComponent { _updateStatusLinks () { const node = this.contentsNode; + const { linkRewriting } = this.props; if (!node) { return; @@ -52,6 +121,44 @@ 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'); + + if (linkRewriting === 'rewrite' && isLinkMisleading(link)) { + // Rewrite misleading links entirely + + while (link.firstChild) { + link.removeChild(link.firstChild); + } + + const prefix = (link.href.match(/https?:\/\/(www\.)?/) || [''])[0]; + const text = link.href.substr(prefix.length, 30); + const suffix = link.href.substr(prefix.length + 30); + const cutoff = !!suffix; + + const prefixTag = document.createElement('span'); + prefixTag.classList.add('invisible'); + prefixTag.textContent = prefix; + link.appendChild(prefixTag); + + const textTag = document.createElement('span'); + if (cutoff) { + textTag.classList.add('ellipsis'); + } + textTag.textContent = text; + link.appendChild(textTag); + + const suffixTag = document.createElement('span'); + suffixTag.classList.add('invisible'); + suffixTag.textContent = suffix; + link.appendChild(suffixTag); + } else if (linkRewriting === 'tag' && isLinkMisleading(link, false)) { + // 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); + } } link.setAttribute('target', '_blank'); 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..3f11dc5e9 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -25,6 +25,9 @@ const messages = defineMessages({ filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' }, filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' }, filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' }, + link_rewriting_none: { id: 'settings.link_rewriting.none', defaultMessage: 'Do not rewrite links' }, + link_rewriting_rewrite: { id: 'settings.link_rewriting.rewrite', defaultMessage: 'Rewrite links that may be misleading' }, + link_rewriting_tag: { id: 'settings.link_rewriting.tag', defaultMessage: 'Tag links with their target host unless it is already explicit' }, }); @injectIntl @@ -66,6 +69,19 @@ export default class LocalSettingsPage extends React.PureComponent { > + + +

diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 6fd3d901b..b020bdfb9 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', + link_rewriting: 'tag', content_warnings : ImmutableMap({ auto_unfold : false, filter : null, diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 803494df6..67a0dfbb2 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -135,6 +135,10 @@ a.unhandled-link { color: lighten($ui-highlight-color, 8%); + + .link-origin-tag { + color: $gold-star; + } } .status__content__spoiler-link { -- cgit From 6600da0f7b48d3afb18f0df8b8ff6962bc7c71c6 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 15:18:38 +0200 Subject: Handle Mastodon-generated links for targets starting with “www.” properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/javascript/flavours/glitch/components/status_content.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index b129e9a8b..fba30d6eb 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -54,7 +54,7 @@ const isLinkMisleading = (link, checkUrlLike = true) => { const targetURL = new URL(link.href); // The following may not work with international domain names - if (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/')) { + if (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/') || ('www.' + linkText).startsWith(targetURL.host + '/')) { return false; } @@ -62,7 +62,7 @@ const isLinkMisleading = (link, checkUrlLike = true) => { const hostname = decodeIDNA(targetURL.hostname); const host = targetURL.host.replace(targetURL.hostname, hostname); const origin = targetURL.origin.replace(targetURL.host, host); - if (linkText === origin || linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/')) { + if (linkText === origin || linkText === host || 'www.' + linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/') || ('www.' + linkText).startsWith(host + '/')) { return false; } -- cgit From c01de0f7214d7241a549b676a80768c9af6983c3 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 15:20:22 +0200 Subject: Ensure link rewriting setting changes are immediately applied --- app/javascript/flavours/glitch/components/status_content.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index fba30d6eb..1aaadb632 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -280,6 +280,7 @@ export default class StatusContent extends React.PureComponent { mediaIcon, parseClick, disabled, + linkRewriting, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; @@ -354,6 +355,7 @@ export default class StatusContent extends React.PureComponent {
-
+
{media}
); -- cgit From a0b6f1665aa8fca838dfbcf08bf66d1b36a94c05 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 16:29:45 +0200 Subject: Make link target domain tag just a bit smaller --- app/javascript/flavours/glitch/styles/components/status.scss | 1 + 1 file changed, 1 insertion(+) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 67a0dfbb2..d0183c2de 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -138,6 +138,7 @@ .link-origin-tag { color: $gold-star; + font-size: 0.8em; } } -- cgit From 43b137e1f91a5cbe42e16b9cc9db7fdbe5d01099 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 17:48:11 +0200 Subject: Perform case-insensitive comparison of non-International domain names --- app/javascript/flavours/glitch/components/status_content.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 1aaadb632..e51cc8fd5 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -25,6 +25,12 @@ const dot_confusables = '.\u002e\u0660\u06f0\u0701\u0702\u2024\ua4f8\ua60e\u10a5 const linkRegex = new RegExp(`^\\s*(([${h_confusables}][${t_confusables}][${t_confusables}][${p_confusables}][${s_confusables}]?[${column_confusables}][${slash_confusables}][${slash_confusables}]))?[^:/\\n ]+([${dot_confusables}][^:/\\n ]+)+`); +const textMatchesTarget = (text, origin, host) => { + return (text === origin || text === host + || text.startsWith(origin + '/') || text.startsWith(host + '/') + || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); +} + // If `checkUrlLike` is true, consider only URL-like link texts to be misleading const isLinkMisleading = (link, checkUrlLike = true) => { let linkTextParts = []; @@ -54,7 +60,7 @@ const isLinkMisleading = (link, checkUrlLike = true) => { const targetURL = new URL(link.href); // The following may not work with international domain names - if (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/') || ('www.' + linkText).startsWith(targetURL.host + '/')) { + if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { return false; } @@ -62,7 +68,7 @@ const isLinkMisleading = (link, checkUrlLike = true) => { const hostname = decodeIDNA(targetURL.hostname); const host = targetURL.host.replace(targetURL.hostname, hostname); const origin = targetURL.origin.replace(targetURL.host, host); - if (linkText === origin || linkText === host || 'www.' + linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/') || ('www.' + linkText).startsWith(host + '/')) { + if (textMatchesTarget(linkText, origin, host)) { return false; } -- cgit From 76b80a15119b5fbe19ec8b3329616c955a1e5af4 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 18:13:08 +0200 Subject: Perform case-insensitive comparison for international domain names Note: this uses `toLowerCase()` instead of doing proper case folding --- app/javascript/flavours/glitch/components/status_content.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index e51cc8fd5..147c1d1b1 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -65,10 +65,11 @@ const isLinkMisleading = (link, checkUrlLike = true) => { } // The link hasn't been recognized, maybe it features an international domain name - const hostname = decodeIDNA(targetURL.hostname); + const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC'); const host = targetURL.host.replace(targetURL.hostname, hostname); const origin = targetURL.origin.replace(targetURL.host, host); - if (textMatchesTarget(linkText, origin, host)) { + const text = linkText.normalize('NFKC'); + if (textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)) { return false; } -- cgit From ff0ceb28b3f1b19a6851a482f8203e434e50f167 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Thu, 1 Aug 2019 18:48:16 +0200 Subject: Remove link rewriting option as it is easily bypassable --- .../flavours/glitch/components/status.js | 2 +- .../flavours/glitch/components/status_content.js | 71 +++------------------- .../glitch/features/local_settings/page/index.js | 15 ++--- .../features/status/components/detailed_status.js | 2 +- .../flavours/glitch/reducers/local_settings.js | 2 +- 5 files changed, 17 insertions(+), 75 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 06280d8d5..170efad04 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -699,7 +699,7 @@ class Status extends ImmutablePureComponent { onExpandedToggle={this.handleExpandedToggle} parseClick={parseClick} disabled={!router} - linkRewriting={settings.get('link_rewriting')} + tagLinks={settings.get('tag_misleading_links')} /> {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( { return (text === origin || text === host || text.startsWith(origin + '/') || text.startsWith(host + '/') || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); } -// If `checkUrlLike` is true, consider only URL-like link texts to be misleading -const isLinkMisleading = (link, checkUrlLike = true) => { +const isLinkMisleading = (link) => { let linkTextParts = []; // Reconstruct visible text, as we do not have much control over how links @@ -69,12 +51,7 @@ const isLinkMisleading = (link, checkUrlLike = true) => { const host = targetURL.host.replace(targetURL.hostname, hostname); const origin = targetURL.origin.replace(targetURL.host, host); const text = linkText.normalize('NFKC'); - if (textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)) { - return false; - } - - // If the link text looks like an URL or auto-generated link, it is misleading - return !checkUrlLike || linkRegex.test(linkText); + return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); }; export default class StatusContent extends React.PureComponent { @@ -89,11 +66,11 @@ export default class StatusContent extends React.PureComponent { parseClick: PropTypes.func, disabled: PropTypes.bool, onUpdate: PropTypes.func, - linkRewriting: PropTypes.string, + tagLinks: PropTypes.bool, }; static defaultProps = { - linkRewriting: 'tag', + tagLinks: true, }; state = { @@ -102,7 +79,7 @@ export default class StatusContent extends React.PureComponent { _updateStatusLinks () { const node = this.contentsNode; - const { linkRewriting } = this.props; + const { tagLinks } = this.props; if (!node) { return; @@ -129,35 +106,7 @@ export default class StatusContent extends React.PureComponent { link.setAttribute('title', link.href); link.classList.add('unhandled-link'); - if (linkRewriting === 'rewrite' && isLinkMisleading(link)) { - // Rewrite misleading links entirely - - while (link.firstChild) { - link.removeChild(link.firstChild); - } - - const prefix = (link.href.match(/https?:\/\/(www\.)?/) || [''])[0]; - const text = link.href.substr(prefix.length, 30); - const suffix = link.href.substr(prefix.length + 30); - const cutoff = !!suffix; - - const prefixTag = document.createElement('span'); - prefixTag.classList.add('invisible'); - prefixTag.textContent = prefix; - link.appendChild(prefixTag); - - const textTag = document.createElement('span'); - if (cutoff) { - textTag.classList.add('ellipsis'); - } - textTag.textContent = text; - link.appendChild(textTag); - - const suffixTag = document.createElement('span'); - suffixTag.classList.add('invisible'); - suffixTag.textContent = suffix; - link.appendChild(suffixTag); - } else if (linkRewriting === 'tag' && isLinkMisleading(link, false)) { + if (tagLinks && isLinkMisleading(link)) { // Add a tag besides the link to display its origin const tag = document.createElement('span'); @@ -287,7 +236,7 @@ export default class StatusContent extends React.PureComponent { mediaIcon, parseClick, disabled, - linkRewriting, + tagLinks, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; @@ -362,7 +311,7 @@ export default class StatusContent extends React.PureComponent {
-
+
{media}
); 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 3f11dc5e9..bd92a81c2 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -25,9 +25,6 @@ const messages = defineMessages({ filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' }, filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' }, filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' }, - link_rewriting_none: { id: 'settings.link_rewriting.none', defaultMessage: 'Do not rewrite links' }, - link_rewriting_rewrite: { id: 'settings.link_rewriting.rewrite', defaultMessage: 'Rewrite links that may be misleading' }, - link_rewriting_tag: { id: 'settings.link_rewriting.tag', defaultMessage: 'Tag links with their target host unless it is already explicit' }, }); @injectIntl @@ -71,16 +68,12 @@ export default class LocalSettingsPage extends React.PureComponent { - + +

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 29272c0ad..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,7 +241,7 @@ export default class DetailedStatus extends ImmutablePureComponent { onExpandedToggle={onToggleHidden} parseClick={this.parseClick} onUpdate={this.handleChildUpdate} - linkRewriting={settings.get('link_rewriting')} + 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 b020bdfb9..7477c5584 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -22,7 +22,7 @@ const initialState = ImmutableMap({ hicolor_privacy_icons: false, show_content_type_choice: false, filtering_behavior: 'hide', - link_rewriting: 'tag', + tag_misleading_links: true, content_warnings : ImmutableMap({ auto_unfold : false, filter : null, -- cgit From 8b57d704dc455d1ffb1c31bb0ff6a19d58e322ce Mon Sep 17 00:00:00 2001 From: ThibG Date: Sat, 3 Aug 2019 19:10:39 +0200 Subject: [Glitch] Disable list title validation button when list title is empty Port 089c6410208d294e7f1995e000bd796d4625246f to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/features/list_editor/components/edit_list_form.js | 2 +- .../flavours/glitch/features/lists/components/new_list_form.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'app/javascript/flavours') 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 { Date: Sat, 3 Aug 2019 19:10:50 +0200 Subject: [Glitch] Change icon button styles to make hover/focus states more obvious Port c8fd82332749cd58f85dd398d32559a02499c945 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/text_icon_button.js | 29 --------------- .../glitch/features/compose/components/options.js | 2 +- .../compose/components/text_icon_button.js | 43 ++++++++++++++++++++++ .../flavours/glitch/styles/components/index.scss | 36 +++++++++++++++--- 4 files changed, 74 insertions(+), 36 deletions(-) delete mode 100644 app/javascript/flavours/glitch/components/text_icon_button.js create mode 100644 app/javascript/flavours/glitch/features/compose/components/text_icon_button.js (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js deleted file mode 100644 index 9c8ffab1f..000000000 --- a/app/javascript/flavours/glitch/components/text_icon_button.js +++ /dev/null @@ -1,29 +0,0 @@ -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 ( - - ); - } - -} 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/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..7f2005060 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -0,0 +1,43 @@ +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 = { + 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 ( + + ); + } + +} 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; } -- cgit From 68eb58b8058579f551b8aa94e800283cd0f93f7f Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Mon, 5 Aug 2019 14:25:48 +0200 Subject: Fix color of dropdown icons --- app/javascript/flavours/glitch/features/compose/components/dropdown.js | 1 + 1 file changed, 1 insertion(+) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 8d982208f..764dcea69 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -182,6 +182,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { className='value' disabled={disabled} icon={icon} + inverted onClick={handleToggle} size={18} style={{ -- cgit From cbd75fe1284f67c941967f20ab9fef508ed70e1c Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 11:31:28 +0200 Subject: Remove href attribute of invalid links instead of crashing --- .../flavours/glitch/components/status_content.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index a7e17d252..20e640bcb 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -106,14 +106,19 @@ export default class StatusContent extends React.PureComponent { link.setAttribute('title', link.href); link.classList.add('unhandled-link'); - 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); + 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 (TypeError) { + // Just to be safe + if (tagLinks) link.removeAttribute('href'); } } -- cgit From 3ea7a334d89d2c4075b1dbf649d692ff49325f2e Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 12:56:19 +0200 Subject: Fix up try/catch block in invalid URL handling --- app/javascript/flavours/glitch/components/status_content.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 20e640bcb..f8b101dc4 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -116,9 +116,9 @@ export default class StatusContent extends React.PureComponent { link.insertAdjacentText('beforeend', ' '); link.insertAdjacentElement('beforeend', tag); } - } catch (TypeError) { - // Just to be safe - if (tagLinks) link.removeAttribute('href'); + } catch (e) { + // The URL is invalid, remove the href just to be safe + if (tagLinks && e instanceof TypeError) link.removeAttribute('href'); } } -- cgit From 6afdb6c2b698737e63f781aba7fae71ab28106d6 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:28 +0200 Subject: [Glitch] Trap tab in modals Port 5c73746b695e5bc540b41f5ae1406eac6220886e to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/modal_root.js | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) (limited to 'app/javascript/flavours') 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 () { -- cgit From fe1de4e49b2ee6b74139d8ac7811104095c7477b Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:46 +0200 Subject: [Glitch] Improve dropdown menu keyboard navigation Port a12f1a0baf3d31ecc9779c25b4bf4a0c9bd95543 to glitch-soc Signed-off-by: Thibaut Girka --- app/javascript/flavours/glitch/actions/modal.js | 3 +- .../flavours/glitch/components/dropdown_menu.js | 44 +++++++++++++--------- .../glitch/containers/dropdown_menu_container.js | 2 +- app/javascript/flavours/glitch/reducers/modal.js | 2 +- 4 files changed, 30 insertions(+), 21 deletions(-) (limited to 'app/javascript/flavours') 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..f29b824d5 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -45,7 +45,10 @@ 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(); + this.activeElement = document.activeElement; + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus(); + } this.setState({ mounted: true }); } @@ -53,6 +56,9 @@ class DropdownMenu extends React.PureComponent { document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.activeElement) { + this.activeElement.focus(); + } } setRef = c => { @@ -81,6 +87,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 +111,14 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Escape': + this.props.onClose(); + break; } } - handleItemKeyDown = e => { - if (e.key === 'Enter') { + handleItemKeyUp = e => { + if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } } @@ -126,7 +147,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • @@ -202,19 +223,6 @@ export default class Dropdown extends React.PureComponent { this.props.onClose(this.state.id); } - handleKeyDown = e => { - switch(e.key) { - case ' ': - case 'Enter': - this.handleClick(e); - e.preventDefault(); - break; - case 'Escape': - this.handleClose(); - break; - } - } - handleItemClick = (i, e) => { const { action, to } = this.props.items[i]; @@ -248,7 +256,7 @@ export default class Dropdown extends React.PureComponent { const open = this.state.id === openDropdownId; return ( -
    +
    ({ }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, onClose(id) { - dispatch(closeModal()); + dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); }, }); 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; } -- cgit From a4b15e2cf063666262e1caab7d213d7ec9d2b67b Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:58 +0200 Subject: [Glitch] Port changes to IconButton Port changes to IconButton from 27a0d02d0d960163e98595b05412c0d03a4875d0 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/icon_button.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index 6a25794d3..c1e2f664c 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -11,6 +11,8 @@ export default class IconButton extends React.PureComponent { title: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -43,6 +45,18 @@ export default class IconButton extends React.PureComponent { } } + 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 +119,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} style={style} tabIndex={tabIndex} disabled={disabled} @@ -124,6 +140,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} style={style} tabIndex={tabIndex} disabled={disabled} -- cgit From 381dbb6569b07554a2543082cbb2e736fb425e2a Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 12:08:19 +0200 Subject: [Glitch] Fix image uploads being perfectly white when canvas read access is blocked Port 111a0628fc161df4d76967d7dc7116b8a43fe8e2 to glitch-soc Signed-off-by: Thibaut Girka --- app/javascript/flavours/glitch/util/resize_image.js | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'app/javascript/flavours') 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); }); -- cgit From 6d2b0fa3f0f32c874863e7fe035270dc31cdcc58 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 13:57:45 +0200 Subject: Refactor composer Dropdown's component a bit to make it closer to upstream --- .../glitch/features/compose/components/dropdown.js | 155 +++++++++------------ 1 file changed, 63 insertions(+), 92 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 764dcea69..6a5f4575e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -12,33 +12,71 @@ 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, + 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 }) => { + 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(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + } + + handleKeyDown = (e) => { + switch (e.key) { case 'Enter': - handleToggle(key); + this.handleToggle(key); break; case 'Escape': - handleClose(); + this.handleClose(); break; } - }, + } + + handleClose = () => { + this.setState({ open: false }); + } // Creates an action modal object. - handleMakeModal () { + handleMakeModal = () => { const component = this; const { items, @@ -76,85 +114,37 @@ 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 }); } } // Rendering. render () { - const { - handleClose, - handleKeyDown, - handleToggle, - } = this.handlers; const { active, disabled, @@ -175,7 +165,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { return (
    @@ -209,22 +199,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, -}; -- cgit From d10f6036cfeebec5b2c160db8659d2c19d29fe9c Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 14:18:09 +0200 Subject: Implement keyboard navigation in glitch-soc composer --- .../flavours/glitch/components/icon_button.js | 9 + .../glitch/features/compose/components/dropdown.js | 44 +++- .../features/compose/components/dropdown_menu.js | 254 ++++++++++++--------- 3 files changed, 194 insertions(+), 113 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index c1e2f664c..521353238 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -13,6 +13,7 @@ export default class IconButton extends React.PureComponent { onClick: PropTypes.func, onMouseDown: PropTypes.func, onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -45,6 +46,12 @@ 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); @@ -121,6 +128,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} disabled={disabled} @@ -142,6 +150,7 @@ export default class IconButton extends React.PureComponent { 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/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 6a5f4575e..60035b705 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -36,11 +36,12 @@ export default class ComposerOptionsDropdown extends React.PureComponent { state = { needsModalUpdate: false, open: false, + openedViaKeyboard: undefined, placement: 'bottom', }; // Toggles opening and closing the dropdown. - handleToggle = ({ target }) => { + handleToggle = ({ target, type }) => { const { onModalOpen } = this.props; const { open } = this.state; @@ -55,23 +56,52 @@ export default class ComposerOptionsDropdown extends React.PureComponent { } } 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 }); + this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); } } handleKeyDown = (e) => { switch (e.key) { - case 'Enter': - this.handleToggle(key); - break; case 'Escape': this.handleClose(); break; } } + 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.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ open: false }); } @@ -174,6 +204,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent { icon={icon} inverted onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} size={18} style={{ height: null, @@ -192,6 +225,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { onChange={onChange} onClose={this.handleClose} value={value} + openedViaKeyboard={this.state.openedViaKeyboard} />
    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 = ; - } else if (icon) { - prefix = - } - - // The result. - return ( -
    - {prefix} - -
    - {text} - {meta} -
    -
    - ); - } - -}; - // 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 = ; + } else if (icon) { + prefix = + } + + return ( +
    + {prefix} + +
    + {text} + {meta} +
    +
    + ); + } + // 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
    - {items ? items.map( - ({ - name, - ...rest - }) => ( - - ) - ) : null} + {!!items && items.map(item => this.renderItem(item))}
    )} -- cgit From e8e980cdac9d1c3a81d9f30412f6de4cd021c225 Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 7 Aug 2019 13:58:53 +0200 Subject: [Glitch] Improve focus handling with dropdown menus Port 396b8cdd0f071fab85b09855f882b19c07cccd44 to glitch-soc --- .../flavours/glitch/components/dropdown_menu.js | 42 ++++++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index f29b824d5..39d7ba50c 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -45,7 +45,6 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - this.activeElement = document.activeElement; if (this.focusedItem && this.props.openedViaKeyboard) { this.focusedItem.focus(); } @@ -56,9 +55,6 @@ class DropdownMenu extends React.PureComponent { document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.activeElement) { - this.activeElement.focus(); - } } setRef = c => { @@ -117,7 +113,7 @@ class DropdownMenu extends React.PureComponent { } } - handleItemKeyUp = e => { + handleItemKeyPress = e => { if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } @@ -147,7 +143,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • @@ -214,15 +210,44 @@ 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); } + 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; + } + } + handleItemClick = (i, e) => { const { action, to } = this.props.items[i]; @@ -265,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} /> -- cgit From e8ad0a800616a1f95fd71ab8457869e716212ca0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 7 Aug 2019 10:01:19 +0200 Subject: [Glitch] Fix hashtag links always being lowercase Port 5e35aa82802b09a63d4625fa9c1837ad75178553 to glitch-soc Signed-off-by: Thibaut Girka --- app/javascript/flavours/glitch/components/status_content.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/javascript/flavours') diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index f8b101dc4..95a4fe3fa 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -172,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}`); -- cgit