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}
+
+
+ `;
+ 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"