From 6f794cca9b173ddf6d3a3b71088ff15dd93d53c5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 15 Apr 2021 13:05:27 -0600 Subject: [PATCH 01/37] Fill in some metadata for the sent event --- .../views/rooms/VoiceRecordComposerTile.tsx | 23 +++++++++++++++++++ src/voice/VoiceRecording.ts | 15 +++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 1210a44958..f46b7c6311 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -55,7 +55,30 @@ export default class VoiceRecordComposerTile extends React.PureComponent r['content_uri']); From 7d9562137ef2768c34214c122ba99b77cf5a28f4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 19 Apr 2021 21:54:08 -0600 Subject: [PATCH 02/37] Replace deprecated processor with a worklet --- src/@types/global.d.ts | 27 ++++++++++++++++++++++++ src/voice/RecorderWorklet.ts | 37 +++++++++++++++++++++++++++++++++ src/voice/VoiceRecording.ts | 40 ++++++++++++++++++++++-------------- src/voice/consts.ts | 29 ++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 src/voice/RecorderWorklet.ts create mode 100644 src/voice/consts.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index ee0963e537..78dad28566 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -129,4 +129,31 @@ declare global { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber columnNumber?: number; } + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + interface AudioWorkletProcessor { + readonly port: MessagePort; + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: Record + ): boolean; + + } + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + const AudioWorkletProcessor: { + prototype: AudioWorkletProcessor; + new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor; + }; + + // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 + function registerProcessor( + name: string, + processorCtor: (new ( + options?: AudioWorkletNodeOptions + ) => AudioWorkletProcessor) & { + parameterDescriptors?: AudioParamDescriptor[]; + } + ); } diff --git a/src/voice/RecorderWorklet.ts b/src/voice/RecorderWorklet.ts new file mode 100644 index 0000000000..11f24fce4c --- /dev/null +++ b/src/voice/RecorderWorklet.ts @@ -0,0 +1,37 @@ +/* +Copyright 2021 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 {ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts"; + +// from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope +declare const currentTime: number; +declare const currentFrame: number; +declare const sampleRate: number; + +class MxVoiceWorklet extends AudioWorkletProcessor { + constructor() { + super(); + } + + process(inputs, outputs, parameters) { + this.port.postMessage({ev: PayloadEvent.Timekeep, timeSeconds: currentTime}); + return true; + } +} + +registerProcessor(WORKLET_NAME, MxVoiceWorklet); + +export default null; // to appease module loaders (we never use the export) diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index fc52a38fa9..8e506c235c 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -23,6 +23,7 @@ import {clamp} from "../utils/numbers"; import EventEmitter from "events"; import {IDestroyable} from "../utils/IDestroyable"; import {Singleflight} from "../utils/Singleflight"; +import {PayloadEvent, WORKLET_NAME} from "./consts"; const CHANNELS = 1; // stereo isn't important const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. @@ -49,7 +50,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderSource: MediaStreamAudioSourceNode; private recorderStream: MediaStream; private recorderFFT: AnalyserNode; - private recorderProcessor: ScriptProcessorNode; + private recorderWorklet: AudioWorkletNode; private buffer = new Uint8Array(0); private mxc: string; private recording = false; @@ -93,18 +94,28 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // it makes the time domain less than helpful. this.recorderFFT.fftSize = 64; - // We use an audio processor to get accurate timing information. - // The size of the audio buffer largely decides how quickly we push timing/waveform data - // out of this class. Smaller buffers mean we update more frequently as we can't hold as - // many bytes. Larger buffers mean slower updates. For scale, 1024 gives us about 30Hz of - // updates and 2048 gives us about 20Hz. We use 1024 to get as close to perceived realtime - // as possible. Must be a power of 2. - this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS); + // Set up our worklet. We use this for timing information and waveform analysis: the + // web audio API prefers this be done async to avoid holding the main thread with math. + const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript; + if (!mxRecorderWorkletPath) { + throw new Error("Unable to create recorder: no worklet script registered"); + } + await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath); + this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME); // Connect our inputs and outputs this.recorderSource.connect(this.recorderFFT); - this.recorderSource.connect(this.recorderProcessor); - this.recorderProcessor.connect(this.recorderContext.destination); + this.recorderSource.connect(this.recorderWorklet); + this.recorderWorklet.connect(this.recorderContext.destination); + + // Dev note: we can't use `addEventListener` for some reason. It just doesn't work. + this.recorderWorklet.port.onmessage = (ev) => { + switch(ev.data['ev']) { + case PayloadEvent.Timekeep: + this.processAudioUpdate(ev.data['timeSeconds']); + break; + } + }; this.recorder = new Recorder({ encoderPath, // magic from webpack @@ -151,7 +162,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.mxc; } - private processAudioUpdate = (ev: AudioProcessingEvent) => { + private processAudioUpdate = (timeSeconds: number) => { if (!this.recording) return; // The time domain is the input to the FFT, which means we use an array of the same @@ -175,12 +186,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { this.observable.update({ waveform: translatedData, - timeSeconds: ev.playbackTime, + timeSeconds: timeSeconds, }); // Now that we've updated the data/waveform, let's do a time check. We don't want to // go horribly over the limit. We also emit a warning state if needed. - const secondsLeft = TARGET_MAX_LENGTH - ev.playbackTime; + const secondsLeft = TARGET_MAX_LENGTH - timeSeconds; if (secondsLeft <= 0) { // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping this.stop(); @@ -204,7 +215,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } this.observable = new SimpleObservable(); await this.makeRecorder(); - this.recorderProcessor.addEventListener("audioprocess", this.processAudioUpdate); await this.recorder.start(); this.recording = true; this.emit(RecordingState.Started); @@ -218,6 +228,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // Disconnect the source early to start shutting down resources this.recorderSource.disconnect(); + this.recorderWorklet.disconnect(); await this.recorder.stop(); // close the context after the recorder so the recorder doesn't try to @@ -229,7 +240,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // Finally do our post-processing and clean up this.recording = false; - this.recorderProcessor.removeEventListener("audioprocess", this.processAudioUpdate); await this.recorder.close(); this.emit(RecordingState.Ended); diff --git a/src/voice/consts.ts b/src/voice/consts.ts new file mode 100644 index 0000000000..dbd3b574f4 --- /dev/null +++ b/src/voice/consts.ts @@ -0,0 +1,29 @@ +/* +Copyright 2021 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 const WORKLET_NAME = "mx-voice-worklet"; + +export enum PayloadEvent { + Timekeep = "timekeep", +} + +export interface IPayload { + ev: PayloadEvent; +} + +export interface ITimingPayload extends IPayload { + timeSeconds: number; +} From 61730f2f881292bfcdf5becd249c0fc4c45edb1e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 19 Apr 2021 23:05:06 -0600 Subject: [PATCH 03/37] Populate waveform data on voice message event --- .../views/rooms/VoiceRecordComposerTile.tsx | 8 ++++- src/utils/arrays.ts | 2 +- src/voice/RecorderWorklet.ts | 36 ++++++++++++++++++- src/voice/VoiceRecording.ts | 12 +++++++ src/voice/consts.ts | 8 +++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index f46b7c6311..05beb3a0ca 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -77,7 +77,13 @@ export default class VoiceRecordComposerTile extends React.PureComponent Math.round(v * 1024)), }, }); await VoiceRecordingStore.instance.disposeRecording(); diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 52308937f7..8ab66dfb29 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -54,7 +54,7 @@ export function arraySeed(val: T, length: number): T[] { * @param a The array to clone. Must be defined. * @returns A copy of the array. */ -export function arrayFastClone(a: any[]): any[] { +export function arrayFastClone(a: T[]): T[] { return a.slice(0, a.length); } diff --git a/src/voice/RecorderWorklet.ts b/src/voice/RecorderWorklet.ts index 11f24fce4c..8d6f1e9627 100644 --- a/src/voice/RecorderWorklet.ts +++ b/src/voice/RecorderWorklet.ts @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts"; +import {IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts"; +import {percentageOf} from "../utils/numbers"; // from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope declare const currentTime: number; @@ -22,12 +23,45 @@ declare const currentFrame: number; declare const sampleRate: number; class MxVoiceWorklet extends AudioWorkletProcessor { + private nextAmplitudeSecond = 0; + constructor() { super(); } process(inputs, outputs, parameters) { + // We only fire amplitude updates once a second to avoid flooding the recording instance + // with useless data. Much of the data would end up discarded, so we ratelimit ourselves + // here. + const currentSecond = Math.round(currentTime); + if (currentSecond === this.nextAmplitudeSecond) { + // We're expecting exactly one mono input source, so just grab the very first frame of + // samples for the analysis. + const monoChan = inputs[0][0]; + + // The amplitude of the frame's samples is effectively the loudness of the frame. This + // translates into a bar which can be rendered as part of the whole recording clip's + // waveform. + // + // We translate the amplitude down to 0-1 for sanity's sake. + const minVal = monoChan.reduce((m, v) => Math.min(m, v), Number.MAX_SAFE_INTEGER); + const maxVal = monoChan.reduce((m, v) => Math.max(m, v), Number.MIN_SAFE_INTEGER); + const amplitude = percentageOf(maxVal, -1, 1) - percentageOf(minVal, -1, 1); + + this.port.postMessage({ + ev: PayloadEvent.AmplitudeMark, + amplitude: amplitude, + forSecond: currentSecond, + }); + this.nextAmplitudeSecond++; + } + + // We mostly use this worklet to fire regular clock updates through to components this.port.postMessage({ev: PayloadEvent.Timekeep, timeSeconds: currentTime}); + + // We're supposed to return false when we're "done" with the audio clip, but seeing as + // we are acting as a passive processor we are never truly "done". The browser will clean + // us up when it is done with us. return true; } } diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index 8e506c235c..716936f636 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -24,6 +24,7 @@ import EventEmitter from "events"; import {IDestroyable} from "../utils/IDestroyable"; import {Singleflight} from "../utils/Singleflight"; import {PayloadEvent, WORKLET_NAME} from "./consts"; +import {arrayFastClone} from "../utils/arrays"; const CHANNELS = 1; // stereo isn't important const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. @@ -55,11 +56,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private mxc: string; private recording = false; private observable: SimpleObservable; + private amplitudes: number[] = []; // at each second mark, generated public constructor(private client: MatrixClient) { super(); } + public get finalWaveform(): number[] { + return arrayFastClone(this.amplitudes); + } + public get contentType(): string { return "audio/ogg"; } @@ -114,6 +120,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { case PayloadEvent.Timekeep: this.processAudioUpdate(ev.data['timeSeconds']); break; + case PayloadEvent.AmplitudeMark: + // Sanity check to make sure we're adding about one sample per second + if (ev.data['forSecond'] === this.amplitudes.length) { + this.amplitudes.push(ev.data['amplitude']); + } + break; } }; diff --git a/src/voice/consts.ts b/src/voice/consts.ts index dbd3b574f4..c530c60f0b 100644 --- a/src/voice/consts.ts +++ b/src/voice/consts.ts @@ -18,6 +18,7 @@ export const WORKLET_NAME = "mx-voice-worklet"; export enum PayloadEvent { Timekeep = "timekeep", + AmplitudeMark = "amplitude_mark", } export interface IPayload { @@ -25,5 +26,12 @@ export interface IPayload { } export interface ITimingPayload extends IPayload { + ev: PayloadEvent.Timekeep; timeSeconds: number; } + +export interface IAmplitudePayload extends IPayload { + ev: PayloadEvent.AmplitudeMark; + forSecond: number; + amplitude: number; +} From 4f75e2944cd8fd399c9de461c68505961b2cb7a7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 19 Apr 2021 23:11:41 -0600 Subject: [PATCH 04/37] Appease the linter --- src/components/views/rooms/VoiceRecordComposerTile.tsx | 10 +++++----- src/voice/RecorderWorklet.ts | 4 ++-- src/voice/VoiceRecording.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 05beb3a0ca..9b7f0da472 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -53,11 +53,11 @@ export default class VoiceRecordComposerTile extends React.PureComponent { - switch(ev.data['ev']) { + switch (ev.data['ev']) { case PayloadEvent.Timekeep: this.processAudioUpdate(ev.data['timeSeconds']); break; From c30b62ef355d4ce59648821249f47b81c01f8019 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 20 Apr 2021 11:12:47 +0100 Subject: [PATCH 05/37] Fix alignment issue with nested spaces being cut off wrong --- res/css/structures/_SpacePanel.scss | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index 202eaf0f4d..59f2ea947c 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -35,7 +35,7 @@ $activeBorderColor: $secondary-fg-color; .mx_SpacePanel_spaceTreeWrapper { flex: 1; - overflow-y: scroll; + padding: 8px 8px 16px 0; } .mx_SpacePanel_toggleCollapse { @@ -59,11 +59,10 @@ $activeBorderColor: $secondary-fg-color; margin: 0; list-style: none; padding: 0; - padding-left: 16px; - } - .mx_AutoHideScrollbar { - padding: 8px 0 16px; + > .mx_SpaceItem { + padding-left: 16px; + } } .mx_SpaceButton_toggleCollapse { From b519d851277c2d87ee8b3cd278f89a21927f47b6 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 20 Apr 2021 09:32:12 -0600 Subject: [PATCH 06/37] Update src/voice/RecorderWorklet.ts to use sanity Co-authored-by: Germain --- src/voice/RecorderWorklet.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/voice/RecorderWorklet.ts b/src/voice/RecorderWorklet.ts index eab6bc5f21..48387fc06e 100644 --- a/src/voice/RecorderWorklet.ts +++ b/src/voice/RecorderWorklet.ts @@ -44,8 +44,8 @@ class MxVoiceWorklet extends AudioWorkletProcessor { // waveform. // // We translate the amplitude down to 0-1 for sanity's sake. - const minVal = monoChan.reduce((m, v) => Math.min(m, v), Number.MAX_SAFE_INTEGER); - const maxVal = monoChan.reduce((m, v) => Math.max(m, v), Number.MIN_SAFE_INTEGER); + const minVal = Math.min(...monoChan); + const maxVal = Math.max(...monoChan); const amplitude = percentageOf(maxVal, -1, 1) - percentageOf(minVal, -1, 1); this.port.postMessage({ From 60828913d22541b8295ea8cce2874a210be23887 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 08:13:03 +0100 Subject: [PATCH 07/37] Iterate the spaces face pile design --- res/css/structures/_SpaceRoomView.scss | 25 +---------- res/css/views/elements/_FacePile.scss | 27 ++++++++++- src/components/views/elements/FacePile.tsx | 45 ++++++++++++++----- .../views/elements/TextWithTooltip.js | 7 ++- src/i18n/strings/en_EN.json | 6 +++ 5 files changed, 70 insertions(+), 40 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 2e7cfb55d9..cb7006fb86 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -225,34 +225,11 @@ $SpaceRoomViewInnerWidth: 428px; .mx_FacePile_faces { cursor: pointer; - > span:hover { + &:hover { .mx_BaseAvatar { filter: brightness(0.8); } } - - > span:first-child { - position: relative; - - .mx_BaseAvatar { - filter: brightness(0.8); - } - - &::before { - content: ""; - z-index: 1; - position: absolute; - top: 0; - left: 0; - height: 30px; - width: 30px; - background: #ffffff; // white icon fill - mask-position: center; - mask-size: 24px; - mask-repeat: no-repeat; - mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); - } - } } } diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 9a992f59d1..0f453eb3ff 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -20,7 +20,7 @@ limitations under the License. flex-direction: row-reverse; vertical-align: middle; - > span + span { + > .mx_FacePile_face + .mx_FacePile_face { margin-right: -8px; } @@ -31,9 +31,32 @@ limitations under the License. .mx_BaseAvatar_initial { margin: 1px; // to offset the border on the image } + + .mx_FacePile_more { + position: relative; + border-radius: 100%; + width: 30px; + height: 30px; + background-color: $groupFilterPanel-bg-color; + + &::before { + content: ""; + z-index: 1; + position: absolute; + top: 0; + left: 0; + height: 30px; + width: 30px; + background: $tertiary-fg-color; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } } - > span { + .mx_FacePile_summary { margin-left: 12px; font-size: $font-14px; line-height: $font-24px; diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index e223744352..67b218494a 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { HTMLAttributes } from "react"; +import React, { HTMLAttributes, ReactNode, useContext } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { sortBy } from "lodash"; @@ -24,6 +24,7 @@ import { _t } from "../../../languageHandler"; import DMRoomMap from "../../../utils/DMRoomMap"; import TextWithTooltip from "../elements/TextWithTooltip"; import { useRoomMembers } from "../../../hooks/useRoomMembers"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; const DEFAULT_NUM_FACES = 5; @@ -36,6 +37,7 @@ interface IProps extends HTMLAttributes { const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length; const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => { + const cli = useContext(MatrixClientContext); let members = useRoomMembers(room); // sort users with an explicit avatar first @@ -46,21 +48,40 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, . // sort known users first iteratees.unshift(member => isKnownMember(member)); } - if (members.length < 1) return null; - const shownMembers = sortBy(members, iteratees).slice(0, numShown); + // exclude ourselves from the shown members list + const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown); + if (shownMembers.length < 1) return null; + + const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", "); + + let tooltip: ReactNode; + if (props.onClick) { + tooltip =
+
+ { _t("View all %(count)s members", { count: members.length }) } +
+
+ { _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) } +
+
; + } else { + tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", { + count: members.length, + commaSeparatedMembers, + }); + } + return
-
- { shownMembers.map(member => { - return - - ; - }) } -
- { onlyKnownUsers && + + { members.length > numShown ? : null } + { shownMembers.map(m => + )} + + { onlyKnownUsers && { _t("%(count)s people you know have already joined", { count: members.length }) } } -
+ ; }; export default FacePile; diff --git a/src/components/views/elements/TextWithTooltip.js b/src/components/views/elements/TextWithTooltip.js index 0bd491768c..a6fc00fc2e 100644 --- a/src/components/views/elements/TextWithTooltip.js +++ b/src/components/views/elements/TextWithTooltip.js @@ -25,6 +25,7 @@ export default class TextWithTooltip extends React.Component { class: PropTypes.string, tooltipClass: PropTypes.string, tooltip: PropTypes.node.isRequired, + tooltipProps: PropTypes.object, }; constructor() { @@ -46,15 +47,17 @@ export default class TextWithTooltip extends React.Component { render() { const Tooltip = sdk.getComponent("elements.Tooltip"); - const {class: className, children, tooltip, tooltipClass, ...props} = this.props; + const {class: className, children, tooltip, tooltipClass, tooltipProps, ...props} = this.props; return ( {children} {this.state.hover && } + className={"mx_TextWithTooltip_tooltip"} + /> } ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 133d24e3c8..f1b700540f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1916,7 +1916,13 @@ "Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.", "collapse": "collapse", "expand": "expand", + "View all %(count)s members|other": "View all %(count)s members", + "View all %(count)s members|one": "View 1 member", + "Including %(commaSeparatedMembers)s": "Including %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|other": "%(count)s members including %(commaSeparatedMembers)s", + "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", + "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", "Rotate Right": "Rotate Right", "Rotate Left": "Rotate Left", "Zoom out": "Zoom out", From 90cd5d0472d4bb09640d17a2e8c7bb132cc0b17c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 08:18:28 +0100 Subject: [PATCH 08/37] Remove old redundant hover effect --- res/css/structures/_SpaceRoomView.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index cb7006fb86..2dbf0fe0fe 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -224,12 +224,6 @@ $SpaceRoomViewInnerWidth: 428px; .mx_FacePile_faces { cursor: pointer; - - &:hover { - .mx_BaseAvatar { - filter: brightness(0.8); - } - } } } From ee80c27b2b65ecde0bde2db1d88f2c6f9be58237 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 08:22:11 +0100 Subject: [PATCH 09/37] Improve edge cases with spaces context switching --- src/stores/SpaceStore.tsx | 84 +++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index dc0c691505..9650eb5544 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -110,30 +110,32 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._suggestedRooms; } - public async setActiveSpace(space: Room | null) { + public async setActiveSpace(space: Room | null, contextSwitch = true) { if (space === this.activeSpace) return; this._activeSpace = space; this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); this.emit(SUGGESTED_ROOMS, this._suggestedRooms = []); - // view last selected room from space - const roomId = window.localStorage.getItem(getLastViewedRoomsStorageKey(this.activeSpace)); + if (contextSwitch) { + // view last selected room from space + const roomId = window.localStorage.getItem(getLastViewedRoomsStorageKey(this.activeSpace)); - if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") { - defaultDispatcher.dispatch({ - action: "view_room", - room_id: roomId, - }); - } else if (space) { - defaultDispatcher.dispatch({ - action: "view_room", - room_id: space.roomId, - }); - } else { - defaultDispatcher.dispatch({ - action: "view_home_page", - }); + if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: roomId, + }); + } else if (space) { + defaultDispatcher.dispatch({ + action: "view_room", + room_id: space.roomId, + }); + } else { + defaultDispatcher.dispatch({ + action: "view_home_page", + }); + } } // persist space selected @@ -512,36 +514,30 @@ export class SpaceStoreClass extends AsyncStoreWithClient { switch (payload.action) { case "view_room": { const room = this.matrixClient?.getRoom(payload.room_id); + if (!room) break; - // persist last viewed room from a space - - // Don't save if the room is a space room. This would cause a problem: - // When switching to a space home, we first view that room and - // only after that we switch to that space. This causes us to - // save the space home to be the last viewed room in the home - // space. - if (room && !room.isSpaceRoom()) { - window.localStorage.setItem(getLastViewedRoomsStorageKey(this.activeSpace), payload.room_id); - } - - if (room?.getMyMembership() === "join") { - if (room.isSpaceRoom()) { - this.setActiveSpace(room); - } else if (!this.spaceFilteredRooms.get(this._activeSpace?.roomId || HOME_SPACE).has(room.roomId)) { - // TODO maybe reverse these first 2 clauses once space panel active is fixed - let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); - if (!parent) { - parent = this.getCanonicalParent(room.roomId); - } - if (!parent) { - const parents = Array.from(this.parentMap.get(room.roomId) || []); - parent = parents.find(p => this.matrixClient.getRoom(p)); - } - if (parent) { - this.setActiveSpace(parent); - } + if (room.isSpaceRoom()) { + this.setActiveSpace(room); + } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { + // TODO maybe reverse these first 2 clauses once space panel active is fixed + let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); + if (!parent) { + parent = this.getCanonicalParent(room.roomId); + } + if (!parent) { + const parents = Array.from(this.parentMap.get(room.roomId) || []); + parent = parents.find(p => this.matrixClient.getRoom(p)); + } + if (parent) { + // don't trigger a context switch when we are switching a space to match the chosen room + this.setActiveSpace(parent, false); } } + + // Persist last viewed room from a space + // we don't await setActiveSpace above as we only care about this.activeSpace being up to date + // synchronously for the below code - everything else can and should be async. + window.localStorage.setItem(getLastViewedRoomsStorageKey(this.activeSpace), payload.room_id); break; } case "after_leave_room": From ec0612f70dbef27425238127a7d7be1e9bb0e1fc Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 08:30:44 +0100 Subject: [PATCH 10/37] Fix spaces notification dots wrongly including upgraded (hidden) rooms --- src/stores/SpaceStore.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 7ee6067805..55eee4586e 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -389,8 +389,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.spaceFilteredRooms.forEach((roomIds, s) => { // Update NotificationStates - const rooms = this.matrixClient.getRooms().filter(room => roomIds.has(room.roomId)); - this.getNotificationState(s)?.setRooms(rooms); + this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => roomIds.has(room.roomId))); }); }, 100, {trailing: true, leading: true}); From b64b956aa46d8c7da889489d2da4026ee88ea9cb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 08:39:16 +0100 Subject: [PATCH 11/37] when automatically switching space to match room fall back to the home space --- src/stores/SpaceStore.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 5b087bb054..daa05af7cf 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -535,10 +535,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const parents = Array.from(this.parentMap.get(room.roomId) || []); parent = parents.find(p => this.matrixClient.getRoom(p)); } - if (parent) { - // don't trigger a context switch when we are switching a space to match the chosen room - this.setActiveSpace(parent, false); - } + // don't trigger a context switch when we are switching a space to match the chosen room + this.setActiveSpace(parent || null, false); } // Persist last viewed room from a space From 28fa1cb44ce3ea0f1fe5083c88b185fd87bf96a4 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 09:05:02 +0100 Subject: [PATCH 12/37] Reset space contexts as some users may have loops stuck in their local storage --- src/stores/SpaceStore.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index daa05af7cf..80722ad3ac 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -51,11 +51,7 @@ export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); const MAX_SUGGESTED_ROOMS = 20; -const getLastViewedRoomsStorageKey = (space?: Room) => { - const lastViewRooms = "mx_last_viewed_rooms"; - const homeSpace = "home_space"; - return `${lastViewRooms}_${space?.roomId || homeSpace}`; -} +const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] return arr.reduce((result, room: Room) => { @@ -119,7 +115,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (contextSwitch) { // view last selected room from space - const roomId = window.localStorage.getItem(getLastViewedRoomsStorageKey(this.activeSpace)); + const roomId = window.localStorage.getItem(getSpaceContextKey(this.activeSpace)); if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") { defaultDispatcher.dispatch({ @@ -542,7 +538,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Persist last viewed room from a space // we don't await setActiveSpace above as we only care about this.activeSpace being up to date // synchronously for the below code - everything else can and should be async. - window.localStorage.setItem(getLastViewedRoomsStorageKey(this.activeSpace), payload.room_id); + window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); break; } case "after_leave_room": From ca07b1ed04fc1357296a7dfca83b0265544193bb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 09:06:53 +0100 Subject: [PATCH 13/37] Update res/css/views/elements/_FacePile.scss Co-authored-by: Germain --- res/css/views/elements/_FacePile.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/res/css/views/elements/_FacePile.scss b/res/css/views/elements/_FacePile.scss index 0f453eb3ff..c691baffb5 100644 --- a/res/css/views/elements/_FacePile.scss +++ b/res/css/views/elements/_FacePile.scss @@ -45,8 +45,8 @@ limitations under the License. position: absolute; top: 0; left: 0; - height: 30px; - width: 30px; + height: inherit; + width: inherit; background: $tertiary-fg-color; mask-position: center; mask-size: 20px; From 23c61752cdfcaebde67db358d38684225c969900 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 09:08:25 +0100 Subject: [PATCH 14/37] Add comment --- src/components/views/elements/FacePile.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/views/elements/FacePile.tsx b/src/components/views/elements/FacePile.tsx index 67b218494a..aeca2e844b 100644 --- a/src/components/views/elements/FacePile.tsx +++ b/src/components/views/elements/FacePile.tsx @@ -53,6 +53,8 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, . const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown); if (shownMembers.length < 1) return null; + // We reverse the order of the shown faces in CSS to simplify their visual overlap, + // reverse members in tooltip order to make the order between the two match up. const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", "); let tooltip: ReactNode; From 60ef657f64fda8cb4a20c8331cef1eff4b9e335d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 09:41:07 +0100 Subject: [PATCH 15/37] Properly hide spaces from the room list --- src/stores/room-list/RoomListStore.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 88df05b5d0..caab46a0c2 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -599,11 +599,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { private getPlausibleRooms(): Room[] { if (!this.matrixClient) return []; - let rooms = [ - ...this.matrixClient.getVisibleRooms(), - // also show space invites in the room list - ...this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "invite"), - ].filter(r => VisibilityProvider.instance.isRoomVisible(r)); + let rooms = this.matrixClient.getVisibleRooms().filter(r => VisibilityProvider.instance.isRoomVisible(r)); if (this.prefilterConditions.length > 0) { rooms = rooms.filter(r => { From 7efd4a43a5d1a9477379f215d47e0845ed7dde7a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 11:01:49 +0100 Subject: [PATCH 16/37] Show space invites at the top of the space panel --- src/components/views/spaces/SpacePanel.tsx | 22 +++++++-- .../views/spaces/SpaceTreeLevel.tsx | 6 ++- src/stores/SpaceStore.tsx | 47 +++++++++++++------ 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index bacf1bd929..36ab423885 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -25,7 +25,12 @@ import SpaceCreateMenu from "./SpaceCreateMenu"; import {SpaceItem} from "./SpaceTreeLevel"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; -import SpaceStore, {HOME_SPACE, UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../../stores/SpaceStore"; +import SpaceStore, { + HOME_SPACE, + UPDATE_INVITED_SPACES, + UPDATE_SELECTED_SPACE, + UPDATE_TOP_LEVEL_SPACES, +} from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState"; import NotificationBadge from "../rooms/NotificationBadge"; @@ -105,19 +110,21 @@ const SpaceButton: React.FC = ({ ; } -const useSpaces = (): [Room[], Room | null] => { +const useSpaces = (): [Room[], Room[], Room | null] => { + const [invites, setInvites] = useState(SpaceStore.instance.invitedSpaces); + useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); const [spaces, setSpaces] = useState(SpaceStore.instance.spacePanelSpaces); useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); const [activeSpace, setActiveSpace] = useState(SpaceStore.instance.activeSpace); useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); - return [spaces, activeSpace]; + return [invites, spaces, activeSpace]; }; const SpacePanel = () => { // We don't need the handle as we position the menu in a constant location // eslint-disable-next-line @typescript-eslint/no-unused-vars const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - const [spaces, activeSpace] = useSpaces(); + const [invites, spaces, activeSpace] = useSpaces(); const [isPanelCollapsed, setPanelCollapsed] = useState(true); const newClasses = classNames("mx_SpaceButton_new", { @@ -209,6 +216,13 @@ const SpacePanel = () => { notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)} isNarrow={isPanelCollapsed} /> + { invites.map(s => setPanelCollapsed(false)} + />) } { spaces.map(s => { mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition, mx_SpaceButton_narrow: isNarrow, }); - const notificationState = SpaceStore.instance.getNotificationState(space.roomId); + const notificationState = space.getMyMembership() === "invite" + ? StaticNotificationState.forSymbol("!", NotificationColor.Red) + : SpaceStore.instance.getNotificationState(space.roomId); let childItems; if (childSpaces && !collapsed) { diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 80722ad3ac..c28e24a460 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -46,6 +46,7 @@ export const HOME_SPACE = Symbol("home-space"); export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); +export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); // Space Room ID/HOME_SPACE will be emitted when a Space's children change @@ -93,6 +94,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // The space currently selected in the Space Panel - if null then `Home` is selected private _activeSpace?: Room = null; private _suggestedRooms: ISpaceSummaryRoom[] = []; + private _invitedSpaces = new Set(); + + public get invitedSpaces(): Room[] { + return Array.from(this._invitedSpaces); + } public get spacePanelSpaces(): Room[] { return this.rootSpaces; @@ -214,25 +220,27 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return sortBy(parents, r => r.roomId)?.[0] || null; } - public getSpaces = () => { - return this.matrixClient.getRooms().filter(r => r.isSpaceRoom() && r.getMyMembership() === "join"); - }; - public getSpaceFilteredRoomIds = (space: Room | null): Set => { return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); }; private rebuild = throttle(() => { - // get all most-upgraded rooms & spaces except spaces which have been left (historical) - const visibleRooms = this.matrixClient.getVisibleRooms().filter(r => { - return !r.isSpaceRoom() || r.getMyMembership() === "join"; - }); + const [visibleSpaces, visibleRooms] = partitionSpacesAndRooms(this.matrixClient.getVisibleRooms()); + const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce((arr, s) => { + if (s.getMyMembership() === "join") { + arr[0].push(s); + } else if (s.getMyMembership() === "invite") { + arr[1].push(s); + } + return arr; + }, [[], []]); - const unseenChildren = new Set(visibleRooms); + // exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview + const unseenChildren = new Set([...visibleRooms, ...joinedSpaces]); const backrefs = new EnhancedMap>(); // Sort spaces by room ID to force the cycle breaking to be deterministic - const spaces = sortBy(visibleRooms.filter(r => r.isSpaceRoom()), space => space.roomId); + const spaces = sortBy(joinedSpaces, space => space.roomId); // TODO handle cleaning up links when a Space is removed spaces.forEach(space => { @@ -296,6 +304,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onRoomsUpdate(); // TODO only do this if a change has happened this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces); + + // build initial state of invited spaces as we would have missed the emitted events about the room at launch + this._invitedSpaces = new Set(invitedSpaces); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); }, 100, {trailing: true, leading: true}); onSpaceUpdate = () => { @@ -303,6 +315,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } private showInHomeSpace = (room: Room) => { + if (room.isSpaceRoom()) return false; return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites @@ -333,8 +346,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - // put all invites (rooms & spaces) in the Home Space - const invites = this.matrixClient.getRooms().filter(r => r.getMyMembership() === "invite"); + // put all room invites in the Home Space + const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); visibleRooms.forEach(room => { @@ -392,8 +405,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }, 100, {trailing: true, leading: true}); - private onRoom = (room: Room) => { - if (room?.isSpaceRoom()) { + private onRoom = (room: Room, membership?: string, oldMembership?: string) => { + if ((membership || room.getMyMembership()) === "invite") { + this._invitedSpaces.add(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else if (oldMembership === "invite") { + this._invitedSpaces.delete(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else if (room?.isSpaceRoom()) { this.onSpaceUpdate(); this.emit(room.roomId); } else { From a51aeaa04d10291fccb46c8b3f4099f3eeae3843 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 11:24:52 +0100 Subject: [PATCH 17/37] Disable context menu on space invite tiles as no options sensibly work --- src/components/views/spaces/SpaceTreeLevel.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 71ef4c562c..2e2901ce64 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -85,6 +85,7 @@ export class SpaceItem extends React.PureComponent { } private onContextMenu = (ev: React.MouseEvent) => { + if (this.props.space.getMyMembership() !== "join") return; ev.preventDefault(); ev.stopPropagation(); this.setState({ @@ -187,6 +188,8 @@ export class SpaceItem extends React.PureComponent { }; private renderContextMenu(): React.ReactElement { + if (this.props.space.getMyMembership() !== "join") return null; + let contextMenu = null; if (this.state.contextMenuPosition) { const userId = this.context.getUserId(); From 108a3088efe3bc30a090cc215fea77d5e8d6a52b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 11:25:11 +0100 Subject: [PATCH 18/37] Hide explore rooms quick action when active space is an invite --- src/components/views/rooms/RoomList.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 8ac706fc15..e4e638fc67 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -548,6 +548,9 @@ export default class RoomList extends React.PureComponent { } public render() { + const cli = MatrixClientPeg.get(); + const userId = cli.getUserId(); + let explorePrompt: JSX.Element; if (!this.props.isMinimized) { if (this.state.isNameFiltering) { @@ -568,21 +571,23 @@ export default class RoomList extends React.PureComponent { { this.props.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") } ; - } else if (this.props.activeSpace) { + } else if ( + this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join" + ) { explorePrompt =
{ _t("Quick actions") }
- { this.props.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && {_t("Invite people")} } - {_t("Explore rooms")} - + }
; } else if (Object.values(this.state.sublists).some(list => list.length > 0)) { const unfilteredLists = RoomListStore.instance.unfilteredLists From e05200269f814b53b5a4b0ea7bcc1ecdf1af8392 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 12:07:03 +0100 Subject: [PATCH 19/37] fix comment --- src/components/views/spaces/SpaceTreeLevel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/spaces/SpaceTreeLevel.tsx b/src/components/views/spaces/SpaceTreeLevel.tsx index 2e2901ce64..6825d84013 100644 --- a/src/components/views/spaces/SpaceTreeLevel.tsx +++ b/src/components/views/spaces/SpaceTreeLevel.tsx @@ -69,7 +69,7 @@ export class SpaceItem extends React.PureComponent { super(props); this.state = { - collapsed: !props.isNested, // default to collapsed for root items + collapsed: !props.isNested, // default to collapsed for root items contextMenuPosition: null, }; } From e219fe082adf3c2669323251bd9571493bee58d3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Apr 2021 12:07:16 +0100 Subject: [PATCH 20/37] Tweak context switching edge case for space invites --- src/stores/SpaceStore.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index c28e24a460..a9a73e164f 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -123,7 +123,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // view last selected room from space const roomId = window.localStorage.getItem(getSpaceContextKey(this.activeSpace)); - if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") { + // if the space being selected is an invite then always view that invite + // else if the last viewed room in this space is joined then view that + // else view space home or home depending on what is being clicked on + if (space?.getMyMembership !== "invite" && + this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" + ) { defaultDispatcher.dispatch({ action: "view_room", room_id: roomId, From ad53b0e2e26756f169eb5400fb0cdcd33f3bcfe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 22 Apr 2021 14:56:12 +0200 Subject: [PATCH 21/37] Add normalizeWheelEvent() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Å imon Brandner --- src/utils/Mouse.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/utils/Mouse.ts diff --git a/src/utils/Mouse.ts b/src/utils/Mouse.ts new file mode 100644 index 0000000000..a85c6492c4 --- /dev/null +++ b/src/utils/Mouse.ts @@ -0,0 +1,50 @@ +/* +Copyright 2021 Å imon Brandner + +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. +*/ + +/** + * Different browsers use different deltaModes. This causes different behaviour. + * To avoid that we use this function to convert any event to pixels. + * @param {WheelEvent} event to normalize + * @returns {WheelEvent} normalized event event + */ +export function normalizeWheelEvent(event: WheelEvent): WheelEvent { + const LINE_HEIGHT = 18; + + let deltaX; + let deltaY; + let deltaZ; + + if (event.deltaMode === 1) { // Units are lines + deltaX = (event.deltaX * LINE_HEIGHT); + deltaY = (event.deltaY * LINE_HEIGHT); + deltaZ = (event.deltaZ * LINE_HEIGHT); + } else { + deltaX = event.deltaX; + deltaY = event.deltaY; + deltaZ = event.deltaZ; + } + + return new WheelEvent( + "syntheticWheel", + { + deltaMode: 0, + deltaY: deltaY, + deltaX: deltaX, + deltaZ: deltaZ, + ...event, + }, + ); +} From 2e6397d8aac1b10cc1011427893cb6eed15800c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Thu, 22 Apr 2021 14:56:35 +0200 Subject: [PATCH 22/37] Wire up normalizeWheelEvent() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Å imon Brandner --- src/components/views/elements/ImageView.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index bb69e24855..cbced07bfe 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -32,13 +32,14 @@ import dis from '../../../dispatcher/dispatcher'; import {replaceableComponent} from "../../../utils/replaceableComponent"; import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks" import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {normalizeWheelEvent} from "../../../utils/Mouse"; const MIN_ZOOM = 100; const MAX_ZOOM = 300; // This is used for the buttons const ZOOM_STEP = 10; // This is used for mouse wheel events -const ZOOM_COEFFICIENT = 7.5; +const ZOOM_COEFFICIENT = 0.5; // If we have moved only this much we can zoom const ZOOM_DISTANCE = 10; @@ -115,7 +116,9 @@ export default class ImageView extends React.Component { private onWheel = (ev: WheelEvent) => { ev.stopPropagation(); ev.preventDefault(); - const newZoom = this.state.zoom - (ev.deltaY * ZOOM_COEFFICIENT); + + const {deltaY} = normalizeWheelEvent(ev); + const newZoom = this.state.zoom - (deltaY * ZOOM_COEFFICIENT); if (newZoom <= MIN_ZOOM) { this.setState({ From b332f6b1aec6d0c1e3f5a03c9d494a641e8a8a25 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 22 Apr 2021 13:59:02 +0100 Subject: [PATCH 23/37] Use floats for image background opacity It seems percentages for opacity are still newish, and they seem to confuse something which is clamping them to the 0 - 1 range (which makes sense for floats, not percentages). Anyway, for now we can get what we want here by using float values. Fixes https://github.com/vector-im/element-web/issues/17036 --- res/themes/dark/css/_dark.scss | 2 +- res/themes/legacy-dark/css/_legacy-dark.scss | 2 +- res/themes/legacy-light/css/_legacy-light.scss | 2 +- res/themes/light/css/_light.scss | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index bd7057c3e4..925d268eb0 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -85,7 +85,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; -$lightbox-background-bg-opacity: 85%; +$lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #21262c; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 9b2365a621..28e6e22326 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -83,7 +83,7 @@ $dialog-close-fg-color: #9fa9ba; $dialog-background-bg-color: $header-panel-bg-color; $lightbox-background-bg-color: #000; -$lightbox-background-bg-opacity: 85%; +$lightbox-background-bg-opacity: 0.85; $settings-grey-fg-color: #a2a2a2; $settings-profile-placeholder-bg-color: #e7e7e7; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 0956f433b2..7b6bdad4a4 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -127,7 +127,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; -$lightbox-background-bg-opacity: 95%; +$lightbox-background-bg-opacity: 0.95; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index b307dbaba3..5b46138dae 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -118,7 +118,7 @@ $dialog-close-fg-color: #c1c1c1; $dialog-background-bg-color: #e9e9e9; $lightbox-background-bg-color: #000; -$lightbox-background-bg-opacity: 95%; +$lightbox-background-bg-opacity: 0.95; $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); From 14809dfda7f5b1e58539e239028124b6da4e6f79 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 08:22:31 -0600 Subject: [PATCH 24/37] Misc cleanup --- src/@types/global.d.ts | 1 - src/voice/RecorderWorklet.ts | 4 ---- 2 files changed, 5 deletions(-) diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 78dad28566..41257c21f0 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -138,7 +138,6 @@ declare global { outputs: Float32Array[][], parameters: Record ): boolean; - } // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 diff --git a/src/voice/RecorderWorklet.ts b/src/voice/RecorderWorklet.ts index 48387fc06e..7343d37066 100644 --- a/src/voice/RecorderWorklet.ts +++ b/src/voice/RecorderWorklet.ts @@ -25,10 +25,6 @@ declare const currentTime: number; class MxVoiceWorklet extends AudioWorkletProcessor { private nextAmplitudeSecond = 0; - constructor() { - super(); - } - process(inputs, outputs, parameters) { // We only fire amplitude updates once a second to avoid flooding the recording instance // with useless data. Much of the data would end up discarded, so we ratelimit ourselves From 2b6551d06aac930eaf947e2b97ecb36a1b60dd27 Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 22 Apr 2021 16:17:53 +0100 Subject: [PATCH 25/37] Remove reliance on DOM API to generated message preview --- src/HtmlUtils.tsx | 13 ++++++++----- .../room-list/previews/MessageEventPreview.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 1dc342fac5..6b2568d68c 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) { return
; } -export function sanitizedHtmlNodeInnerText(insaneHtml: string) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); - const contentDiv = document.createElement("div"); - contentDiv.innerHTML = saneHtml; - return contentDiv.innerText; +export function getHtmlText(insaneHtml: string) { + return sanitizeHtml(insaneHtml, { + allowedTags: [], + allowedAttributes: {}, + selfClosing: [], + allowedSchemes: [], + disallowedTagsMode: 'discard', + }) } /** diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index deed7dcf2c..b900afc13f 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -20,7 +20,7 @@ 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"; -import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils"; +import { getHtmlText } from "../../../HtmlUtils"; export class MessageEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID): string { @@ -55,7 +55,7 @@ export class MessageEventPreview implements IPreview { } if (hasHtml) { - body = sanitizedHtmlNodeInnerText(body); + body = getHtmlText(body); } if (msgtype === 'm.emote') { From cc5a7671a724a908571fc8c20d46cdb1751f650d Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Apr 2021 11:39:58 -0400 Subject: [PATCH 26/37] Keep invite button separate from space info 60828913d22541b8295ea8cce2874a210be23887 caused the space info and invite buttons to have no separation when you are the only person in a space, since the margin was set on the face pile, which may be absent. Signed-off-by: Robin Townsend --- res/css/structures/_SpaceRoomView.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index 2dbf0fe0fe..269f16beb7 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -214,12 +214,11 @@ $SpaceRoomViewInnerWidth: 428px; .mx_SpaceRoomView_info { display: inline-block; - margin: 0; + margin: 0 auto 0 0; } .mx_FacePile { display: inline-block; - margin-left: auto; margin-right: 12px; .mx_FacePile_faces { From a3e846685d795d81a369ff4b23f39ead31163095 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 16:24:41 -0600 Subject: [PATCH 27/37] Add array utility tests (and support upsampling in resample) See contained diff. The upsampling is important for Voice Messages, but is being done here because it's easier to add all the tests at once. This also introduces a new Object utility - that will be tested on its own commit. --- src/utils/arrays.ts | 51 ++++++-- src/utils/objects.ts | 20 ++- test/arrays-test.ts | 294 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 test/arrays-test.ts diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index 8ab66dfb29..cea377bfe9 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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,23 +15,47 @@ limitations under the License. */ /** - * Quickly resample an array to have less data points. This isn't a perfect representation, - * though this does work best if given a large array to downsample to a much smaller array. - * @param {number[]} input The input array to downsample. + * Quickly resample an array to have less/more data points. If an input which is larger + * than the desired size is provided, it will be downsampled. Similarly, if the input + * is smaller than the desired size then it will be upsampled. + * @param {number[]} input The input array to resample. * @param {number} points The number of samples to end up with. - * @returns {number[]} The downsampled array. + * @returns {number[]} The resampled array. */ export function arrayFastResample(input: number[], points: number): number[] { - // Heavily inpired by matrix-media-repo (used with permission) + if (input.length === points) return input; // short-circuit a complicated call + + // Heavily inspired by matrix-media-repo (used with permission) // https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10 - const everyNth = Math.round(input.length / points); - const samples: number[] = []; - for (let i = 0; i < input.length; i += everyNth) { - samples.push(input[i]); + let samples: number[] = []; + if (input.length > points) { + // Danger: this loop can cause out of memory conditions if the input is too small. + const everyNth = Math.round(input.length / points); + for (let i = 0; i < input.length; i += everyNth) { + samples.push(input[i]); + } + } else { + // Smaller inputs mean we have to spread the values over the desired length. We + // end up overshooting the target length in doing this, so we'll resample down + // before returning. This recursion is risky, but mathematically should not go + // further than 1 level deep. + const spreadFactor = Math.ceil(points / input.length); + for (const val of input) { + samples.push(...arraySeed(val, spreadFactor)); + } + samples = arrayFastResample(samples, points); } + + // Sanity fill, just in case while (samples.length < points) { samples.push(input[input.length - 1]); } + + // Sanity trim, just in case + if (samples.length > points) { + samples = samples.slice(0, points); + } + return samples; } @@ -178,6 +202,13 @@ export class GroupedArray { constructor(private val: Map) { } + /** + * The value of this group, after all applicable alterations. + */ + public get value(): Map { + return this.val; + } + /** * Orders the grouping into an array using the provided key order. * @param keyOrder The key order. diff --git a/src/utils/objects.ts b/src/utils/objects.ts index e7f4f0f907..2c9361beba 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -141,3 +141,21 @@ export function objectKeyChanges(a: O, b: O): (keyof O)[] { export function objectClone(obj: O): O { return JSON.parse(JSON.stringify(obj)); } + +/** + * Converts a series of entries to an object. + * @param entries The entries to convert. + * @returns The converted object. + */ +// NOTE: Deprecated once we have Object.fromEntries() support. +// @ts-ignore - return type is complaining about non-string keys, but we know better +export function objectFromEntries(entries: Iterable<[K, V]>): {[k: K]: V} { + const obj: { + // @ts-ignore - same as return type + [k: K]: V} = {}; + for (const e of entries) { + // @ts-ignore - same as return type + obj[e[0]] = e[1]; + } + return obj; +} diff --git a/test/arrays-test.ts b/test/arrays-test.ts new file mode 100644 index 0000000000..33c4ee452e --- /dev/null +++ b/test/arrays-test.ts @@ -0,0 +1,294 @@ +/* +Copyright 2021 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, + arrayFastClone, + arrayFastResample, + arrayHasDiff, + arrayHasOrderChange, + arrayMerge, + arraySeed, + arrayUnion, + ArrayUtil, + GroupedArray, +} from "../src/utils/arrays"; +import {objectFromEntries} from "../src/utils/objects"; + +function expectSample(i: number, input: number[], expected: number[]) { + console.log(`Resample case index: ${i}`); // for debugging test failures + const result = arrayFastResample(input, expected.length); + expect(result).toBeDefined(); + expect(result).toHaveLength(expected.length); + expect(result).toEqual(expected); +} + +describe('arrays', () => { + describe('arrayFastResample', () => { + it('should downsample', () => { + [ + {input: [1, 2, 3, 4, 5], output: [1, 4]}, // Odd -> Even + {input: [1, 2, 3, 4, 5], output: [1, 3, 5]}, // Odd -> Odd + {input: [1, 2, 3, 4], output: [1, 2, 3]}, // Even -> Odd + {input: [1, 2, 3, 4], output: [1, 3]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output)); + }); + + it('should upsample', () => { + [ + {input: [1, 2, 3], output: [1, 1, 2, 2, 3, 3]}, // Odd -> Even + {input: [1, 2, 3], output: [1, 1, 2, 2, 3]}, // Odd -> Odd + {input: [1, 2], output: [1, 1, 1, 2, 2]}, // Even -> Odd + {input: [1, 2], output: [1, 1, 1, 2, 2, 2]}, // Even -> Even + ].forEach((c, i) => expectSample(i, c.input, c.output)); + }); + + it('should maintain sample', () => { + [ + {input: [1, 2, 3], output: [1, 2, 3]}, // Odd + {input: [1, 2], output: [1, 2]}, // Even + ].forEach((c, i) => expectSample(i, c.input, c.output)); + }); + }); + + describe('arraySeed', () => { + it('should create an array of given length', () => { + const val = 1; + const output = [val, val, val]; + const result = arraySeed(val, output.length); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + it('should maintain pointers', () => { + const val = {}; // this works because `{} !== {}`, which is what toEqual checks + const output = [val, val, val]; + const result = arraySeed(val, output.length); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + + describe('arrayFastClone', () => { + it('should break pointer reference on source array', () => { + const val = {}; // we'll test to make sure the values maintain pointers too + const input = [val, val, val]; + const result = arrayFastClone(input); + expect(result).toBeDefined(); + expect(result).toHaveLength(input.length); + expect(result).toEqual(input); // we want the array contents to match... + expect(result).not.toBe(input); // ... but be a different reference + }); + }); + + describe('arrayHasOrderChange', () => { + it('should flag true on B ordering difference', () => { + const a = [1, 2, 3]; + const b = [3, 2, 1]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(true); + }); + + it('should flag false on no ordering difference', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(false); + }); + + it('should flag true on A length > B length', () => { + const a = [1, 2, 3, 4]; + const b = [1, 2, 3]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(true); + }); + + it('should flag true on A length < B length', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = arrayHasOrderChange(a, b); + expect(result).toBe(true); + }); + }); + + describe('arrayHasDiff', () => { + it('should flag true on A length > B length', () => { + const a = [1, 2, 3, 4]; + const b = [1, 2, 3]; + const result = arrayHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on A length < B length', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = arrayHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on element differences', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + const result = arrayHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag false if same but order different', () => { + const a = [1, 2, 3]; + const b = [3, 1, 2]; + const result = arrayHasDiff(a, b); + expect(result).toBe(false); + }); + + it('should flag false if same', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3]; + const result = arrayHasDiff(a, b); + expect(result).toBe(false); + }); + }); + + describe('arrayDiff', () => { + it('should see added from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = arrayDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.added).toEqual([4]); + }); + + it('should see removed from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2]; + const result = arrayDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.removed).toEqual([3]); + }); + + it('should see added and removed in the same set', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = arrayDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.added).toEqual([4]); + expect(result.removed).toEqual([3]); + }); + }); + + describe('arrayUnion', () => { + it('should return a union', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = arrayUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual([1, 2]); + }); + + it('should return an empty array on no matches', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + const result = arrayUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + }); + + describe('arrayMerge', () => { + it('should merge 3 arrays with deduplication', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4, 5]; // note missing 3 + const c = [6, 7, 8, 9]; + const result = arrayMerge(a, b, c); + expect(result).toBeDefined(); + expect(result).toHaveLength(9); + expect(result).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it('should deduplicate a single array', () => { + // dev note: this is technically an edge case, but it is described behaviour if the + // function is only provided one function (it'll merge the array against itself) + const a = [1, 1, 2, 2, 3, 3]; + const result = arrayMerge(a); + expect(result).toBeDefined(); + expect(result).toHaveLength(3); + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('ArrayUtil', () => { + it('should maintain the pointer to the given array', () => { + const input = [1, 2, 3]; + const result = new ArrayUtil(input); + expect(result.value).toBe(input); + }); + + it('should group appropriately', () => { + const input = [['a', 1], ['b', 2], ['c', 3], ['a', 4], ['a', 5], ['b', 6]]; + const output = { + 'a': [['a', 1], ['a', 4], ['a', 5]], + 'b': [['b', 2], ['b', 6]], + 'c': [['c', 3]], + }; + const result = new ArrayUtil(input).groupBy(p => p[0]); + expect(result).toBeDefined(); + expect(result.value).toBeDefined(); + + const asObject = objectFromEntries(result.value.entries()); + expect(asObject).toMatchObject(output); + }); + }); + + describe('GroupedArray', () => { + it('should maintain the pointer to the given map', () => { + const input = new Map([ + ['a', [1, 2, 3]], + ['b', [7, 8, 9]], + ['c', [4, 5, 6]], + ]); + const result = new GroupedArray(input); + expect(result.value).toBe(input); + }); + + it('should ordering by the provided key order', () => { + const input = new Map([ + ['a', [1, 2, 3]], + ['b', [7, 8, 9]], // note counting diff + ['c', [4, 5, 6]], + ]); + const output = [4, 5, 6, 1, 2, 3, 7, 8, 9]; + const keyOrder = ['c', 'a', 'b']; // note weird order to cause the `output` to be strange + const result = new GroupedArray(input).orderBy(keyOrder); + expect(result).toBeDefined(); + expect(result.value).toBeDefined(); + expect(result.value).toEqual(output); + }); + }); +}); + From 772ff4e257930ae65bc3843f98de1740400f852e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 19:28:56 -0600 Subject: [PATCH 28/37] Add object utility tests --- test/objects-test.ts | 262 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 test/objects-test.ts diff --git a/test/objects-test.ts b/test/objects-test.ts new file mode 100644 index 0000000000..912d371ba2 --- /dev/null +++ b/test/objects-test.ts @@ -0,0 +1,262 @@ +/* +Copyright 2021 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 { + objectClone, + objectDiff, + objectExcluding, + objectFromEntries, + objectHasDiff, + objectKeyChanges, + objectShallowClone, + objectWithOnly, +} from "../src/utils/objects"; + +describe('objects', () => { + describe('objectExcluding', () => { + it('should exclude the given properties', () => { + const input = {hello: "world", test: true}; + const output = {hello: "world"}; + const props = ["test", "doesnotexist"]; // we also make sure it doesn't explode on missing props + const result = objectExcluding(input, props); // any is to test the missing prop + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + }); + + describe('objectWithOnly', () => { + it('should exclusively use the given properties', () => { + const input = {hello: "world", test: true}; + const output = {hello: "world"}; + const props = ["hello", "doesnotexist"]; // we also make sure it doesn't explode on missing props + const result = objectWithOnly(input, props); // any is to test the missing prop + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + }); + + describe('objectShallowClone', () => { + it('should create a new object', () => { + const input = {test: 1}; + const result = objectShallowClone(input); + expect(result).toBeDefined(); + expect(result).not.toBe(input); + expect(result).toMatchObject(input); + }); + + it('should only clone the top level properties', () => { + const input = {a: 1, b: {c: 2}}; + const result = objectShallowClone(input); + expect(result).toBeDefined(); + expect(result).toMatchObject(input); + expect(result.b).toBe(input.b); + }); + + it('should support custom clone functions', () => { + const input = {a: 1, b: 2}; + const output = {a: 4, b: 8}; + const result = objectShallowClone(input, (k, v) => { + // XXX: inverted expectation for ease of assertion + expect(Object.keys(input)).toContain(k); + + return v * 4; + }); + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + }); + + describe('objectHasDiff', () => { + it('should return false for the same pointer', () => { + const a = {}; + const result = objectHasDiff(a, a); + expect(result).toBe(false); + }); + + it('should return true if keys for A > keys for B', () => { + const a = {a: 1, b: 2}; + const b = {a: 1}; + const result = objectHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should return true if keys for A < keys for B', () => { + const a = {a: 1}; + const b = {a: 1, b: 2}; + const result = objectHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should return false if the objects are the same but different pointers', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2}; + const result = objectHasDiff(a, b); + expect(result).toBe(false); + }); + + it('should consider pointers when testing values', () => { + const a = {a: {}, b: 2}; // `{}` is shorthand for `new Object()` + const b = {a: {}, b: 2}; + const result = objectHasDiff(a, b); + expect(result).toBe(true); // even though the keys are the same, the value pointers vary + }); + }); + + describe('objectDiff', () => { + it('should return empty sets for the same object', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2}; + const result = objectDiff(a, b); + expect(result).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should return empty sets for the same object pointer', () => { + const a = {a: 1, b: 2}; + const result = objectDiff(a, a); + expect(result).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + }); + + it('should indicate when property changes are made', () => { + const a = {a: 1, b: 2}; + const b = {a: 11, b: 2}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(1); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toEqual(['a']); + }); + + it('should indicate when properties are added', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2, c: 3}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.added).toEqual(['c']); + }); + + it('should indicate when properties are removed', () => { + const a = {a: 1, b: 2}; + const b = {a: 1}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(0); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.removed).toEqual(['b']); + }); + + it('should indicate when multiple aspects change', () => { + const a = {a: 1, b: 2, c: 3}; + const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4}; + const result = objectDiff(a, b); + expect(result.changed).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toHaveLength(1); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.changed).toEqual(['b']); + expect(result.removed).toEqual(['c']); + expect(result.added).toEqual(['d']); + }); + }); + + describe('objectKeyChanges', () => { + it('should return an empty set if no properties changed', () => { + const a = {a: 1, b: 2}; + const b = {a: 1, b: 2}; + const result = objectKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should return an empty set if no properties changed for the same pointer', () => { + const a = {a: 1, b: 2}; + const result = objectKeyChanges(a, a); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should return properties which were changed, added, or removed', () => { + const a = {a: 1, b: 2, c: 3}; + const b: (typeof a | {d: number}) = {a: 1, b: 22, d: 4}; + const result = objectKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(3); + expect(result).toEqual(['c', 'd', 'b']); // order isn't important, but the test cares + }); + }); + + describe('objectClone', () => { + it('should deep clone an object', () => { + const a = { + hello: "world", + test: { + another: "property", + test: 42, + third: { + prop: true, + }, + }, + }; + const result = objectClone(a); + expect(result).toBeDefined(); + expect(result).not.toBe(a); + expect(result).toMatchObject(a); + expect(result.test).not.toBe(a.test); + expect(result.test.third).not.toBe(a.test.third); + }); + }); + + describe('objectFromEntries', () => { + it('should create an object from an array of entries', () => { + const output = {a: 1, b: 2, c: 3}; + const result = objectFromEntries(Object.entries(output)); + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + }); + + it('should maintain pointers in values', () => { + const output = {a: {}, b: 2, c: 3}; + const result = objectFromEntries(Object.entries(output)); + expect(result).toBeDefined(); + expect(result).toMatchObject(output); + expect(result['a']).toBe(output.a); + }); + }); +}); From 21cae1502a3003b02d8700b60f94a5e6b27fb5a7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 19:51:11 -0600 Subject: [PATCH 29/37] Add map utility tests --- test/maps-test.ts | 245 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 test/maps-test.ts diff --git a/test/maps-test.ts b/test/maps-test.ts new file mode 100644 index 0000000000..5363ab3d03 --- /dev/null +++ b/test/maps-test.ts @@ -0,0 +1,245 @@ +/* +Copyright 2021 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 {EnhancedMap, mapDiff, mapKeyChanges} from "../src/utils/maps"; + +describe('maps', () => { + describe('mapDiff', () => { + it('should indicate no differences when the pointers are the same', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapDiff(a, a); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(0); + }); + + it('should indicate no differences when there are none', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(0); + }); + + it('should indicate added properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(0); + expect(result.added).toEqual([4]); + }); + + it('should indicate removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.changed).toHaveLength(0); + expect(result.removed).toEqual([3]); + }); + + it('should indicate changed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 4]]); // note change + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(1); + expect(result.changed).toEqual([3]); + }); + + it('should indicate changed, added, and removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 8], [4, 4]]); // note change + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.changed).toHaveLength(1); + expect(result.added).toEqual([4]); + expect(result.removed).toEqual([3]); + expect(result.changed).toEqual([2]); + }); + + it('should indicate changes for difference in pointers', () => { + const a = new Map([[1, {}]]); // {} always creates a new object + const b = new Map([[1, {}]]); + const result = mapDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.changed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(0); + expect(result.changed).toHaveLength(1); + expect(result.changed).toEqual([1]); + }); + }); + + describe('mapKeyChanges', () => { + it('should indicate no changes for unchanged pointers', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapKeyChanges(a, a); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should indicate no changes for unchanged maps with different pointers', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + it('should indicate changes for added properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([4]); + }); + + it('should indicate changes for removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const b = new Map([[1, 1], [2, 2], [3, 3]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([4]); + }); + + it('should indicate changes for changed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]); + const b = new Map([[1, 1], [2, 2], [3, 3], [4, 55]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([4]); + }); + + it('should indicate changes for properties with different pointers', () => { + const a = new Map([[1, {}]]); // {} always creates a new object + const b = new Map([[1, {}]]); + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(1); + expect(result).toEqual([1]); + }); + + it('should indicate changes for changed, added, and removed properties', () => { + const a = new Map([[1, 1], [2, 2], [3, 3]]); + const b = new Map([[1, 1], [2, 8], [4, 4]]); // note change + const result = mapKeyChanges(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(3); + expect(result).toEqual([3, 4, 2]); // order irrelevant, but the test cares + }); + }); + + describe('EnhancedMap', () => { + // Most of these tests will make sure it implements the Map class + + it('should be empty by default', () => { + const result = new EnhancedMap(); + expect(result.size).toBe(0); + }); + + it('should use the provided entries', () => { + const obj = {a: 1, b: 2}; + const result = new EnhancedMap(Object.entries(obj)); + expect(result.size).toBe(2); + expect(result.get('a')).toBe(1); + expect(result.get('b')).toBe(2); + }); + + it('should create keys if they do not exist', () => { + const key = 'a'; + const val = {}; // we'll check pointers + + const result = new EnhancedMap(); + expect(result.size).toBe(0); + + let get = result.getOrCreate(key, val); + expect(get).toBeDefined(); + expect(get).toBe(val); + expect(result.size).toBe(1); + + get = result.getOrCreate(key, 44); // specifically change `val` + expect(get).toBeDefined(); + expect(get).toBe(val); + expect(result.size).toBe(1); + + get = result.get(key); // use the base class function + expect(get).toBeDefined(); + expect(get).toBe(val); + expect(result.size).toBe(1); + }); + + it('should proxy remove to delete and return it', () => { + const val = {}; + const result = new EnhancedMap(); + result.set('a', val); + + expect(result.size).toBe(1); + + const removed = result.remove('a'); + expect(result.size).toBe(0); + expect(removed).toBeDefined(); + expect(removed).toBe(val); + }); + + it('should support removing unknown keys', () => { + const val = {}; + const result = new EnhancedMap(); + result.set('a', val); + + expect(result.size).toBe(1); + + const removed = result.remove('not-a'); + expect(result.size).toBe(1); + expect(removed).not.toBeDefined(); + }); + }); +}); From 0d4218ee35371ddd0354ffd8d770927c8abe20f4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 20:07:38 -0600 Subject: [PATCH 30/37] Add enum utility tests --- src/utils/enums.ts | 22 +++++++++++---- test/enums-test.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 test/enums-test.ts diff --git a/src/utils/enums.ts b/src/utils/enums.ts index f7f4787896..d3ca318c28 100644 --- a/src/utils/enums.ts +++ b/src/utils/enums.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020, 2021 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. @@ -19,11 +19,23 @@ limitations under the License. * @param e The enum. * @returns The enum values. */ -export function getEnumValues(e: any): T[] { +export function getEnumValues(e: any): (string | number)[] { + // String-based enums will simply be objects ({Key: "value"}), but number-based + // enums will instead map themselves twice: in one direction for {Key: 12} and + // the reverse for easy lookup, presumably ({12: Key}). In the reverse mapping, + // the key is a string, not a number. + // + // For this reason, we try to determine what kind of enum we're dealing with. + const keys = Object.keys(e); - return keys - .filter(k => ['string', 'number'].includes(typeof(e[k]))) - .map(k => e[k]); + const values: (string | number)[] = []; + for (const key of keys) { + const value = e[key]; + if (Number.isFinite(value) || e[value.toString()] !== Number(key)) { + values.push(value); + } + } + return values; } /** diff --git a/test/enums-test.ts b/test/enums-test.ts new file mode 100644 index 0000000000..e519186a51 --- /dev/null +++ b/test/enums-test.ts @@ -0,0 +1,67 @@ +/* +Copyright 2021 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 {getEnumValues, isEnumValue} from "../src/utils/enums"; + +enum TestStringEnum { + First = "__first__", + Second = "__second__", +} + +enum TestNumberEnum { + FirstKey = 10, + SecondKey = 20, +} + +describe('enums', () => { + describe('getEnumValues', () => { + it('should work on string enums', () => { + const result = getEnumValues(TestStringEnum); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual(['__first__', '__second__']); + }); + + it('should work on number enums', () => { + const result = getEnumValues(TestNumberEnum); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual([10, 20]); + }); + }); + + describe('isEnumValue', () => { + it('should return true on values in a string enum', () => { + const result = isEnumValue(TestStringEnum, '__first__'); + expect(result).toBe(true); + }); + + it('should return false on values not in a string enum', () => { + const result = isEnumValue(TestStringEnum, 'not a value'); + expect(result).toBe(false); + }); + + it('should return true on values in a number enum', () => { + const result = isEnumValue(TestNumberEnum, 10); + expect(result).toBe(true); + }); + + it('should return false on values not in a number enum', () => { + const result = isEnumValue(TestStringEnum, 99); + expect(result).toBe(false); + }); + }); +}); From 6124a8319b803ce36e91d84f60597e4b28499b1e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 20:11:20 -0600 Subject: [PATCH 31/37] Add iterable utility tests Unsurprisingly, it's a copy/paste of the array tests --- test/iterables-test.ts | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 test/iterables-test.ts diff --git a/test/iterables-test.ts b/test/iterables-test.ts new file mode 100644 index 0000000000..af0232fb93 --- /dev/null +++ b/test/iterables-test.ts @@ -0,0 +1,77 @@ +/* +Copyright 2021 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 {iterableDiff, iterableUnion} from "../src/utils/iterables"; + +describe('iterables', () => { + describe('iterableUnion', () => { + it('should return a union', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = iterableUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + expect(result).toEqual([1, 2]); + }); + + it('should return an empty array on no matches', () => { + const a = [1, 2, 3]; + const b = [4, 5, 6]; + const result = iterableUnion(a, b); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + }); + + describe('iterableDiff', () => { + it('should see added from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2, 3, 4]; + const result = iterableDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(0); + expect(result.added).toEqual([4]); + }); + + it('should see removed from A->B', () => { + const a = [1, 2, 3]; + const b = [1, 2]; + const result = iterableDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(0); + expect(result.removed).toHaveLength(1); + expect(result.removed).toEqual([3]); + }); + + it('should see added and removed in the same set', () => { + const a = [1, 2, 3]; + const b = [1, 2, 4]; // note diff + const result = iterableDiff(a, b); + expect(result).toBeDefined(); + expect(result.added).toBeDefined(); + expect(result.removed).toBeDefined(); + expect(result.added).toHaveLength(1); + expect(result.removed).toHaveLength(1); + expect(result.added).toEqual([4]); + expect(result.removed).toEqual([3]); + }); + }); +}); From 27af3291ed85c3a95a0b139c992e10dbd2847c62 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 20:26:48 -0600 Subject: [PATCH 32/37] Add number utility tests --- test/numbers-test.ts | 163 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 test/numbers-test.ts diff --git a/test/numbers-test.ts b/test/numbers-test.ts new file mode 100644 index 0000000000..6e0e3f58ce --- /dev/null +++ b/test/numbers-test.ts @@ -0,0 +1,163 @@ +/* +Copyright 2021 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 {clamp, defaultNumber, percentageOf, percentageWithin, sum} from "../src/utils/numbers"; + +describe('numbers', () => { + describe('defaultNumber', () => { + it('should use the default when the input is not a number', () => { + const def = 42; + + let result = defaultNumber(null, def); + expect(result).toBe(def); + + result = defaultNumber(undefined, def); + expect(result).toBe(def); + + result = defaultNumber(Number.NaN, def); + expect(result).toBe(def); + }); + + it('should use the number when it is a number', () => { + const input = 24; + const def = 42; + const result = defaultNumber(input, def); + expect(result).toBe(input); + }); + }); + + describe('clamp', () => { + it('should clamp high numbers', () => { + const input = 101; + const min = 0; + const max = 100; + const result = clamp(input, min, max); + expect(result).toBe(max); + }); + + it('should clamp low numbers', () => { + const input = -1; + const min = 0; + const max = 100; + const result = clamp(input, min, max); + expect(result).toBe(min); + }); + + it('should not clamp numbers in range', () => { + const input = 50; + const min = 0; + const max = 100; + const result = clamp(input, min, max); + expect(result).toBe(input); + }); + + it('should clamp floats', () => { + const min = -0.10; + const max = +0.10; + + let result = clamp(-1.2, min, max); + expect(result).toBe(min); + + result = clamp(1.2, min, max); + expect(result).toBe(max); + + result = clamp(0.02, min, max); + expect(result).toBe(0.02); + }); + }); + + describe('sum', () => { + it('should sum', () => { // duh + const result = sum(1, 2, 1, 4); + expect(result).toBe(8); + }); + }); + + describe('percentageWithin', () => { + it('should work within 0-100', () => { + const result = percentageWithin(0.4, 0, 100); + expect(result).toBe(40); + }); + + it('should work within 0-100 when pct > 1', () => { + const result = percentageWithin(1.4, 0, 100); + expect(result).toBe(140); + }); + + it('should work within 0-100 when pct < 0', () => { + const result = percentageWithin(-1.4, 0, 100); + expect(result).toBe(-140); + }); + + it('should work with ranges other than 0-100', () => { + const result = percentageWithin(0.4, 10, 20); + expect(result).toBe(14); + }); + + it('should work with ranges other than 0-100 when pct > 1', () => { + const result = percentageWithin(1.4, 10, 20); + expect(result).toBe(24); + }); + + it('should work with ranges other than 0-100 when pct < 0', () => { + const result = percentageWithin(-1.4, 10, 20); + expect(result).toBe(-4); + }); + + it('should work with floats', () => { + const result = percentageWithin(0.4, 10.2, 20.4); + expect(result).toBe(14.28); + }); + }); + + // These are the inverse of percentageWithin + describe('percentageOf', () => { + it('should work within 0-100', () => { + const result = percentageOf(40, 0, 100); + expect(result).toBe(0.4); + }); + + it('should work within 0-100 when val > 100', () => { + const result = percentageOf(140, 0, 100); + expect(result).toBe(1.40); + }); + + it('should work within 0-100 when val < 0', () => { + const result = percentageOf(-140, 0, 100); + expect(result).toBe(-1.40); + }); + + it('should work with ranges other than 0-100', () => { + const result = percentageOf(14, 10, 20); + expect(result).toBe(0.4); + }); + + it('should work with ranges other than 0-100 when val > 100', () => { + const result = percentageOf(24, 10, 20); + expect(result).toBe(1.4); + }); + + it('should work with ranges other than 0-100 when val < 0', () => { + const result = percentageOf(-4, 10, 20); + expect(result).toBe(-1.4); + }); + + it('should work with floats', () => { + const result = percentageOf(14.28, 10.2, 20.4); + expect(result).toBe(0.4); + }); + }); +}); From 374f51452ee364e4bc5f8921ef992ceebc562778 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 20:28:49 -0600 Subject: [PATCH 33/37] Add set utility tests --- test/sets-test.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/sets-test.ts diff --git a/test/sets-test.ts b/test/sets-test.ts new file mode 100644 index 0000000000..e884d0e9af --- /dev/null +++ b/test/sets-test.ts @@ -0,0 +1,56 @@ +/* +Copyright 2021 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 {setHasDiff} from "../src/utils/sets"; + +describe('sets', () => { + describe('setHasDiff', () => { + it('should flag true on A length > B length', () => { + const a = new Set([1, 2, 3, 4]); + const b = new Set([1, 2, 3]); + const result = setHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on A length < B length', () => { + const a = new Set([1, 2, 3]); + const b = new Set([1, 2, 3, 4]); + const result = setHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag true on element differences', () => { + const a = new Set([1, 2, 3]); + const b = new Set([4, 5, 6]); + const result = setHasDiff(a, b); + expect(result).toBe(true); + }); + + it('should flag false if same but order different', () => { + const a = new Set([1, 2, 3]); + const b = new Set([3, 1, 2]); + const result = setHasDiff(a, b); + expect(result).toBe(false); + }); + + it('should flag false if same', () => { + const a = new Set([1, 2, 3]); + const b = new Set([1, 2, 3]); + const result = setHasDiff(a, b); + expect(result).toBe(false); + }); + }); +}); From 2c459c482872a6314ab3749a1f04e6b056ff8460 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 22 Apr 2021 20:30:14 -0600 Subject: [PATCH 34/37] Move utility tests to the right place --- test/{ => utils}/Singleflight-test.ts | 2 +- test/{ => utils}/arrays-test.ts | 4 ++-- test/{ => utils}/enums-test.ts | 2 +- test/{ => utils}/iterables-test.ts | 2 +- test/{ => utils}/maps-test.ts | 2 +- test/{ => utils}/numbers-test.ts | 2 +- test/{ => utils}/objects-test.ts | 2 +- test/{ => utils}/sets-test.ts | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) rename test/{ => utils}/Singleflight-test.ts (98%) rename test/{ => utils}/arrays-test.ts (99%) rename test/{ => utils}/enums-test.ts (96%) rename test/{ => utils}/iterables-test.ts (97%) rename test/{ => utils}/maps-test.ts (99%) rename test/{ => utils}/numbers-test.ts (99%) rename test/{ => utils}/objects-test.ts (99%) rename test/{ => utils}/sets-test.ts (97%) diff --git a/test/Singleflight-test.ts b/test/utils/Singleflight-test.ts similarity index 98% rename from test/Singleflight-test.ts rename to test/utils/Singleflight-test.ts index 4f0c6e0da3..80258701bb 100644 --- a/test/Singleflight-test.ts +++ b/test/utils/Singleflight-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Singleflight} from "../src/utils/Singleflight"; +import {Singleflight} from "../../src/utils/Singleflight"; describe('Singleflight', () => { afterEach(() => { diff --git a/test/arrays-test.ts b/test/utils/arrays-test.ts similarity index 99% rename from test/arrays-test.ts rename to test/utils/arrays-test.ts index 33c4ee452e..ececd274b2 100644 --- a/test/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -25,8 +25,8 @@ import { arrayUnion, ArrayUtil, GroupedArray, -} from "../src/utils/arrays"; -import {objectFromEntries} from "../src/utils/objects"; +} from "../../src/utils/arrays"; +import {objectFromEntries} from "../../src/utils/objects"; function expectSample(i: number, input: number[], expected: number[]) { console.log(`Resample case index: ${i}`); // for debugging test failures diff --git a/test/enums-test.ts b/test/utils/enums-test.ts similarity index 96% rename from test/enums-test.ts rename to test/utils/enums-test.ts index e519186a51..423b135f77 100644 --- a/test/enums-test.ts +++ b/test/utils/enums-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {getEnumValues, isEnumValue} from "../src/utils/enums"; +import {getEnumValues, isEnumValue} from "../../src/utils/enums"; enum TestStringEnum { First = "__first__", diff --git a/test/iterables-test.ts b/test/utils/iterables-test.ts similarity index 97% rename from test/iterables-test.ts rename to test/utils/iterables-test.ts index af0232fb93..9b30b6241c 100644 --- a/test/iterables-test.ts +++ b/test/utils/iterables-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {iterableDiff, iterableUnion} from "../src/utils/iterables"; +import {iterableDiff, iterableUnion} from "../../src/utils/iterables"; describe('iterables', () => { describe('iterableUnion', () => { diff --git a/test/maps-test.ts b/test/utils/maps-test.ts similarity index 99% rename from test/maps-test.ts rename to test/utils/maps-test.ts index 5363ab3d03..8764a8f2cf 100644 --- a/test/maps-test.ts +++ b/test/utils/maps-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {EnhancedMap, mapDiff, mapKeyChanges} from "../src/utils/maps"; +import {EnhancedMap, mapDiff, mapKeyChanges} from "../../src/utils/maps"; describe('maps', () => { describe('mapDiff', () => { diff --git a/test/numbers-test.ts b/test/utils/numbers-test.ts similarity index 99% rename from test/numbers-test.ts rename to test/utils/numbers-test.ts index 6e0e3f58ce..36e7d4f7e7 100644 --- a/test/numbers-test.ts +++ b/test/utils/numbers-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {clamp, defaultNumber, percentageOf, percentageWithin, sum} from "../src/utils/numbers"; +import {clamp, defaultNumber, percentageOf, percentageWithin, sum} from "../../src/utils/numbers"; describe('numbers', () => { describe('defaultNumber', () => { diff --git a/test/objects-test.ts b/test/utils/objects-test.ts similarity index 99% rename from test/objects-test.ts rename to test/utils/objects-test.ts index 912d371ba2..b7a80e6761 100644 --- a/test/objects-test.ts +++ b/test/utils/objects-test.ts @@ -23,7 +23,7 @@ import { objectKeyChanges, objectShallowClone, objectWithOnly, -} from "../src/utils/objects"; +} from "../../src/utils/objects"; describe('objects', () => { describe('objectExcluding', () => { diff --git a/test/sets-test.ts b/test/utils/sets-test.ts similarity index 97% rename from test/sets-test.ts rename to test/utils/sets-test.ts index e884d0e9af..98dc218309 100644 --- a/test/sets-test.ts +++ b/test/utils/sets-test.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {setHasDiff} from "../src/utils/sets"; +import {setHasDiff} from "../../src/utils/sets"; describe('sets', () => { describe('setHasDiff', () => { From 024cf7f66c7eb27cc354b4d56ac22ae1615c1c34 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Apr 2021 11:44:33 -0400 Subject: [PATCH 35/37] Cut off long names in add rooms to space dialog Signed-off-by: Robin Townsend --- res/css/views/dialogs/_AddExistingToSpaceDialog.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 80ad4d6c0e..826bb3f7e9 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -148,6 +148,10 @@ limitations under the License. font-size: $font-15px; line-height: 30px; flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 12px; } .mx_FormButton { From 0f84b3dff315b0d19ac99a2685eaf56e81d41ea4 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Apr 2021 11:46:31 -0400 Subject: [PATCH 36/37] Align checkboxes with names in add rooms to space dialog Signed-off-by: Robin Townsend --- res/css/views/dialogs/_AddExistingToSpaceDialog.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 826bb3f7e9..f288241e04 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -154,6 +154,10 @@ limitations under the License. margin-right: 12px; } + .mx_Checkbox { + align-items: center; + } + .mx_FormButton { min-width: 92px; font-weight: normal; From 3d7842d6964af7c2698abc706b5e3bafee584d5f Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Thu, 22 Apr 2021 11:46:56 -0400 Subject: [PATCH 37/37] Remove old FormButton CSS Signed-off-by: Robin Townsend --- res/css/views/dialogs/_AddExistingToSpaceDialog.scss | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index f288241e04..247df52b4a 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -157,12 +157,6 @@ limitations under the License. .mx_Checkbox { align-items: center; } - - .mx_FormButton { - min-width: 92px; - font-weight: normal; - box-sizing: border-box; - } } } @@ -200,8 +194,4 @@ limitations under the License. padding: 0; } } - - .mx_FormButton { - padding: 8px 22px; - } }