diff --git a/www/flow-typed/package-dep-libdefs.js b/www/flow-typed/package-dep-libdefs.js index 5b272fa6e6..24fc34c403 100644 --- a/www/flow-typed/package-dep-libdefs.js +++ b/www/flow-typed/package-dep-libdefs.js @@ -7,3 +7,7 @@ declare module '@material/fab' { declare module '@material/snackbar' { declare module.exports: any; } + +declare module 'bowser' { + declare module.exports: any; +} diff --git a/www/package.json b/www/package.json index 559fdc7e79..6ba6041595 100644 --- a/www/package.json +++ b/www/package.json @@ -48,7 +48,7 @@ "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", "enzyme-to-json": "^3.3.1", - "eslint": "^4.17.0", + "eslint": "^4.18.0", "eslint-config-airbnb": "^16.1.0", "eslint-config-prettier": "^2.9.0", "eslint-import-resolver-webpack": "^0.8.4", @@ -75,7 +75,7 @@ "moment": "^2.20.1", "node-sass": "^4.7.2", "optimize-css-assets-webpack-plugin": "^3.2.0", - "postcss-custom-properties": "^6.2.0", + "postcss-custom-properties": "^7.0.0", "postcss-loader": "^2.1.0", "prettier": "^1.10.2", "react-dev-utils": "^5.0.0", @@ -102,6 +102,7 @@ "autotrack": "^2.4.1", "axios": "^0.17.1", "bootstrap": "4.0.0", + "bowser": "^1.9.2", "classnames": "^2.2.5", "core-js": "^2.5.1", "downshift": "^1.28.0", diff --git a/www/src/js/bootstrapping/browser.js b/www/src/js/bootstrapping/browser.js new file mode 100644 index 0000000000..d6b3403aa2 --- /dev/null +++ b/www/src/js/bootstrapping/browser.js @@ -0,0 +1,87 @@ +// @flow + +// This script checks for browser compatibility and is executed outside React's scope. If a browser +// is incompatible or not optimal for using the app, a pop-up dialog is appended into the DOM body. +// This is so that in cases where the React app completely fails to render anything at all, the +// user will at least be able to see the dialog warning them of the browser incompatibility. + +import bowser from 'bowser'; +import { canUseBrowserLocalStorage } from 'storage/localStorage'; +import styles from './browser.scss'; + +const LOCAL_STORAGE_KEY = 'dismissedBrowserWarning'; +const composeAnchorText = (innerHTML, href) => + `${innerHTML}`; +const linkForChrome = composeAnchorText('Google Chrome', 'https://www.google.com/chrome/'); +const linkForFirefox = composeAnchorText('Mozilla Firefox', 'https://www.mozilla.org/en-US/'); +const linkForChromePlayStore = composeAnchorText( + 'updating your web browser', + 'http://play.google.com/store/apps/details?id=com.android.chrome', +); + +const browserCanUseLocalStorage = canUseBrowserLocalStorage(); + +if ( + !bowser.check( + { + edge: '14', + chrome: '56', + firefox: '52', + safari: '9', + }, + true, + ) +) { + if ( + (browserCanUseLocalStorage && !localStorage.getItem(LOCAL_STORAGE_KEY)) || + !browserCanUseLocalStorage + ) { + const promptText = (() => { + // Users can only update Safari by updating the OS in iOS + if (bowser.ios) + return `NUSMods may not work properly. Please consider updating your device to iOS 11 or higher.`; + if (bowser.android && bowser.chrome) + return `NUSMods may not work properly. Please consider ${linkForChromePlayStore}.`; + return `NUSMods may not work properly. Please consider updating your web browser or switching to the latest version of ${linkForChrome} or ${linkForFirefox}.`; + })(); + const template = ` +
+

Your web browser is outdated or unsupported

+

${promptText}

+
+
+ +
+ ${ + // Show "don't show again" only if the browser supports localStorage + browserCanUseLocalStorage + ? ` +
+
+ + +
+
+ ` + : '' + } +
+
+ `; + const container = document.createElement('div'); + container.className = styles.browserWarning; + container.innerHTML = template; + const body = document.body; + if (body) body.appendChild(container); + + const element = document.getElementById('browserWarning-continue'); + if (element) { + element.addEventListener('click', () => { + const checkbox = document.getElementById('browserWarning-ignore'); + if (browserCanUseLocalStorage && checkbox && checkbox.checked) + localStorage.setItem(LOCAL_STORAGE_KEY, navigator.userAgent); + if (body) body.removeChild(container); + }); + } + } +} diff --git a/www/src/js/bootstrapping/browser.scss b/www/src/js/bootstrapping/browser.scss new file mode 100644 index 0000000000..35615b2617 --- /dev/null +++ b/www/src/js/bootstrapping/browser.scss @@ -0,0 +1,25 @@ +@import '~styles/utils/modules-entry'; + +.browserWarning { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: $browser-warning-z-index; + overflow: hidden; + background: rgba(0, 0, 0, 0.5); + + .modal { + max-width: 48em; + padding: 2em; + margin-right: auto; + margin-left: auto; + background: #fff; + + .checkboxContainer { + padding-top: 0.5em; + padding-bottom: 0.5em; + } + } +} diff --git a/www/src/js/main.js b/www/src/js/main.js index 000321806d..0a441e1542 100644 --- a/www/src/js/main.js +++ b/www/src/js/main.js @@ -2,6 +2,7 @@ import 'bootstrapping/polyfill'; // Import Sentry earliest to capture exceptions import 'bootstrapping/sentry'; +import 'bootstrapping/browser'; import ReactDOM from 'react-dom'; import ReactModal from 'react-modal'; diff --git a/www/src/js/storage/localStorage.js b/www/src/js/storage/localStorage.js index baeafd21de..49a423dc66 100644 --- a/www/src/js/storage/localStorage.js +++ b/www/src/js/storage/localStorage.js @@ -20,13 +20,7 @@ export function createLocalStorageShim() { return storage; } -// Shim localStorage if it doesn't exist -// Returns an object that behaves like localStorage -export default function getLocalStorage() { - // If we've performed all our checks before, just assume results will be the same - // Key assumption: writability of localStorage doesn't change while page is loaded - if (usableLocalStorage) return usableLocalStorage; - +export function canUseBrowserLocalStorage() { try { // Ensure that accessing localStorage doesn't throw // Next line throws on Chrome with cookies disabled @@ -43,14 +37,25 @@ export default function getLocalStorage() { storage.removeItem('____writetest'); } - // Only set storage AFTER we know it can be used - usableLocalStorage = storage; + // Only return true AFTER we know it can be used + return true; } catch (e) { - // Shim if we can't use localStorage - // Once set, don't override - if (!usableLocalStorage) { - usableLocalStorage = createLocalStorageShim(); - } + return false; } +} + +// Returns localStorage if it can be used, if not then it returns a shim +export default function getLocalStorage() { + // If we've performed all our checks before, just assume results will be the same + // Key assumption: writability of localStorage doesn't change while page is loaded + if (usableLocalStorage) return usableLocalStorage; + + // Sets usableLocalStorage on the first execution + if (canUseBrowserLocalStorage()) { + usableLocalStorage = window.localStorage; + } else { + usableLocalStorage = createLocalStorageShim(); + } + return usableLocalStorage; } diff --git a/www/src/js/storage/localStorage.test.js b/www/src/js/storage/localStorage.test.js index f31b363e83..7ed19f22cc 100644 --- a/www/src/js/storage/localStorage.test.js +++ b/www/src/js/storage/localStorage.test.js @@ -1,4 +1,4 @@ -import { createLocalStorageShim } from './localStorage'; +import getLocalStorage, { createLocalStorageShim, canUseBrowserLocalStorage } from './localStorage'; describe('#createLocalStorageShim', () => { test('should store and return data', () => { @@ -42,3 +42,47 @@ describe('#createLocalStorageShim', () => { expect(() => shim.removeItem('key')).not.toThrow(); }); }); + +describe('#canUseBrowserLocalStorage', () => { + test('should return false if localStorage is undefined', () => { + window.localStorage = undefined; + expect(canUseBrowserLocalStorage()).toEqual(false); + }); + + test('should return false if localStorage throws when writing on iOS <=10 on private browsing', () => { + window.localStorage = { + ...createLocalStorageShim(), + // the length is set here because canUseBrowserLocalStorage uses a hack to detect private browsing + length: 0, + setItem: () => { + throw new Error(); + }, + }; + expect(canUseBrowserLocalStorage()).toEqual(false); + }); + + test('should return true if localStorage is localStorage-like object', () => { + window.localStorage = createLocalStorageShim(); + expect(canUseBrowserLocalStorage()).toEqual(true); + }); +}); + +describe('#getLocalStorage', () => { + test("should return the actual browser's localStorage if the browser can use localStorage", () => { + expect(canUseBrowserLocalStorage()).toEqual(true); + expect(getLocalStorage()).toBe(window.localStorage); + }); + + test('should return a shim if browser cannot use localStorage', () => { + window.localStorage = undefined; + expect(canUseBrowserLocalStorage()).toEqual(false); + expect(getLocalStorage()).toMatchObject( + expect.objectContaining({ + clear: expect.any(Function), + setItem: expect.any(Function), + getItem: expect.any(Function), + removeItem: expect.any(Function), + }), + ); + }); +}); diff --git a/www/src/js/test-utils/async.js b/www/src/js/test-utils/async.js index db349a2962..79660e0691 100644 --- a/www/src/js/test-utils/async.js +++ b/www/src/js/test-utils/async.js @@ -9,8 +9,8 @@ export const nextTick = util.promisify(process.nextTick); * components that have async actions, such as making network requests */ export async function waitFor(condition: () => boolean, intervalInMs: number = 5) { + // eslint-disable-next-line no-await-in-loop while (!condition()) { - // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => setTimeout(resolve, intervalInMs)); } } diff --git a/www/src/styles/constants.scss b/www/src/styles/constants.scss index 09f77a9c17..9a1d59e87d 100644 --- a/www/src/styles/constants.scss +++ b/www/src/styles/constants.scss @@ -165,6 +165,7 @@ $nusmods-theme-colors: ( ); // Z-index +$browser-warning-z-index: 9999; $snackbar-z-index: 2500; $sentry-z-index: 2000; $modal-z-index: 1500; diff --git a/www/src/styles/main.scss b/www/src/styles/main.scss index 5c863f094b..9792fc45ab 100644 --- a/www/src/styles/main.scss +++ b/www/src/styles/main.scss @@ -8,8 +8,7 @@ // 3rd-party imports @import 'bootstrap/bootstrap'; @import 'bootstrap/style-override'; - -@import "material/material"; +@import 'material/material'; // Utils @import 'utils/css-variables'; diff --git a/www/yarn.lock b/www/yarn.lock index ca4c699029..70cefc280b 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -1341,6 +1341,10 @@ bootstrap@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.0.0.tgz#ceb03842c145fcc1b9b4e15da2a05656ba68469a" +bowser@^1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.2.tgz#d66fc868ca5f4ba895bee1363c343fe7b37d3394" + brace-expansion@^1.0.0, brace-expansion@^1.1.7: version "1.1.8" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.8.tgz#c07b211c7c952ec1f8efd51a77ef0d1d3990a292" @@ -2940,9 +2944,9 @@ eslint-visitor-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" -eslint@^4.17.0: - version "4.17.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.17.0.tgz#dc24bb51ede48df629be7031c71d9dc0ee4f3ddf" +eslint@^4.18.0: + version "4.18.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.18.0.tgz#ebd0ba795af6dc59aa5cee17938160af5950e051" dependencies: ajv "^5.3.0" babel-code-frame "^6.22.0" @@ -6409,12 +6413,12 @@ postcss-convert-values@^2.3.4: postcss "^5.0.11" postcss-value-parser "^3.1.2" -postcss-custom-properties@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-6.2.0.tgz#5d929a7f06e9b84e0f11334194c0ba9a30acfbe9" +postcss-custom-properties@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-7.0.0.tgz#24dc4fbe6d6ed550ea4fd3b11204660e9ffa3b33" dependencies: balanced-match "^1.0.0" - postcss "^6.0.13" + postcss "^6.0.18" postcss-discard-comments@^2.0.4: version "2.0.4" @@ -6744,6 +6748,14 @@ postcss@^6.0.17: source-map "^0.6.1" supports-color "^5.1.0" +postcss@^6.0.18: + version "6.0.19" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.19.tgz#76a78386f670b9d9494a655bf23ac012effd1555" + dependencies: + chalk "^2.3.1" + source-map "^0.6.1" + supports-color "^5.2.0" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"