diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 16c2bd2f7..a46d2dccd 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -5,14 +5,7 @@ export const singleView = () => ({ type: actions.SINGLE_VIEW }); // hide shortcuts note export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => { - try { - if (localStorage) { - localStorage.setItem('coral:shortcutsNote', 'hide'); - } - } catch (e) { - // above will fail in Safari private mode - } - + localStorage.setItem('coral:shortcutsNote', 'hide'); dispatch({ type: actions.HIDE_SHORTCUTS_NOTE }); }; diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 0cf0a8cf9..158870dec 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -15,7 +15,8 @@ import { hideShortcutsNote } from './actions/moderation'; smoothscroll.polyfill(); function init({ store, localStorage }) { - if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') { + const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide'; + if (shouldHide) { store.dispatch(hideShortcutsNote()); } } diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 5390153e6..5e3b0846f 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -15,9 +15,7 @@ export const checkLogin = () => ( rest('/auth') .then(result => { if (!result.user) { - if (localStorage) { - cleanAuthData(localStorage); - } + cleanAuthData(localStorage); dispatch(checkLoginSuccess(null)); return; } @@ -52,10 +50,8 @@ const checkLoginSuccess = user => ({ }); export const setAuthToken = token => (dispatch, _, { localStorage }) => { - if (localStorage) { - localStorage.setItem('exp', jwtDecode(token).exp); - localStorage.setItem('token', token); - } + localStorage.setItem('exp', jwtDecode(token).exp); + localStorage.setItem('token', token); // Dispatch the set auth token action. For some browsers and situations, we // may not be able to persist the auth token any other way. Keep it in redux! @@ -70,11 +66,8 @@ export const handleSuccessfulLogin = (user, token) => ( { client, localStorage, postMessage } ) => { const { exp } = jwtDecode(token); - - if (localStorage) { - localStorage.setItem('exp', exp); - localStorage.setItem('token', token); - } + localStorage.setItem('exp', exp); + localStorage.setItem('token', token); // Send the message via the messages service to the window.opener if it // exists. @@ -105,9 +98,8 @@ export const logout = () => async ( ) => { await rest('/auth', { method: 'DELETE' }); - if (localStorage) { - cleanAuthData(localStorage); - } + // Clear the auth data persisted to localStorage. + cleanAuthData(localStorage); // Reset the websocket. client.resetWebsocket(); diff --git a/client/coral-framework/services/i18n.js b/client/coral-framework/services/i18n.js index 9c65c9c4e..e1c3ab99e 100644 --- a/client/coral-framework/services/i18n.js +++ b/client/coral-framework/services/i18n.js @@ -52,26 +52,41 @@ let lang; let timeagoInstance; function setLocale(storage, locale) { - try { - if (storage) { - storage.setItem('locale', locale); - } - } catch (err) { - console.error(err); - } + storage.setItem('locale', locale); } -function getLocale(storage) { +// detectLanguage will try to get the locale from storage if available, +// otherwise will try to get it from the navigator, otherwise, it will fallback +// to the default language. +function detectLanguage(storage) { try { - return ( - (storage && storage.getItem('locale')) || - navigator.language || - defaultLanguage - ).split('-')[0]; + const lang = storage.getItem('locale') || navigator.language; + if (lang) { + return lang; + } } catch (err) { - console.error(err); - return null; + console.warn( + 'Error while trying to detect language, will fallback to', + err + ); } + + console.warn('Could not detect language, will fallback to', defaultLanguage); + return defaultLanguage; +} + +// getLocale will get the users locale from the local detector and parse it to a +// format we can work with. +function getLocale(storage) { + // Get the language from the local detector. + const lang = detectLanguage(storage); + + // Some language strings come with additional subtags as defined in: + // + // https://www.ietf.org/rfc/bcp/bcp47.txt + // + // So we should strip that off if we find it. + return lang.split('-')[0]; } export function setupTranslations() { diff --git a/client/coral-framework/services/storage.js b/client/coral-framework/services/storage.js index 66e2c96cd..7b7acf655 100644 --- a/client/coral-framework/services/storage.js +++ b/client/coral-framework/services/storage.js @@ -1,40 +1,116 @@ import uuid from 'uuid/v4'; -function getStorage(type) { - let storage; - try { - storage = window[type]; - const x = '__storage_test__'; - storage.setItem(x, x); - storage.removeItem(x); - } catch (e) { - const ignore = - e instanceof DOMException && - // everything except Firefox - (e.code === 22 || - // SecurityError related to having 3rd party cookies disabled. - e.code === 18 || - // Firefox +function testStorageAccess(storage) { + const key = '__storage_test__'; - e.code === 1014 || - // test name field too, because code might not be present + // Create a unique test value. + const expectedValue = String(Date.now()); - // everything except Firefox - e.name === 'QuotaExceededError' || - // Firefox - e.name === 'NS_ERROR_DOM_QUOTA_REACHED'); - if (!ignore) { - console.warn(e); + // Try to set, get, and remove that item. + storage.setItem(key, expectedValue); + const canSetGet = expectedValue === storage.getItem(key); + storage.removeItem(key); + + if (!canSetGet) { + // We can't access the desired storage! + throw new Error('Storage access test failed'); + } +} + +// InMemoryStorage is a dumb implementation of the Storage interface that will +// not persist the data at all. It implements the Storage interface found: +// +// https://developer.mozilla.org/en-US/docs/Web/API/Storage +// +class InMemoryStorage { + constructor() { + this.storage = {}; + } + + get length() { + return Object.keys(this.storage).length; + } + + key(n) { + if (this.length <= n) { + return undefined; } - // When third party cookies are disabled, session storage is readable/ - // writable, but localStorage is not. Try to get the sessionStorage to use. - if (type !== 'sessionStorage') { - return getStorage('sessionStorage'); + return this.storage[Object.keys(this.storage)[n]]; + } + + getItem(key) { + return this.storage[key]; + } + + setItem(key, value) { + this.storage[key] = value; + + try { + // Test sessionStorage. We could have been given access recently. + testStorageAccess(sessionStorage); + + // Test passed! Set the item in sessionStorage. + sessionStorage.setItem(key, value); + console.log( + 'Attempt to persist InMemoryStorage value to sessionStorage succeeded' + ); + } catch (err) { + console.warn( + 'Attempt to persist InMemoryStorage value to sessionStorage failed', + err + ); } } - return storage; + removeItem(key) { + delete this.storage[key]; + + try { + // Test sessionStorage. We could have been given access recently. + testStorageAccess(sessionStorage); + + // Test passed! Remove the item from sessionStorage. + sessionStorage.removeItem(key); + console.log( + 'Attempt to persist InMemoryStorage delete to sessionStorage succeeded' + ); + } catch (err) { + console.warn( + 'Attempt to persist InMemoryStorage delete to sessionStorage failed', + err + ); + } + } +} + +// getStorage will test to see if the requested storage type is available, if it +// is not, it will try sessionStorage, and if that is also not available, it +// will fallback to InMemoryStorage. +function getStorage(type) { + try { + // Get the desired storage from the window and test it out. + const storage = window[type]; + testStorageAccess(storage); + + // Storage test was successful! Return it. + return storage; + } catch (err) { + // When third party cookies are disabled, session storage is readable/ + // writable, but localStorage is not. Try to get the sessionStorage to use. + if (type !== 'sessionStorage') { + console.warn('Could not access', type, 'trying sessionStorage', err); + return getStorage('sessionStorage'); + } + + console.warn( + 'Could not access sessionStorage falling back to InMemoryStorage', + err + ); + } + + // No acceptable storage could be found, returning the InMemoryStorage. + return new InMemoryStorage(); } /** diff --git a/client/coral-ui/components/TextField.js b/client/coral-ui/components/TextField.js index 6cbb7a06b..2ba23e6e8 100644 --- a/client/coral-ui/components/TextField.js +++ b/client/coral-ui/components/TextField.js @@ -27,11 +27,14 @@ const TextField = ({ ); TextField.propTypes = { + id: PropTypes.string, label: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, errorMsg: PropTypes.string, type: PropTypes.string, + className: PropTypes.string, + showErrors: PropTypes.bool, }; export default TextField; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 46fe0ac3c..67478785a 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -29,9 +29,9 @@ const User = { return Comments.getByQuery(query); }, - ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) { + ignoredUsers({ ignoresUsers }, args, { loaders: { Users } }) { // Return nothing if there is nothing to query for. - if (!user.ignoresUsers || user.ignoresUsers.length <= 0) { + if (!ignoresUsers || ignoresUsers.length <= 0) { return []; } diff --git a/package.json b/package.json index b0a37528e..845e2b11a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.3.1", + "version": "4.3.2", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true, diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.js b/plugins/talk-plugin-auth/client/login/components/SignUp.js index cc49e7391..83f81f81d 100644 --- a/plugins/talk-plugin-auth/client/login/components/SignUp.js +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.js @@ -75,6 +75,7 @@ class SignUp extends React.Component { showErrors={!!emailError} errorMsg={emailError} onChange={this.handleEmailChange} + autocomplete="off" /> {passwordError && ( @@ -113,6 +117,7 @@ class SignUp extends React.Component { errorMsg={passwordRepeatError} onChange={this.handlePasswordRepeatChange} minLength="8" + autocomplete="off" /> { @@ -23,6 +23,9 @@ const handle = async (ctx, comment) => { id user { id + ignoredUsers { + id + } notificationSettings { onReply } @@ -53,13 +56,23 @@ const handle = async (ctx, comment) => { return; } + // Pull out the author of the new comment. + const authorID = get(comment, 'author_id'); + // Check to see if this is yourself replying to yourself, if that's the case // don't send a notification. - if (userID === get(comment, 'author_id')) { + if (userID === authorID) { ctx.log.info('user id of parent comment is the same as the new comment'); return; } + // Check to see if this user is ignoring the user who replied to their + // comment. + if (map(get(comment, 'user.ignoredUsers', []), 'id').indexOf(authorID)) { + ctx.log.info('parent user has ignored the author of the new comment'); + return; + } + // The user does have notifications for replied comments enabled, queue the // notification to be sent. return { userID, date: comment.created_at, context: comment.id };