diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index c6efb56a9d..661a0c7077 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -59,7 +59,7 @@ import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; import DMRoomMap from '../../utils/DMRoomMap'; import { countRoomsWithNotif } from '../../RoomNotifs'; -import { setTheme } from "../../theme"; +import { ThemeWatcher } from "../../theme"; import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { defer } from "../../utils/promise"; @@ -274,7 +274,8 @@ export default createReactClass({ componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); - this._themeWatchRef = SettingsStore.watchSetting("theme", null, this._onThemeChanged); + this._themeWatcher = new ThemeWatcher(); + this._themeWatcher.start(); this.focusComposer = false; @@ -361,7 +362,7 @@ export default createReactClass({ componentWillUnmount: function() { Lifecycle.stopMatrixClient(); dis.unregister(this.dispatcherRef); - SettingsStore.unwatchSetting(this._themeWatchRef); + this._themeWatcher.stop(); window.removeEventListener("focus", this.onFocus); window.removeEventListener('resize', this.handleResize); this.state.resizeNotifier.removeListener("middlePanelResized", this._dispatchTimelineResize); @@ -384,13 +385,6 @@ export default createReactClass({ } }, - _onThemeChanged: function(settingName, roomId, atLevel, newValue) { - dis.dispatch({ - action: 'set_theme', - value: newValue, - }); - }, - startPageChangeTimer() { // Tor doesn't support performance if (!performance || !performance.mark) return null; @@ -672,9 +666,6 @@ export default createReactClass({ }); break; } - case 'set_theme': - setTheme(payload.value); - break; case 'on_logging_in': // We are now logging in, so set the state to reflect that // NB. This does not touch 'ready' since if our dispatches diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index ffd9d73cca..db5978c792 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -34,7 +34,7 @@ import dis from '../../../dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; -import SettingsStore from "../../../settings/SettingsStore"; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; @@ -69,8 +69,11 @@ export default class AppTile extends React.Component { * @return {Object} Updated component state to be set with setState */ _getNewState(newProps) { - const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_'); - const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); + // This is a function to make the impact of calling SettingsStore slightly less + const hasPermissionToLoad = () => { + const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId); + return !!currentlyAllowedWidgets[newProps.eventId]; + }; const PersistedElement = sdk.getComponent("elements.PersistedElement"); return { @@ -78,10 +81,9 @@ export default class AppTile extends React.Component { // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), widgetUrl: this._addWurlParams(newProps.url), - widgetPermissionId: widgetPermissionId, // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user - hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId, + hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), error: null, deleting: false, widgetPageTitle: newProps.widgetPageTitle, @@ -446,24 +448,38 @@ export default class AppTile extends React.Component { }); } - /* TODO -- Store permission in account data so that it is persisted across multiple devices */ _grantWidgetPermission() { - console.warn('Granting permission to load widget - ', this.state.widgetUrl); - localStorage.setItem(this.state.widgetPermissionId, true); - this.setState({hasPermissionToLoad: true}); - // Now that we have permission, fetch the IM token - this.setScalarToken(); + const roomId = this.props.room.roomId; + console.info("Granting permission for widget to load: " + this.props.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[this.props.eventId] = true; + SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { + this.setState({hasPermissionToLoad: true}); + + // Fetch a token for the integration manager, now that we're allowed to + this.setScalarToken(); + }).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); } _revokeWidgetPermission() { - console.warn('Revoking permission to load widget - ', this.state.widgetUrl); - localStorage.removeItem(this.state.widgetPermissionId); - this.setState({hasPermissionToLoad: false}); + const roomId = this.props.room.roomId; + console.info("Revoking permission for widget to load: " + this.props.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[this.props.eventId] = false; + SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { + this.setState({hasPermissionToLoad: false}); - // Force the widget to be non-persistent - ActiveWidgetStore.destroyPersistentWidget(this.props.id); - const PersistedElement = sdk.getComponent("elements.PersistedElement"); - PersistedElement.destroyElement(this._persistKey); + // Force the widget to be non-persistent (able to be deleted/forgotten) + ActiveWidgetStore.destroyPersistentWidget(this.props.id); + const PersistedElement = sdk.getComponent("elements.PersistedElement"); + PersistedElement.destroyElement(this._persistKey); + }).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); } formatAppTileName() { @@ -720,6 +736,7 @@ AppTile.displayName ='AppTile'; AppTile.propTypes = { id: PropTypes.string.isRequired, + eventId: PropTypes.string, // required for room widgets url: PropTypes.string.isRequired, name: PropTypes.string.isRequired, room: PropTypes.object.isRequired, diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js index 391e7728f6..19e4be6083 100644 --- a/src/components/views/elements/PersistentApp.js +++ b/src/components/views/elements/PersistentApp.js @@ -67,13 +67,15 @@ module.exports = createReactClass({ return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId(); }); const app = WidgetUtils.makeAppConfig( - appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), persistentWidgetInRoomId, + appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(), + persistentWidgetInRoomId, appEvent.getId(), ); const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId); const AppTile = sdk.getComponent('elements.AppTile'); return { - return WidgetUtils.makeAppConfig(ev.getStateKey(), ev.getContent(), ev.getSender()); + return WidgetUtils.makeAppConfig( + ev.getStateKey(), ev.getContent(), ev.getSender(), ev.getRoomId(), ev.getId(), + ); }); }, @@ -159,6 +161,7 @@ module.exports = createReactClass({ return ( { - dis.dispatch({action: 'set_theme', value: oldTheme}); + dis.dispatch({action: 'recheck_theme'}); this.setState({theme: oldTheme}); }); this.setState({theme: newTheme}); // The settings watcher doesn't fire until the echo comes back from the // server, so to make the theme change immediately we need to manually // do the dispatch now - dis.dispatch({action: 'set_theme', value: newTheme}); + // XXX: The local echoed value appears to be unreliable, in particular + // when settings custom themes(!) so adding forceTheme to override + // the value from settings. + dis.dispatch({action: 'recheck_theme', forceTheme: newTheme}); }; + _onUseSystemThemeChanged = (checked) => { + this.setState({useSystemTheme: checked}); + dis.dispatch({action: 'recheck_theme'}); + } + + _onPasswordChangeError = (err) => { // TODO: Figure out a design that doesn't involve replacing the current dialog let errMsg = err.error || ""; @@ -297,11 +307,24 @@ export default class GeneralUserSettingsTab extends React.Component { _renderThemeSection() { const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); + + const themeWatcher = new ThemeWatcher(); + let systemThemeSection; + if (themeWatcher.isSystemThemeSupported()) { + systemThemeSection =
+ +
; + } return (
{_t("Theme")} + {systemThemeSection} + value={this.state.theme} onChange={this._onThemeChange} + disabled={this.state.useSystemTheme} + > {Object.entries(enumerateThemes()).map(([theme, text]) => { return ; })} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a4781fc52e..7709a4a398 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -364,6 +364,7 @@ "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Mirror local video feed": "Mirror local video feed", "Enable Community Filter Panel": "Enable Community Filter Panel", + "Match system dark mode setting": "Match system dark mode setting", "Allow Peer-to-Peer for 1:1 calls": "Allow Peer-to-Peer for 1:1 calls", "Send analytics data": "Send analytics data", "Never send encrypted messages to unverified devices from this device": "Never send encrypted messages to unverified devices from this device", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 92f7da7dae..89693f7c50 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -281,6 +281,11 @@ export const SETTINGS = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: [], }, + "use_system_theme": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: true, + displayName: _td("Match system dark mode setting"), + }, "webRtcAllowPeerToPeer": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, displayName: _td('Allow Peer-to-Peer for 1:1 calls'), diff --git a/src/theme.js b/src/theme.js index 8a15c606d7..fa7e3f783b 100644 --- a/src/theme.js +++ b/src/theme.js @@ -19,8 +19,75 @@ import {_t} from "./languageHandler"; export const DEFAULT_THEME = "light"; import Tinter from "./Tinter"; +import dis from "./dispatcher"; import SettingsStore from "./settings/SettingsStore"; +export class ThemeWatcher { + static _instance = null; + + constructor() { + this._themeWatchRef = null; + this._systemThemeWatchRef = null; + this._dispatcherRef = null; + + // we have both here as each may either match or not match, so by having both + // we can get the tristate of dark/light/unsupported + this._preferDark = global.matchMedia("(prefers-color-scheme: dark)"); + this._preferLight = global.matchMedia("(prefers-color-scheme: light)"); + + this._currentTheme = this.getEffectiveTheme(); + } + + start() { + this._themeWatchRef = SettingsStore.watchSetting("theme", null, this._onChange); + this._systemThemeWatchRef = SettingsStore.watchSetting("use_system_theme", null, this._onChange); + this._preferDark.addEventListener('change', this._onChange); + this._preferLight.addEventListener('change', this._onChange); + this._dispatcherRef = dis.register(this._onAction); + } + + stop() { + this._preferDark.removeEventListener('change', this._onChange); + this._preferLight.removeEventListener('change', this._onChange); + SettingsStore.unwatchSetting(this._systemThemeWatchRef); + SettingsStore.unwatchSetting(this._themeWatchRef); + dis.unregister(this._dispatcherRef); + } + + _onChange = () => { + this.recheck(); + } + + _onAction = (payload) => { + if (payload.action === 'recheck_theme') { + // XXX forceTheme + this.recheck(payload.forceTheme); + } + } + + // XXX: forceTheme param aded here as local echo appears to be unreliable + // https://github.com/vector-im/riot-web/issues/11443 + recheck(forceTheme) { + const oldTheme = this._currentTheme; + this._currentTheme = forceTheme === undefined ? this.getEffectiveTheme() : forceTheme; + if (oldTheme !== this._currentTheme) { + setTheme(this._currentTheme); + } + } + + getEffectiveTheme() { + if (SettingsStore.getValue('use_system_theme')) { + if (this._preferDark.matches) return 'dark'; + if (this._preferLight.matches) return 'light'; + } + return SettingsStore.getValue('theme'); + } + + isSystemThemeSupported() { + return this._preferDark.matches || this._preferLight.matches; + } +} + export function enumerateThemes() { const BUILTIN_THEMES = { "light": _t("Light theme"), @@ -83,7 +150,8 @@ export function getBaseTheme(theme) { */ export function setTheme(theme) { if (!theme) { - theme = SettingsStore.getValue("theme"); + const themeWatcher = new ThemeWatcher(); + theme = themeWatcher.getEffectiveTheme(); } let stylesheetName = theme; if (theme.startsWith("custom-")) { diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index eb26ff1484..9bab78dee4 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -400,7 +400,7 @@ export default class WidgetUtils { return client.setAccountData('m.widgets', userWidgets); } - static makeAppConfig(appId, app, senderUserId, roomId) { + static makeAppConfig(appId, app, senderUserId, roomId, eventId) { const myUserId = MatrixClientPeg.get().credentials.userId; const user = MatrixClientPeg.get().getUser(myUserId); const params = { @@ -419,6 +419,7 @@ export default class WidgetUtils { app.creatorUserId = senderUserId; app.id = appId; + app.eventId = eventId; app.name = app.name || app.type; if (app.data) {