diff --git a/package.json b/package.json index dde76d1d41..f3b8104663 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "highlight.js": "^10.1.2", "html-entities": "^1.3.1", "is-ip": "^2.0.0", + "katex": "^0.12.0", + "cheerio": "^1.0.0-rc.3", "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 6a352d46a3..84c21364ce 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -231,9 +231,29 @@ limitations under the License. justify-content: center; } + &.mx_UserMenu_contextMenu_guestPrompts, &.mx_UserMenu_contextMenu_hostingLink { padding-top: 0; } + + &.mx_UserMenu_contextMenu_guestPrompts { + display: inline-block; + + > span { + font-weight: 600; + display: block; + + & + span { + margin-top: 8px; + } + } + + .mx_AccessibleButton_kind_link { + font-weight: normal; + font-size: inherit; + padding: 0; + } + } } .mx_IconizedContextMenu_icon { diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index 94f42efe83..da86797f42 100644 --- a/res/css/views/rooms/_Stickers.scss +++ b/res/css/views/rooms/_Stickers.scss @@ -22,7 +22,7 @@ iframe { // Sticker picker depends on the fixed height previously used for all tiles - height: 273px; + height: 283px; // height of the popout minus the AppTile menu bar } } diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 3be203ab98..abfe5cc9bf 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -80,6 +80,7 @@ import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } import Analytics from './Analytics'; import CountlyAnalytics from "./CountlyAnalytics"; import {UIFeature} from "./settings/UIFeature"; +import { CallError } from "matrix-js-sdk/src/webrtc/call"; enum AudioID { Ring = 'ringAudio', @@ -226,11 +227,17 @@ export default class CallHandler { } private setCallListeners(call: MatrixCall) { - call.on(CallEvent.Error, (err) => { + call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; - Analytics.trackEvent('voip', 'callError', 'error', err); + Analytics.trackEvent('voip', 'callError', 'error', err.toString()); console.error("Call error:", err); + + if (err.code === CallErrorCode.NoUserMedia) { + this.showMediaCaptureError(call); + return; + } + if ( MatrixClientPeg.get().getTurnServers().length === 0 && SettingsStore.getValue("fallbackICEServerAllowed") === null @@ -377,6 +384,34 @@ export default class CallHandler { }, null, true); } + private showMediaCaptureError(call: MatrixCall) { + let title; + let description; + + if (call.type === CallType.Voice) { + title = _t("Unable to access microphone"); + description =
+ {_t( + "Call failed because no microphone could not be accessed. " + + "Check that a microphone is plugged in and set up correctly.", + )} +
; + } else if (call.type === CallType.Video) { + title = _t("Unable to access webcam / microphone"); + description =
+ {_t("Call failed because no webcam or microphone could not be accessed. Check that:")} + +
; + } + + Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, { + title, description, + }, null, true); + } private placeCall( roomId: string, type: PlaceCallType, diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 07bfd4858a..2301ad250b 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,9 +27,12 @@ import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; +import katex from 'katex'; +import { AllHtmlEntities } from 'html-entities'; +import SettingsStore from './settings/SettingsStore'; +import cheerio from 'cheerio'; import {MatrixClientPeg} from './MatrixClientPeg'; -import SettingsStore from './settings/SettingsStore'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; @@ -240,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { allowedAttributes: { // custom ones first: font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix - span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + div: ['data-mx-maths'], a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], @@ -414,6 +418,21 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts if (isHtmlMessage) { isDisplayedWithHtml = true; safeBody = sanitizeHtml(formattedBody, sanitizeParams); + + if (SettingsStore.getValue("feature_latex_maths")) { + const phtml = cheerio.load(safeBody, + { _useHtmlParser2: true, decodeEntities: false }) + phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { + return katex.renderToString( + AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), + { + throwOnError: false, + displayMode: e.name == 'div', + output: "htmlAndMathml", + }); + }); + safeBody = phtml.html(); + } } } finally { delete sanitizeParams.textFilter; @@ -515,7 +534,6 @@ export function checkBlockNode(node: Node) { case "H6": case "PRE": case "BLOCKQUOTE": - case "DIV": case "P": case "UL": case "OL": @@ -528,6 +546,9 @@ export function checkBlockNode(node: Node) { case "TH": case "TD": return true; + case "DIV": + // don't treat math nodes as block nodes for deserializing + return !(node as HTMLElement).hasAttribute("data-mx-maths"); default: return false; } diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 8451568dd1..ac96d59b09 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -49,6 +49,7 @@ import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; import CallHandler from './CallHandler'; +import LifecycleCustomisations from "./customisations/Lifecycle"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -589,9 +590,9 @@ export function logout(): void { if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions - // Also we sometimes want to re-log in a guest session - // if we abort the login - onLoggedOut(); + // Also we sometimes want to re-log in a guest session if we abort the login. + // defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch. + setImmediate(() => onLoggedOut()); return; } @@ -716,6 +717,7 @@ export async function onLoggedOut(): Promise { dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); await clearStorage({deleteEverything: true}); + LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } /** diff --git a/src/Markdown.js b/src/Markdown.js index 492450e87d..dc4d442aff 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -23,6 +23,11 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; function is_allowed_html_tag(node) { + if (node.literal != null && + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) { + return true; + } + // Regex won't work for tags with attrs, but we only // allow anyway. const matches = /^<\/?(.*)>$/.exec(node.literal); @@ -30,6 +35,7 @@ function is_allowed_html_tag(node) { const tag = matches[1]; return ALLOWED_HTML_TAGS.indexOf(tag) > -1; } + return false; } diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 75208b8cfe..08bd472225 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -29,7 +29,7 @@ import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import {getCustomTheme} from "../../theme"; import {getHostingLink} from "../../utils/HostingLink"; -import {ButtonEvent} from "../views/elements/AccessibleButton"; +import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import {getHomePageUrl} from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -205,6 +205,16 @@ export default class UserMenu extends React.Component { this.setState({contextMenuPosition: null}); // also close the menu }; + private onSignInClick = () => { + dis.dispatch({ action: 'start_login' }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onRegisterClick = () => { + dis.dispatch({ action: 'start_registration' }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private onHomeClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); @@ -261,10 +271,29 @@ export default class UserMenu extends React.Component { const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); - let hostingLink; + let topSection; const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( + if (MatrixClientPeg.get().isGuest()) { + topSection = ( +
+ {_t("Got an account? Sign in", {}, { + a: sub => ( + + {sub} + + ), + })} + {_t("New here? Create an account", {}, { + a: sub => ( + + {sub} + + ), + })} +
+ ) + } else if (signupLink) { + topSection = (
{_t( "Upgrade to your own domain", {}, @@ -422,6 +451,20 @@ export default class UserMenu extends React.Component { ) + } else if (MatrixClientPeg.get().isGuest()) { + primaryOptionList = ( + + + { homeButton } + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + ); } const classes = classNames({ @@ -451,7 +494,7 @@ export default class UserMenu extends React.Component { />
- {hostingLink} + {topSection} {primaryOptionList} {secondarySection} ; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index b862a1e912..7e0ae965bb 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -375,17 +375,20 @@ export default class AppTile extends React.Component { ); - // all widgets can theoretically be allowed to remain on screen, so we wrap - // them all in a PersistedElement from the get-go. If we wait, the iframe will - // be re-mounted later, which means the widget has to start over, which is bad. + if (!this.props.userWidget) { + // All room widgets can theoretically be allowed to remain on screen, so we + // wrap them all in a PersistedElement from the get-go. If we wait, the iframe + // will be re-mounted later, which means the widget has to start over, which is + // bad. - // Also wrap the PersistedElement in a div to fix the height, otherwise - // AppTile's border is in the wrong place - appTileBody =
- - {appTileBody} - -
; + // Also wrap the PersistedElement in a div to fix the height, otherwise + // AppTile's border is in the wrong place + appTileBody =
+ + {appTileBody} + +
; + } } } diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index bafbc816b9..22b758b1ca 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -21,9 +21,18 @@ import PropTypes from 'prop-types'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import AccessibleButton from '../elements/AccessibleButton'; import Spinner from '../elements/Spinner'; +import withValidation from '../elements/Validation'; import { _t } from '../../../languageHandler'; import * as sdk from "../../../index"; import Modal from "../../../Modal"; +import PassphraseField from "../auth/PassphraseField"; +import CountlyAnalytics from "../../../CountlyAnalytics"; + +const FIELD_OLD_PASSWORD = 'field_old_password'; +const FIELD_NEW_PASSWORD = 'field_new_password'; +const FIELD_NEW_PASSWORD_CONFIRM = 'field_new_password_confirm'; + +const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. export default class ChangePassword extends React.Component { static propTypes = { @@ -63,6 +72,7 @@ export default class ChangePassword extends React.Component { } state = { + fieldValid: {}, phase: ChangePassword.Phases.Edit, oldPassword: "", newPassword: "", @@ -168,26 +178,84 @@ export default class ChangePassword extends React.Component { ); }; + markFieldValid(fieldID, valid) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + onChangeOldPassword = (ev) => { this.setState({ oldPassword: ev.target.value, }); }; + onOldPasswordValidate = async fieldState => { + const result = await this.validateOldPasswordRules(fieldState); + this.markFieldValid(FIELD_OLD_PASSWORD, result.valid); + return result; + }; + + validateOldPasswordRules = withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Passwords can't be empty"), + }, + ], + }); + onChangeNewPassword = (ev) => { this.setState({ newPassword: ev.target.value, }); }; + onNewPasswordValidate = result => { + this.markFieldValid(FIELD_NEW_PASSWORD, result.valid); + }; + onChangeNewPasswordConfirm = (ev) => { this.setState({ newPasswordConfirm: ev.target.value, }); }; - onClickChange = (ev) => { + onNewPasswordConfirmValidate = async fieldState => { + const result = await this.validatePasswordConfirmRules(fieldState); + this.markFieldValid(FIELD_NEW_PASSWORD_CONFIRM, result.valid); + return result; + }; + + validatePasswordConfirmRules = withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t("Confirm password"), + }, + { + key: "match", + test({ value }) { + return !value || value === this.state.newPassword; + }, + invalid: () => _t("Passwords don't match"), + }, + ], + }); + + onClickChange = async (ev) => { ev.preventDefault(); + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); + return; + } + const oldPassword = this.state.oldPassword; const newPassword = this.state.newPassword; const confirmPassword = this.state.newPasswordConfirm; @@ -201,9 +269,75 @@ export default class ChangePassword extends React.Component { } }; - render() { - // TODO: Live validation on `new pw == confirm pw` + async verifyFieldsBeforeSubmit() { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement; + if (activeElement) { + activeElement.blur(); + } + const fieldIDsInDisplayOrder = [ + FIELD_OLD_PASSWORD, + FIELD_NEW_PASSWORD, + FIELD_NEW_PASSWORD_CONFIRM, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + // We must wait for these validations to finish before queueing + // up the setState below so our setState goes in the queue after + // all the setStates from these validate calls (that's how we + // know they've finished). + await field.validate({ allowEmpty: false }); + } + + // Validation and state updates are async, so we need to wait for them to complete + // first. Queue a `setState` callback and wait for it to resolve. + await new Promise(resolve => this.setState({}, resolve)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + } + + allFieldsValid() { + const keys = Object.keys(this.state.fieldValid); + for (let i = 0; i < keys.length; ++i) { + if (!this.state.fieldValid[keys[i]]) { + return false; + } + } + return true; + } + + findFirstInvalidField(fieldIDs) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + render() { const rowClassName = this.props.rowClassName; const buttonClassName = this.props.buttonClassName; @@ -213,28 +347,35 @@ export default class ChangePassword extends React.Component {
this[FIELD_OLD_PASSWORD] = field} type="password" label={_t('Current password')} value={this.state.oldPassword} onChange={this.onChangeOldPassword} + onValidate={this.onOldPasswordValidate} />
- this[FIELD_NEW_PASSWORD] = field} type="password" - label={_t('New Password')} + label='New Password' + minScore={PASSWORD_MIN_SCORE} value={this.state.newPassword} autoFocus={this.props.autoFocusNewPasswordInput} onChange={this.onChangeNewPassword} + onValidate={this.onNewPasswordValidate} autoComplete="new-password" />
this[FIELD_NEW_PASSWORD_CONFIRM] = field} type="password" label={_t("Confirm password")} value={this.state.newPasswordConfirm} onChange={this.onChangeNewPasswordConfirm} + onValidate={this.onNewPasswordConfirmValidate} autoComplete="new-password" />
diff --git a/src/customisations/Lifecycle.ts b/src/customisations/Lifecycle.ts new file mode 100644 index 0000000000..eba2af715a --- /dev/null +++ b/src/customisations/Lifecycle.ts @@ -0,0 +1,30 @@ +/* +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. +*/ + +function onLoggedOutAndStorageCleared(): void { + // E.g. redirect user or call other APIs after logout +} + +// This interface summarises all available customisation points and also marks +// them all as optional. This allows customisers to only define and export the +// customisations they need while still maintaining type safety. +export interface ILifecycleCustomisations { + onLoggedOutAndStorageCleared?: typeof onLoggedOutAndStorageCleared; +} + +// A real customisation module will define and export one or more of the +// customisation points that make up `ILifecycleCustomisations`. +export default {} as ILifecycleCustomisations; diff --git a/src/customisations/RoomList.ts b/src/customisations/RoomList.ts new file mode 100644 index 0000000000..758b212aa2 --- /dev/null +++ b/src/customisations/RoomList.ts @@ -0,0 +1,45 @@ +/* + * 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 { Room } from "matrix-js-sdk/src/models/room"; + +// Populate this file with the details of your customisations when copying it. + +/** + * Determines if a room is visible in the room list or not. By default, + * all rooms are visible. Where special handling is performed by Element, + * those rooms will not be able to override their visibility in the room + * list - Element will make the decision without calling this function. + * + * This function should be as fast as possible to avoid slowing down the + * client. + * @param {Room} room The room to check the visibility of. + * @returns {boolean} True if the room should be visible, false otherwise. + */ +function isRoomVisible(room: Room): boolean { + return true; +} + +// This interface summarises all available customisation points and also marks +// them all as optional. This allows customisers to only define and export the +// customisations they need while still maintaining type safety. +export interface IRoomListCustomisations { + isRoomVisible?: typeof isRoomVisible; +} + +// A real customisation module will define and export one or more of the +// customisation points that make up the interface above. +export const RoomListCustomisations: IRoomListCustomisations = {}; diff --git a/src/customisations/Security.ts b/src/customisations/Security.ts index eb7c27dcc5..96b5b62cdb 100644 --- a/src/customisations/Security.ts +++ b/src/customisations/Security.ts @@ -67,24 +67,13 @@ function setupEncryptionNeeded(kind: SetupEncryptionKind): boolean { // them all as optional. This allows customisers to only define and export the // customisations they need while still maintaining type safety. export interface ISecurityCustomisations { - examineLoginResponse?: ( - response: any, - credentials: IMatrixClientCreds, - ) => void; - persistCredentials?: ( - credentials: IMatrixClientCreds, - ) => void; - createSecretStorageKey?: () => Uint8Array, - getSecretStorageKey?: () => Uint8Array, - catchAccessSecretStorageError?: ( - e: Error, - ) => void, - setupEncryptionNeeded?: ( - kind: SetupEncryptionKind, - ) => boolean, - getDehydrationKey?: ( - keyInfo: ISecretStorageKeyInfo, - ) => Promise, + examineLoginResponse?: typeof examineLoginResponse; + persistCredentials?: typeof persistCredentials; + createSecretStorageKey?: typeof createSecretStorageKey, + getSecretStorageKey?: typeof getSecretStorageKey, + catchAccessSecretStorageError?: typeof catchAccessSecretStorageError, + setupEncryptionNeeded?: typeof setupEncryptionNeeded, + getDehydrationKey?: typeof getDehydrationKey, } // A real customisation module will define and export one or more of the diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts index ec697b193c..6336b4c46b 100644 --- a/src/editor/deserialize.ts +++ b/src/editor/deserialize.ts @@ -21,6 +21,7 @@ import { walkDOMDepthFirst } from "./dom"; import { checkBlockNode } from "../HtmlUtils"; import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks"; import { PartCreator } from "./parts"; +import SdkConfig from "../SdkConfig"; function parseAtRoomMentions(text: string, partCreator: PartCreator) { const ATROOM = "@room"; @@ -130,6 +131,23 @@ function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLEl } break; } + case "DIV": + case "SPAN": { + // math nodes are translated back into delimited latex strings + if (n.hasAttribute("data-mx-maths")) { + const delimLeft = (n.nodeName == "SPAN") ? + (SdkConfig.get()['latex_maths_delims'] || {})['inline_left'] || "$" : + (SdkConfig.get()['latex_maths_delims'] || {})['display_left'] || "$$"; + const delimRight = (n.nodeName == "SPAN") ? + (SdkConfig.get()['latex_maths_delims'] || {})['inline_right'] || "$" : + (SdkConfig.get()['latex_maths_delims'] || {})['display_right'] || "$$"; + const tex = n.getAttribute("data-mx-maths"); + return partCreator.plain(delimLeft + tex + delimRight); + } else if (!checkDescendInto(n)) { + return partCreator.plain(n.textContent); + } + break; + } case "OL": state.listIndex.push((n).start || 1); /* falls through */ diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index c550f54291..c1f4da306b 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -18,6 +18,10 @@ limitations under the License. import Markdown from '../Markdown'; import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; import EditorModel from "./model"; +import { AllHtmlEntities } from 'html-entities'; +import SettingsStore from '../settings/SettingsStore'; +import SdkConfig from '../SdkConfig'; +import cheerio from 'cheerio'; export function mdSerialize(model: EditorModel) { return model.parts.reduce((html, part) => { @@ -38,10 +42,43 @@ export function mdSerialize(model: EditorModel) { } export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) { - const md = mdSerialize(model); + let md = mdSerialize(model); + + if (SettingsStore.getValue("feature_latex_maths")) { + const displayPattern = (SdkConfig.get()['latex_maths_delims'] || {})['display_pattern'] || + "\\$\\$(([^$]|\\\\\\$)*)\\$\\$"; + const inlinePattern = (SdkConfig.get()['latex_maths_delims'] || {})['inline_pattern'] || + "\\$(([^$]|\\\\\\$)*)\\$"; + + md = md.replace(RegExp(displayPattern, "gm"), function(m, p1) { + const p1e = AllHtmlEntities.encode(p1); + return `
\n\n
\n\n`; + }); + + md = md.replace(RegExp(inlinePattern, "gm"), function(m, p1) { + const p1e = AllHtmlEntities.encode(p1); + return ``; + }); + + // make sure div tags always start on a new line, otherwise it will confuse + // the markdown parser + md = md.replace(/(.)
${tex}`) + } + }); + return phtml.html(); } // ensure removal of escape backslashes in non-Markdown messages if (md.indexOf("\\") > -1) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 68d837114a..bd8895d4c0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -46,6 +46,13 @@ "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at turn.matrix.org, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.", "Try using turn.matrix.org": "Try using turn.matrix.org", "OK": "OK", + "Unable to access microphone": "Unable to access microphone", + "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because no microphone could not be accessed. Check that a microphone is plugged in and set up correctly.", + "Unable to access webcam / microphone": "Unable to access webcam / microphone", + "Call failed because no webcam or microphone could not be accessed. Check that:": "Call failed because no webcam or microphone could not be accessed. Check that:", + "A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly", + "Permission is granted to use the webcam": "Permission is granted to use the webcam", + "No other application is using the webcam": "No other application is using the webcam", "Unable to capture screen": "Unable to capture screen", "Existing Call": "Existing Call", "You are already in a call.": "You are already in a call.", @@ -756,6 +763,7 @@ "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "Change notification settings": "Change notification settings", + "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "New spinner design": "New spinner design", "Message Pinning": "Message Pinning", @@ -956,9 +964,9 @@ "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Changing password will currently reset any end-to-end encryption keys on all sessions, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.", "Export E2E room keys": "Export E2E room keys", "Do you want to set an email address?": "Do you want to set an email address?", - "Current password": "Current password", - "New Password": "New Password", "Confirm password": "Confirm password", + "Passwords don't match": "Passwords don't match", + "Current password": "Current password", "Change Password": "Change Password", "Your homeserver does not support cross-signing.": "Your homeserver does not support cross-signing.", "Cross-signing is ready for use.": "Cross-signing is ready for use.", @@ -2307,7 +2315,6 @@ "If you don't specify an email address, you won't be able to reset your password. Are you sure?": "If you don't specify an email address, you won't be able to reset your password. Are you sure?", "Use an email address to recover your account": "Use an email address to recover your account", "Enter email address (required on this homeserver)": "Enter email address (required on this homeserver)", - "Passwords don't match": "Passwords don't match", "Other users can invite you to rooms using your contact details": "Other users can invite you to rooms using your contact details", "Enter phone number (required on this homeserver)": "Enter phone number (required on this homeserver)", "Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only", @@ -2463,6 +2470,8 @@ "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Failed to find the general chat for this community": "Failed to find the general chat for this community", + "Got an account? Sign in": "Got an account? Sign in", + "New here? Create an account": "New here? Create an account", "Notification settings": "Notification settings", "Security & privacy": "Security & privacy", "All settings": "All settings", @@ -2485,6 +2494,7 @@ "Your Matrix account on ": "Your Matrix account on ", "No identity server is configured: add one in server settings to reset your password.": "No identity server is configured: add one in server settings to reset your password.", "Sign in instead": "Sign in instead", + "New Password": "New Password", "A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.", "Send Reset Email": "Send Reset Email", "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 706fc5b12e..6bec31a1cb 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -117,6 +117,12 @@ export interface ISetting { } export const SETTINGS: {[setting: string]: ISetting} = { + "feature_latex_maths": { + isFeature: true, + displayName: _td("Render LaTeX maths in messages"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_communities_v2_prototypes": { isFeature: true, displayName: _td( diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 0f3138fe9e..b2fe630760 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -34,6 +34,7 @@ import { MarkedExecution } from "../../utils/MarkedExecution"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import { NameFilterCondition } from "./filters/NameFilterCondition"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; +import { VisibilityProvider } from "./filters/VisibilityProvider"; interface IState { tagsEnabled?: boolean; @@ -401,6 +402,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise { + if (!VisibilityProvider.instance.isRoomVisible(room)) { + return; // don't do anything on rooms that aren't visible + } + const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { if (SettingsStore.getValue("advancedRoomListLogging")) { @@ -544,7 +549,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient { public async regenerateAllLists({trigger = true}) { console.warn("Regenerating all room lists"); - const rooms = this.matrixClient.getVisibleRooms(); + const rooms = this.matrixClient.getVisibleRooms() + .filter(r => VisibilityProvider.instance.isRoomVisible(r)); const customTags = new Set(); if (this.state.tagsEnabled) { for (const room of rooms) { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 439141edb4..25059aabe7 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -34,6 +34,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import SettingsStore from "../../../settings/SettingsStore"; +import { VisibilityProvider } from "../filters/VisibilityProvider"; /** * Fired when the Algorithm has determined a list has been updated. @@ -188,6 +189,10 @@ export class Algorithm extends EventEmitter { // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // otherwise we risk duplicating rooms. + if (val && !VisibilityProvider.instance.isRoomVisible(val)) { + val = null; // the room isn't visible - lie to the rest of this function + } + // Set the last sticky room to indicate that we're in a change. The code throughout the // class can safely handle a null room, so this should be safe to do as a backup. this._lastStickyRoom = this._stickyRoom || {}; diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts new file mode 100644 index 0000000000..553dd33ce0 --- /dev/null +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -0,0 +1,54 @@ +/* + * 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 {Room} from "matrix-js-sdk/src/models/room"; +import { RoomListCustomisations } from "../../../customisations/RoomList"; + +export class VisibilityProvider { + private static internalInstance: VisibilityProvider; + + private constructor() { + } + + public static get instance(): VisibilityProvider { + if (!VisibilityProvider.internalInstance) { + VisibilityProvider.internalInstance = new VisibilityProvider(); + } + return VisibilityProvider.internalInstance; + } + + public isRoomVisible(room: Room): boolean { + /* eslint-disable prefer-const */ + let isVisible = true; // Returned at the end of this function + let forced = false; // When true, this function won't bother calling the customisation points + /* eslint-enable prefer-const */ + + // ------ + // TODO: The `if` statements to control visibility of custom room types + // would go here. The remainder of this function assumes that the statements + // will be here. + // + // When removing this comment block, please remove the lint disable lines in the area. + // ------ + + const isVisibleFn = RoomListCustomisations.isRoomVisible; + if (!forced && isVisibleFn) { + isVisible = isVisibleFn(room); + } + + return isVisible; + } +} diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index 07cd51edbd..bf55e9c430 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -36,6 +36,7 @@ describe("", () => { MatrixClientPeg.matrixClient = { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, + isGuest: () => false, }; const ev = mkEvent({ @@ -59,6 +60,7 @@ describe("", () => { MatrixClientPeg.matrixClient = { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, + isGuest: () => false, }; const ev = mkEvent({ @@ -83,6 +85,7 @@ describe("", () => { MatrixClientPeg.matrixClient = { getRoom: () => mkStubRoom("room_id"), getAccountData: () => undefined, + isGuest: () => false, }; }); @@ -135,6 +138,7 @@ describe("", () => { getHomeserverUrl: () => "https://my_server/", on: () => undefined, removeListener: () => undefined, + isGuest: () => false, }; }); diff --git a/yarn.lock b/yarn.lock index 966a70d373..c06494d319 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6206,6 +6206,13 @@ jsx-ast-utils@^2.4.1: array-includes "^3.1.1" object.assign "^4.1.0" +katex@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9" + integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg== + dependencies: + commander "^2.19.0" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"