mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-15 20:54:59 +08:00
Merge branch 'develop' into travis/error-states
This commit is contained in:
commit
82119ad595
@ -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 {
|
||||
|
@ -214,45 +214,15 @@ $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 {
|
||||
cursor: pointer;
|
||||
|
||||
> span: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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -148,12 +148,14 @@ 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 {
|
||||
min-width: 92px;
|
||||
font-weight: normal;
|
||||
box-sizing: border-box;
|
||||
.mx_Checkbox {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -192,8 +194,4 @@ limitations under the License.
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_FormButton {
|
||||
padding: 8px 22px;
|
||||
}
|
||||
}
|
||||
|
@ -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: inherit;
|
||||
width: inherit;
|
||||
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;
|
||||
|
@ -87,7 +87,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;
|
||||
|
@ -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: #e7e7e7;
|
||||
|
@ -129,7 +129,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);
|
||||
|
@ -120,7 +120,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);
|
||||
|
26
src/@types/global.d.ts
vendored
26
src/@types/global.d.ts
vendored
@ -129,4 +129,30 @@ 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<string, Float32Array>
|
||||
): 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[];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) {
|
||||
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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<HTMLSpanElement> {
|
||||
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,42 @@ 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;
|
||||
|
||||
// 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;
|
||||
if (props.onClick) {
|
||||
tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("View all %(count)s members", { count: members.length }) }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers }) }
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
|
||||
count: members.length,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
}
|
||||
|
||||
return <div {...props} className="mx_FacePile">
|
||||
<div className="mx_FacePile_faces">
|
||||
{ shownMembers.map(member => {
|
||||
return <TextWithTooltip key={member.userId} tooltip={member.name}>
|
||||
<MemberAvatar member={member} width={28} height={28} />
|
||||
</TextWithTooltip>;
|
||||
}) }
|
||||
</div>
|
||||
{ onlyKnownUsers && <span>
|
||||
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
|
||||
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
|
||||
{ shownMembers.map(m =>
|
||||
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
|
||||
</TextWithTooltip>
|
||||
{ onlyKnownUsers && <span className="mx_FacePile_summary">
|
||||
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
||||
</span> }
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default FacePile;
|
||||
|
@ -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<IProps, IState> {
|
||||
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({
|
||||
|
@ -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 (
|
||||
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}>
|
||||
{children}
|
||||
{this.state.hover && <Tooltip
|
||||
{...tooltipProps}
|
||||
label={tooltip}
|
||||
tooltipClassName={tooltipClass}
|
||||
className={"mx_TextWithTooltip_tooltip"} /> }
|
||||
className={"mx_TextWithTooltip_tooltip"}
|
||||
/> }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -545,6 +545,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
public render() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
|
||||
let explorePrompt: JSX.Element;
|
||||
if (!this.props.isMinimized) {
|
||||
if (this.state.isNameFiltering) {
|
||||
@ -565,21 +568,23 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||
{ this.props.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
} else if (this.props.activeSpace) {
|
||||
} else if (
|
||||
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
|
||||
) {
|
||||
explorePrompt = <div className="mx_RoomList_explorePrompt">
|
||||
<div>{ _t("Quick actions") }</div>
|
||||
{ this.props.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && <AccessibleButton
|
||||
{ this.props.activeSpace.canInvite(userId) && <AccessibleButton
|
||||
className="mx_RoomList_explorePrompt_spaceInvite"
|
||||
onClick={this.onSpaceInviteClick}
|
||||
>
|
||||
{_t("Invite people")}
|
||||
</AccessibleButton> }
|
||||
<AccessibleButton
|
||||
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
|
||||
className="mx_RoomList_explorePrompt_spaceExplore"
|
||||
onClick={this.onExplore}
|
||||
>
|
||||
{_t("Explore rooms")}
|
||||
</AccessibleButton>
|
||||
</AccessibleButton> }
|
||||
</div>;
|
||||
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
|
||||
const unfilteredLists = RoomListStore.instance.unfilteredLists
|
||||
|
@ -53,9 +53,38 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||
await this.state.recorder.stop();
|
||||
const mxc = await this.state.recorder.upload();
|
||||
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
|
||||
body: "Voice message",
|
||||
msgtype: "org.matrix.msc2516.voice",
|
||||
url: mxc,
|
||||
"body": "Voice message",
|
||||
"msgtype": "org.matrix.msc2516.voice",
|
||||
//"msgtype": MsgType.Audio,
|
||||
"url": mxc,
|
||||
"info": {
|
||||
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
||||
mimetype: this.state.recorder.contentType,
|
||||
size: this.state.recorder.contentLength,
|
||||
},
|
||||
|
||||
// MSC1767 experiment
|
||||
"org.matrix.msc1767.text": "Voice message",
|
||||
"org.matrix.msc1767.file": {
|
||||
url: mxc,
|
||||
name: "Voice message.ogg",
|
||||
mimetype: this.state.recorder.contentType,
|
||||
size: this.state.recorder.contentLength,
|
||||
},
|
||||
"org.matrix.msc1767.audio": {
|
||||
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
||||
// TODO: @@ TravisR: Waveform? (MSC1767 decision)
|
||||
},
|
||||
"org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment
|
||||
duration: Math.round(this.state.recorder.durationSeconds * 1000),
|
||||
|
||||
// Events can't have floats, so we try to maintain resolution by using 1024
|
||||
// as a maximum value. The waveform contains values between zero and 1, so this
|
||||
// should come out largely sane.
|
||||
//
|
||||
// We're expecting about one data point per second of audio.
|
||||
waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)),
|
||||
},
|
||||
});
|
||||
await VoiceRecordingStore.instance.disposeRecording();
|
||||
this.setState({recorder: null});
|
||||
|
@ -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<IButtonProps> = ({
|
||||
</li>;
|
||||
}
|
||||
|
||||
const useSpaces = (): [Room[], Room | null] => {
|
||||
const useSpaces = (): [Room[], Room[], Room | null] => {
|
||||
const [invites, setInvites] = useState<Room[]>(SpaceStore.instance.invitedSpaces);
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites);
|
||||
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces);
|
||||
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
|
||||
const [activeSpace, setActiveSpace] = useState<Room>(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<void>();
|
||||
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 => <SpaceItem
|
||||
key={s.roomId}
|
||||
space={s}
|
||||
activeSpaces={activeSpaces}
|
||||
isPanelCollapsed={isPanelCollapsed}
|
||||
onExpand={() => setPanelCollapsed(false)}
|
||||
/>) }
|
||||
{ spaces.map(s => <SpaceItem
|
||||
key={s.roomId}
|
||||
space={s}
|
||||
|
@ -45,6 +45,8 @@ import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
|
||||
import {EventType} from "matrix-js-sdk/src/@types/event";
|
||||
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
|
||||
import {NotificationColor} from "../../../stores/notifications/NotificationColor";
|
||||
|
||||
interface IItemProps {
|
||||
space?: Room;
|
||||
@ -67,7 +69,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
collapsed: !props.isNested, // default to collapsed for root items
|
||||
collapsed: !props.isNested, // default to collapsed for root items
|
||||
contextMenuPosition: null,
|
||||
};
|
||||
}
|
||||
@ -83,6 +85,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||
}
|
||||
|
||||
private onContextMenu = (ev: React.MouseEvent) => {
|
||||
if (this.props.space.getMyMembership() !== "join") return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.setState({
|
||||
@ -185,6 +188,8 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||
};
|
||||
|
||||
private renderContextMenu(): React.ReactElement {
|
||||
if (this.props.space.getMyMembership() !== "join") return null;
|
||||
|
||||
let contextMenu = null;
|
||||
if (this.state.contextMenuPosition) {
|
||||
const userId = this.context.getUserId();
|
||||
@ -300,7 +305,9 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
||||
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) {
|
||||
|
@ -1917,7 +1917,13 @@
|
||||
"Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.": "Please <newIssueLink>create a new issue</newIssueLink> 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",
|
||||
|
@ -46,16 +46,13 @@ 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
|
||||
|
||||
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) => {
|
||||
@ -97,6 +94,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
// 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<Room>();
|
||||
|
||||
public get invitedSpaces(): Room[] {
|
||||
return Array.from(this._invitedSpaces);
|
||||
}
|
||||
|
||||
public get spacePanelSpaces(): Room[] {
|
||||
return this.rootSpaces;
|
||||
@ -110,32 +112,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
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(getSpaceContextKey(this.activeSpace));
|
||||
|
||||
if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: roomId,
|
||||
context_switch: true,
|
||||
});
|
||||
} else if (space) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: space.roomId,
|
||||
context_switch: true,
|
||||
});
|
||||
} else {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
// 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,
|
||||
context_switch: true,
|
||||
});
|
||||
} else if (space) {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_room",
|
||||
room_id: space.roomId,
|
||||
context_switch: true,
|
||||
});
|
||||
} else {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "view_home_page",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// persist space selected
|
||||
@ -216,25 +225,27 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
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<string> => {
|
||||
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<Room>(visibleRooms);
|
||||
// exclude invited spaces from unseenChildren as they will be forcibly shown at the top level of the treeview
|
||||
const unseenChildren = new Set<Room>([...visibleRooms, ...joinedSpaces]);
|
||||
const backrefs = new EnhancedMap<string, Set<string>>();
|
||||
|
||||
// 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 => {
|
||||
@ -298,6 +309,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
|
||||
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 = () => {
|
||||
@ -305,6 +320,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
}
|
||||
|
||||
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
|
||||
@ -335,8 +351,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
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<string>(invites.map(room => room.roomId)));
|
||||
|
||||
visibleRooms.forEach(room => {
|
||||
@ -389,13 +405,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
|
||||
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});
|
||||
|
||||
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 {
|
||||
@ -517,37 +538,30 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||
|
||||
// Don't auto-switch rooms when reacting to a context-switch
|
||||
// as this is not helpful and can create loops of rooms/space switching
|
||||
if (payload.context_switch) break;
|
||||
if (!room || payload.context_switch) 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.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));
|
||||
}
|
||||
// don't trigger a context switch when we are switching a space to match the chosen room
|
||||
this.setActiveSpace(parent || null, false);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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(getSpaceContextKey(this.activeSpace), payload.room_id);
|
||||
break;
|
||||
}
|
||||
case "after_leave_room":
|
||||
|
@ -599,11 +599,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
|
||||
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 => {
|
||||
|
@ -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') {
|
||||
|
50
src/utils/Mouse.ts
Normal file
50
src/utils/Mouse.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
@ -54,7 +78,7 @@ export function arraySeed<T>(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<T>(a: T[]): T[] {
|
||||
return a.slice(0, a.length);
|
||||
}
|
||||
|
||||
@ -178,6 +202,13 @@ export class GroupedArray<K, T> {
|
||||
constructor(private val: Map<K, T[]>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The value of this group, after all applicable alterations.
|
||||
*/
|
||||
public get value(): Map<K, T[]> {
|
||||
return this.val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders the grouping into an array using the provided key order.
|
||||
* @param keyOrder The key order.
|
||||
|
@ -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<T>(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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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<O extends {}>(a: O, b: O): (keyof O)[] {
|
||||
export function objectClone<O extends {}>(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<K, V>(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;
|
||||
}
|
||||
|
67
src/voice/RecorderWorklet.ts
Normal file
67
src/voice/RecorderWorklet.ts
Normal file
@ -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 {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;
|
||||
// declare const currentFrame: number;
|
||||
// declare const sampleRate: number;
|
||||
|
||||
class MxVoiceWorklet extends AudioWorkletProcessor {
|
||||
private nextAmplitudeSecond = 0;
|
||||
|
||||
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 = Math.min(...monoChan);
|
||||
const maxVal = Math.max(...monoChan);
|
||||
const amplitude = percentageOf(maxVal, -1, 1) - percentageOf(minVal, -1, 1);
|
||||
|
||||
this.port.postMessage(<IAmplitudePayload>{
|
||||
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(<ITimingPayload>{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;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor(WORKLET_NAME, MxVoiceWorklet);
|
||||
|
||||
export default null; // to appease module loaders (we never use the export)
|
@ -23,6 +23,8 @@ 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";
|
||||
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.
|
||||
@ -49,16 +51,34 @@ 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;
|
||||
private observable: SimpleObservable<IRecordingUpdate>;
|
||||
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";
|
||||
}
|
||||
|
||||
public get contentLength(): number {
|
||||
return this.buffer.length;
|
||||
}
|
||||
|
||||
public get durationSeconds(): number {
|
||||
if (!this.recorder) throw new Error("Duration not available without a recording");
|
||||
return this.recorderContext.currentTime;
|
||||
}
|
||||
|
||||
private async makeRecorder() {
|
||||
this.recorderStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
@ -80,18 +100,34 @@ 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;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
this.recorder = new Recorder({
|
||||
encoderPath, // magic from webpack
|
||||
@ -138,7 +174,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
|
||||
@ -162,12 +198,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();
|
||||
@ -191,7 +227,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||
}
|
||||
this.observable = new SimpleObservable<IRecordingUpdate>();
|
||||
await this.makeRecorder();
|
||||
this.recorderProcessor.addEventListener("audioprocess", this.processAudioUpdate);
|
||||
await this.recorder.start();
|
||||
this.recording = true;
|
||||
this.emit(RecordingState.Started);
|
||||
@ -205,6 +240,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
|
||||
@ -216,7 +252,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);
|
||||
|
||||
@ -240,7 +275,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||
|
||||
this.emit(RecordingState.Uploading);
|
||||
this.mxc = await this.client.uploadContent(new Blob([this.buffer], {
|
||||
type: "audio/ogg",
|
||||
type: this.contentType,
|
||||
}), {
|
||||
onlyContentUri: false, // to stop the warnings in the console
|
||||
}).then(r => r['content_uri']);
|
||||
|
37
src/voice/consts.ts
Normal file
37
src/voice/consts.ts
Normal file
@ -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.
|
||||
*/
|
||||
|
||||
export const WORKLET_NAME = "mx-voice-worklet";
|
||||
|
||||
export enum PayloadEvent {
|
||||
Timekeep = "timekeep",
|
||||
AmplitudeMark = "amplitude_mark",
|
||||
}
|
||||
|
||||
export interface IPayload {
|
||||
ev: PayloadEvent;
|
||||
}
|
||||
|
||||
export interface ITimingPayload extends IPayload {
|
||||
ev: PayloadEvent.Timekeep;
|
||||
timeSeconds: number;
|
||||
}
|
||||
|
||||
export interface IAmplitudePayload extends IPayload {
|
||||
ev: PayloadEvent.AmplitudeMark;
|
||||
forSecond: number;
|
||||
amplitude: number;
|
||||
}
|
@ -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(() => {
|
294
test/utils/arrays-test.ts
Normal file
294
test/utils/arrays-test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
67
test/utils/enums-test.ts
Normal file
67
test/utils/enums-test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
77
test/utils/iterables-test.ts
Normal file
77
test/utils/iterables-test.ts
Normal file
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
245
test/utils/maps-test.ts
Normal file
245
test/utils/maps-test.ts
Normal file
@ -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<K, V> 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<string, any>();
|
||||
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<string, any>();
|
||||
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<string, any>();
|
||||
result.set('a', val);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
|
||||
const removed = result.remove('not-a');
|
||||
expect(result.size).toBe(1);
|
||||
expect(removed).not.toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
163
test/utils/numbers-test.ts
Normal file
163
test/utils/numbers-test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
262
test/utils/objects-test.ts
Normal file
262
test/utils/objects-test.ts
Normal file
@ -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, <any>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, <any>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);
|
||||
});
|
||||
});
|
||||
});
|
56
test/utils/sets-test.ts
Normal file
56
test/utils/sets-test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user