diff --git a/website/src/actions/__snapshots__/settings.test.ts.snap b/website/src/actions/__snapshots__/settings.test.ts.snap index d1be0c35bc..d4c8c7b063 100644 --- a/website/src/actions/__snapshots__/settings.test.ts.snap +++ b/website/src/actions/__snapshots__/settings.test.ts.snap @@ -21,16 +21,9 @@ exports[`settings should dispatch a select of a semester value 1`] = ` } `; -exports[`settings should dispatch a selection of a mode 1`] = ` +exports[`settings should dispatch a selection of a color scheme preference 1`] = ` { - "payload": "LIGHT", - "type": "SELECT_MODE", -} -`; - -exports[`settings should dispatch a toggle of a mode 1`] = ` -{ - "payload": null, - "type": "TOGGLE_MODE", + "payload": "LIGHT_COLOR_SCHEME_PREFERENCE", + "type": "SELECT_COLOR_SCHEME", } `; diff --git a/website/src/actions/settings.test.ts b/website/src/actions/settings.test.ts index 6a5191a390..2e5d5ed999 100644 --- a/website/src/actions/settings.test.ts +++ b/website/src/actions/settings.test.ts @@ -1,9 +1,8 @@ import { Faculty, Semester } from 'types/modules'; +import { LIGHT_COLOR_SCHEME_PREFERENCE } from 'types/settings'; import * as actions from 'actions/settings'; -import { LIGHT_MODE } from 'types/settings'; - describe('settings', () => { test('should dispatch a select of a semester value', () => { const semester: Semester = 1; @@ -15,12 +14,9 @@ describe('settings', () => { expect(actions.selectNewStudent(newStudent)).toMatchSnapshot(); }); - test('should dispatch a selection of a mode', () => { - expect(actions.selectMode(LIGHT_MODE)).toMatchSnapshot(); - }); - - test('should dispatch a toggle of a mode', () => { - expect(actions.toggleMode()).toMatchSnapshot(); + test('should dispatch a selection of a color scheme preference', () => { + const colorSchemePreference = LIGHT_COLOR_SCHEME_PREFERENCE; + expect(actions.selectColorScheme(colorSchemePreference)).toMatchSnapshot(); }); test('should dispatch a select of a faculty value', () => { diff --git a/website/src/actions/settings.ts b/website/src/actions/settings.ts index a9ffab3315..271be2905b 100644 --- a/website/src/actions/settings.ts +++ b/website/src/actions/settings.ts @@ -1,5 +1,5 @@ import { Faculty, Semester } from 'types/modules'; -import { Mode } from 'types/settings'; +import { ColorSchemePreference } from 'types/settings'; import { ModuleTableOrder } from 'types/reducers'; import { RegPeriod, ScheduleType } from 'config'; @@ -29,22 +29,14 @@ export function selectFaculty(faculty: Faculty) { }; } -export const SELECT_MODE = 'SELECT_MODE' as const; -export function selectMode(mode: Mode) { +export const SELECT_COLOR_SCHEME = 'SELECT_COLOR_SCHEME' as const; +export function selectColorScheme(mode: ColorSchemePreference) { return { - type: SELECT_MODE, + type: SELECT_COLOR_SCHEME, payload: mode, }; } -export const TOGGLE_MODE = 'TOGGLE_MODE' as const; -export function toggleMode() { - return { - type: TOGGLE_MODE, - payload: null, - }; -} - export const DISMISS_MODREG_NOTIFICATION = 'DISMISS_MODREG_NOTIFICATION' as const; export function dismissModregNotification(round: RegPeriod) { return { diff --git a/website/src/apis/export.ts b/website/src/apis/export.ts index 9abadede65..128fde0abd 100644 --- a/website/src/apis/export.ts +++ b/website/src/apis/export.ts @@ -4,6 +4,7 @@ import { Semester } from 'types/modules'; import { extractStateForExport } from 'utils/export'; import { State } from 'types/state'; import { SemTimetableConfig } from 'types/timetables'; +import { ColorScheme } from 'types/settings'; export type ExportOptions = { pixelRatio?: number; @@ -14,18 +15,29 @@ const baseUrl = 'https://export.nusmods.com/api/export'; function serializeState( semester: Semester, timetable: SemTimetableConfig, + colorScheme: ColorScheme, state: State, options: ExportOptions = {}, ) { return qs.stringify({ - data: JSON.stringify(extractStateForExport(semester, timetable, state)), + data: JSON.stringify(extractStateForExport(semester, timetable, colorScheme, state)), ...options, }); } export default { - image: (semester: Semester, timetable: SemTimetableConfig, state: State, pixelRatio = 1) => - `${baseUrl}/image?${serializeState(semester, timetable, state, { pixelRatio })}`, - pdf: (semester: Semester, timetable: SemTimetableConfig, state: State) => - `${baseUrl}/pdf?${serializeState(semester, timetable, state)}`, + image: ( + semester: Semester, + timetable: SemTimetableConfig, + colorScheme: ColorScheme, + state: State, + pixelRatio = 1, + ) => + `${baseUrl}/image?${serializeState(semester, timetable, colorScheme, state, { pixelRatio })}`, + pdf: ( + semester: Semester, + timetable: SemTimetableConfig, + colorScheme: ColorScheme, + state: State, + ) => `${baseUrl}/pdf?${serializeState(semester, timetable, colorScheme, state)}`, }; diff --git a/website/src/entry/export/main.tsx b/website/src/entry/export/main.tsx index 50780d26ae..02c43d558a 100644 --- a/website/src/entry/export/main.tsx +++ b/website/src/entry/export/main.tsx @@ -7,7 +7,7 @@ import { ExportData } from 'types/export'; import configureStore from 'bootstrapping/configure-store'; import { setExportedData } from 'actions/export'; -import { DARK_MODE } from 'types/settings'; +import { DARK_COLOR_SCHEME } from 'types/settings'; import { State as StoreState } from 'types/state'; import TimetableOnly from './TimetableOnly'; @@ -32,7 +32,7 @@ window.setData = function setData(modules, data, callback) { const { semester, timetable, colors } = data; if (document.body) { - document.body.classList.toggle('mode-dark', data.settings.mode === DARK_MODE); + document.body.classList.toggle('mode-dark', data.settings.colorScheme === DARK_COLOR_SCHEME); } store.dispatch(setExportedData(modules, data)); diff --git a/website/src/reducers/index.test.ts b/website/src/reducers/index.test.ts index 8a197c3845..bf7309a83f 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -3,6 +3,7 @@ import { VERTICAL } from 'types/reducers'; import reducers from 'reducers'; import { setExportedData } from 'actions/export'; import modules from '__mocks__/modules/index'; +import { DARK_COLOR_SCHEME, DARK_COLOR_SCHEME_PREFERENCE } from 'types/settings'; /* eslint-disable no-useless-computed-key */ @@ -34,7 +35,7 @@ const exportData: ExportData = { showTitle: true, }, settings: { - mode: 'DARK', + colorScheme: DARK_COLOR_SCHEME, }, }; @@ -78,7 +79,7 @@ test('reducers should set export data state', () => { }); expect(state.settings).toMatchObject({ - mode: 'DARK', + colorScheme: DARK_COLOR_SCHEME_PREFERENCE, }); expect(state.theme).toEqual({ diff --git a/website/src/reducers/settings.test.ts b/website/src/reducers/settings.test.ts index 1289e7ec23..9bca8cee3d 100644 --- a/website/src/reducers/settings.test.ts +++ b/website/src/reducers/settings.test.ts @@ -3,14 +3,18 @@ import { SettingsState } from 'types/reducers'; import * as actions from 'actions/settings'; import reducer from 'reducers/settings'; -import { DARK_MODE, LIGHT_MODE } from 'types/settings'; +import { + DARK_COLOR_SCHEME_PREFERENCE, + LIGHT_COLOR_SCHEME_PREFERENCE, + SYSTEM_COLOR_SCHEME_PREFERENCE, +} from 'types/settings'; import { initAction, rehydrateAction } from 'test-utils/redux'; import config, { RegPeriod } from 'config'; const initialState: SettingsState = { newStudent: false, faculty: '', - mode: LIGHT_MODE, + colorScheme: SYSTEM_COLOR_SCHEME_PREFERENCE, hiddenInTimetable: [], modRegNotification: { enabled: true, @@ -25,7 +29,14 @@ const initialState: SettingsState = { const settingsWithNewStudent: SettingsState = { ...initialState, newStudent: true }; const faculty = 'School of Computing'; const settingsWithFaculty: SettingsState = { ...initialState, faculty }; -const settingsWithDarkMode: SettingsState = { ...initialState, mode: DARK_MODE }; +const settingsWithLightMode: SettingsState = { + ...initialState, + colorScheme: LIGHT_COLOR_SCHEME_PREFERENCE, +}; +const settingsWithDarkMode: SettingsState = { + ...initialState, + colorScheme: DARK_COLOR_SCHEME_PREFERENCE, +}; const settingsWithDismissedNotifications: SettingsState = produce(initialState, (draft) => { draft.modRegNotification.dismissed = [ { type: 'Select Courses', name: '1' }, @@ -52,23 +63,18 @@ describe('settings', () => { expect(nextState).toEqual(settingsWithFaculty); }); - test('can select mode', () => { - const action = actions.selectMode(DARK_MODE); + test('can select color scheme', () => { + const action = actions.selectColorScheme(DARK_COLOR_SCHEME_PREFERENCE); const nextState: SettingsState = reducer(initialState, action); expect(nextState).toEqual(settingsWithDarkMode); - const action2 = actions.selectMode(LIGHT_MODE); - const nextState2: SettingsState = reducer(nextState, action2); - expect(nextState2).toEqual(initialState); - }); - - test('can toggle mode', () => { - const action = actions.toggleMode(); - const nextState: SettingsState = reducer(initialState, action); - expect(nextState).toEqual(settingsWithDarkMode); + const action2 = actions.selectColorScheme(LIGHT_COLOR_SCHEME_PREFERENCE); + const nextState2: SettingsState = reducer(initialState, action2); + expect(nextState2).toEqual(settingsWithLightMode); - const nextState2: SettingsState = reducer(nextState, action); - expect(nextState2).toEqual(initialState); + const action3 = actions.selectColorScheme(SYSTEM_COLOR_SCHEME_PREFERENCE); + const nextState3: SettingsState = reducer(nextState, action3); + expect(nextState3).toEqual(initialState); }); test('set module table order', () => { diff --git a/website/src/reducers/settings.ts b/website/src/reducers/settings.ts index 79dccacdbd..41dabe1cc5 100644 --- a/website/src/reducers/settings.ts +++ b/website/src/reducers/settings.ts @@ -9,20 +9,20 @@ import { DISMISS_MODREG_NOTIFICATION, ENABLE_MODREG_NOTIFICATION, SELECT_FACULTY, - SELECT_MODE, + SELECT_COLOR_SCHEME, SELECT_NEW_STUDENT, SET_LOAD_DISQUS_MANUALLY, SET_MODULE_TABLE_SORT, TOGGLE_BETA_TESTING_STATUS, TOGGLE_MODREG_NOTIFICATION_GLOBALLY, - TOGGLE_MODE, SET_MODREG_SCHEDULE_TYPE, } from 'actions/settings'; import { SET_EXPORTED_DATA } from 'actions/constants'; import { DIMENSIONS, withTracker } from 'bootstrapping/matomo'; -import { DARK_MODE, LIGHT_MODE } from 'types/settings'; +import { SYSTEM_COLOR_SCHEME_PREFERENCE } from 'types/settings'; import config from 'config'; import { isRoundDismissed } from 'selectors/modreg'; +import { colorSchemeToPreference } from 'utils/colorScheme'; export const defaultModRegNotificationState = { semesterKey: config.getSemesterKey(), @@ -34,7 +34,7 @@ export const defaultModRegNotificationState = { const defaultSettingsState: SettingsState = { newStudent: false, faculty: '', - mode: LIGHT_MODE, + colorScheme: SYSTEM_COLOR_SCHEME_PREFERENCE, hiddenInTimetable: [], modRegNotification: defaultModRegNotificationState, moduleTableOrder: 'exam', @@ -54,17 +54,11 @@ function settings(state: SettingsState = defaultSettingsState, action: Actions): ...state, faculty: action.payload, }; - case SELECT_MODE: + case SELECT_COLOR_SCHEME: return { ...state, - mode: action.payload, + colorScheme: action.payload, }; - case TOGGLE_MODE: - return { - ...state, - mode: state.mode === LIGHT_MODE ? DARK_MODE : LIGHT_MODE, - }; - case TOGGLE_MODREG_NOTIFICATION_GLOBALLY: return produce(state, (draft) => { draft.modRegNotification.enabled = action.payload.enabled; @@ -89,11 +83,14 @@ function settings(state: SettingsState = defaultSettingsState, action: Actions): draft.modRegNotification.scheduleType = action.payload; }); - case SET_EXPORTED_DATA: + case SET_EXPORTED_DATA: { + const { colorScheme, ...otherSettings } = action.payload.settings; return { ...state, - ...action.payload.settings, + ...otherSettings, + colorScheme: colorSchemeToPreference(action.payload.settings.colorScheme), }; + } case SET_MODULE_TABLE_SORT: return { diff --git a/website/src/types/export.ts b/website/src/types/export.ts index 2710271e01..ad16e02eed 100644 --- a/website/src/types/export.ts +++ b/website/src/types/export.ts @@ -1,6 +1,6 @@ import { SemTimetableConfig } from 'types/timetables'; import { Semester, ModuleCode } from 'types/modules'; -import { Mode } from 'types/settings'; +import type { ColorScheme } from 'types/settings'; import { ColorMapping, ThemeState } from 'types/reducers'; export type ExportData = { @@ -10,6 +10,6 @@ export type ExportData = { readonly hidden: ModuleCode[]; readonly theme: ThemeState; readonly settings: { - mode: Mode; + colorScheme: ColorScheme; }; }; diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index cd37df9e8f..5418bb7d6f 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -1,7 +1,7 @@ import { AxiosError } from 'axios'; import { RegPeriodType, ScheduleType } from 'config'; -import { Mode } from './settings'; +import { ColorSchemePreference } from './settings'; import { ColorIndex, Lesson, TimetableConfig } from './timetables'; import { Faculty, @@ -98,7 +98,7 @@ export type ModuleTableOrder = 'exam' | 'mc' | 'code'; export type SettingsState = { readonly newStudent: boolean; readonly faculty: Faculty | null; - readonly mode: Mode; + readonly colorScheme: ColorSchemePreference; readonly hiddenInTimetable: ModuleCode[]; readonly modRegNotification: ModRegNotificationSettings; readonly moduleTableOrder: ModuleTableOrder; diff --git a/website/src/types/settings.ts b/website/src/types/settings.ts index 3de27e2448..c3a0679c10 100644 --- a/website/src/types/settings.ts +++ b/website/src/types/settings.ts @@ -1,10 +1,29 @@ export type ThemeId = string; +/** + * Themes are the "color palette" of the website. They define the set of colors + * being used across the website. + */ export type Theme = { readonly id: ThemeId; readonly name: string; }; -export type Mode = 'LIGHT' | 'DARK'; -export const LIGHT_MODE: Mode = 'LIGHT'; -export const DARK_MODE: Mode = 'DARK'; +/** + * Color schemes simply define whether the website is in light or dark mode. This + * is not to be confused with themese, which define the color palette of the website. + */ +export type ColorScheme = 'LIGHT_COLOR_SCHEME' | 'DARK_COLOR_SCHEME'; + +export const LIGHT_COLOR_SCHEME: ColorScheme = 'LIGHT_COLOR_SCHEME'; +export const DARK_COLOR_SCHEME: ColorScheme = 'DARK_COLOR_SCHEME'; + +export type ColorSchemePreference = + | 'SYSTEM_COLOR_SCHEME_PREFERENCE' + | 'LIGHT_COLOR_SCHEME_PREFERENCE' + | 'DARK_COLOR_SCHEME_PREFERENCE'; + +export const SYSTEM_COLOR_SCHEME_PREFERENCE: ColorSchemePreference = + 'SYSTEM_COLOR_SCHEME_PREFERENCE'; +export const LIGHT_COLOR_SCHEME_PREFERENCE: ColorSchemePreference = 'LIGHT_COLOR_SCHEME_PREFERENCE'; +export const DARK_COLOR_SCHEME_PREFERENCE: ColorSchemePreference = 'DARK_COLOR_SCHEME_PREFERENCE'; diff --git a/website/src/utils/colorScheme.ts b/website/src/utils/colorScheme.ts new file mode 100644 index 0000000000..469d5b6611 --- /dev/null +++ b/website/src/utils/colorScheme.ts @@ -0,0 +1,27 @@ +import { + ColorScheme, + DARK_COLOR_SCHEME, + DARK_COLOR_SCHEME_PREFERENCE, + LIGHT_COLOR_SCHEME, + LIGHT_COLOR_SCHEME_PREFERENCE, +} from 'types/settings'; + +export function colorSchemeToPreference(colorScheme: ColorScheme) { + switch (colorScheme) { + case LIGHT_COLOR_SCHEME: + return LIGHT_COLOR_SCHEME_PREFERENCE; + case DARK_COLOR_SCHEME: + default: + return DARK_COLOR_SCHEME_PREFERENCE; + } +} + +export function invertColorScheme(colorScheme: ColorScheme) { + switch (colorScheme) { + case LIGHT_COLOR_SCHEME: + return DARK_COLOR_SCHEME; + case DARK_COLOR_SCHEME: + default: + return LIGHT_COLOR_SCHEME; + } +} diff --git a/website/src/utils/css.ts b/website/src/utils/css.ts index 209cb808e3..cc4ff72dc8 100644 --- a/website/src/utils/css.ts +++ b/website/src/utils/css.ts @@ -28,6 +28,10 @@ export function breakpointUp(size: Breakpoint) { return { minWidth: breakpoints[size] } satisfies QueryObject; } +export function prefersColorScheme(colorScheme: 'light' | 'dark') { + return { prefersColorScheme: colorScheme } satisfies QueryObject; +} + export function touchScreenOnly() { return { pointer: 'coarse' } satisfies QueryObject; } diff --git a/website/src/utils/export.ts b/website/src/utils/export.ts index 7b0e97d047..1d65ec3feb 100644 --- a/website/src/utils/export.ts +++ b/website/src/utils/export.ts @@ -3,10 +3,12 @@ import { ExportData } from 'types/export'; import { getSemesterTimetableColors } from 'selectors/timetables'; import { State } from 'types/state'; import { SemTimetableConfig } from 'types/timetables'; +import { ColorScheme } from 'types/settings'; export function extractStateForExport( semester: Semester, timetable: SemTimetableConfig, + colorScheme: ColorScheme, state: State, ): ExportData { const colors = getSemesterTimetableColors(state)(semester); @@ -19,7 +21,7 @@ export function extractStateForExport( hidden, theme: state.theme, settings: { - mode: state.settings.mode, + colorScheme, }, }; } diff --git a/website/src/views/AppShell.tsx b/website/src/views/AppShell.tsx index 6b032ef0fc..6cb57ade07 100644 --- a/website/src/views/AppShell.tsx +++ b/website/src/views/AppShell.tsx @@ -1,13 +1,13 @@ import { FC, useCallback, useEffect, useState } from 'react'; +import type { SemTimetableConfig } from 'types/timetables'; +import type { Semester } from 'types/modules'; +import { DARK_COLOR_SCHEME } from 'types/settings'; import { Helmet } from 'react-helmet'; import { NavLink, useHistory } from 'react-router-dom'; import { useDispatch, useSelector, useStore } from 'react-redux'; import classnames from 'classnames'; import { each } from 'lodash'; -import { DARK_MODE } from 'types/settings'; -import type { Semester } from 'types/modules'; -import type { SemTimetableConfig } from 'types/timetables'; import weekText from 'utils/weekText'; import { captureException } from 'utils/error'; @@ -35,6 +35,7 @@ import FeedbackModal from './components/FeedbackModal'; import KeyboardShortcuts from './components/KeyboardShortcuts'; import styles from './AppShell.scss'; +import useColorScheme from './hooks/useColorScheme'; /** * Fetch module list on mount. @@ -113,8 +114,8 @@ const AppShell: FC = ({ children }) => { const moduleList = useSelector((state: State) => state.moduleBank.moduleList); const isModuleListReady = moduleList.length; - const mode = useSelector((state: State) => state.settings.mode); - const isDarkMode = mode === DARK_MODE; + const colorScheme = useColorScheme(); + const isDarkMode = colorScheme === DARK_COLOR_SCHEME; const theme = useSelector((state: State) => state.theme.id); diff --git a/website/src/views/components/KeyboardShortcuts.tsx b/website/src/views/components/KeyboardShortcuts.tsx index abb9303b3f..3ce7ae91f6 100644 --- a/website/src/views/components/KeyboardShortcuts.tsx +++ b/website/src/views/components/KeyboardShortcuts.tsx @@ -4,14 +4,16 @@ import { useDispatch, useStore } from 'react-redux'; import Mousetrap from 'mousetrap'; import { groupBy, map } from 'lodash'; -import { DARK_MODE } from 'types/settings'; +import { DARK_COLOR_SCHEME_PREFERENCE } from 'types/settings'; import themes from 'data/themes.json'; import { cycleTheme, toggleTimetableOrientation } from 'actions/theme'; import { openNotification } from 'actions/app'; -import { toggleMode } from 'actions/settings'; +import { selectColorScheme } from 'actions/settings'; import { intersperse } from 'utils/array'; import ComponentMap from 'utils/ComponentMap'; import type { State } from 'types/state'; +import useColorScheme from 'views/hooks/useColorScheme'; +import { colorSchemeToPreference, invertColorScheme } from 'utils/colorScheme'; import Modal from './Modal'; import styles from './KeyboardShortcuts.scss'; @@ -31,6 +33,7 @@ const THEME_NOTIFICATION_TIMEOUT = 1000; const KeyboardShortcuts: React.FC = () => { const [helpShown, setHelpShown] = useState(false); + const colorScheme = useColorScheme(); const closeModal = useCallback(() => setHelpShown(false), []); const store = useStore(); @@ -99,16 +102,15 @@ const KeyboardShortcuts: React.FC = () => { // Toggle night mode bind('x', APPEARANCE, 'Toggle Night Mode', () => { - dispatch(toggleMode()); - - // We fetch the current mode from the redux store directly, instead of - // using useSelector, as useSelector will capture the old stale value - const { mode } = store.getState().settings; - + const newColorScheme = colorSchemeToPreference(invertColorScheme(colorScheme)); + dispatch(selectColorScheme(newColorScheme)); dispatch( - openNotification(`Night mode ${mode === DARK_MODE ? 'on' : 'off'}`, { - overwritable: true, - }), + openNotification( + `Night mode ${newColorScheme === DARK_COLOR_SCHEME_PREFERENCE ? 'on' : 'off'}`, + { + overwritable: true, + }, + ), ); }); @@ -148,7 +150,7 @@ const KeyboardShortcuts: React.FC = () => { shortcuts.current.forEach(({ key }) => Mousetrap.unbind(key)); shortcuts.current = []; }; - }, [dispatch, helpShown, history, store]); + }, [dispatch, helpShown, colorScheme, history, store]); function renderShortcut(shortcut: Shortcut): React.ReactNode { if (typeof shortcut === 'string') { diff --git a/website/src/views/components/disqus/DisqusComments.tsx b/website/src/views/components/disqus/DisqusComments.tsx index 1a592a456c..d7bb9c0219 100644 --- a/website/src/views/components/disqus/DisqusComments.tsx +++ b/website/src/views/components/disqus/DisqusComments.tsx @@ -3,7 +3,7 @@ import classnames from 'classnames'; import { connect } from 'react-redux'; import { MessageSquare } from 'react-feather'; -import { Mode } from 'types/settings'; +import { ColorSchemePreference } from 'types/settings'; import config from 'config'; import { DisqusConfig } from 'types/views'; import insertScript from 'utils/insertScript'; @@ -15,7 +15,7 @@ type Props = DisqusConfig & { // Disqus autodetects page background color so that its own font color has // enough contrast to be read, but only when the widget is loaded, so we use // this to reload the widget after night mode is activated or deactivated - mode: Mode; + colorScheme: ColorSchemePreference; loadDisqusManually: boolean; }; @@ -39,7 +39,7 @@ class DisqusComments extends PureComponent { // Wait a bit for the page colors to change before reloading instance // 2 second delay is found empirically, and is longer than necessary to // account for lag in slower user agents - if (prevProps.mode !== this.props.mode) { + if (prevProps.colorScheme !== this.props.colorScheme) { setTimeout(this.loadInstance, 2000); } else { this.loadInstance(); @@ -105,5 +105,5 @@ class DisqusComments extends PureComponent { export default connect((state: StoreState) => ({ loadDisqusManually: state.settings.loadDisqusManually, - mode: state.settings.mode, + colorScheme: state.settings.colorScheme, }))(DisqusComments); diff --git a/website/src/views/hooks/useColorScheme.tsx b/website/src/views/hooks/useColorScheme.tsx new file mode 100644 index 0000000000..6e6b490068 --- /dev/null +++ b/website/src/views/hooks/useColorScheme.tsx @@ -0,0 +1,29 @@ +import { + DARK_COLOR_SCHEME, + DARK_COLOR_SCHEME_PREFERENCE, + LIGHT_COLOR_SCHEME, + LIGHT_COLOR_SCHEME_PREFERENCE, + SYSTEM_COLOR_SCHEME_PREFERENCE, +} from 'types/settings'; +import type { ColorScheme } from 'types/settings'; +import { prefersColorScheme } from 'utils/css'; +import { useSelector } from 'react-redux'; +import type { State } from 'types/state'; +import useMediaQuery from './useMediaQuery'; + +/** + * @returns Whether the user's (operating) system prefers dark mode. + */ +export default function useColorScheme(): ColorScheme { + const colorSchemePreference = useSelector((state: State) => state.settings.colorScheme); + const systemPrefersDarkColorScheme = useMediaQuery(prefersColorScheme('dark')); + switch (colorSchemePreference) { + case SYSTEM_COLOR_SCHEME_PREFERENCE: + return systemPrefersDarkColorScheme ? DARK_COLOR_SCHEME : LIGHT_COLOR_SCHEME; + case DARK_COLOR_SCHEME_PREFERENCE: + return DARK_COLOR_SCHEME; + case LIGHT_COLOR_SCHEME_PREFERENCE: + default: + return LIGHT_COLOR_SCHEME; + } +} diff --git a/website/src/views/settings/ModeSelect.tsx b/website/src/views/settings/ModeSelect.tsx index 301ce04c52..69f99558ee 100644 --- a/website/src/views/settings/ModeSelect.tsx +++ b/website/src/views/settings/ModeSelect.tsx @@ -1,36 +1,45 @@ import * as React from 'react'; import classnames from 'classnames'; -import { Mode, LIGHT_MODE, DARK_MODE } from 'types/settings'; +import { + ColorSchemePreference, + SYSTEM_COLOR_SCHEME_PREFERENCE, + LIGHT_COLOR_SCHEME_PREFERENCE, + DARK_COLOR_SCHEME_PREFERENCE, +} from 'types/settings'; type Props = { - mode: Mode; - onSelectMode: (mode: Mode) => void; + colorScheme: ColorSchemePreference; + onSelectColorScheme: (mode: ColorSchemePreference) => void; }; -type ModeOption = { value: Mode; label: string }; +type ModeOption = { value: ColorSchemePreference; label: string }; const MODES: ModeOption[] = [ + { + label: 'Auto', + value: SYSTEM_COLOR_SCHEME_PREFERENCE, + }, { label: 'On', - value: DARK_MODE, + value: DARK_COLOR_SCHEME_PREFERENCE, }, { label: 'Off', - value: LIGHT_MODE, + value: LIGHT_COLOR_SCHEME_PREFERENCE, }, ]; -const ModeSelect: React.FC = ({ mode, onSelectMode }) => ( +const ModeSelect: React.FC = ({ colorScheme, onSelectColorScheme }) => (
{MODES.map(({ value, label }) => ( diff --git a/website/src/views/settings/SettingsContainer.tsx b/website/src/views/settings/SettingsContainer.tsx index d2f067cf49..adb3be4d22 100644 --- a/website/src/views/settings/SettingsContainer.tsx +++ b/website/src/views/settings/SettingsContainer.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import { isEqual } from 'lodash'; -import { Mode, ThemeId } from 'types/settings'; +import { ColorSchemePreference, ThemeId } from 'types/settings'; import { Tracker } from 'types/vendor/piwik'; import { ModRegNotificationSettings } from 'types/reducers'; import { State as StoreState } from 'types/state'; @@ -15,7 +15,7 @@ import { dismissModregNotification, enableModRegNotification, selectFaculty, - selectMode, + selectColorScheme, setLoadDisqusManually, setModRegScheduleType, toggleBetaTesting, @@ -42,13 +42,13 @@ import styles from './SettingsContainer.scss'; type Props = { currentThemeId: string; - mode: Mode; + colorScheme: ColorSchemePreference; betaTester: boolean; loadDisqusManually: boolean; modRegNotification: ModRegNotificationSettings; selectTheme: (theme: ThemeId) => void; - selectMode: (mode: Mode) => void; + selectColorScheme: (colorScheme: ColorSchemePreference) => void; toggleBetaTesting: () => void; setLoadDisqusManually: (status: boolean) => void; @@ -61,7 +61,7 @@ type Props = { const SettingsContainer: React.FC = ({ currentThemeId, - mode, + colorScheme, betaTester, loadDisqusManually, modRegNotification, @@ -114,7 +114,7 @@ const SettingsContainer: React.FC = ({

- +

@@ -294,7 +294,7 @@ const SettingsContainer: React.FC = ({ }; const mapStateToProps = (state: StoreState) => ({ - mode: state.settings.mode, + colorScheme: state.settings.colorScheme, currentThemeId: state.theme.id, betaTester: state.settings.beta || false, loadDisqusManually: state.settings.loadDisqusManually, @@ -304,7 +304,7 @@ const mapStateToProps = (state: StoreState) => ({ const connectedSettings = connect(mapStateToProps, { selectTheme, selectFaculty, - selectMode, + selectColorScheme, toggleBetaTesting, setLoadDisqusManually, toggleModRegNotificationGlobally, diff --git a/website/src/views/timetable/ExportMenu.tsx b/website/src/views/timetable/ExportMenu.tsx index 2797e2dd0c..91bc63e23d 100644 --- a/website/src/views/timetable/ExportMenu.tsx +++ b/website/src/views/timetable/ExportMenu.tsx @@ -1,6 +1,6 @@ -import { PureComponent } from 'react'; +import { useCallback, useState } from 'react'; import Downshift, { ChildrenFunction } from 'downshift'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import classnames from 'classnames'; import { Link } from 'react-router-dom'; import { AlertTriangle, Calendar, ChevronDown, Download, FileText, Image } from 'react-feather'; @@ -14,8 +14,9 @@ import Online from 'views/components/Online'; import Modal from 'views/components/Modal'; import ComponentMap from 'utils/ComponentMap'; import { Counter } from 'utils/react'; -import { State as StoreState } from 'types/state'; +import { State } from 'types/state'; +import useColorScheme from 'views/hooks/useColorScheme'; import styles from './ExportMenu.scss'; type ExportAction = 'CALENDAR' | 'IMAGE' | 'PDF'; @@ -24,129 +25,126 @@ const IMAGE: ExportAction = 'IMAGE'; const PDF: ExportAction = 'PDF'; type Props = { - state: StoreState; semester: Semester; timetable: SemTimetableConfig; - downloadAsIcal: (semester: Semester) => void; }; -type State = { - isMacWarningOpen: boolean; -}; +const ExportMenuComponent: React.FC = ({ semester, timetable }) => { + const [isMacWarningOpen, setIsMacWarningOpen] = useState(false); -export class ExportMenuComponent extends PureComponent { - override state: State = { - isMacWarningOpen: false, - }; - - onSelect = (item: ExportAction | null) => { - if (item === CALENDAR) { - this.props.downloadAsIcal(this.props.semester); - - // macOS calendar client has a ridiculous bug where it would sometimes disregard - // EXDATE statements which causes events to show up during holidays, recess week, etc. - if (navigator.platform === 'MacIntel') { - this.setState({ - isMacWarningOpen: true, - }); - } - } - }; - - closeMacOSWarningModal = () => this.setState({ isMacWarningOpen: false }); - - renderDropdown: ChildrenFunction = ({ - isOpen, - getItemProps, - getMenuProps, - toggleMenu, - highlightedIndex, - }) => { - const { semester, timetable, state } = this.props; - const counter = new Counter(); - - return ( -
- - -
- - - Image (.png) - - - - PDF (.pdf) - - - - {SUPPORTS_DOWNLOAD && ( - - )} -
+ const dispatch = useDispatch(); + const state = useSelector((storeState: State) => storeState); - -
- -

The calendar you have just downloaded may not work with the macOS Calendar app.

-
-
- - Find out more - - + const colorScheme = useColorScheme(); + + const onSelect = useCallback( + (item: ExportAction | null) => { + if (item === CALENDAR) { + dispatch(downloadAsIcal(semester)); + + // macOS calendar client has a ridiculous bug where it would sometimes disregard + // EXDATE statements which causes events to show up during holidays, recess week, etc. + if (navigator.platform === 'MacIntel') { + setIsMacWarningOpen(true); + } + } + }, + [semester, dispatch], + ); + + const closeMacOSWarningModal = useCallback( + () => setIsMacWarningOpen(false), + [setIsMacWarningOpen], + ); + + const renderDropdown: ChildrenFunction = useCallback( + ({ isOpen, getItemProps, getMenuProps, toggleMenu, highlightedIndex }) => { + const counter = new Counter(); + + return ( +
+ + +
+ + + Image (.png) + + + + PDF (.pdf) + + + + {SUPPORTS_DOWNLOAD && ( + + )}
- -
- ); - }; - override render() { - return {this.renderDropdown}; - } -} + +
+ +

The calendar you have just downloaded may not work with the macOS Calendar app.

+
+
+ + Find out more + + +
+
+
+ ); + }, + [isMacWarningOpen, closeMacOSWarningModal, semester, timetable, colorScheme, state], + ); + + return {renderDropdown}; +}; -export default connect((state: StoreState) => ({ state }), { downloadAsIcal })(ExportMenuComponent); +export default ExportMenuComponent;