;
+ }
+
+ onClick(event: React.MouseEvent) {
+ const width = (event.target as HTMLElement).clientWidth;
+ // nativeEvent is safe to use because https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/offsetX
+ // is supported by all modern browsers
+ const relativeClick = (event.nativeEvent.offsetX / width);
+ const nearestValue = this.props.values[Math.round(relativeClick * (this.props.values.length - 1))];
+ this.props.onSelectionChange(nearestValue);
+ }
+}
+
+interface IDotProps {
+ // Callback for behavior onclick
+ onClick: () => void,
+
+ // Whether the dot should appear active
+ active: boolean,
+
+ // The label on the dot
+ label: string,
+
+ // Whether the slider is disabled
+ disabled: boolean;
+}
+
+class Dot extends React.PureComponent {
+ render(): React.ReactNode {
+ let className = "mx_Slider_dot"
+ if (!this.props.disabled && this.props.active) {
+ className += " mx_Slider_dotActive";
+ }
+
+ return
+
+
+
+ {this.props.label}
+
+
+ ;
+ }
+}
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index e2420ef2b1c..31797cffb4e 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -34,7 +34,7 @@ import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
import * as ObjectUtils from "../../../ObjectUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {E2E_STATE} from "./E2EIcon";
-import toRem from "../../../utils/rem";
+import {toRem} from "../../../utils/units";
const eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js
index 85d443d55a0..2fe577377d2 100644
--- a/src/components/views/rooms/ReadReceiptMarker.js
+++ b/src/components/views/rooms/ReadReceiptMarker.js
@@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import * as sdk from "../../../index";
-import toRem from "../../../utils/rem";
+import {toRem} from "../../../utils/units";
let bounce = false;
try {
diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.js b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.js
new file mode 100644
index 00000000000..5b49dd0abdc
--- /dev/null
+++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.js
@@ -0,0 +1,281 @@
+/*
+Copyright 2019 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import {_t} from "../../../../../languageHandler";
+import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore";
+import * as sdk from "../../../../../index";
+import {enumerateThemes, ThemeWatcher} from "../../../../../theme";
+import Field from "../../../elements/Field";
+import Slider from "../../../elements/Slider";
+import AccessibleButton from "../../../elements/AccessibleButton";
+import dis from "../../../../../dispatcher/dispatcher";
+import { FontWatcher } from "../../../../../FontWatcher";
+
+export default class AppearanceUserSettingsTab extends React.Component {
+ constructor() {
+ super();
+
+ this.state = {
+ fontSize: SettingsStore.getValue("fontSize", null),
+ ...this._calculateThemeState(),
+ customThemeUrl: "",
+ customThemeMessage: {isError: false, text: ""},
+ useCustomFontSize: SettingsStore.getValue("useCustomFontSize"),
+ };
+ }
+
+ _calculateThemeState() {
+ // We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
+ // show the right values for things.
+
+ const themeChoice = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "theme");
+ const systemThemeExplicit = SettingsStore.getValueAt(
+ SettingLevel.DEVICE, "use_system_theme", null, false, true);
+ const themeExplicit = SettingsStore.getValueAt(
+ SettingLevel.DEVICE, "theme", null, false, true);
+
+ // If the user has enabled system theme matching, use that.
+ if (systemThemeExplicit) {
+ return {
+ theme: themeChoice,
+ useSystemTheme: true,
+ };
+ }
+
+ // If the user has set a theme explicitly, use that (no system theme matching)
+ if (themeExplicit) {
+ return {
+ theme: themeChoice,
+ useSystemTheme: false,
+ };
+ }
+
+ // Otherwise assume the defaults for the settings
+ return {
+ theme: themeChoice,
+ useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"),
+ };
+ }
+
+ _onThemeChange = (e) => {
+ const newTheme = e.target.value;
+ if (this.state.theme === newTheme) return;
+
+ // doing getValue in the .catch will still return the value we failed to set,
+ // so remember what the value was before we tried to set it so we can revert
+ const oldTheme = SettingsStore.getValue('theme');
+ SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme).catch(() => {
+ 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
+ // 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});
+ SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
+ dis.dispatch({action: 'recheck_theme'});
+ };
+
+ _onFontSizeChanged = (size) => {
+ this.setState({fontSize: size});
+ SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, size);
+ };
+
+ _onValidateFontSize = ({value}) => {
+ console.log({value});
+
+ const parsedSize = parseFloat(value);
+ const min = FontWatcher.MIN_SIZE;
+ const max = FontWatcher.MAX_SIZE;
+
+ if (isNaN(parsedSize)) {
+ return {valid: false, feedback: _t("Size must be a number")};
+ }
+
+ if (!(min <= parsedSize && parsedSize <= max)) {
+ return {
+ valid: false,
+ feedback: _t('Custom font size can only be between %(min)s pt and %(max)s pt', {min, max}),
+ };
+ }
+
+ SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, value);
+ return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
+ }
+
+ _onAddCustomTheme = async () => {
+ let currentThemes = SettingsStore.getValue("custom_themes");
+ if (!currentThemes) currentThemes = [];
+ currentThemes = currentThemes.map(c => c); // cheap clone
+
+ if (this._themeTimer) {
+ clearTimeout(this._themeTimer);
+ }
+
+ try {
+ const r = await fetch(this.state.customThemeUrl);
+ const themeInfo = await r.json();
+ if (!themeInfo || typeof(themeInfo['name']) !== 'string' || typeof(themeInfo['colors']) !== 'object') {
+ this.setState({customThemeMessage: {text: _t("Invalid theme schema."), isError: true}});
+ return;
+ }
+ currentThemes.push(themeInfo);
+ } catch (e) {
+ console.error(e);
+ this.setState({customThemeMessage: {text: _t("Error downloading theme information."), isError: true}});
+ return; // Don't continue on error
+ }
+
+ await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
+ this.setState({customThemeUrl: "", customThemeMessage: {text: _t("Theme added!"), isError: false}});
+
+ this._themeTimer = setTimeout(() => {
+ this.setState({customThemeMessage: {text: "", isError: false}});
+ }, 3000);
+ };
+
+ _onCustomThemeChange = (e) => {
+ this.setState({customThemeUrl: e.target.value});
+ };
+
+ render() {
+ return (
+
{this._renderDiscoverySection()}
{this._renderIntegrationManagerSection() /* Has its own title */}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index dcb6326dabd..b7417762f18 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -400,6 +400,7 @@
"Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.",
"Please contact your homeserver administrator.": "Please contact your homeserver administrator.",
"Failed to join room": "Failed to join room",
+ "Font scaling": "Font scaling",
"Message Pinning": "Message Pinning",
"Custom user status messages": "Custom user status messages",
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
@@ -410,6 +411,8 @@
"Use IRC layout": "Use IRC layout",
"Enable cross-signing to verify per-user instead of per-session": "Enable cross-signing to verify per-user instead of per-session",
"Show info about bridges in room settings": "Show info about bridges in room settings",
+ "Font size": "Font size",
+ "Custom font size": "Custom font size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
"Use compact timeline layout": "Use compact timeline layout",
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
@@ -748,22 +751,26 @@
"Use an Integration Manager to manage bots, widgets, and sticker packs.": "Use an Integration Manager to manage bots, widgets, and sticker packs.",
"Manage integrations": "Manage integrations",
"Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.",
+ "Size must be a number": "Size must be a number",
+ "Custom font size can only be between %(min)s pt and %(max)s pt": "Custom font size can only be between %(min)s pt and %(max)s pt",
+ "Use between %(min)s pt and %(max)s pt": "Use between %(min)s pt and %(max)s pt",
+ "Invalid theme schema.": "Invalid theme schema.",
+ "Error downloading theme information.": "Error downloading theme information.",
+ "Theme added!": "Theme added!",
+ "Appearance": "Appearance",
+ "Custom theme URL": "Custom theme URL",
+ "Add theme": "Add theme",
+ "Theme": "Theme",
"Flair": "Flair",
"Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?",
"Success": "Success",
"Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them",
- "Invalid theme schema.": "Invalid theme schema.",
- "Error downloading theme information.": "Error downloading theme information.",
- "Theme added!": "Theme added!",
"Profile": "Profile",
"Email addresses": "Email addresses",
"Phone numbers": "Phone numbers",
"Set a new account password...": "Set a new account password...",
"Account": "Account",
"Language and region": "Language and region",
- "Custom theme URL": "Custom theme URL",
- "Add theme": "Add theme",
- "Theme": "Theme",
"Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.",
"Account management": "Account management",
"Deactivating your account is a permanent action - be careful!": "Deactivating your account is a permanent action - be careful!",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index f54b32a8b55..400012e1681 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -29,6 +29,7 @@ import ThemeController from './controllers/ThemeController';
import PushToMatrixClientController from './controllers/PushToMatrixClientController';
import ReloadOnChangeController from "./controllers/ReloadOnChangeController";
import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases";
+import FontSizeController from './controllers/FontSizeController';
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config'];
@@ -94,6 +95,12 @@ export const SETTINGS = {
// // not use this for new settings.
// invertedSettingName: "my-negative-setting",
// },
+ "feature_font_scaling": {
+ isFeature: true,
+ displayName: _td("Font scaling"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"feature_pinning": {
isFeature: true,
displayName: _td("Message Pinning"),
@@ -164,6 +171,17 @@ export const SETTINGS = {
displayName: _td("Show info about bridges in room settings"),
default: false,
},
+ "fontSize": {
+ displayName: _td("Font size"),
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ default: 16,
+ controller: new FontSizeController(),
+ },
+ "useCustomFontSize": {
+ displayName: _td("Custom font size"),
+ supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+ default: false,
+ },
"MessageComposerInput.suggestEmoji": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable Emoji suggestions while typing'),
diff --git a/src/settings/SettingsStore.js b/src/settings/SettingsStore.js
index 36111dd46f0..4b18a27c6c6 100644
--- a/src/settings/SettingsStore.js
+++ b/src/settings/SettingsStore.js
@@ -370,6 +370,21 @@ export default class SettingsStore {
return SettingsStore._getFinalValue(setting, level, roomId, null, null);
}
+ /**
+ * Gets the default value of a setting.
+ * @param {string} settingName The name of the setting to read the value of.
+ * @param {String} roomId The room ID to read the setting value in, may be null.
+ * @return {*} The default value
+ */
+ static getDefaultValue(settingName) {
+ // Verify that the setting is actually a setting
+ if (!SETTINGS[settingName]) {
+ throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
+ }
+
+ return SETTINGS[settingName].default;
+ }
+
static _getFinalValue(setting, level, roomId, calculatedValue, calculatedAtLevel) {
let resultingValue = calculatedValue;
diff --git a/src/settings/controllers/FontSizeController.js b/src/settings/controllers/FontSizeController.js
new file mode 100644
index 00000000000..3ef01ab99bd
--- /dev/null
+++ b/src/settings/controllers/FontSizeController.js
@@ -0,0 +1,32 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import SettingController from "./SettingController";
+import dis from "../../dispatcher/dispatcher";
+
+export default class FontSizeController extends SettingController {
+ constructor() {
+ super();
+ }
+
+ onChange(level, roomId, newValue) {
+ // Dispatch font size change so that everything open responds to the change.
+ dis.dispatch({
+ action: "update-font-size",
+ size: newValue,
+ });
+ }
+}
diff --git a/src/theme.js b/src/theme.js
index 1da39d50fa3..72b6e934435 100644
--- a/src/theme.js
+++ b/src/theme.js
@@ -81,7 +81,7 @@ export class ThemeWatcher {
}
getEffectiveTheme() {
- // Dev note: Much of this logic is replicated in the GeneralUserSettingsTab
+ // Dev note: Much of this logic is replicated in the AppearanceUserSettingsTab
// XXX: checking the isLight flag here makes checking it in the ThemeController
// itself completely redundant since we just override the result here and we're
diff --git a/src/utils/rem.js b/src/utils/units.ts
similarity index 73%
rename from src/utils/rem.js
rename to src/utils/units.ts
index 1f18c9de052..54dd6b0523b 100644
--- a/src/utils/rem.js
+++ b/src/utils/units.ts
@@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+/* Simple utils for formatting style values
+ */
+
// converts a pixel value to rem.
-export default function(pixelVal) {
- return pixelVal / 15 + "rem";
+export function toRem(pixelValue: number): string {
+ return pixelValue / 15 + "rem";
+}
+
+export function toPx(pixelValue: number): string {
+ return pixelValue + "px";
}
diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js
index 59671327ce0..4e93b3bb648 100644
--- a/test/components/views/messages/TextualBody-test.js
+++ b/test/components/views/messages/TextualBody-test.js
@@ -206,7 +206,7 @@ describe("", () => {
'Hey ' +
'' +
'Member' +
'');
});