@@ -55,8 +44,8 @@ export default class Welcome extends React.PureComponent {
className="mx_WelcomePage"
url={pageUrl}
replaceMap={{
- "$riot:ssoUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "sso"),
- "$riot:casUrl": tmpClient.getSsoLoginUrl(callbackUrl.toString(), "cas"),
+ "$riot:ssoUrl": "#/start_sso",
+ "$riot:casUrl": "#/start_cas",
}}
/>
diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js
index ef0fa83fb6..3e4418f945 100644
--- a/src/components/views/elements/AppTile.js
+++ b/src/components/views/elements/AppTile.js
@@ -29,7 +29,7 @@ import { _t } from '../../../languageHandler';
import * as sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
-import MessageSpinner from './MessageSpinner';
+import Spinner from './Spinner';
import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
@@ -741,7 +741,7 @@ export default class AppTile extends React.Component {
if (this.props.show) {
const loadingElement = (
-
+
);
if (!this.state.hasPermissionToLoad) {
diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx
index fbee431d6e..834edff7df 100644
--- a/src/components/views/elements/Field.tsx
+++ b/src/components/views/elements/Field.tsx
@@ -54,6 +54,8 @@ interface IProps {
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode;
+ // If specified the tooltip will be shown regardless of feedback
+ forceTooltipVisible?: boolean;
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName?: string;
@@ -242,10 +244,9 @@ export default class Field extends React.PureComponent
{
const Tooltip = sdk.getComponent("elements.Tooltip");
let fieldTooltip;
if (tooltipContent || this.state.feedback) {
- const addlClassName = tooltipClassName ? tooltipClassName : '';
fieldTooltip = ;
}
diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js
index ad70471d89..ad88868790 100644
--- a/src/components/views/elements/InlineSpinner.js
+++ b/src/components/views/elements/InlineSpinner.js
@@ -16,6 +16,8 @@ limitations under the License.
import React from "react";
import createReactClass from 'create-react-class';
+import {_t} from "../../../languageHandler";
+import SettingsStore from "../../../settings/SettingsStore";
export default createReactClass({
displayName: 'InlineSpinner',
@@ -25,9 +27,25 @@ export default createReactClass({
const h = this.props.h || 16;
const imgClass = this.props.imgClassName || "";
+ let divClass;
+ let imageSource;
+ if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
+ divClass = "mx_InlineSpinner mx_Spinner_spin";
+ imageSource = require("../../../../res/img/spinner.svg");
+ } else {
+ divClass = "mx_InlineSpinner";
+ imageSource = require("../../../../res/img/spinner.gif");
+ }
+
return (
-
-
+
+
);
},
diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js
deleted file mode 100644
index 1775fdd4d7..0000000000
--- a/src/components/views/elements/MessageSpinner.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
-Copyright 2017 Vector Creations Ltd
-
-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 createReactClass from 'create-react-class';
-
-export default createReactClass({
- displayName: 'MessageSpinner',
-
- render: function() {
- const w = this.props.w || 32;
- const h = this.props.h || 32;
- const imgClass = this.props.imgClassName || "";
- const msg = this.props.msg || "Loading...";
- return (
-
-
{ msg }
-
-
- );
- },
-});
diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx
index 9bdd04d803..4f41db51e2 100644
--- a/src/components/views/elements/SettingsFlag.tsx
+++ b/src/components/views/elements/SettingsFlag.tsx
@@ -30,6 +30,7 @@ interface IProps {
isExplicit?: boolean;
// XXX: once design replaces all toggles make this the default
useCheckbox?: boolean;
+ disabled?: boolean;
onChange?(checked: boolean): void;
}
@@ -78,14 +79,23 @@ export default class SettingsFlag extends React.Component
{
else label = _t(label);
if (this.props.useCheckbox) {
- return
+ return
{label}
;
} else {
return (
{label}
-
+
);
}
diff --git a/src/components/views/elements/Spinner.js b/src/components/views/elements/Spinner.js
index b1fe97d5d2..08ba0cf921 100644
--- a/src/components/views/elements/Spinner.js
+++ b/src/components/views/elements/Spinner.js
@@ -16,19 +16,39 @@ limitations under the License.
*/
import React from "react";
-import createReactClass from 'create-react-class';
+import PropTypes from "prop-types";
+import {_t} from "../../../languageHandler";
+import SettingsStore from "../../../settings/SettingsStore";
-export default createReactClass({
- displayName: 'Spinner',
+const Spinner = ({w = 32, h = 32, imgClassName, message}) => {
+ let divClass;
+ let imageSource;
+ if (SettingsStore.isFeatureEnabled('feature_new_spinner')) {
+ divClass = "mx_Spinner mx_Spinner_spin";
+ imageSource = require("../../../../res/img/spinner.svg");
+ } else {
+ divClass = "mx_Spinner";
+ imageSource = require("../../../../res/img/spinner.gif");
+ }
- render: function() {
- const w = this.props.w || 32;
- const h = this.props.h || 32;
- const imgClass = this.props.imgClassName || "";
- return (
-
-
-
- );
- },
-});
+ return (
+
+ { message &&
{ message}
}
+
+
+ );
+};
+Spinner.propTypes = {
+ w: PropTypes.number,
+ h: PropTypes.number,
+ imgClassName: PropTypes.string,
+ message: PropTypes.node,
+};
+
+export default Spinner;
diff --git a/src/components/views/elements/StyledRadioGroup.tsx b/src/components/views/elements/StyledRadioGroup.tsx
new file mode 100644
index 0000000000..050a8b7adb
--- /dev/null
+++ b/src/components/views/elements/StyledRadioGroup.tsx
@@ -0,0 +1,61 @@
+/*
+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 React from "react";
+import classNames from "classnames";
+
+import StyledRadioButton from "./StyledRadioButton";
+
+interface IDefinition {
+ value: T;
+ className?: string;
+ disabled?: boolean;
+ label: React.ReactChild;
+ description?: React.ReactChild;
+}
+
+interface IProps {
+ name: string;
+ className?: string;
+ definitions: IDefinition[];
+ value?: T; // if not provided no options will be selected
+ onChange(newValue: T);
+}
+
+function StyledRadioGroup({name, definitions, value, className, onChange}: IProps) {
+ const _onChange = e => {
+ onChange(e.target.value);
+ };
+
+ return
+ {definitions.map(d =>
+
+ {d.label}
+
+ {d.description}
+ )}
+ ;
+}
+
+export default StyledRadioGroup;
diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js
index a642936fec..37f85a108f 100644
--- a/src/components/views/messages/MAudioBody.js
+++ b/src/components/views/messages/MAudioBody.js
@@ -22,6 +22,7 @@ import MFileBody from './MFileBody';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
+import InlineSpinner from '../elements/InlineSpinner';
export default class MAudioBody extends React.Component {
constructor(props) {
@@ -94,7 +95,7 @@ export default class MAudioBody extends React.Component {
// Not sure how tall the audio player is so not sure how tall it should actually be.
return (
-
+
);
}
diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index ad238a728e..c92ae475bf 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -26,6 +26,7 @@ import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import InlineSpinner from '../elements/InlineSpinner';
export default class MImageBody extends React.Component {
static propTypes = {
@@ -365,12 +366,7 @@ export default class MImageBody extends React.Component {
// e2e image hasn't been decrypted yet
if (content.file !== undefined && this.state.decryptedUrl === null) {
- placeholder = ;
+ placeholder = ;
} else if (!this.state.imgLoaded) {
// Deliberately, getSpinner is left unimplemented here, MStickerBody overides
placeholder = this.getPlaceholder();
diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js
index 03f345e042..fdc04deffc 100644
--- a/src/components/views/messages/MVideoBody.js
+++ b/src/components/views/messages/MVideoBody.js
@@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
+import InlineSpinner from '../elements/InlineSpinner';
export default createReactClass({
displayName: 'MVideoBody',
@@ -147,7 +148,7 @@ export default createReactClass({
return (
-
+
);
diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx
index 5dada64b52..5f80460d03 100644
--- a/src/components/views/messages/RedactedBody.tsx
+++ b/src/components/views/messages/RedactedBody.tsx
@@ -19,6 +19,8 @@ import {MatrixClient} from "matrix-js-sdk/src/client";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import {formatFullDate} from "../../../DateUtils";
+import SettingsStore from "../../../settings/SettingsStore";
interface IProps {
mxEvent: MatrixEvent;
@@ -36,8 +38,12 @@ const RedactedBody = React.forwardRef(({mxEvent}, ref) => {
text = _t("Message deleted by %(name)s", { name: sender ? sender.name : redactedBecauseUserId });
}
+ const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
+ const fullDate = formatFullDate(new Date(unsigned.redacted_because.origin_server_ts), showTwelveHour);
+ const titleText = _t("Message deleted on %(date)s", { date: fullDate });
+
return (
-
+
{ text }
);
diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js
index bf65c7fb7c..254e28dffa 100644
--- a/src/components/views/rooms/E2EIcon.js
+++ b/src/components/views/rooms/E2EIcon.js
@@ -28,6 +28,7 @@ export const E2E_STATE = {
WARNING: "warning",
UNKNOWN: "unknown",
NORMAL: "normal",
+ UNAUTHENTICATED: "unauthenticated",
};
const crossSigningUserTitles = {
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 7508cf3372..88c4ed2e7d 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -313,35 +313,52 @@ export default createReactClass({
return;
}
- // If we directly trust the device, short-circuit here
- const verified = await this.context.isEventSenderVerified(mxEvent);
- if (verified) {
+ const encryptionInfo = this.context.getEventEncryptionInfo(mxEvent);
+ const senderId = mxEvent.getSender();
+ const userTrust = this.context.checkUserTrust(senderId);
+
+ if (encryptionInfo.mismatchedSender) {
+ // something definitely wrong is going on here
this.setState({
- verified: E2E_STATE.VERIFIED,
- }, () => {
- // Decryption may have caused a change in size
- this.props.onHeightChanged();
- });
+ verified: E2E_STATE.WARNING,
+ }, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
- if (!this.context.checkUserTrust(mxEvent.getSender()).isCrossSigningVerified()) {
+ if (!userTrust.isCrossSigningVerified()) {
+ // user is not verified, so default to everything is normal
this.setState({
verified: E2E_STATE.NORMAL,
- }, this.props.onHeightChanged);
+ }, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
- const eventSenderTrust = await this.context.checkEventSenderTrust(mxEvent);
+ const eventSenderTrust = this.context.checkDeviceTrust(
+ senderId, encryptionInfo.sender.deviceId,
+ );
if (!eventSenderTrust) {
this.setState({
verified: E2E_STATE.UNKNOWN,
- }, this.props.onHeightChanged); // Decryption may have cause a change in size
+ }, this.props.onHeightChanged); // Decryption may have caused a change in size
+ return;
+ }
+
+ if (!eventSenderTrust.isVerified()) {
+ this.setState({
+ verified: E2E_STATE.WARNING,
+ }, this.props.onHeightChanged); // Decryption may have caused a change in size
+ return;
+ }
+
+ if (!encryptionInfo.authenticated) {
+ this.setState({
+ verified: E2E_STATE.UNAUTHENTICATED,
+ }, this.props.onHeightChanged); // Decryption may have caused a change in size
return;
}
this.setState({
- verified: eventSenderTrust.isVerified() ? E2E_STATE.VERIFIED : E2E_STATE.WARNING,
+ verified: E2E_STATE.VERIFIED,
}, this.props.onHeightChanged); // Decryption may have caused a change in size
},
@@ -526,6 +543,8 @@ export default createReactClass({
return; // no icon if we've not even cross-signed the user
} else if (this.state.verified === E2E_STATE.VERIFIED) {
return; // no icon for verified
+ } else if (this.state.verified === E2E_STATE.UNAUTHENTICATED) {
+ return ();
} else if (this.state.verified === E2E_STATE.UNKNOWN) {
return ();
} else {
@@ -976,6 +995,12 @@ function E2ePadlockUnknown(props) {
);
}
+function E2ePadlockUnauthenticated(props) {
+ return (
+
+ );
+}
+
class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,
diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx
index b742f8e8e7..6929341845 100644
--- a/src/components/views/rooms/NotificationBadge.tsx
+++ b/src/components/views/rooms/NotificationBadge.tsx
@@ -18,27 +18,24 @@ import React from "react";
import classNames from "classnames";
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
import { Room } from "matrix-js-sdk/src/models/room";
-import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
-import AccessibleButton from "../../views/elements/AccessibleButton";
-import RoomAvatar from "../../views/avatars/RoomAvatar";
-import dis from '../../../dispatcher/dispatcher';
-import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
-import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { EventEmitter } from "events";
import { arrayDiff } from "../../../utils/arrays";
import { IDestroyable } from "../../../utils/IDestroyable";
+import SettingsStore from "../../../settings/SettingsStore";
+import { DefaultTagID, TagID } from "../../../stores/room-list/models";
+import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
export const NOTIFICATION_STATE_UPDATE = "update";
export enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
- Bold, // no badge, show as unread
+ Bold, // no badge, show as unread // TODO: This goes away with new notification structures
Grey, // unread notified messages
Red, // unread pings
}
@@ -53,18 +50,45 @@ interface IProps {
notification: INotificationState;
/**
- * If true, the badge will conditionally display a badge without count for the user.
+ * If true, the badge will show a count if at all possible. This is typically
+ * used to override the user's preference for things like room sublists.
*/
- allowNoCount: boolean;
+ forceCount: boolean;
+
+ /**
+ * The room ID, if any, the badge represents.
+ */
+ roomId?: string;
}
interface IState {
+ showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
}
export default class NotificationBadge extends React.PureComponent {
+ private countWatcherRef: string;
+
constructor(props: IProps) {
super(props);
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
+
+ this.state = {
+ showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
+ };
+
+ this.countWatcherRef = SettingsStore.watchSetting(
+ "Notifications.alwaysShowBadgeCounts", this.roomId,
+ this.countPreferenceChanged,
+ );
+ }
+
+ private get roomId(): string {
+ // We should convert this to null for safety with the SettingsStore
+ return this.props.roomId || null;
+ }
+
+ public componentWillUnmount() {
+ SettingsStore.unwatchSetting(this.countWatcherRef);
}
public componentDidUpdate(prevProps: Readonly) {
@@ -75,24 +99,34 @@ export default class NotificationBadge extends React.PureComponent {
+ this.setState({showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId)});
+ };
+
private onNotificationUpdate = () => {
this.forceUpdate(); // notification state changed - update
};
public render(): React.ReactElement {
// Don't show a badge if we don't need to
- if (this.props.notification.color <= NotificationColor.Bold) return null;
+ if (this.props.notification.color <= NotificationColor.None) return null;
const hasNotif = this.props.notification.color >= NotificationColor.Red;
const hasCount = this.props.notification.color >= NotificationColor.Grey;
- const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount");
+ const hasUnread = this.props.notification.color >= NotificationColor.Bold;
+ const couldBeEmpty = (!this.state.showCounts || hasUnread) && !hasNotif;
+ let isEmptyBadge = couldBeEmpty && (!this.state.showCounts || !hasCount);
+ if (this.props.forceCount) {
+ isEmptyBadge = false;
+ if (!hasCount) return null; // Can't render a badge
+ }
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
if (isEmptyBadge) symbol = "";
const classes = classNames({
'mx_NotificationBadge': true,
- 'mx_NotificationBadge_visible': hasCount,
+ 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount,
'mx_NotificationBadge_highlighted': hasNotif,
'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
@@ -107,14 +141,28 @@ export default class NotificationBadge extends React.PureComponent {
+ if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
+ if (room.roomId !== this.room.roomId) return; // not for us - ignore
+ this.updateNotificationState();
+ };
+
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
@@ -205,13 +259,38 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable
}
}
-export class ListNotificationState extends EventEmitter implements IDestroyable {
+export class TagSpecificNotificationState extends RoomNotificationState {
+ private static TAG_TO_COLOR: {
+ // @ts-ignore - TS wants this to be a string key, but we know better
+ [tagId: TagID]: NotificationColor,
+ } = {
+ [DefaultTagID.DM]: NotificationColor.Red,
+ };
+
+ private readonly colorWhenNotIdle?: NotificationColor;
+
+ constructor(room: Room, tagId: TagID) {
+ super(room);
+
+ const specificColor = TagSpecificNotificationState.TAG_TO_COLOR[tagId];
+ if (specificColor) this.colorWhenNotIdle = specificColor;
+ }
+
+ public get color(): NotificationColor {
+ if (!this.colorWhenNotIdle) return super.color;
+
+ if (super.color !== NotificationColor.None) return this.colorWhenNotIdle;
+ return super.color;
+ }
+}
+
+export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState {
private _count: number;
private _color: NotificationColor;
private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {};
- constructor(private byTileCount = false) {
+ constructor(private byTileCount = false, private tagId: TagID) {
super();
}
@@ -246,7 +325,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable
state.destroy();
}
for (const newRoom of diff.added) {
- const state = new RoomNotificationState(newRoom);
+ const state = new TagSpecificNotificationState(newRoom, this.tagId);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
if (this.states[newRoom.roomId]) {
// "Should never happen" disclaimer.
@@ -259,6 +338,12 @@ export class ListNotificationState extends EventEmitter implements IDestroyable
this.calculateTotalState();
}
+ public getForRoom(room: Room) {
+ const state = this.states[room.roomId];
+ if (!state) throw new Error("Unknown room for notification state");
+ return state;
+ }
+
public destroy() {
for (const state of Object.values(this.states)) {
state.destroy();
diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx
index 4fdce360c1..be3a7c4fdd 100644
--- a/src/components/views/rooms/RoomList2.tsx
+++ b/src/components/views/rooms/RoomList2.tsx
@@ -193,6 +193,7 @@ export default class RoomList2 extends React.Component {
components.push(
{
@@ -78,8 +81,9 @@ export default class RoomSublist2 extends React.Component {
super(props);
this.state = {
- notificationState: new ListNotificationState(this.props.isInvite),
+ notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
menuDisplayed: false,
+ isResizing: false,
};
this.state.notificationState.setRooms(this.props.rooms);
}
@@ -109,13 +113,21 @@ export default class RoomSublist2 extends React.Component {
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
+ private onResizeStart = () => {
+ this.setState({isResizing: true});
+ };
+
+ private onResizeStop = () => {
+ this.setState({isResizing: false});
+ };
+
private onShowAllClick = () => {
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onShowLessClick = () => {
- this.props.layout.visibleTiles = this.props.layout.minVisibleTiles;
+ this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
@@ -130,13 +142,13 @@ export default class RoomSublist2 extends React.Component {
};
private onUnreadFirstChanged = async () => {
- const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance;
+ const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
- await RoomListStore.instance.setListOrder(this.props.layout.tagId, newAlgorithm);
+ await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
};
private onTagSortChanged = async (sort: SortAlgorithm) => {
- await RoomListStore.instance.setTagSorting(this.props.layout.tagId, sort);
+ await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
};
private onMessagePreviewChanged = () => {
@@ -176,7 +188,7 @@ export default class RoomSublist2 extends React.Component {
key={`room-${room.roomId}`}
showMessagePreview={this.props.layout.showPreviews}
isMinimized={this.props.isMinimized}
- tag={this.props.layout.tagId}
+ tag={this.props.tagId}
/>
);
}
@@ -189,8 +201,8 @@ export default class RoomSublist2 extends React.Component {
let contextMenu = null;
if (this.state.menuDisplayed) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
- const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.layout.tagId) === SortAlgorithm.Alphabetic;
- const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.layout.tagId) === ListAlgorithm.Importance;
+ const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
+ const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
contextMenu = (
{
this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
- name={`mx_${this.props.layout.tagId}_sortBy`}
+ name={`mx_${this.props.tagId}_sortBy`}
>
{_t("Activity")}
this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical}
- name={`mx_${this.props.layout.tagId}_sortBy`}
+ name={`mx_${this.props.tagId}_sortBy`}
>
{_t("A-Z")}
@@ -267,7 +279,7 @@ export default class RoomSublist2 extends React.Component {
// TODO: Collapsed state
- const badge = ;
+ const badge = ;
let addRoomButton = null;
if (!!this.props.onAddRoom) {
@@ -291,7 +303,18 @@ export default class RoomSublist2 extends React.Component {
'mx_RoomSublist2_headerContainer_withAux': !!addRoomButton,
});
+ const badgeContainer = (
+
+ {badge}
+
+ );
+
// TODO: a11y (see old component)
+ // Note: the addRoomButton conditionally gets moved around
+ // the DOM depending on whether or not the list is minimized.
+ // If we're minimized, we want it below the header so it
+ // doesn't become sticky.
+ // The same applies to the notification badge.
return (
@@ -307,11 +330,11 @@ export default class RoomSublist2 extends React.Component
{
{this.props.label}
{this.renderMenu()}
- {addRoomButton}
-
- {badge}
-
+ {this.props.isMinimized ? null : badgeContainer}
+ {this.props.isMinimized ? null : addRoomButton}
+ {this.props.isMinimized ? badgeContainer : null}
+ {this.props.isMinimized ? addRoomButton : null}
);
}}
@@ -343,6 +366,12 @@ export default class RoomSublist2 extends React.Component {
const nVisible = Math.floor(layout.visibleTiles);
const visibleTiles = tiles.slice(0, nVisible);
+ const maxTilesFactored = layout.tilesWithResizerBoxFactor(tiles.length);
+ const showMoreBtnClasses = classNames({
+ 'mx_RoomSublist2_showNButton': true,
+ 'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
+ });
+
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
@@ -357,14 +386,14 @@ export default class RoomSublist2 extends React.Component {
);
if (this.props.isMinimized) showMoreText = null;
showNButton = (
-
+
{/* set by CSS masking */}
{showMoreText}
);
- } else if (tiles.length <= nVisible && tiles.length > this.props.layout.minVisibleTiles) {
+ } else if (tiles.length <= nVisible && tiles.length > this.props.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
let showLessText = (
@@ -373,7 +402,7 @@ export default class RoomSublist2 extends React.Component {
);
if (this.props.isMinimized) showLessText = null;
showNButton = (
-
+
{/* set by CSS masking */}
@@ -419,6 +448,8 @@ export default class RoomSublist2 extends React.Component
{
resizeHandles={handles}
onResize={this.onResize}
className="mx_RoomSublist2_resizeBox"
+ onResizeStart={this.onResizeStart}
+ onResizeStop={this.onResizeStop}
>
{visibleTiles}
{showNButton}
diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx
index 9f4870d437..2791bd9730 100644
--- a/src/components/views/rooms/RoomTile2.tsx
+++ b/src/components/views/rooms/RoomTile2.tsx
@@ -26,11 +26,15 @@ import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
-import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
+import NotificationBadge, {
+ INotificationState,
+ NotificationColor,
+ TagSpecificNotificationState
+} from "./NotificationBadge";
import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
-import { MessagePreviewStore } from "../../../stores/MessagePreviewStore";
+import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import RoomTileIcon from "./RoomTileIcon";
/*******************************************************************
@@ -79,7 +83,7 @@ export default class RoomTile2 extends React.Component {
this.state = {
hover: false,
- notificationState: new RoomNotificationState(this.props.room),
+ notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
generalMenuDisplayed: false,
};
@@ -131,11 +135,8 @@ export default class RoomTile2 extends React.Component {
ev.preventDefault();
ev.stopPropagation();
- if (tagId === DefaultTagID.DM) {
- // TODO: DM Flagging
- } else {
- // TODO: XOR favourites and low priority
- }
+ // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211
+ // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210
};
private onLeaveRoomClick = (ev: ButtonEvent) => {
@@ -192,12 +193,6 @@ export default class RoomTile2 extends React.Component {
{_t("Low Priority")}
-
- this.onTagRoom(e, DefaultTagID.DM)}>
-
- {_t("Direct Chat")}
-
-
@@ -248,7 +243,13 @@ export default class RoomTile2 extends React.Component {
'mx_RoomTile2_minimized': this.props.isMinimized,
});
- const badge = ;
+ const badge = (
+
+ );
// TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name;
@@ -261,7 +262,7 @@ export default class RoomTile2 extends React.Component {
let messagePreview = null;
if (this.props.showMessagePreview && !this.props.isMinimized) {
// The preview store heavily caches this info, so should be safe to hammer.
- const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room);
+ const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
// Only show the preview if there is one to show.
if (text) {
diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js
index 7eb239cbca..aa512d4365 100644
--- a/src/components/views/settings/CrossSigningPanel.js
+++ b/src/components/views/settings/CrossSigningPanel.js
@@ -154,13 +154,6 @@ export default class CrossSigningPanel extends React.PureComponent {
errorSection = {error.toString()}
;
}
- // Whether the various keys exist on your account (but not necessarily
- // on this device).
- const enabledForAccount = (
- crossSigningPrivateKeysInStorage &&
- secretStorageKeyInAccount
- );
-
let summarisedStatus;
if (homeserverSupportsCrossSigning === undefined) {
const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner');
@@ -184,8 +177,19 @@ export default class CrossSigningPanel extends React.PureComponent {
)};
}
+ const keysExistAnywhere = (
+ secretStorageKeyInAccount ||
+ crossSigningPrivateKeysInStorage ||
+ crossSigningPublicKeysOnDevice
+ );
+ const keysExistEverywhere = (
+ secretStorageKeyInAccount &&
+ crossSigningPrivateKeysInStorage &&
+ crossSigningPublicKeysOnDevice
+ );
+
let resetButton;
- if (enabledForAccount) {
+ if (keysExistAnywhere) {
resetButton = (
@@ -197,10 +201,7 @@ export default class CrossSigningPanel extends React.PureComponent {
// TODO: determine how better to expose this to users in addition to prompts at login/toast
let bootstrapButton;
- if (
- (!enabledForAccount || !crossSigningPublicKeysOnDevice) &&
- homeserverSupportsCrossSigning
- ) {
+ if (!keysExistEverywhere && homeserverSupportsCrossSigning) {
bootstrapButton = (
diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.js b/src/components/views/settings/tabs/room/NotificationSettingsTab.js
index 96e6b3d354..c521e228e0 100644
--- a/src/components/views/settings/tabs/room/NotificationSettingsTab.js
+++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.js
@@ -39,12 +39,11 @@ export default class NotificationsSettingsTab extends React.Component {
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
- Notifier.getSoundForRoom(this.props.roomId).then((soundData) => {
- if (!soundData) {
- return;
- }
- this.setState({currentSound: soundData.name || soundData.url});
- });
+ const soundData = Notifier.getSoundForRoom(this.props.roomId);
+ if (!soundData) {
+ return;
+ }
+ this.setState({currentSound: soundData.name || soundData.url});
this._soundUpload = createRef();
}
diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
index 46723ec7cd..f02147608d 100644
--- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
@@ -21,7 +21,6 @@ import {_t} from "../../../../../languageHandler";
import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore";
import { enumerateThemes } from "../../../../../theme";
import ThemeWatcher from "../../../../../settings/watchers/ThemeWatcher";
-import Field from "../../../elements/Field";
import Slider from "../../../elements/Slider";
import AccessibleButton from "../../../elements/AccessibleButton";
import dis from "../../../../../dispatcher/dispatcher";
@@ -32,7 +31,9 @@ import { IValidationResult, IFieldState } from '../../../elements/Validation';
import StyledRadioButton from '../../../elements/StyledRadioButton';
import StyledCheckbox from '../../../elements/StyledCheckbox';
import SettingsFlag from '../../../elements/SettingsFlag';
+import Field from '../../../elements/Field';
import EventTilePreview from '../../../elements/EventTilePreview';
+import StyledRadioGroup from "../../../elements/StyledRadioGroup";
interface IProps {
}
@@ -55,6 +56,9 @@ interface IState extends IThemeState {
customThemeUrl: string;
customThemeMessage: CustomThemeMessage;
useCustomFontSize: boolean;
+ useSystemFont: boolean;
+ systemFont: string;
+ showAdvanced: boolean;
useIRCLayout: boolean;
}
@@ -73,6 +77,9 @@ export default class AppearanceUserSettingsTab extends React.Component): void => {
- const newTheme = e.target.value;
+ private onThemeChange = (newTheme: string): void => {
if (this.state.theme === newTheme) return;
// doing getValue in the .catch will still return the value we failed to set,
@@ -271,19 +277,18 @@ export default class AppearanceUserSettingsTab extends React.Component
{_t("Theme")}
{systemThemeSection}
-
- {orderedThemes.map(theme => {
- return
- {theme.name}
- ;
- })}
+
+ ({
+ value: t.id,
+ label: t.name,
+ disabled: this.state.useSystemTheme,
+ className: "mx_ThemeSelector_" + t.id,
+ }))}
+ onChange={this.onThemeChange}
+ value={this.state.useSystemTheme ? undefined : this.state.theme}
+ />
{customThemeForm}
@@ -374,6 +379,53 @@ export default class AppearanceUserSettingsTab extends React.Component
;
};
+ private renderAdvancedSection() {
+ const toggle = this.setState({showAdvanced: !this.state.showAdvanced})}
+ >
+ {this.state.showAdvanced ? "Hide advanced" : "Show advanced"}
+
;
+
+ let advanced: React.ReactNode;
+
+ if (this.state.showAdvanced) {
+ advanced = <>
+
+ this.setState({useSystemFont: checked})}
+ />
+ {
+ this.setState({
+ systemFont: value.target.value,
+ });
+
+ SettingsStore.setValue("systemFont", null, SettingLevel.DEVICE, value.target.value);
+ }}
+ tooltipContent="Set the name of a font installed on your system & Riot will attempt to use it."
+ forceTooltipVisible={true}
+ disabled={!this.state.useSystemFont}
+ value={this.state.systemFont}
+ />
+ >;
+ }
+ return
+ {toggle}
+ {advanced}
+
;
+ }
+
render() {
return (
@@ -384,6 +436,7 @@ export default class AppearanceUserSettingsTab extends React.Component
);
}
diff --git a/src/components/views/terms/InlineTermsAgreement.js b/src/components/views/terms/InlineTermsAgreement.js
index bccd686cd3..55719fe57f 100644
--- a/src/components/views/terms/InlineTermsAgreement.js
+++ b/src/components/views/terms/InlineTermsAgreement.js
@@ -18,6 +18,7 @@ import React from "react";
import PropTypes from "prop-types";
import {_t, pickBestLanguage} from "../../../languageHandler";
import * as sdk from "../../..";
+import {objectClone} from "../../../utils/objects";
export default class InlineTermsAgreement extends React.Component {
static propTypes = {
@@ -56,7 +57,7 @@ export default class InlineTermsAgreement extends React.Component {
}
_togglePolicy = (index) => {
- const policies = JSON.parse(JSON.stringify(this.state.policies)); // deep & cheap clone
+ const policies = objectClone(this.state.policies);
policies[index].checked = !policies[index].checked;
this.setState({policies});
};
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 5f7ca1293c..379a0a4451 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -69,4 +69,14 @@ export enum Action {
* Opens the user menu (previously known as the top left menu). No additional payload information required.
*/
ToggleUserMenu = "toggle_user_menu",
+
+ /**
+ * Sets the apps root font size. Should be used with UpdateFontSizePayload
+ */
+ UpdateFontSize = "update_font_size",
+
+ /**
+ * Sets a system font. Should be used with UpdateSystemFontPayload
+ */
+ UpdateSystemFont = "update_system_font",
}
diff --git a/src/dispatcher/payloads/UpdateFontSizePayload.ts b/src/dispatcher/payloads/UpdateFontSizePayload.ts
new file mode 100644
index 0000000000..6577acd594
--- /dev/null
+++ b/src/dispatcher/payloads/UpdateFontSizePayload.ts
@@ -0,0 +1,27 @@
+/*
+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 { ActionPayload } from "../payloads";
+import { Action } from "../actions";
+
+export interface UpdateFontSizePayload extends ActionPayload {
+ action: Action.UpdateFontSize;
+
+ /**
+ * The font size to set the root to
+ */
+ size: number;
+}
diff --git a/src/dispatcher/payloads/UpdateSystemFontPayload.ts b/src/dispatcher/payloads/UpdateSystemFontPayload.ts
new file mode 100644
index 0000000000..aa59db5aa9
--- /dev/null
+++ b/src/dispatcher/payloads/UpdateSystemFontPayload.ts
@@ -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 { ActionPayload } from "../payloads";
+import { Action } from "../actions";
+
+export interface UpdateSystemFontPayload extends ActionPayload {
+ action: Action.UpdateSystemFont;
+
+ /**
+ * Specify whether to use a system font or the stylesheet font
+ */
+ useSystemFont: boolean;
+
+ /**
+ * The system font to use
+ */
+ font: string;
+}
diff --git a/src/hooks/useAccountData.ts b/src/hooks/useAccountData.ts
new file mode 100644
index 0000000000..dd0d53f0d3
--- /dev/null
+++ b/src/hooks/useAccountData.ts
@@ -0,0 +1,50 @@
+/*
+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 {useCallback, useState} from "react";
+import {MatrixClient} from "matrix-js-sdk/src/client";
+import {MatrixEvent} from "matrix-js-sdk/src/models/event";
+import {Room} from "matrix-js-sdk/src/models/room";
+
+import {useEventEmitter} from "./useEventEmitter";
+
+const tryGetContent = (ev?: MatrixEvent) => ev ? ev.getContent() : undefined;
+
+// Hook to simplify listening to Matrix account data
+export const useAccountData = (cli: MatrixClient, eventType: string) => {
+ const [value, setValue] = useState(() => tryGetContent(cli.getAccountData(eventType)));
+
+ const handler = useCallback((event) => {
+ if (event.getType() !== eventType) return;
+ setValue(event.getContent());
+ }, [cli, eventType]);
+ useEventEmitter(cli, "accountData", handler);
+
+ return value || {} as T;
+};
+
+// Hook to simplify listening to Matrix room account data
+export const useRoomAccountData = (room: Room, eventType: string) => {
+ const [value, setValue] = useState(() => tryGetContent(room.getAccountData(eventType)));
+
+ const handler = useCallback((event) => {
+ if (event.getType() !== eventType) return;
+ setValue(event.getContent());
+ }, [room, eventType]);
+ useEventEmitter(room, "Room.accountData", handler);
+
+ return value || {} as T;
+};
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 646f43af33..495300f3fe 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -247,7 +247,6 @@
"%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.",
"%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.",
"%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.",
- "sent an image.": "sent an image.",
"%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.",
"%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.",
"%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.",
@@ -421,11 +420,66 @@
"Restart": "Restart",
"Upgrade your Riot": "Upgrade your Riot",
"A new version of Riot is available!": "A new version of Riot is available!",
- "You: %(message)s": "You: %(message)s",
+ "Guest": "Guest",
"There was an error joining the room": "There was an error joining the room",
"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",
+ "You joined the call": "You joined the call",
+ "%(senderName)s joined the call": "%(senderName)s joined the call",
+ "Call in progress": "Call in progress",
+ "You left the call": "You left the call",
+ "%(senderName)s left the call": "%(senderName)s left the call",
+ "Call ended": "Call ended",
+ "You started a call": "You started a call",
+ "%(senderName)s started a call": "%(senderName)s started a call",
+ "Waiting for answer": "Waiting for answer",
+ "%(senderName)s is calling": "%(senderName)s is calling",
+ "You created the room": "You created the room",
+ "%(senderName)s created the room": "%(senderName)s created the room",
+ "You made the chat encrypted": "You made the chat encrypted",
+ "%(senderName)s made the chat encrypted": "%(senderName)s made the chat encrypted",
+ "You made history visible to new members": "You made history visible to new members",
+ "%(senderName)s made history visible to new members": "%(senderName)s made history visible to new members",
+ "You made history visible to anyone": "You made history visible to anyone",
+ "%(senderName)s made history visible to anyone": "%(senderName)s made history visible to anyone",
+ "You made history visible to future members": "You made history visible to future members",
+ "%(senderName)s made history visible to future members": "%(senderName)s made history visible to future members",
+ "You were invited": "You were invited",
+ "%(targetName)s was invited": "%(targetName)s was invited",
+ "You left": "You left",
+ "%(targetName)s left": "%(targetName)s left",
+ "You were kicked (%(reason)s)": "You were kicked (%(reason)s)",
+ "%(targetName)s was kicked (%(reason)s)": "%(targetName)s was kicked (%(reason)s)",
+ "You were kicked": "You were kicked",
+ "%(targetName)s was kicked": "%(targetName)s was kicked",
+ "You rejected the invite": "You rejected the invite",
+ "%(targetName)s rejected the invite": "%(targetName)s rejected the invite",
+ "You were uninvited": "You were uninvited",
+ "%(targetName)s was uninvited": "%(targetName)s was uninvited",
+ "You were banned (%(reason)s)": "You were banned (%(reason)s)",
+ "%(targetName)s was banned (%(reason)s)": "%(targetName)s was banned (%(reason)s)",
+ "You were banned": "You were banned",
+ "%(targetName)s was banned": "%(targetName)s was banned",
+ "You joined": "You joined",
+ "%(targetName)s joined": "%(targetName)s joined",
+ "You changed your name": "You changed your name",
+ "%(targetName)s changed their name": "%(targetName)s changed their name",
+ "You changed your avatar": "You changed your avatar",
+ "%(targetName)s changed their avatar": "%(targetName)s changed their avatar",
+ "%(senderName)s %(emote)s": "%(senderName)s %(emote)s",
+ "%(senderName)s: %(message)s": "%(senderName)s: %(message)s",
+ "You changed the room name": "You changed the room name",
+ "%(senderName)s changed the room name": "%(senderName)s changed the room name",
+ "%(senderName)s: %(reaction)s": "%(senderName)s: %(reaction)s",
+ "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
+ "You uninvited %(targetName)s": "You uninvited %(targetName)s",
+ "%(senderName)s uninvited %(targetName)s": "%(senderName)s uninvited %(targetName)s",
+ "You invited %(targetName)s": "You invited %(targetName)s",
+ "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s",
+ "You changed the room topic": "You changed the room topic",
+ "%(senderName)s changed the room topic": "%(senderName)s changed the room topic",
+ "New spinner design": "New spinner design",
"Font scaling": "Font scaling",
"Message Pinning": "Message Pinning",
"Custom user status messages": "Custom user status messages",
@@ -440,7 +494,7 @@
"Font size": "Font size",
"Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
- "Use compact timeline layout": "Use compact timeline layout",
+ "Use a more compact ‘Modern’ layout": "Use a more compact ‘Modern’ layout",
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
"Show join/leave messages (invites/kicks/bans unaffected)": "Show join/leave messages (invites/kicks/bans unaffected)",
"Show avatar changes": "Show avatar changes",
@@ -460,6 +514,8 @@
"Mirror local video feed": "Mirror local video feed",
"Enable Community Filter Panel": "Enable Community Filter Panel",
"Match system theme": "Match system theme",
+ "Use a system font": "Use a system font",
+ "System font name": "System font name",
"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 sessions from this session": "Never send encrypted messages to unverified sessions from this session",
@@ -1028,6 +1084,7 @@
"Encrypted by an unverified session": "Encrypted by an unverified session",
"Unencrypted": "Unencrypted",
"Encrypted by a deleted session": "Encrypted by a deleted session",
+ "The authenticity of this encrypted message can't be guaranteed on this device.": "The authenticity of this encrypted message can't be guaranteed on this device.",
"Please select the destination room for this message": "Please select the destination room for this message",
"Invite only": "Invite only",
"Scroll to most recent messages": "Scroll to most recent messages",
@@ -1163,7 +1220,6 @@
"Unread messages.": "Unread messages.",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
- "Direct Chat": "Direct Chat",
"Leave Room": "Leave Room",
"Room options": "Room options",
"Add a topic": "Add a topic",
@@ -1371,6 +1427,7 @@
"reacted with %(shortName)s": "reacted with %(shortName)s",
"Message deleted": "Message deleted",
"Message deleted by %(name)s": "Message deleted by %(name)s",
+ "Message deleted on %(date)s": "Message deleted on %(date)s",
"%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s",
"%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.",
"%(senderDisplayName)s changed the room avatar to ": "%(senderDisplayName)s changed the room avatar to ",
@@ -1841,6 +1898,7 @@
"Mentions only": "Mentions only",
"Leave": "Leave",
"Forget": "Forget",
+ "Direct Chat": "Direct Chat",
"Clear status": "Clear status",
"Update status": "Update status",
"Set status": "Set status",
@@ -2056,7 +2114,6 @@
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position",
- "Guest": "Guest",
"Your profile": "Your profile",
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
diff --git a/src/notifications/ContentRules.js b/src/notifications/ContentRules.ts
similarity index 69%
rename from src/notifications/ContentRules.js
rename to src/notifications/ContentRules.ts
index 8c285220c7..a3ec017e37 100644
--- a/src/notifications/ContentRules.js
+++ b/src/notifications/ContentRules.ts
@@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+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.
@@ -15,9 +15,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-'use strict';
+import {PushRuleVectorState, State} from "./PushRuleVectorState";
+import {IExtendedPushRule, IPushRuleSet, IRuleSets} from "./types";
-import {PushRuleVectorState} from "./PushRuleVectorState";
+export interface IContentRules {
+ vectorState: State;
+ rules: IExtendedPushRule[];
+ externalRules: IExtendedPushRule[];
+}
+
+export const SCOPE = "global";
+export const KIND = "content";
export class ContentRules {
/**
@@ -31,7 +39,7 @@ export class ContentRules {
* externalRules: a list of other keyword rules, with states other than
* vectorState
*/
- static parseContentRules(rulesets) {
+ static parseContentRules(rulesets: IRuleSets): IContentRules {
// first categorise the keyword rules in terms of their actions
const contentRules = this._categoriseContentRules(rulesets);
@@ -51,59 +59,72 @@ export class ContentRules {
if (contentRules.loud.length) {
return {
- vectorState: PushRuleVectorState.LOUD,
+ vectorState: State.Loud,
rules: contentRules.loud,
- externalRules: [].concat(contentRules.loud_but_disabled, contentRules.on, contentRules.on_but_disabled, contentRules.other),
+ externalRules: [
+ ...contentRules.loud_but_disabled,
+ ...contentRules.on,
+ ...contentRules.on_but_disabled,
+ ...contentRules.other,
+ ],
};
} else if (contentRules.loud_but_disabled.length) {
return {
- vectorState: PushRuleVectorState.OFF,
+ vectorState: State.Off,
rules: contentRules.loud_but_disabled,
- externalRules: [].concat(contentRules.on, contentRules.on_but_disabled, contentRules.other),
+ externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other],
};
} else if (contentRules.on.length) {
return {
- vectorState: PushRuleVectorState.ON,
+ vectorState: State.On,
rules: contentRules.on,
- externalRules: [].concat(contentRules.on_but_disabled, contentRules.other),
+ externalRules: [...contentRules.on_but_disabled, ...contentRules.other],
};
} else if (contentRules.on_but_disabled.length) {
return {
- vectorState: PushRuleVectorState.OFF,
+ vectorState: State.Off,
rules: contentRules.on_but_disabled,
externalRules: contentRules.other,
};
} else {
return {
- vectorState: PushRuleVectorState.ON,
+ vectorState: State.On,
rules: [],
externalRules: contentRules.other,
};
}
}
- static _categoriseContentRules(rulesets) {
- const contentRules = {on: [], on_but_disabled: [], loud: [], loud_but_disabled: [], other: []};
+ static _categoriseContentRules(rulesets: IRuleSets) {
+ const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = {
+ on: [],
+ on_but_disabled: [],
+ loud: [],
+ loud_but_disabled: [],
+ other: [],
+ };
+
for (const kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i];
// check it's not a default rule
- if (r.rule_id[0] === '.' || kind !== 'content') {
+ if (r.rule_id[0] === '.' || kind !== "content") {
continue;
}
- r.kind = kind; // is this needed? not sure
+ // this is needed as we are flattening an object of arrays into a single array
+ r.kind = kind;
switch (PushRuleVectorState.contentRuleVectorStateKind(r)) {
- case PushRuleVectorState.ON:
+ case State.On:
if (r.enabled) {
contentRules.on.push(r);
} else {
contentRules.on_but_disabled.push(r);
}
break;
- case PushRuleVectorState.LOUD:
+ case State.Loud:
if (r.enabled) {
contentRules.loud.push(r);
} else {
diff --git a/src/notifications/NotificationUtils.js b/src/notifications/NotificationUtils.ts
similarity index 80%
rename from src/notifications/NotificationUtils.js
rename to src/notifications/NotificationUtils.ts
index bf393da060..e3b7f66447 100644
--- a/src/notifications/NotificationUtils.js
+++ b/src/notifications/NotificationUtils.ts
@@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+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.
@@ -15,7 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-'use strict';
+import {Action, Actions} from "./types";
+
+interface IEncodedActions {
+ notify: boolean;
+ sound?: string;
+ highlight?: boolean;
+}
export class NotificationUtils {
// Encodes a dictionary of {
@@ -24,12 +30,12 @@ export class NotificationUtils {
// "highlight: true/false,
// }
// to a list of push actions.
- static encodeActions(action) {
+ static encodeActions(action: IEncodedActions) {
const notify = action.notify;
const sound = action.sound;
const highlight = action.highlight;
if (notify) {
- const actions = ["notify"];
+ const actions: Action[] = [Actions.Notify];
if (sound) {
actions.push({"set_tweak": "sound", "value": sound});
}
@@ -40,7 +46,7 @@ export class NotificationUtils {
}
return actions;
} else {
- return ["dont_notify"];
+ return [Actions.DontNotify];
}
}
@@ -50,18 +56,18 @@ export class NotificationUtils {
// "highlight: true/false,
// }
// If the actions couldn't be decoded then returns null.
- static decodeActions(actions) {
+ static decodeActions(actions: Action[]): IEncodedActions {
let notify = false;
let sound = null;
let highlight = false;
for (let i = 0; i < actions.length; ++i) {
const action = actions[i];
- if (action === "notify") {
+ if (action === Actions.Notify) {
notify = true;
- } else if (action === "dont_notify") {
+ } else if (action === Actions.DontNotify) {
notify = false;
- } else if (typeof action === 'object') {
+ } else if (typeof action === "object") {
if (action.set_tweak === "sound") {
sound = action.value;
} else if (action.set_tweak === "highlight") {
@@ -81,7 +87,7 @@ export class NotificationUtils {
highlight = true;
}
- const result = {notify: notify, highlight: highlight};
+ const result: IEncodedActions = { notify, highlight };
if (sound !== null) {
result.sound = sound;
}
diff --git a/src/notifications/PushRuleVectorState.js b/src/notifications/PushRuleVectorState.ts
similarity index 69%
rename from src/notifications/PushRuleVectorState.js
rename to src/notifications/PushRuleVectorState.ts
index 263226ce1c..d33426cfc4 100644
--- a/src/notifications/PushRuleVectorState.js
+++ b/src/notifications/PushRuleVectorState.ts
@@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+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.
@@ -15,43 +15,42 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-'use strict';
-
import {StandardActions} from "./StandardActions";
import {NotificationUtils} from "./NotificationUtils";
+import {IPushRule} from "./types";
+
+export enum State {
+ /** The push rule is disabled */
+ Off = "off",
+ /** The user will receive push notification for this rule */
+ On = "on",
+ /** The user will receive push notification for this rule with sound and
+ highlight if this is legitimate */
+ Loud = "loud",
+}
export class PushRuleVectorState {
- // Backwards compatibility (things should probably be using .states instead)
- static OFF = "off";
- static ON = "on";
- static LOUD = "loud";
+ // Backwards compatibility (things should probably be using the enum above instead)
+ static OFF = State.Off;
+ static ON = State.On;
+ static LOUD = State.Loud;
/**
* Enum for state of a push rule as defined by the Vector UI.
* @readonly
* @enum {string}
*/
- static states = {
- /** The push rule is disabled */
- OFF: PushRuleVectorState.OFF,
-
- /** The user will receive push notification for this rule */
- ON: PushRuleVectorState.ON,
-
- /** The user will receive push notification for this rule with sound and
- highlight if this is legitimate */
- LOUD: PushRuleVectorState.LOUD,
- };
+ static states = State;
/**
* Convert a PushRuleVectorState to a list of actions
*
* @return [object] list of push-rule actions
*/
- static actionsFor(pushRuleVectorState) {
- if (pushRuleVectorState === PushRuleVectorState.ON) {
+ static actionsFor(pushRuleVectorState: State) {
+ if (pushRuleVectorState === State.On) {
return StandardActions.ACTION_NOTIFY;
- } else if (pushRuleVectorState === PushRuleVectorState.LOUD) {
+ } else if (pushRuleVectorState === State.Loud) {
return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND;
}
}
@@ -63,7 +62,7 @@ export class PushRuleVectorState {
* category or in PushRuleVectorState.LOUD, regardless of its enabled
* state. Returns null if it does not match these categories.
*/
- static contentRuleVectorStateKind(rule) {
+ static contentRuleVectorStateKind(rule: IPushRule): State {
const decoded = NotificationUtils.decodeActions(rule.actions);
if (!decoded) {
@@ -81,10 +80,10 @@ export class PushRuleVectorState {
let stateKind = null;
switch (tweaks) {
case 0:
- stateKind = PushRuleVectorState.ON;
+ stateKind = State.On;
break;
case 2:
- stateKind = PushRuleVectorState.LOUD;
+ stateKind = State.Loud;
break;
}
return stateKind;
diff --git a/src/notifications/StandardActions.js b/src/notifications/StandardActions.ts
similarity index 98%
rename from src/notifications/StandardActions.js
rename to src/notifications/StandardActions.ts
index b54cea332a..c17010af9a 100644
--- a/src/notifications/StandardActions.js
+++ b/src/notifications/StandardActions.ts
@@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-'use strict';
-
import {NotificationUtils} from "./NotificationUtils";
const encodeActions = NotificationUtils.encodeActions;
diff --git a/src/notifications/types.ts b/src/notifications/types.ts
new file mode 100644
index 0000000000..9622193740
--- /dev/null
+++ b/src/notifications/types.ts
@@ -0,0 +1,111 @@
+/*
+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.
+*/
+
+export enum NotificationSetting {
+ AllMessages = "all_messages", // .m.rule.message = notify
+ DirectMessagesMentionsKeywords = "dm_mentions_keywords", // .m.rule.message = mark_unread. This is the new default.
+ MentionsKeywordsOnly = "mentions_keywords", // .m.rule.message = mark_unread; .m.rule.room_one_to_one = mark_unread
+ Never = "never", // .m.rule.master = enabled (dont_notify)
+}
+
+export interface ISoundTweak {
+ set_tweak: "sound";
+ value: string;
+}
+export interface IHighlightTweak {
+ set_tweak: "highlight";
+ value?: boolean;
+}
+
+export type Tweak = ISoundTweak | IHighlightTweak;
+
+export enum Actions {
+ Notify = "notify",
+ DontNotify = "dont_notify", // no-op
+ Coalesce = "coalesce", // unused
+ MarkUnread = "mark_unread", // new
+}
+
+export type Action = Actions | Tweak;
+
+// Push rule kinds in descending priority order
+export enum Kind {
+ Override = "override",
+ ContentSpecific = "content",
+ RoomSpecific = "room",
+ SenderSpecific = "sender",
+ Underride = "underride",
+}
+
+export interface IEventMatchCondition {
+ kind: "event_match";
+ key: string;
+ pattern: string;
+}
+
+export interface IContainsDisplayNameCondition {
+ kind: "contains_display_name";
+}
+
+export interface IRoomMemberCountCondition {
+ kind: "room_member_count";
+ is: string;
+}
+
+export interface ISenderNotificationPermissionCondition {
+ kind: "sender_notification_permission";
+ key: string;
+}
+
+export type Condition =
+ IEventMatchCondition |
+ IContainsDisplayNameCondition |
+ IRoomMemberCountCondition |
+ ISenderNotificationPermissionCondition;
+
+export enum RuleIds {
+ MasterRule = ".m.rule.master", // The master rule (all notifications disabling)
+ MessageRule = ".m.rule.message",
+ EncryptedMessageRule = ".m.rule.encrypted",
+ RoomOneToOneRule = ".m.rule.room_one_to_one",
+ EncryptedRoomOneToOneRule = ".m.rule.room_one_to_one",
+}
+
+export interface IPushRule {
+ enabled: boolean;
+ rule_id: RuleIds | string;
+ actions: Action[];
+ default: boolean;
+ conditions?: Condition[]; // only applicable to `underride` and `override` rules
+ pattern?: string; // only applicable to `content` rules
+}
+
+// push rule extended with kind, used by ContentRules and js-sdk's pushprocessor
+export interface IExtendedPushRule extends IPushRule {
+ kind: Kind;
+}
+
+export interface IPushRuleSet {
+ override: IPushRule[];
+ content: IPushRule[];
+ room: IPushRule[];
+ sender: IPushRule[];
+ underride: IPushRule[];
+}
+
+export interface IRuleSets {
+ global: IPushRuleSet;
+}
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index ca8647e067..820329f6c6 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -30,6 +30,8 @@ import PushToMatrixClientController from './controllers/PushToMatrixClientContro
import ReloadOnChangeController from "./controllers/ReloadOnChangeController";
import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases";
import FontSizeController from './controllers/FontSizeController';
+import SystemFontController from './controllers/SystemFontController';
+import UseSystemFontController from './controllers/UseSystemFontController';
// 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'];
@@ -95,6 +97,12 @@ export const SETTINGS = {
// // not use this for new settings.
// invertedSettingName: "my-negative-setting",
// },
+ "feature_new_spinner": {
+ isFeature: true,
+ displayName: _td("New spinner design"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"feature_font_scaling": {
isFeature: true,
displayName: _td("Font scaling"),
@@ -188,9 +196,14 @@ export const SETTINGS = {
default: true,
invertedSettingName: 'MessageComposerInput.dontSuggestEmoji',
},
+ // TODO: Wire up appropriately to UI (FTUE notifications)
+ "Notifications.alwaysShowBadgeCounts": {
+ supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
+ default: false,
+ },
"useCompactLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
- displayName: _td('Use compact timeline layout'),
+ displayName: _td('Use a more compact ‘Modern’ layout'),
default: false,
},
"showRedactions": {
@@ -314,6 +327,18 @@ export const SETTINGS = {
default: true,
displayName: _td("Match system theme"),
},
+ "useSystemFont": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: false,
+ displayName: _td("Use a system font"),
+ controller: new UseSystemFontController(),
+ },
+ "systemFont": {
+ supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
+ default: "",
+ displayName: _td("System font name"),
+ controller: new SystemFontController(),
+ },
"webRtcAllowPeerToPeer": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
displayName: _td('Allow Peer-to-Peer for 1:1 calls'),
diff --git a/src/settings/WatchManager.js b/src/settings/WatchManager.js
index 472b13966f..3f54ca929e 100644
--- a/src/settings/WatchManager.js
+++ b/src/settings/WatchManager.js
@@ -51,8 +51,17 @@ export class WatchManager {
const roomWatchers = this._watchers[settingName];
const callbacks = [];
- if (inRoomId !== null && roomWatchers[inRoomId]) callbacks.push(...roomWatchers[inRoomId]);
- if (roomWatchers[null]) callbacks.push(...roomWatchers[null]);
+ if (inRoomId !== null && roomWatchers[inRoomId]) {
+ callbacks.push(...roomWatchers[inRoomId]);
+ }
+
+ if (!inRoomId) {
+ // Fire updates to all the individual room watchers too, as they probably
+ // care about the change higher up.
+ callbacks.push(...Object.values(roomWatchers).reduce((r, a) => [...r, ...a], []));
+ } else if (roomWatchers[null]) {
+ callbacks.push(...roomWatchers[null]);
+ }
for (const callback of callbacks) {
callback(inRoomId, atLevel, newValueAtLevel);
diff --git a/src/settings/controllers/FontSizeController.js b/src/settings/controllers/FontSizeController.ts
similarity index 80%
rename from src/settings/controllers/FontSizeController.js
rename to src/settings/controllers/FontSizeController.ts
index 3ef01ab99b..6440fd32fe 100644
--- a/src/settings/controllers/FontSizeController.js
+++ b/src/settings/controllers/FontSizeController.ts
@@ -16,6 +16,8 @@ limitations under the License.
import SettingController from "./SettingController";
import dis from "../../dispatcher/dispatcher";
+import { UpdateFontSizePayload } from "../../dispatcher/payloads/UpdateFontSizePayload";
+import { Action } from "../../dispatcher/actions";
export default class FontSizeController extends SettingController {
constructor() {
@@ -24,8 +26,8 @@ export default class FontSizeController extends SettingController {
onChange(level, roomId, newValue) {
// Dispatch font size change so that everything open responds to the change.
- dis.dispatch({
- action: "update-font-size",
+ dis.dispatch({
+ action: Action.UpdateFontSize,
size: newValue,
});
}
diff --git a/src/settings/controllers/SystemFontController.ts b/src/settings/controllers/SystemFontController.ts
new file mode 100644
index 0000000000..4f591efc17
--- /dev/null
+++ b/src/settings/controllers/SystemFontController.ts
@@ -0,0 +1,36 @@
+/*
+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 SettingsStore from "../SettingsStore";
+import dis from "../../dispatcher/dispatcher";
+import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload";
+import { Action } from "../../dispatcher/actions";
+
+export default class SystemFontController extends SettingController {
+ constructor() {
+ super();
+ }
+
+ onChange(level, roomId, newValue) {
+ // Dispatch font size change so that everything open responds to the change.
+ dis.dispatch({
+ action: Action.UpdateSystemFont,
+ useSystemFont: SettingsStore.getValue("useSystemFont"),
+ font: newValue,
+ });
+ }
+}
diff --git a/src/settings/controllers/UseSystemFontController.ts b/src/settings/controllers/UseSystemFontController.ts
new file mode 100644
index 0000000000..d598b25962
--- /dev/null
+++ b/src/settings/controllers/UseSystemFontController.ts
@@ -0,0 +1,36 @@
+/*
+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 SettingsStore from "../SettingsStore";
+import dis from "../../dispatcher/dispatcher";
+import { UpdateSystemFontPayload } from "../../dispatcher/payloads/UpdateSystemFontPayload";
+import { Action } from "../../dispatcher/actions";
+
+export default class UseSystemFontController extends SettingController {
+ constructor() {
+ super();
+ }
+
+ onChange(level, roomId, newValue) {
+ // Dispatch font size change so that everything open responds to the change.
+ dis.dispatch({
+ action: Action.UpdateSystemFont,
+ useSystemFont: newValue,
+ font: SettingsStore.getValue("systemFont"),
+ });
+ }
+}
diff --git a/src/settings/handlers/AccountSettingsHandler.js b/src/settings/handlers/AccountSettingsHandler.js
index fea2e92c62..732ce6c550 100644
--- a/src/settings/handlers/AccountSettingsHandler.js
+++ b/src/settings/handlers/AccountSettingsHandler.js
@@ -18,6 +18,7 @@ limitations under the License.
import {MatrixClientPeg} from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore";
+import {objectClone, objectKeyChanges} from "../../utils/objects";
const BREADCRUMBS_LEGACY_EVENT_TYPE = "im.vector.riot.breadcrumb_rooms";
const BREADCRUMBS_EVENT_TYPE = "im.vector.setting.breadcrumbs";
@@ -45,7 +46,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
newClient.on("accountData", this._onAccountData);
}
- _onAccountData(event) {
+ _onAccountData(event, prevEvent) {
if (event.getType() === "org.matrix.preview_urls") {
let val = event.getContent()['disable'];
if (typeof(val) !== "boolean") {
@@ -56,8 +57,10 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
this._watchers.notifyUpdate("urlPreviewsEnabled", null, SettingLevel.ACCOUNT, val);
} else if (event.getType() === "im.vector.web.settings") {
- // We can't really discern what changed, so trigger updates for everything
- for (const settingName of Object.keys(event.getContent())) {
+ // Figure out what changed and fire those updates
+ const prevContent = prevEvent ? prevEvent.getContent() : {};
+ const changedSettings = objectKeyChanges(prevContent, event.getContent());
+ for (const settingName of changedSettings) {
const val = event.getContent()[settingName];
this._watchers.notifyUpdate(settingName, null, SettingLevel.ACCOUNT, val);
}
@@ -159,7 +162,7 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
const event = cli.getAccountData(eventType);
if (!event || !event.getContent()) return null;
- return event.getContent();
+ return objectClone(event.getContent()); // clone to prevent mutation
}
_notifyBreadcrumbsUpdate(event) {
diff --git a/src/settings/handlers/MatrixClientBackedSettingsHandler.js b/src/settings/handlers/MatrixClientBackedSettingsHandler.js
index effe7ae9a7..63725b4dff 100644
--- a/src/settings/handlers/MatrixClientBackedSettingsHandler.js
+++ b/src/settings/handlers/MatrixClientBackedSettingsHandler.js
@@ -42,6 +42,10 @@ export default class MatrixClientBackedSettingsHandler extends SettingsHandler {
MatrixClientBackedSettingsHandler._instances.push(this);
}
+ get client() {
+ return MatrixClientBackedSettingsHandler._matrixClient;
+ }
+
initMatrixClient() {
console.warn("initMatrixClient not overridden");
}
diff --git a/src/settings/handlers/RoomAccountSettingsHandler.js b/src/settings/handlers/RoomAccountSettingsHandler.js
index 1e9d3f7bed..b2af81779b 100644
--- a/src/settings/handlers/RoomAccountSettingsHandler.js
+++ b/src/settings/handlers/RoomAccountSettingsHandler.js
@@ -18,6 +18,7 @@ limitations under the License.
import {MatrixClientPeg} from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore";
+import {objectClone, objectKeyChanges} from "../../utils/objects";
const ALLOWED_WIDGETS_EVENT_TYPE = "im.vector.setting.allowed_widgets";
@@ -40,7 +41,7 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
newClient.on("Room.accountData", this._onAccountData);
}
- _onAccountData(event, room) {
+ _onAccountData(event, room, prevEvent) {
const roomId = room.roomId;
if (event.getType() === "org.matrix.room.preview_urls") {
@@ -55,8 +56,10 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
} else if (event.getType() === "org.matrix.room.color_scheme") {
this._watchers.notifyUpdate("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, event.getContent());
} else if (event.getType() === "im.vector.web.settings") {
- // We can't really discern what changed, so trigger updates for everything
- for (const settingName of Object.keys(event.getContent())) {
+ // Figure out what changed and fire those updates
+ const prevContent = prevEvent ? prevEvent.getContent() : {};
+ const changedSettings = objectKeyChanges(prevContent, event.getContent());
+ for (const settingName of changedSettings) {
const val = event.getContent()[settingName];
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_ACCOUNT, val);
}
@@ -134,6 +137,6 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
const event = room.getAccountData(eventType);
if (!event || !event.getContent()) return null;
- return event.getContent();
+ return objectClone(event.getContent()); // clone to prevent mutation
}
}
diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js
index 6407818450..d8e775742c 100644
--- a/src/settings/handlers/RoomSettingsHandler.js
+++ b/src/settings/handlers/RoomSettingsHandler.js
@@ -18,6 +18,7 @@ limitations under the License.
import {MatrixClientPeg} from '../../MatrixClientPeg';
import MatrixClientBackedSettingsHandler from "./MatrixClientBackedSettingsHandler";
import {SettingLevel} from "../SettingsStore";
+import {objectClone, objectKeyChanges} from "../../utils/objects";
/**
* Gets and sets settings at the "room" level.
@@ -38,8 +39,15 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
newClient.on("RoomState.events", this._onEvent);
}
- _onEvent(event) {
+ _onEvent(event, state, prevEvent) {
const roomId = event.getRoomId();
+ const room = this.client.getRoom(roomId);
+
+ // Note: the tests often fire setting updates that don't have rooms in the store, so
+ // we fail softly here. We shouldn't assume that the state being fired is current
+ // state, but we also don't need to explode just because we didn't find a room.
+ if (!room) console.warn(`Unknown room caused setting update: ${roomId}`);
+ if (room && state !== room.currentState) return; // ignore state updates which are not current
if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable'];
@@ -51,8 +59,10 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
this._watchers.notifyUpdate("urlPreviewsEnabled", roomId, SettingLevel.ROOM, val);
} else if (event.getType() === "im.vector.web.settings") {
- // We can't really discern what changed, so trigger updates for everything
- for (const settingName of Object.keys(event.getContent())) {
+ // Figure out what changed and fire those updates
+ const prevContent = prevEvent ? prevEvent.getContent() : {};
+ const changedSettings = objectKeyChanges(prevContent, event.getContent());
+ for (const settingName of changedSettings) {
this._watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM, event.getContent()[settingName]);
}
}
@@ -107,6 +117,6 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
const event = room.currentState.getStateEvents(eventType, "");
if (!event || !event.getContent()) return null;
- return event.getContent();
+ return objectClone(event.getContent()); // clone to prevent mutation
}
}
diff --git a/src/settings/watchers/FontWatcher.ts b/src/settings/watchers/FontWatcher.ts
index 5527284cd0..9af5156704 100644
--- a/src/settings/watchers/FontWatcher.ts
+++ b/src/settings/watchers/FontWatcher.ts
@@ -18,6 +18,8 @@ import dis from '../../dispatcher/dispatcher';
import SettingsStore, {SettingLevel} from '../SettingsStore';
import IWatcher from "./Watcher";
import { toPx } from '../../utils/units';
+import { Action } from '../../dispatcher/actions';
+import { UpdateSystemFontPayload } from '../../dispatcher/payloads/UpdateSystemFontPayload';
export class FontWatcher implements IWatcher {
public static readonly MIN_SIZE = 8;
@@ -33,6 +35,10 @@ export class FontWatcher implements IWatcher {
public start() {
this.setRootFontSize(SettingsStore.getValue("baseFontSize"));
+ this.setSystemFont({
+ useSystemFont: SettingsStore.getValue("useSystemFont"),
+ font: SettingsStore.getValue("systemFont"),
+ });
this.dispatcherRef = dis.register(this.onAction);
}
@@ -41,8 +47,10 @@ export class FontWatcher implements IWatcher {
}
private onAction = (payload) => {
- if (payload.action === 'update-font-size') {
+ if (payload.action === Action.UpdateFontSize) {
this.setRootFontSize(payload.size);
+ } else if (payload.action === Action.UpdateSystemFont) {
+ this.setSystemFont(payload);
}
};
@@ -54,4 +62,8 @@ export class FontWatcher implements IWatcher {
}
(document.querySelector(":root")).style.fontSize = toPx(fontSize);
};
+
+ private setSystemFont = ({useSystemFont, font}) => {
+ document.body.style.fontFamily = useSystemFont ? font : "";
+ };
}
diff --git a/src/stores/MessagePreviewStore.ts b/src/stores/MessagePreviewStore.ts
deleted file mode 100644
index 3dad643ae6..0000000000
--- a/src/stores/MessagePreviewStore.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
-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 { ActionPayload } from "../dispatcher/payloads";
-import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
-import defaultDispatcher from "../dispatcher/dispatcher";
-import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy";
-import { textForEvent } from "../TextForEvent";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
-import { _t } from "../languageHandler";
-
-const PREVIEWABLE_EVENTS = [
- // This is the same list from RiotX
- {type: "m.room.message", isState: false},
- {type: "m.room.name", isState: true},
- {type: "m.room.topic", isState: true},
- {type: "m.room.member", isState: true},
- {type: "m.room.history_visibility", isState: true},
- {type: "m.call.invite", isState: false},
- {type: "m.call.hangup", isState: false},
- {type: "m.call.answer", isState: false},
- {type: "m.room.encrypted", isState: false},
- {type: "m.room.encryption", isState: true},
- {type: "m.room.third_party_invite", isState: true},
- {type: "m.sticker", isState: false},
- {type: "m.room.create", isState: true},
-];
-
-// The maximum number of events we're willing to look back on to get a preview.
-const MAX_EVENTS_BACKWARDS = 50;
-
-interface IState {
- [roomId: string]: string | null; // null indicates the preview is empty
-}
-
-export class MessagePreviewStore extends AsyncStoreWithClient {
- private static internalInstance = new MessagePreviewStore();
-
- private constructor() {
- super(defaultDispatcher, {});
- }
-
- public static get instance(): MessagePreviewStore {
- return MessagePreviewStore.internalInstance;
- }
-
- /**
- * Gets the pre-translated preview for a given room
- * @param room The room to get the preview for.
- * @returns {string} The preview, or null if none present.
- */
- public getPreviewForRoom(room: Room): string {
- if (!room) return null; // invalid room, just return nothing
-
- // It's faster to do a lookup this way than it is to use Object.keys().includes()
- // We only want to generate a preview if there's one actually missing and not explicitly
- // set as 'none'.
- const val = this.state[room.roomId];
- if (val !== null && typeof(val) !== "string") {
- this.generatePreview(room);
- }
-
- return this.state[room.roomId];
- }
-
- private generatePreview(room: Room) {
- const timeline = room.getLiveTimeline();
- if (!timeline) return; // usually only happens in tests
- const events = timeline.getEvents();
-
- for (let i = events.length - 1; i >= 0; i--) {
- if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached
-
- const event = events[i];
- const preview = this.generatePreviewForEvent(event);
- if (preview.isPreviewable) {
- // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
- this.updateState({[room.roomId]: preview.preview});
- return; // break - we found some text
- }
- }
-
- // if we didn't find anything, subscribe ourselves to an update
- // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
- this.updateState({[room.roomId]: null});
- }
-
- protected async onAction(payload: ActionPayload) {
- if (!this.matrixClient) return;
-
- // TODO: Remove when new room list is made the default
- if (!RoomListStoreTempProxy.isUsingNewStore()) return;
-
- if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {
- const event = payload.event; // TODO: Type out the dispatcher
- if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important
-
- const preview = this.generatePreviewForEvent(event);
- if (preview.isPreviewable) {
- await this.updateState({[event.getRoomId()]: preview.preview});
- return; // break - we found some text
- }
- }
- }
-
- private generatePreviewForEvent(event: MatrixEvent): { isPreviewable: boolean, preview: string } {
- if (PREVIEWABLE_EVENTS.some(p => p.type === event.getType() && p.isState === event.isState())) {
- const isSelf = event.getSender() === this.matrixClient.getUserId();
- let text = textForEvent(event, /*skipUserPrefix=*/isSelf);
- if (!text || text.trim().length === 0) text = null; // force null if useless to us
- if (text && isSelf) {
- // XXX: i18n doesn't really work here if the language doesn't support prefixing.
- // We'd ideally somehow route the `You:` bit to the textForEvent call, however
- // threading that through is non-trivial.
- text = _t("You: %(message)s", {message: text});
- }
- return {isPreviewable: true, preview: text};
- }
- return {isPreviewable: false, preview: null};
- }
-}
diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts
new file mode 100644
index 0000000000..45d8829e30
--- /dev/null
+++ b/src/stores/OwnProfileStore.ts
@@ -0,0 +1,122 @@
+/*
+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 { ActionPayload } from "../dispatcher/payloads";
+import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
+import defaultDispatcher from "../dispatcher/dispatcher";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { User } from "matrix-js-sdk/src/models/user";
+import { throttle } from "lodash";
+import { MatrixClientPeg } from "../MatrixClientPeg";
+import { _t } from "../languageHandler";
+
+interface IState {
+ displayName?: string;
+ avatarUrl?: string;
+}
+
+export class OwnProfileStore extends AsyncStoreWithClient {
+ private static internalInstance = new OwnProfileStore();
+
+ private monitoredUser: User;
+
+ private constructor() {
+ super(defaultDispatcher, {});
+ }
+
+ public static get instance(): OwnProfileStore {
+ return OwnProfileStore.internalInstance;
+ }
+
+ /**
+ * Gets the display name for the user, or null if not present.
+ */
+ public get displayName(): string {
+ if (!this.matrixClient) return this.state.displayName || null;
+
+ if (this.matrixClient.isGuest()) {
+ return _t("Guest");
+ } else if (this.state.displayName) {
+ return this.state.displayName;
+ } else {
+ return this.matrixClient.getUserId();
+ }
+ }
+
+ /**
+ * Gets the MXC URI of the user's avatar, or null if not present.
+ */
+ public get avatarMxc(): string {
+ return this.state.avatarUrl || null;
+ }
+
+ /**
+ * Gets the user's avatar as an HTTP URL of the given size. If the user's
+ * avatar is not present, this returns null.
+ * @param size The size of the avatar
+ * @returns The HTTP URL of the user's avatar
+ */
+ public getHttpAvatarUrl(size: number): string {
+ if (!this.avatarMxc) return null;
+ return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size);
+ }
+
+ protected async onNotReady() {
+ if (this.monitoredUser) {
+ this.monitoredUser.removeListener("User.displayName", this.onProfileUpdate);
+ this.monitoredUser.removeListener("User.avatarUrl", this.onProfileUpdate);
+ }
+ if (this.matrixClient) {
+ this.matrixClient.removeListener("RoomState.events", this.onStateEvents);
+ }
+ await this.reset({});
+ }
+
+ protected async onReady() {
+ const myUserId = this.matrixClient.getUserId();
+ this.monitoredUser = this.matrixClient.getUser(myUserId);
+ if (this.monitoredUser) {
+ this.monitoredUser.on("User.displayName", this.onProfileUpdate);
+ this.monitoredUser.on("User.avatarUrl", this.onProfileUpdate);
+ }
+
+ // We also have to listen for membership events for ourselves as the above User events
+ // are fired only with presence, which matrix.org (and many others) has disabled.
+ this.matrixClient.on("RoomState.events", this.onStateEvents);
+
+ await this.onProfileUpdate(); // trigger an initial update
+ }
+
+ protected async onAction(payload: ActionPayload) {
+ // we don't actually do anything here
+ }
+
+ private onProfileUpdate = async () => {
+ // We specifically do not use the User object we stored for profile info as it
+ // could easily be wrong (such as per-room instead of global profile).
+ const profileInfo = await this.matrixClient.getProfileInfo(this.matrixClient.getUserId());
+ await this.updateState({displayName: profileInfo.displayname, avatarUrl: profileInfo.avatar_url});
+ };
+
+ // TSLint wants this to be a member, but we don't want that.
+ // tslint:disable-next-line
+ private onStateEvents = throttle(async (ev: MatrixEvent) => {
+ const myUserId = MatrixClientPeg.get().getUserId();
+ if (ev.getType() === 'm.room.member' && ev.getSender() === myUserId && ev.getStateKey() === myUserId) {
+ await this.onProfileUpdate();
+ }
+ }, 200, {trailing: true, leading: true});
+}
diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts
index ebc7b95854..8ca8ad637b 100644
--- a/src/stores/room-list/ListLayout.ts
+++ b/src/stores/room-list/ListLayout.ts
@@ -18,6 +18,10 @@ import { TagID } from "./models";
const TILE_HEIGHT_PX = 44;
+// the .65 comes from the CSS where the show more button is
+// mathematically 65% of a tile when floating.
+const RESIZER_BOX_FACTOR = 0.65;
+
interface ISerializedListLayout {
numTiles: number;
showPreviews: boolean;
@@ -67,6 +71,7 @@ export class ListLayout {
}
public get visibleTiles(): number {
+ if (this._n === 0) return this.defaultVisibleTiles;
return Math.max(this._n, this.minVisibleTiles);
}
@@ -76,9 +81,13 @@ export class ListLayout {
}
public get minVisibleTiles(): number {
- // the .65 comes from the CSS where the show more button is
- // mathematically 65% of a tile when floating.
- return 4.65;
+ return 1 + RESIZER_BOX_FACTOR;
+ }
+
+ public get defaultVisibleTiles(): number {
+ // TODO: Remove dogfood flag
+ const val = Number(localStorage.getItem("mx_dogfood_rl_defTiles") || 4);
+ return val + RESIZER_BOX_FACTOR;
}
public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
@@ -92,6 +101,10 @@ export class ListLayout {
return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
}
+ public tilesWithResizerBoxFactor(n: number): number {
+ return n + RESIZER_BOX_FACTOR;
+ }
+
public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
}
diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts
new file mode 100644
index 0000000000..b727069f9f
--- /dev/null
+++ b/src/stores/room-list/MessagePreviewStore.ts
@@ -0,0 +1,204 @@
+/*
+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 { ActionPayload } from "../../dispatcher/payloads";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { RoomListStoreTempProxy } from "./RoomListStoreTempProxy";
+import { MessageEventPreview } from "./previews/MessageEventPreview";
+import { NameEventPreview } from "./previews/NameEventPreview";
+import { TagID } from "./models";
+import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
+import { TopicEventPreview } from "./previews/TopicEventPreview";
+import { MembershipEventPreview } from "./previews/MembershipEventPreview";
+import { HistoryVisibilityEventPreview } from "./previews/HistoryVisibilityEventPreview";
+import { CallInviteEventPreview } from "./previews/CallInviteEventPreview";
+import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview";
+import { CallHangupEvent } from "./previews/CallHangupEvent";
+import { EncryptionEventPreview } from "./previews/EncryptionEventPreview";
+import { ThirdPartyInviteEventPreview } from "./previews/ThirdPartyInviteEventPreview";
+import { StickerEventPreview } from "./previews/StickerEventPreview";
+import { ReactionEventPreview } from "./previews/ReactionEventPreview";
+import { CreationEventPreview } from "./previews/CreationEventPreview";
+
+const PREVIEWS = {
+ 'm.room.message': {
+ isState: false,
+ previewer: new MessageEventPreview(),
+ },
+ 'm.room.name': {
+ isState: true,
+ previewer: new NameEventPreview(),
+ },
+ 'm.room.topic': {
+ isState: true,
+ previewer: new TopicEventPreview(),
+ },
+ 'm.room.member': {
+ isState: true,
+ previewer: new MembershipEventPreview(),
+ },
+ 'm.room.history_visibility': {
+ isState: true,
+ previewer: new HistoryVisibilityEventPreview(),
+ },
+ 'm.call.invite': {
+ isState: false,
+ previewer: new CallInviteEventPreview(),
+ },
+ 'm.call.answer': {
+ isState: false,
+ previewer: new CallAnswerEventPreview(),
+ },
+ 'm.call.hangup': {
+ isState: false,
+ previewer: new CallHangupEvent(),
+ },
+ 'm.room.encryption': {
+ isState: true,
+ previewer: new EncryptionEventPreview(),
+ },
+ 'm.room.third_party_invite': {
+ isState: true,
+ previewer: new ThirdPartyInviteEventPreview(),
+ },
+ 'm.sticker': {
+ isState: false,
+ previewer: new StickerEventPreview(),
+ },
+ 'm.reaction': {
+ isState: false,
+ previewer: new ReactionEventPreview(),
+ },
+ 'm.room.create': {
+ isState: true,
+ previewer: new CreationEventPreview(),
+ },
+};
+
+// The maximum number of events we're willing to look back on to get a preview.
+const MAX_EVENTS_BACKWARDS = 50;
+
+// type merging ftw
+type TAG_ANY = "im.vector.any";
+const TAG_ANY: TAG_ANY = "im.vector.any";
+
+interface IState {
+ [roomId: string]: Map; // null indicates the preview is empty / irrelevant
+}
+
+export class MessagePreviewStore extends AsyncStoreWithClient {
+ private static internalInstance = new MessagePreviewStore();
+
+ private constructor() {
+ super(defaultDispatcher, {});
+ }
+
+ public static get instance(): MessagePreviewStore {
+ return MessagePreviewStore.internalInstance;
+ }
+
+ /**
+ * Gets the pre-translated preview for a given room
+ * @param room The room to get the preview for.
+ * @param inTagId The tag ID in which the room resides
+ * @returns The preview, or null if none present.
+ */
+ public getPreviewForRoom(room: Room, inTagId: TagID): string {
+ if (!room) return null; // invalid room, just return nothing
+
+ const val = this.state[room.roomId];
+ if (!val) this.generatePreview(room, inTagId);
+
+ const previews = this.state[room.roomId];
+ if (!previews) return null;
+
+ if (!previews.has(inTagId)) {
+ return previews.get(TAG_ANY);
+ }
+ return previews.get(inTagId);
+ }
+
+ private generatePreview(room: Room, tagId?: TagID) {
+ const events = room.timeline;
+ if (!events) return; // should only happen in tests
+
+ let map = this.state[room.roomId];
+ if (!map) {
+ map = new Map();
+
+ // We set the state later with the map, so no need to send an update now
+ }
+
+ // Set the tags so we know what to generate
+ if (!map.has(TAG_ANY)) map.set(TAG_ANY, null);
+ if (tagId && !map.has(tagId)) map.set(tagId, null);
+
+ let changed = false;
+ for (let i = events.length - 1; i >= 0; i--) {
+ if (i === events.length - MAX_EVENTS_BACKWARDS) return; // limit reached
+
+ const event = events[i];
+ const previewDef = PREVIEWS[event.getType()];
+ if (!previewDef) continue;
+ if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue;
+
+ const anyPreview = previewDef.previewer.getTextFor(event, null);
+ if (!anyPreview) continue; // not previewable for some reason
+
+ changed = changed || anyPreview !== map.get(TAG_ANY);
+ map.set(TAG_ANY, anyPreview);
+
+ const tagsToGenerate = Array.from(map.keys()).filter(t => t !== TAG_ANY); // we did the any tag above
+ for (const genTagId of tagsToGenerate) {
+ const realTagId: TagID = genTagId === TAG_ANY ? null : genTagId;
+ const preview = previewDef.previewer.getTextFor(event, realTagId);
+ if (preview === anyPreview) {
+ changed = changed || anyPreview !== map.get(genTagId);
+ map.delete(genTagId);
+ } else {
+ changed = changed || preview !== map.get(genTagId);
+ map.set(genTagId, preview);
+ }
+ }
+
+ if (changed) {
+ // Update state for good measure - causes emit for update
+ // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
+ this.updateState({[room.roomId]: map});
+ }
+ return; // we're done
+ }
+
+ // At this point, we didn't generate a preview so clear it
+ // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls
+ this.updateState({[room.roomId]: null});
+ }
+
+ protected async onAction(payload: ActionPayload) {
+ if (!this.matrixClient) return;
+
+ // TODO: Remove when new room list is made the default
+ if (!RoomListStoreTempProxy.isUsingNewStore()) return;
+
+ if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {
+ const event = payload.event; // TODO: Type out the dispatcher
+ if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important
+ this.generatePreview(this.matrixClient.getRoom(event.getRoomId()), TAG_ANY);
+ }
+ }
+}
diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts
index 1081fb26ec..e5a4d91b04 100644
--- a/src/stores/room-list/RoomListStore2.ts
+++ b/src/stores/room-list/RoomListStore2.ts
@@ -158,12 +158,12 @@ export class RoomListStore2 extends AsyncStore {
// First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
- console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`);
- const room = this.matrixClient.getRoom(payload.event.roomId);
+ const room = payload.room;
if (!room) {
- console.warn(`Own read receipt was in unknown room ${payload.event.roomId}`);
+ console.warn(`Own read receipt was in unknown room ${room.roomId}`);
return;
}
+ console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
return;
}
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index 06c6df1703..94d7bff4c8 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -170,12 +170,16 @@ export class Algorithm extends EventEmitter {
// When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
- let position = this.cachedRooms[tag].indexOf(val);
+
+ // We specifically do NOT use the ordered rooms set as it contains the sticky room, which
+ // means we'll be off by 1 when the user is switching rooms. This leads to visual jumping
+ // when the user is moving south in the list (not north, because of math).
+ let position = this.getOrderedRoomsWithoutSticky()[tag].indexOf(val);
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
// 🐉 Here be dragons.
// Before we can go through with lying to the underlying algorithm about a room
- // we need to ensure that when we do we're ready for the innevitable sticky room
+ // we need to ensure that when we do we're ready for the inevitable sticky room
// update we'll receive. To prepare for that, we first remove the sticky room and
// recalculate the state ourselves so that when the underlying algorithm calls for
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
@@ -208,6 +212,12 @@ export class Algorithm extends EventEmitter {
position: position,
tag: tag,
};
+
+ // We update the filtered rooms just in case, as otherwise users will end up visiting
+ // a room while filtering and it'll disappear. We don't update the filter earlier in
+ // this function simply because we don't have to.
+ this.recalculateFilteredRoomsForTag(tag);
+ if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateFilteredRoomsForTag(lastStickyRoom.tag);
this.recalculateStickyRoom();
// Finally, trigger an update
@@ -231,9 +241,7 @@ export class Algorithm extends EventEmitter {
// We optimize our lookups by trying to reduce sample size as much as possible
// to the rooms we know will be deduped by the Set.
const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone
- if (this._stickyRoom && this._stickyRoom.tag === tagId && this._stickyRoom.room) {
- rooms.push(this._stickyRoom.room);
- }
+ this.tryInsertStickyRoomToFilterSet(rooms, tagId);
let remainingRooms = rooms.map(r => r);
let allowedRoomsInThisTag = [];
let lastFilterPriority = orderedFilters[0].relativePriority;
@@ -263,6 +271,7 @@ export class Algorithm extends EventEmitter {
this.emit(LIST_UPDATED_EVENT);
}
+ // TODO: Remove or use.
protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void {
const filters = this.allowedByFilter.keys();
for (const room of added) {
@@ -281,7 +290,8 @@ export class Algorithm extends EventEmitter {
protected recalculateFilteredRoomsForTag(tagId: TagID): void {
console.log(`Recalculating filtered rooms for ${tagId}`);
delete this.filteredRooms[tagId];
- const rooms = this.cachedRooms[tagId];
+ const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone
+ this.tryInsertStickyRoomToFilterSet(rooms, tagId);
const filteredRooms = rooms.filter(r => this.allowedRoomsByFilters.has(r));
if (filteredRooms.length > 0) {
this.filteredRooms[tagId] = filteredRooms;
@@ -289,6 +299,17 @@ export class Algorithm extends EventEmitter {
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
}
+ protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) {
+ if (!this._stickyRoom || !this._stickyRoom.room || this._stickyRoom.tag !== tagId) return;
+
+ const position = this._stickyRoom.position;
+ if (position >= rooms.length) {
+ rooms.push(this._stickyRoom.room);
+ } else {
+ rooms.splice(position, 0, this._stickyRoom.room);
+ }
+ }
+
/**
* Recalculate the sticky room position. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
@@ -377,6 +398,20 @@ export class Algorithm extends EventEmitter {
return this.filteredRooms;
}
+ /**
+ * This returns the same as getOrderedRooms(), but without the sticky room
+ * map as it causes issues for sticky room handling (see sticky room handling
+ * for more information).
+ * @returns {ITagMap} The cached list of rooms, ordered,
+ * for each tag. May be empty, but never null/undefined.
+ */
+ private getOrderedRoomsWithoutSticky(): ITagMap {
+ if (!this.hasFilters) {
+ return this.cachedRooms;
+ }
+ return this.filteredRooms;
+ }
+
/**
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead.
diff --git a/src/stores/room-list/previews/CallAnswerEventPreview.ts b/src/stores/room-list/previews/CallAnswerEventPreview.ts
new file mode 100644
index 0000000000..b7207307e2
--- /dev/null
+++ b/src/stores/room-list/previews/CallAnswerEventPreview.ts
@@ -0,0 +1,35 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class CallAnswerEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
+ if (isSelf(event)) {
+ return _t("You joined the call");
+ } else {
+ return _t("%(senderName)s joined the call", {senderName: getSenderName(event)});
+ }
+ } else {
+ return _t("Call in progress");
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/CallHangupEvent.ts b/src/stores/room-list/previews/CallHangupEvent.ts
new file mode 100644
index 0000000000..adc7d1aac8
--- /dev/null
+++ b/src/stores/room-list/previews/CallHangupEvent.ts
@@ -0,0 +1,35 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class CallHangupEvent implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
+ if (isSelf(event)) {
+ return _t("You left the call");
+ } else {
+ return _t("%(senderName)s left the call", {senderName: getSenderName(event)});
+ }
+ } else {
+ return _t("Call ended");
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/CallInviteEventPreview.ts b/src/stores/room-list/previews/CallInviteEventPreview.ts
new file mode 100644
index 0000000000..47486e3701
--- /dev/null
+++ b/src/stores/room-list/previews/CallInviteEventPreview.ts
@@ -0,0 +1,39 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class CallInviteEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
+ if (isSelf(event)) {
+ return _t("You started a call");
+ } else {
+ return _t("%(senderName)s started a call", {senderName: getSenderName(event)});
+ }
+ } else {
+ if (isSelf(event)) {
+ return _t("Waiting for answer");
+ } else {
+ return _t("%(senderName)s is calling", {senderName: getSenderName(event)});
+ }
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/CreationEventPreview.ts b/src/stores/room-list/previews/CreationEventPreview.ts
new file mode 100644
index 0000000000..62bb5fe53a
--- /dev/null
+++ b/src/stores/room-list/previews/CreationEventPreview.ts
@@ -0,0 +1,31 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class CreationEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (isSelf(event)) {
+ return _t("You created the room");
+ } else {
+ return _t("%(senderName)s created the room", {senderName: getSenderName(event)});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/EncryptionEventPreview.ts b/src/stores/room-list/previews/EncryptionEventPreview.ts
new file mode 100644
index 0000000000..d00fd7e7f9
--- /dev/null
+++ b/src/stores/room-list/previews/EncryptionEventPreview.ts
@@ -0,0 +1,31 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class EncryptionEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (isSelf(event)) {
+ return _t("You made the chat encrypted");
+ } else {
+ return _t("%(senderName)s made the chat encrypted", {senderName: getSenderName(event)});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts
new file mode 100644
index 0000000000..ac77a181f8
--- /dev/null
+++ b/src/stores/room-list/previews/HistoryVisibilityEventPreview.ts
@@ -0,0 +1,42 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class HistoryVisibilityEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ const visibility = event.getContent()['history_visibility'];
+ const isUs = isSelf(event);
+
+ if (visibility === 'invited' || visibility === 'joined') {
+ return isUs
+ ? _t("You made history visible to new members")
+ : _t("%(senderName)s made history visible to new members", {senderName: getSenderName(event)});
+ } else if (visibility === 'world_readable') {
+ return isUs
+ ? _t("You made history visible to anyone")
+ : _t("%(senderName)s made history visible to anyone", {senderName: getSenderName(event)});
+ } else { // shared, default
+ return isUs
+ ? _t("You made history visible to future members")
+ : _t("%(senderName)s made history visible to future members", {senderName: getSenderName(event)});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/IPreview.ts b/src/stores/room-list/previews/IPreview.ts
new file mode 100644
index 0000000000..9beb92bfbf
--- /dev/null
+++ b/src/stores/room-list/previews/IPreview.ts
@@ -0,0 +1,31 @@
+/*
+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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { TagID } from "../models";
+
+/**
+ * Represents an event preview.
+ */
+export interface IPreview {
+ /**
+ * Gets the text which represents the event as a preview.
+ * @param event The event to preview.
+ * @param tagId Optional. The tag where the room the event was sent in resides.
+ * @returns The preview.
+ */
+ getTextFor(event: MatrixEvent, tagId?: TagID): string;
+}
diff --git a/src/stores/room-list/previews/MembershipEventPreview.ts b/src/stores/room-list/previews/MembershipEventPreview.ts
new file mode 100644
index 0000000000..44339aab5f
--- /dev/null
+++ b/src/stores/room-list/previews/MembershipEventPreview.ts
@@ -0,0 +1,90 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getTargetName, isSelfTarget } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class MembershipEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ const newMembership = event.getContent()['membership'];
+ const oldMembership = event.getPrevContent()['membership'];
+ const reason = event.getContent()['reason'];
+ const isUs = isSelfTarget(event);
+
+ if (newMembership === 'invite') {
+ return isUs
+ ? _t("You were invited")
+ : _t("%(targetName)s was invited", {targetName: getTargetName(event)});
+ } else if (newMembership === 'leave' && oldMembership !== 'invite') {
+ if (event.getSender() === event.getStateKey()) {
+ return isUs
+ ? _t("You left")
+ : _t("%(targetName)s left", {targetName: getTargetName(event)});
+ } else {
+ if (reason) {
+ return isUs
+ ? _t("You were kicked (%(reason)s)", {reason})
+ : _t("%(targetName)s was kicked (%(reason)s)", {targetName: getTargetName(event), reason});
+ } else {
+ return isUs
+ ? _t("You were kicked")
+ : _t("%(targetName)s was kicked", {targetName: getTargetName(event)});
+ }
+ }
+ } else if (newMembership === 'leave' && oldMembership === 'invite') {
+ if (event.getSender() === event.getStateKey()) {
+ return isUs
+ ? _t("You rejected the invite")
+ : _t("%(targetName)s rejected the invite", {targetName: getTargetName(event)});
+ } else {
+ return isUs
+ ? _t("You were uninvited")
+ : _t("%(targetName)s was uninvited", {targetName: getTargetName(event)});
+ }
+ } else if (newMembership === 'ban') {
+ if (reason) {
+ return isUs
+ ? _t("You were banned (%(reason)s)", {reason})
+ : _t("%(targetName)s was banned (%(reason)s)", {targetName: getTargetName(event), reason});
+ } else {
+ return isUs
+ ? _t("You were banned")
+ : _t("%(targetName)s was banned", {targetName: getTargetName(event)});
+ }
+ } else if (newMembership === 'join' && oldMembership !== 'join') {
+ return isUs
+ ? _t("You joined")
+ : _t("%(targetName)s joined", {targetName: getTargetName(event)});
+ } else {
+ const isDisplayNameChange = event.getContent()['displayname'] !== event.getPrevContent()['displayname'];
+ const isAvatarChange = event.getContent()['avatar_url'] !== event.getPrevContent()['avatar_url'];
+ if (isDisplayNameChange) {
+ return isUs
+ ? _t("You changed your name")
+ : _t("%(targetName)s changed their name", {targetName: getTargetName(event)});
+ } else if (isAvatarChange) {
+ return isUs
+ ? _t("You changed your avatar")
+ : _t("%(targetName)s changed their avatar", {targetName: getTargetName(event)});
+ } else {
+ return null; // no change
+ }
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts
new file mode 100644
index 0000000000..6f0dc14a58
--- /dev/null
+++ b/src/stores/room-list/previews/MessageEventPreview.ts
@@ -0,0 +1,55 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { _t } from "../../../languageHandler";
+import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
+import ReplyThread from "../../../components/views/elements/ReplyThread";
+
+export class MessageEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ let eventContent = event.getContent();
+
+ if (event.isRelation("m.replace")) {
+ // It's an edit, generate the preview on the new text
+ eventContent = event.getContent()['m.new_content'];
+ }
+
+ let body = (eventContent['body'] || '').trim();
+ const msgtype = eventContent['msgtype'];
+ if (!body || !msgtype) return null; // invalid event, no preview
+
+ // XXX: Newer relations have a getRelation() function which is not compatible with replies.
+ const mRelatesTo = event.getWireContent()['m.relates_to'];
+ if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
+ // If this is a reply, get the real reply and use that
+ body = (ReplyThread.stripPlainReply(body) || '').trim();
+ if (!body) return null; // invalid event, no preview
+ }
+
+ if (msgtype === 'm.emote') {
+ return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
+ }
+
+ if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
+ return body;
+ } else {
+ return _t("%(senderName)s: %(message)s", {senderName: getSenderName(event), message: body});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/NameEventPreview.ts b/src/stores/room-list/previews/NameEventPreview.ts
new file mode 100644
index 0000000000..4197abacfb
--- /dev/null
+++ b/src/stores/room-list/previews/NameEventPreview.ts
@@ -0,0 +1,31 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class NameEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (isSelf(event)) {
+ return _t("You changed the room name");
+ } else {
+ return _t("%(senderName)s changed the room name", {senderName: getSenderName(event)});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/ReactionEventPreview.ts b/src/stores/room-list/previews/ReactionEventPreview.ts
new file mode 100644
index 0000000000..d58f592feb
--- /dev/null
+++ b/src/stores/room-list/previews/ReactionEventPreview.ts
@@ -0,0 +1,34 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class ReactionEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ const reaction = event.getRelation().key;
+ if (!reaction) return;
+
+ if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
+ return reaction;
+ } else {
+ return _t("%(senderName)s: %(reaction)s", {senderName: getSenderName(event), reaction});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/StickerEventPreview.ts b/src/stores/room-list/previews/StickerEventPreview.ts
new file mode 100644
index 0000000000..f8263a4a45
--- /dev/null
+++ b/src/stores/room-list/previews/StickerEventPreview.ts
@@ -0,0 +1,34 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class StickerEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ const stickerName = event.getContent()['body'];
+ if (!stickerName) return null;
+
+ if (isSelf(event) || !shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
+ return stickerName;
+ } else {
+ return _t("%(senderName)s: %(stickerName)s", {senderName: getSenderName(event), stickerName});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts
new file mode 100644
index 0000000000..b22cd9fac9
--- /dev/null
+++ b/src/stores/room-list/previews/ThirdPartyInviteEventPreview.ts
@@ -0,0 +1,42 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf } from "./utils";
+import { _t } from "../../../languageHandler";
+import { isValid3pidInvite } from "../../../RoomInvite";
+
+export class ThirdPartyInviteEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (!isValid3pidInvite(event)) {
+ const targetName = event.getPrevContent().display_name || _t("Someone");
+ if (isSelf(event)) {
+ return _t("You uninvited %(targetName)s", {targetName});
+ } else {
+ return _t("%(senderName)s uninvited %(targetName)s", {senderName: getSenderName(event), targetName});
+ }
+ } else {
+ const targetName = event.getContent().display_name;
+ if (isSelf(event)) {
+ return _t("You invited %(targetName)s", {targetName});
+ } else {
+ return _t("%(senderName)s invited %(targetName)s", {senderName: getSenderName(event), targetName});
+ }
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/TopicEventPreview.ts b/src/stores/room-list/previews/TopicEventPreview.ts
new file mode 100644
index 0000000000..9b499aae8f
--- /dev/null
+++ b/src/stores/room-list/previews/TopicEventPreview.ts
@@ -0,0 +1,31 @@
+/*
+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 { IPreview } from "./IPreview";
+import { TagID } from "../models";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { getSenderName, isSelf } from "./utils";
+import { _t } from "../../../languageHandler";
+
+export class TopicEventPreview implements IPreview {
+ public getTextFor(event: MatrixEvent, tagId?: TagID): string {
+ if (isSelf(event)) {
+ return _t("You changed the room topic");
+ } else {
+ return _t("%(senderName)s changed the room topic", {senderName: getSenderName(event)});
+ }
+ }
+}
diff --git a/src/stores/room-list/previews/utils.ts b/src/stores/room-list/previews/utils.ts
new file mode 100644
index 0000000000..ebbecd7bbd
--- /dev/null
+++ b/src/stores/room-list/previews/utils.ts
@@ -0,0 +1,49 @@
+/*
+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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import { DefaultTagID, TagID } from "../models";
+
+export function isSelf(event: MatrixEvent): boolean {
+ const selfUserId = MatrixClientPeg.get().getUserId();
+ if (event.getType() === 'm.room.member') {
+ return event.getStateKey() === selfUserId;
+ }
+ return event.getSender() === selfUserId;
+}
+
+export function isSelfTarget(event: MatrixEvent): boolean {
+ const selfUserId = MatrixClientPeg.get().getUserId();
+ return event.getStateKey() === selfUserId;
+}
+
+export function shouldPrefixMessagesIn(roomId: string, tagId: TagID): boolean {
+ if (tagId !== DefaultTagID.DM) return true;
+
+ // We don't prefix anything in 1:1s
+ const room = MatrixClientPeg.get().getRoom(roomId);
+ if (!room) return true;
+ return room.currentState.getJoinedMemberCount() !== 2;
+}
+
+export function getSenderName(event: MatrixEvent): string {
+ return event.sender ? event.sender.name : event.getSender();
+}
+
+export function getTargetName(event: MatrixEvent): string {
+ return event.target ? event.target.name : event.getStateKey();
+}
diff --git a/src/theme.js b/src/theme.js
index 22cfd8b076..c79e466933 100644
--- a/src/theme.js
+++ b/src/theme.js
@@ -35,11 +35,67 @@ export function enumerateThemes() {
return Object.assign({}, customThemeNames, BUILTIN_THEMES);
}
+function clearCustomTheme() {
+ // remove all css variables, we assume these are there because of the custom theme
+ const inlineStyleProps = Object.values(document.body.style);
+ for (const prop of inlineStyleProps) {
+ if (prop.startsWith("--")) {
+ document.body.style.removeProperty(prop);
+ }
+ }
+ const customFontFaceStyle = document.querySelector("head > style[title='custom-theme-font-faces']");
+ if (customFontFaceStyle) {
+ customFontFaceStyle.remove();
+ }
+}
+
+const allowedFontFaceProps = [
+ "font-display",
+ "font-family",
+ "font-stretch",
+ "font-style",
+ "font-weight",
+ "font-variant",
+ "font-feature-settings",
+ "font-variation-settings",
+ "src",
+ "unicode-range",
+];
+
+function generateCustomFontFaceCSS(faces) {
+ return faces.map(face => {
+ const src = face.src && face.src.map(srcElement => {
+ let format;
+ if (srcElement.format) {
+ format = `format("${srcElement.format}")`;
+ }
+ if (srcElement.url) {
+ return `url("${srcElement.url}") ${format}`;
+ } else if (srcElement.local) {
+ return `local("${srcElement.local}") ${format}`;
+ }
+ return "";
+ }).join(", ");
+ const props = Object.keys(face).filter(prop => allowedFontFaceProps.includes(prop));
+ const body = props.map(prop => {
+ let value;
+ if (prop === "src") {
+ value = src;
+ } else if (prop === "font-family") {
+ value = `"${face[prop]}"`;
+ } else {
+ value = face[prop];
+ }
+ return `${prop}: ${value}`;
+ }).join(";");
+ return `@font-face {${body}}`;
+ }).join("\n");
+}
function setCustomThemeVars(customTheme) {
const {style} = document.body;
- function setCSSVariable(name, hexColor, doPct = true) {
+ function setCSSColorVariable(name, hexColor, doPct = true) {
style.setProperty(`--${name}`, hexColor);
if (doPct) {
// uses #rrggbbaa to define the color with alpha values at 0%, 15% and 50%
@@ -53,13 +109,30 @@ function setCustomThemeVars(customTheme) {
for (const [name, value] of Object.entries(customTheme.colors)) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i += 1) {
- setCSSVariable(`${name}_${i}`, value[i], false);
+ setCSSColorVariable(`${name}_${i}`, value[i], false);
}
} else {
- setCSSVariable(name, value);
+ setCSSColorVariable(name, value);
}
}
}
+ if (customTheme.fonts) {
+ const {fonts} = customTheme;
+ if (fonts.faces) {
+ const css = generateCustomFontFaceCSS(fonts.faces);
+ const style = document.createElement("style");
+ style.setAttribute("title", "custom-theme-font-faces");
+ style.setAttribute("type", "text/css");
+ style.appendChild(document.createTextNode(css));
+ document.head.appendChild(style);
+ }
+ if (fonts.general) {
+ style.setProperty("--font-family", fonts.general);
+ }
+ if (fonts.monospace) {
+ style.setProperty("--font-family-monospace", fonts.monospace);
+ }
+ }
}
export function getCustomTheme(themeName) {
@@ -88,6 +161,7 @@ export async function setTheme(theme) {
const themeWatcher = new ThemeWatcher();
theme = themeWatcher.getEffectiveTheme();
}
+ clearCustomTheme();
let stylesheetName = theme;
if (theme.startsWith("custom-")) {
const customTheme = getCustomTheme(theme.substr(7));
@@ -136,7 +210,7 @@ export async function setTheme(theme) {
if (a == styleElements[stylesheetName]) return;
a.disabled = true;
});
- const bodyStyles = global.getComputedStyle(document.getElementsByTagName("body")[0]);
+ const bodyStyles = global.getComputedStyle(document.body);
if (bodyStyles.backgroundColor) {
document.querySelector('meta[name="theme-color"]').content = bodyStyles.backgroundColor;
}
diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx
index 7cd59222dd..b186a65d9d 100644
--- a/src/toasts/AnalyticsToast.tsx
+++ b/src/toasts/AnalyticsToast.tsx
@@ -24,14 +24,12 @@ import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore";
const onAccept = () => {
- console.log("DEBUG onAccept AnalyticsToast");
dis.dispatch({
action: 'accept_cookies',
});
};
const onReject = () => {
- console.log("DEBUG onReject AnalyticsToast");
dis.dispatch({
action: "reject_cookies",
});
diff --git a/src/utils/ResizeNotifier.js b/src/utils/ResizeNotifier.js
index d65bc4bd07..f726a43e08 100644
--- a/src/utils/ResizeNotifier.js
+++ b/src/utils/ResizeNotifier.js
@@ -15,9 +15,13 @@ limitations under the License.
*/
/**
- * Fires when the middle panel has been resized.
+ * Fires when the middle panel has been resized (throttled).
* @event module:utils~ResizeNotifier#"middlePanelResized"
*/
+/**
+ * Fires when the middle panel has been resized by a pixel.
+ * @event module:utils~ResizeNotifier#"middlePanelResizedNoisy"
+ */
import { EventEmitter } from "events";
import { throttle } from "lodash";
@@ -29,15 +33,24 @@ export default class ResizeNotifier extends EventEmitter {
this._throttledMiddlePanel = throttle(() => this.emit("middlePanelResized"), 200);
}
+ _noisyMiddlePanel() {
+ this.emit("middlePanelResizedNoisy");
+ }
+
+ _updateMiddlePanel() {
+ this._throttledMiddlePanel();
+ this._noisyMiddlePanel();
+ }
+
// can be called in quick succession
notifyLeftHandleResized() {
// don't emit event for own region
- this._throttledMiddlePanel();
+ this._updateMiddlePanel();
}
// can be called in quick succession
notifyRightHandleResized() {
- this._throttledMiddlePanel();
+ this._updateMiddlePanel();
}
// can be called in quick succession
@@ -48,7 +61,7 @@ export default class ResizeNotifier extends EventEmitter {
// taller than the available space
this.emit("leftPanelResized");
- this._throttledMiddlePanel();
+ this._updateMiddlePanel();
}
}
diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index b48ec481ba..f7f4be202b 100644
--- a/src/utils/WidgetUtils.js
+++ b/src/utils/WidgetUtils.js
@@ -31,6 +31,7 @@ import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Capability} from "../widgets/WidgetApi";
import {Room} from "matrix-js-sdk/src/models/room";
import {WidgetType} from "../widgets/WidgetType";
+import {objectClone} from "./objects";
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
@@ -222,7 +223,7 @@ export default class WidgetUtils {
const client = MatrixClientPeg.get();
// Get the current widgets and clone them before we modify them, otherwise
// we'll modify the content of the old event.
- const userWidgets = JSON.parse(JSON.stringify(WidgetUtils.getUserWidgets()));
+ const userWidgets = objectClone(WidgetUtils.getUserWidgets());
// Delete existing widget with ID
try {
diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts
index fea376afcd..8175d89464 100644
--- a/src/utils/arrays.ts
+++ b/src/utils/arrays.ts
@@ -46,6 +46,28 @@ export function arrayDiff(a: T[], b: T[]): { added: T[], removed: T[] } {
};
}
+/**
+ * Returns the union of two arrays.
+ * @param a The first array. Must be defined.
+ * @param b The second array. Must be defined.
+ * @returns The union of the arrays.
+ */
+export function arrayUnion(a: T[], b: T[]): T[] {
+ return a.filter(i => b.includes(i));
+}
+
+/**
+ * Merges arrays, deduping contents using a Set.
+ * @param a The arrays to merge.
+ * @returns The merged array.
+ */
+export function arrayMerge(...a: T[][]): T[] {
+ return Array.from(a.reduce((c, v) => {
+ v.forEach(i => c.add(i));
+ return c;
+ }, new Set()));
+}
+
/**
* Helper functions to perform LINQ-like queries on arrays.
*/
diff --git a/src/utils/objects.ts b/src/utils/objects.ts
new file mode 100644
index 0000000000..14fa928ce2
--- /dev/null
+++ b/src/utils/objects.ts
@@ -0,0 +1,60 @@
+/*
+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 { arrayDiff, arrayMerge, arrayUnion } from "./arrays";
+
+/**
+ * Determines the keys added, changed, and removed between two objects.
+ * For changes, simple triple equal comparisons are done, not in-depth
+ * tree checking.
+ * @param a The first object. Must be defined.
+ * @param b The second object. Must be defined.
+ * @returns The difference between the keys of each object.
+ */
+export function objectDiff(a: any, b: any): { changed: string[], added: string[], removed: string[] } {
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ const keyDiff = arrayDiff(aKeys, bKeys);
+ const possibleChanges = arrayUnion(aKeys, bKeys);
+ const changes = possibleChanges.filter(k => a[k] !== b[k]);
+
+ return {changed: changes, added: keyDiff.added, removed: keyDiff.removed};
+}
+
+/**
+ * Gets all the key changes (added, removed, or value difference) between
+ * two objects. Triple equals is used to compare values, not in-depth tree
+ * checking.
+ * @param a The first object. Must be defined.
+ * @param b The second object. Must be defined.
+ * @returns The keys which have been added, removed, or changed between the
+ * two objects.
+ */
+export function objectKeyChanges(a: any, b: any): string[] {
+ const diff = objectDiff(a, b);
+ return arrayMerge(diff.removed, diff.added, diff.changed);
+}
+
+/**
+ * Clones an object by running it through JSON parsing. Note that this
+ * will destroy any complicated object types which do not translate to
+ * JSON.
+ * @param obj The object to clone.
+ * @returns The cloned object
+ */
+export function objectClone(obj: any): any {
+ return JSON.parse(JSON.stringify(obj));
+}
diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts
index 39a4554a81..4775c30c95 100644
--- a/src/widgets/WidgetApi.ts
+++ b/src/widgets/WidgetApi.ts
@@ -19,6 +19,7 @@ limitations under the License.
import { randomString } from "matrix-js-sdk/src/randomstring";
import { EventEmitter } from "events";
+import { objectClone } from "../utils/objects";
export enum Capability {
Screenshot = "m.capability.screenshot",
@@ -140,7 +141,7 @@ export class WidgetApi extends EventEmitter {
private replyToRequest(payload: ToWidgetRequest, reply: any) {
if (!window.parent) return;
- const request = JSON.parse(JSON.stringify(payload));
+ const request = objectClone(payload);
request.response = reply;
window.parent.postMessage(request, this.origin);
diff --git a/yarn.lock b/yarn.lock
index 64dd36aaaf..29c97a604b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5893,8 +5893,8 @@ mathml-tag-names@^2.0.1:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
- version "6.2.2"
- resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/1c194e81637fb07fe6ad67cda33be0d5d4c10115"
+ version "7.0.0"
+ resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f683f4544aa5da150836b01c754062809119fa97"
dependencies:
"@babel/runtime" "^7.8.3"
another-json "^0.2.0"