Merge branch 'develop' into travis/error-states

This commit is contained in:
Travis Ralston 2021-04-23 15:53:37 -06:00
commit 82119ad595
36 changed files with 1724 additions and 191 deletions

View file

@ -35,7 +35,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_spaceTreeWrapper { .mx_SpacePanel_spaceTreeWrapper {
flex: 1; flex: 1;
overflow-y: scroll; padding: 8px 8px 16px 0;
} }
.mx_SpacePanel_toggleCollapse { .mx_SpacePanel_toggleCollapse {
@ -59,11 +59,10 @@ $activeBorderColor: $secondary-fg-color;
margin: 0; margin: 0;
list-style: none; list-style: none;
padding: 0; padding: 0;
padding-left: 16px;
}
.mx_AutoHideScrollbar { > .mx_SpaceItem {
padding: 8px 0 16px; padding-left: 16px;
}
} }
.mx_SpaceButton_toggleCollapse { .mx_SpaceButton_toggleCollapse {

View file

@ -214,45 +214,15 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceRoomView_info { .mx_SpaceRoomView_info {
display: inline-block; display: inline-block;
margin: 0; margin: 0 auto 0 0;
} }
.mx_FacePile { .mx_FacePile {
display: inline-block; display: inline-block;
margin-left: auto;
margin-right: 12px; margin-right: 12px;
.mx_FacePile_faces { .mx_FacePile_faces {
cursor: pointer; 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');
}
}
} }
} }

View file

@ -148,12 +148,14 @@ limitations under the License.
font-size: $font-15px; font-size: $font-15px;
line-height: 30px; line-height: 30px;
flex-grow: 1; flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 12px;
} }
.mx_FormButton { .mx_Checkbox {
min-width: 92px; align-items: center;
font-weight: normal;
box-sizing: border-box;
} }
} }
} }
@ -192,8 +194,4 @@ limitations under the License.
padding: 0; padding: 0;
} }
} }
.mx_FormButton {
padding: 8px 22px;
}
} }

View file

@ -20,7 +20,7 @@ limitations under the License.
flex-direction: row-reverse; flex-direction: row-reverse;
vertical-align: middle; vertical-align: middle;
> span + span { > .mx_FacePile_face + .mx_FacePile_face {
margin-right: -8px; margin-right: -8px;
} }
@ -31,9 +31,32 @@ limitations under the License.
.mx_BaseAvatar_initial { .mx_BaseAvatar_initial {
margin: 1px; // to offset the border on the image 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; margin-left: 12px;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px; line-height: $font-24px;

View file

@ -87,7 +87,7 @@ $dialog-close-fg-color: #9fa9ba;
$dialog-background-bg-color: $header-panel-bg-color; $dialog-background-bg-color: $header-panel-bg-color;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 85%; $lightbox-background-bg-opacity: 0.85;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #21262c; $settings-profile-placeholder-bg-color: #21262c;

View file

@ -85,7 +85,7 @@ $dialog-close-fg-color: #9fa9ba;
$dialog-background-bg-color: $header-panel-bg-color; $dialog-background-bg-color: $header-panel-bg-color;
$lightbox-background-bg-color: #000; $lightbox-background-bg-color: #000;
$lightbox-background-bg-opacity: 85%; $lightbox-background-bg-opacity: 0.85;
$settings-grey-fg-color: #a2a2a2; $settings-grey-fg-color: #a2a2a2;
$settings-profile-placeholder-bg-color: #e7e7e7; $settings-profile-placeholder-bg-color: #e7e7e7;

View file

@ -129,7 +129,7 @@ $dialog-close-fg-color: #c1c1c1;
$dialog-background-bg-color: #e9e9e9; $dialog-background-bg-color: #e9e9e9;
$lightbox-background-bg-color: #000; $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: rgba(0, 0, 0, 0.7);
$imagebody-giflabel-border: rgba(0, 0, 0, 0.2); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);

View file

@ -120,7 +120,7 @@ $dialog-close-fg-color: #c1c1c1;
$dialog-background-bg-color: #e9e9e9; $dialog-background-bg-color: #e9e9e9;
$lightbox-background-bg-color: #000; $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: rgba(0, 0, 0, 0.7);
$imagebody-giflabel-border: rgba(0, 0, 0, 0.2); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2);

View file

@ -129,4 +129,30 @@ declare global {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
columnNumber?: number; 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[];
}
);
} }

View file

@ -130,11 +130,14 @@ export function sanitizedHtmlNode(insaneHtml: string) {
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
} }
export function sanitizedHtmlNodeInnerText(insaneHtml: string) { export function getHtmlText(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return sanitizeHtml(insaneHtml, {
const contentDiv = document.createElement("div"); allowedTags: [],
contentDiv.innerHTML = saneHtml; allowedAttributes: {},
return contentDiv.innerText; selfClosing: [],
allowedSchemes: [],
disallowedTagsMode: 'discard',
})
} }
/** /**

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { sortBy } from "lodash"; import { sortBy } from "lodash";
@ -24,6 +24,7 @@ import { _t } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import TextWithTooltip from "../elements/TextWithTooltip"; import TextWithTooltip from "../elements/TextWithTooltip";
import { useRoomMembers } from "../../../hooks/useRoomMembers"; import { useRoomMembers } from "../../../hooks/useRoomMembers";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
const DEFAULT_NUM_FACES = 5; 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 isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => { const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
let members = useRoomMembers(room); let members = useRoomMembers(room);
// sort users with an explicit avatar first // sort users with an explicit avatar first
@ -46,21 +48,42 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, .
// sort known users first // sort known users first
iteratees.unshift(member => isKnownMember(member)); 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"> return <div {...props} className="mx_FacePile">
<div className="mx_FacePile_faces"> <TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
{ shownMembers.map(member => { { members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
return <TextWithTooltip key={member.userId} tooltip={member.name}> { shownMembers.map(m =>
<MemberAvatar member={member} width={28} height={28} /> <MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
</TextWithTooltip>; </TextWithTooltip>
}) } { onlyKnownUsers && <span className="mx_FacePile_summary">
</div>
{ onlyKnownUsers && <span>
{ _t("%(count)s people you know have already joined", { count: members.length }) } { _t("%(count)s people you know have already joined", { count: members.length }) }
</span> } </span> }
</div> </div>;
}; };
export default FacePile; export default FacePile;

View file

@ -32,13 +32,14 @@ import dis from '../../../dispatcher/dispatcher';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks" import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks"
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {normalizeWheelEvent} from "../../../utils/Mouse";
const MIN_ZOOM = 100; const MIN_ZOOM = 100;
const MAX_ZOOM = 300; const MAX_ZOOM = 300;
// This is used for the buttons // This is used for the buttons
const ZOOM_STEP = 10; const ZOOM_STEP = 10;
// This is used for mouse wheel events // 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 // If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
@ -115,7 +116,9 @@ export default class ImageView extends React.Component<IProps, IState> {
private onWheel = (ev: WheelEvent) => { private onWheel = (ev: WheelEvent) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); 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) { if (newZoom <= MIN_ZOOM) {
this.setState({ this.setState({

View file

@ -25,6 +25,7 @@ export default class TextWithTooltip extends React.Component {
class: PropTypes.string, class: PropTypes.string,
tooltipClass: PropTypes.string, tooltipClass: PropTypes.string,
tooltip: PropTypes.node.isRequired, tooltip: PropTypes.node.isRequired,
tooltipProps: PropTypes.object,
}; };
constructor() { constructor() {
@ -46,15 +47,17 @@ export default class TextWithTooltip extends React.Component {
render() { render() {
const Tooltip = sdk.getComponent("elements.Tooltip"); 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 ( return (
<span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}> <span {...props} onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className={className}>
{children} {children}
{this.state.hover && <Tooltip {this.state.hover && <Tooltip
{...tooltipProps}
label={tooltip} label={tooltip}
tooltipClassName={tooltipClass} tooltipClassName={tooltipClass}
className={"mx_TextWithTooltip_tooltip"} /> } className={"mx_TextWithTooltip_tooltip"}
/> }
</span> </span>
); );
} }

View file

@ -545,6 +545,9 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
} }
public render() { public render() {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
let explorePrompt: JSX.Element; let explorePrompt: JSX.Element;
if (!this.props.isMinimized) { if (!this.props.isMinimized) {
if (this.state.isNameFiltering) { 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") } { this.props.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") }
</AccessibleButton> </AccessibleButton>
</div>; </div>;
} else if (this.props.activeSpace) { } else if (
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
) {
explorePrompt = <div className="mx_RoomList_explorePrompt"> explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Quick actions") }</div> <div>{ _t("Quick actions") }</div>
{ this.props.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && <AccessibleButton { this.props.activeSpace.canInvite(userId) && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceInvite" className="mx_RoomList_explorePrompt_spaceInvite"
onClick={this.onSpaceInviteClick} onClick={this.onSpaceInviteClick}
> >
{_t("Invite people")} {_t("Invite people")}
</AccessibleButton> } </AccessibleButton> }
<AccessibleButton { this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceExplore" className="mx_RoomList_explorePrompt_spaceExplore"
onClick={this.onExplore} onClick={this.onExplore}
> >
{_t("Explore rooms")} {_t("Explore rooms")}
</AccessibleButton> </AccessibleButton> }
</div>; </div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) { } else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists const unfilteredLists = RoomListStore.instance.unfilteredLists

View file

@ -53,9 +53,38 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await this.state.recorder.stop(); await this.state.recorder.stop();
const mxc = await this.state.recorder.upload(); const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, { MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
body: "Voice message", "body": "Voice message",
msgtype: "org.matrix.msc2516.voice", "msgtype": "org.matrix.msc2516.voice",
url: mxc, //"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(); await VoiceRecordingStore.instance.disposeRecording();
this.setState({recorder: null}); this.setState({recorder: null});

View file

@ -25,7 +25,12 @@ import SpaceCreateMenu from "./SpaceCreateMenu";
import {SpaceItem} from "./SpaceTreeLevel"; import {SpaceItem} from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; 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 AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState"; import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
import NotificationBadge from "../rooms/NotificationBadge"; import NotificationBadge from "../rooms/NotificationBadge";
@ -105,19 +110,21 @@ const SpaceButton: React.FC<IButtonProps> = ({
</li>; </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); const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces);
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces);
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace); const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace);
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace);
return [spaces, activeSpace]; return [invites, spaces, activeSpace];
}; };
const SpacePanel = () => { const SpacePanel = () => {
// We don't need the handle as we position the menu in a constant location // We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>(); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
const [spaces, activeSpace] = useSpaces(); const [invites, spaces, activeSpace] = useSpaces();
const [isPanelCollapsed, setPanelCollapsed] = useState(true); const [isPanelCollapsed, setPanelCollapsed] = useState(true);
const newClasses = classNames("mx_SpaceButton_new", { const newClasses = classNames("mx_SpaceButton_new", {
@ -209,6 +216,13 @@ const SpacePanel = () => {
notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)} notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)}
isNarrow={isPanelCollapsed} isNarrow={isPanelCollapsed}
/> />
{ invites.map(s => <SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>) }
{ spaces.map(s => <SpaceItem { spaces.map(s => <SpaceItem
key={s.roomId} key={s.roomId}
space={s} space={s}

View file

@ -45,6 +45,8 @@ import RoomViewStore from "../../../stores/RoomViewStore";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {EventType} from "matrix-js-sdk/src/@types/event"; import {EventType} from "matrix-js-sdk/src/@types/event";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import {NotificationColor} from "../../../stores/notifications/NotificationColor";
interface IItemProps { interface IItemProps {
space?: Room; space?: Room;
@ -67,7 +69,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
super(props); super(props);
this.state = { this.state = {
collapsed: !props.isNested, // default to collapsed for root items collapsed: !props.isNested, // default to collapsed for root items
contextMenuPosition: null, contextMenuPosition: null,
}; };
} }
@ -83,6 +85,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
} }
private onContextMenu = (ev: React.MouseEvent) => { private onContextMenu = (ev: React.MouseEvent) => {
if (this.props.space.getMyMembership() !== "join") return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({ this.setState({
@ -185,6 +188,8 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
}; };
private renderContextMenu(): React.ReactElement { private renderContextMenu(): React.ReactElement {
if (this.props.space.getMyMembership() !== "join") return null;
let contextMenu = null; let contextMenu = null;
if (this.state.contextMenuPosition) { if (this.state.contextMenuPosition) {
const userId = this.context.getUserId(); 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_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isNarrow, 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; let childItems;
if (childSpaces && !collapsed) { if (childSpaces && !collapsed) {

View file

@ -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.", "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", "collapse": "collapse",
"expand": "expand", "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|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 Right": "Rotate Right",
"Rotate Left": "Rotate Left", "Rotate Left": "Rotate Left",
"Zoom out": "Zoom out", "Zoom out": "Zoom out",

View file

@ -46,16 +46,13 @@ export const HOME_SPACE = Symbol("home-space");
export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const SUGGESTED_ROOMS = Symbol("suggested-rooms");
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); 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"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
// Space Room ID/HOME_SPACE will be emitted when a Space's children change // Space Room ID/HOME_SPACE will be emitted when a Space's children change
const MAX_SUGGESTED_ROOMS = 20; const MAX_SUGGESTED_ROOMS = 20;
const getLastViewedRoomsStorageKey = (space?: Room) => { const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`;
const lastViewRooms = "mx_last_viewed_rooms";
const homeSpace = "home_space";
return `${lastViewRooms}_${space?.roomId || homeSpace}`;
}
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
return arr.reduce((result, room: Room) => { 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 // The space currently selected in the Space Panel - if null then `Home` is selected
private _activeSpace?: Room = null; private _activeSpace?: Room = null;
private _suggestedRooms: ISpaceSummaryRoom[] = []; private _suggestedRooms: ISpaceSummaryRoom[] = [];
private _invitedSpaces = new Set<Room>();
public get invitedSpaces(): Room[] {
return Array.from(this._invitedSpaces);
}
public get spacePanelSpaces(): Room[] { public get spacePanelSpaces(): Room[] {
return this.rootSpaces; return this.rootSpaces;
@ -110,32 +112,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._suggestedRooms; return this._suggestedRooms;
} }
public async setActiveSpace(space: Room | null) { public async setActiveSpace(space: Room | null, contextSwitch = true) {
if (space === this.activeSpace) return; if (space === this.activeSpace) return;
this._activeSpace = space; this._activeSpace = space;
this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); this.emit(UPDATE_SELECTED_SPACE, this.activeSpace);
this.emit(SUGGESTED_ROOMS, this._suggestedRooms = []); this.emit(SUGGESTED_ROOMS, this._suggestedRooms = []);
// view last selected room from space if (contextSwitch) {
const roomId = window.localStorage.getItem(getLastViewedRoomsStorageKey(this.activeSpace)); // view last selected room from space
const roomId = window.localStorage.getItem(getSpaceContextKey(this.activeSpace));
if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") { // if the space being selected is an invite then always view that invite
defaultDispatcher.dispatch({ // else if the last viewed room in this space is joined then view that
action: "view_room", // else view space home or home depending on what is being clicked on
room_id: roomId, if (space?.getMyMembership !== "invite" &&
context_switch: true, this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join"
}); ) {
} else if (space) { defaultDispatcher.dispatch({
defaultDispatcher.dispatch({ action: "view_room",
action: "view_room", room_id: roomId,
room_id: space.roomId, context_switch: true,
context_switch: true, });
}); } else if (space) {
} else { defaultDispatcher.dispatch({
defaultDispatcher.dispatch({ action: "view_room",
action: "view_home_page", room_id: space.roomId,
}); context_switch: true,
});
} else {
defaultDispatcher.dispatch({
action: "view_home_page",
});
}
} }
// persist space selected // persist space selected
@ -216,25 +225,27 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return sortBy(parents, r => r.roomId)?.[0] || null; 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> => { public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
}; };
private rebuild = throttle(() => { private rebuild = throttle(() => {
// get all most-upgraded rooms & spaces except spaces which have been left (historical) const [visibleSpaces, visibleRooms] = partitionSpacesAndRooms(this.matrixClient.getVisibleRooms());
const visibleRooms = this.matrixClient.getVisibleRooms().filter(r => { const [joinedSpaces, invitedSpaces] = visibleSpaces.reduce((arr, s) => {
return !r.isSpaceRoom() || r.getMyMembership() === "join"; 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>>(); const backrefs = new EnhancedMap<string, Set<string>>();
// Sort spaces by room ID to force the cycle breaking to be deterministic // 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 // TODO handle cleaning up links when a Space is removed
spaces.forEach(space => { 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.onRoomsUpdate(); // TODO only do this if a change has happened
this.emit(UPDATE_TOP_LEVEL_SPACES, this.spacePanelSpaces); 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}); }, 100, {trailing: true, leading: true});
onSpaceUpdate = () => { onSpaceUpdate = () => {
@ -305,6 +320,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
private showInHomeSpace = (room: Room) => { private showInHomeSpace = (room: Room) => {
if (room.isSpaceRoom()) return false;
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space 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 || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites
@ -335,8 +351,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldFilteredRooms = this.spaceFilteredRooms; const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map(); this.spaceFilteredRooms = new Map();
// put all invites (rooms & spaces) in the Home Space // put all room invites in the Home Space
const invites = this.matrixClient.getRooms().filter(r => r.getMyMembership() === "invite"); const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId))); this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
visibleRooms.forEach(room => { visibleRooms.forEach(room => {
@ -389,13 +405,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.spaceFilteredRooms.forEach((roomIds, s) => { this.spaceFilteredRooms.forEach((roomIds, s) => {
// Update NotificationStates // Update NotificationStates
const rooms = this.matrixClient.getRooms().filter(room => roomIds.has(room.roomId)); this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => roomIds.has(room.roomId)));
this.getNotificationState(s)?.setRooms(rooms);
}); });
}, 100, {trailing: true, leading: true}); }, 100, {trailing: true, leading: true});
private onRoom = (room: Room) => { private onRoom = (room: Room, membership?: string, oldMembership?: string) => {
if (room?.isSpaceRoom()) { 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.onSpaceUpdate();
this.emit(room.roomId); this.emit(room.roomId);
} else { } else {
@ -517,37 +538,30 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// Don't auto-switch rooms when reacting to a context-switch // Don't auto-switch rooms when reacting to a context-switch
// as this is not helpful and can create loops of rooms/space switching // 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 // persist last viewed room from a space
// Don't save if the room is a space room. This would cause a problem: if (room.isSpaceRoom()) {
// When switching to a space home, we first view that room and this.setActiveSpace(room);
// only after that we switch to that space. This causes us to } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) {
// save the space home to be the last viewed room in the home // TODO maybe reverse these first 2 clauses once space panel active is fixed
// space. let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
if (room && !room.isSpaceRoom()) { if (!parent) {
window.localStorage.setItem(getLastViewedRoomsStorageKey(this.activeSpace), payload.room_id); 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") { // Persist last viewed room from a space
if (room.isSpaceRoom()) { // we don't await setActiveSpace above as we only care about this.activeSpace being up to date
this.setActiveSpace(room); // synchronously for the below code - everything else can and should be async.
} else if (!this.spaceFilteredRooms.get(this._activeSpace?.roomId || HOME_SPACE).has(room.roomId)) { window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id);
// 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);
}
}
}
break; break;
} }
case "after_leave_room": case "after_leave_room":

View file

@ -599,11 +599,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
private getPlausibleRooms(): Room[] { private getPlausibleRooms(): Room[] {
if (!this.matrixClient) return []; if (!this.matrixClient) return [];
let rooms = [ let rooms = this.matrixClient.getVisibleRooms().filter(r => VisibilityProvider.instance.isRoomVisible(r));
...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));
if (this.prefilterConditions.length > 0) { if (this.prefilterConditions.length > 0) {
rooms = rooms.filter(r => { rooms = rooms.filter(r => {

View file

@ -20,7 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread"; import ReplyThread from "../../../components/views/elements/ReplyThread";
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils"; import { getHtmlText } from "../../../HtmlUtils";
export class MessageEventPreview implements IPreview { export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string { public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@ -55,7 +55,7 @@ export class MessageEventPreview implements IPreview {
} }
if (hasHtml) { if (hasHtml) {
body = sanitizedHtmlNodeInnerText(body); body = getHtmlText(body);
} }
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {

50
src/utils/Mouse.ts Normal file
View 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,
},
);
}

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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, * Quickly resample an array to have less/more data points. If an input which is larger
* though this does work best if given a large array to downsample to a much smaller array. * than the desired size is provided, it will be downsampled. Similarly, if the input
* @param {number[]} input The input array to downsample. * 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. * @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[] { 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 // https://github.com/turt2live/matrix-media-repo/blob/abe72c87d2e29/util/util_audio/fastsample.go#L10
const everyNth = Math.round(input.length / points); let samples: number[] = [];
const samples: number[] = []; if (input.length > points) {
for (let i = 0; i < input.length; i += everyNth) { // Danger: this loop can cause out of memory conditions if the input is too small.
samples.push(input[i]); 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) { while (samples.length < points) {
samples.push(input[input.length - 1]); samples.push(input[input.length - 1]);
} }
// Sanity trim, just in case
if (samples.length > points) {
samples = samples.slice(0, points);
}
return samples; return samples;
} }
@ -54,7 +78,7 @@ export function arraySeed<T>(val: T, length: number): T[] {
* @param a The array to clone. Must be defined. * @param a The array to clone. Must be defined.
* @returns A copy of the array. * @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); return a.slice(0, a.length);
} }
@ -178,6 +202,13 @@ export class GroupedArray<K, T> {
constructor(private val: Map<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. * Orders the grouping into an array using the provided key order.
* @param keyOrder The key order. * @param keyOrder The key order.

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. * @param e The enum.
* @returns The enum values. * @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); const keys = Object.keys(e);
return keys const values: (string | number)[] = [];
.filter(k => ['string', 'number'].includes(typeof(e[k]))) for (const key of keys) {
.map(k => e[k]); const value = e[key];
if (Number.isFinite(value) || e[value.toString()] !== Number(key)) {
values.push(value);
}
}
return values;
} }
/** /**

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { export function objectClone<O extends {}>(obj: O): O {
return JSON.parse(JSON.stringify(obj)); 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;
}

View 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)

View file

@ -23,6 +23,8 @@ import {clamp} from "../utils/numbers";
import EventEmitter from "events"; import EventEmitter from "events";
import {IDestroyable} from "../utils/IDestroyable"; import {IDestroyable} from "../utils/IDestroyable";
import {Singleflight} from "../utils/Singleflight"; import {Singleflight} from "../utils/Singleflight";
import {PayloadEvent, WORKLET_NAME} from "./consts";
import {arrayFastClone} from "../utils/arrays";
const CHANNELS = 1; // stereo isn't important const CHANNELS = 1; // stereo isn't important
const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. 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 recorderSource: MediaStreamAudioSourceNode;
private recorderStream: MediaStream; private recorderStream: MediaStream;
private recorderFFT: AnalyserNode; private recorderFFT: AnalyserNode;
private recorderProcessor: ScriptProcessorNode; private recorderWorklet: AudioWorkletNode;
private buffer = new Uint8Array(0); private buffer = new Uint8Array(0);
private mxc: string; private mxc: string;
private recording = false; private recording = false;
private observable: SimpleObservable<IRecordingUpdate>; private observable: SimpleObservable<IRecordingUpdate>;
private amplitudes: number[] = []; // at each second mark, generated
public constructor(private client: MatrixClient) { public constructor(private client: MatrixClient) {
super(); 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() { private async makeRecorder() {
this.recorderStream = await navigator.mediaDevices.getUserMedia({ this.recorderStream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
@ -80,18 +100,34 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// it makes the time domain less than helpful. // it makes the time domain less than helpful.
this.recorderFFT.fftSize = 64; this.recorderFFT.fftSize = 64;
// We use an audio processor to get accurate timing information. // Set up our worklet. We use this for timing information and waveform analysis: the
// The size of the audio buffer largely decides how quickly we push timing/waveform data // web audio API prefers this be done async to avoid holding the main thread with math.
// out of this class. Smaller buffers mean we update more frequently as we can't hold as const mxRecorderWorkletPath = document.body.dataset.vectorRecorderWorkletScript;
// many bytes. Larger buffers mean slower updates. For scale, 1024 gives us about 30Hz of if (!mxRecorderWorkletPath) {
// updates and 2048 gives us about 20Hz. We use 1024 to get as close to perceived realtime throw new Error("Unable to create recorder: no worklet script registered");
// as possible. Must be a power of 2. }
this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS); await this.recorderContext.audioWorklet.addModule(mxRecorderWorkletPath);
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
// Connect our inputs and outputs // Connect our inputs and outputs
this.recorderSource.connect(this.recorderFFT); this.recorderSource.connect(this.recorderFFT);
this.recorderSource.connect(this.recorderProcessor); this.recorderSource.connect(this.recorderWorklet);
this.recorderProcessor.connect(this.recorderContext.destination); 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({ this.recorder = new Recorder({
encoderPath, // magic from webpack encoderPath, // magic from webpack
@ -138,7 +174,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
return this.mxc; return this.mxc;
} }
private processAudioUpdate = (ev: AudioProcessingEvent) => { private processAudioUpdate = (timeSeconds: number) => {
if (!this.recording) return; if (!this.recording) return;
// The time domain is the input to the FFT, which means we use an array of the same // 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({ this.observable.update({
waveform: translatedData, 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 // 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. // 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) { if (secondsLeft <= 0) {
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
this.stop(); this.stop();
@ -191,7 +227,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
} }
this.observable = new SimpleObservable<IRecordingUpdate>(); this.observable = new SimpleObservable<IRecordingUpdate>();
await this.makeRecorder(); await this.makeRecorder();
this.recorderProcessor.addEventListener("audioprocess", this.processAudioUpdate);
await this.recorder.start(); await this.recorder.start();
this.recording = true; this.recording = true;
this.emit(RecordingState.Started); this.emit(RecordingState.Started);
@ -205,6 +240,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// Disconnect the source early to start shutting down resources // Disconnect the source early to start shutting down resources
this.recorderSource.disconnect(); this.recorderSource.disconnect();
this.recorderWorklet.disconnect();
await this.recorder.stop(); await this.recorder.stop();
// close the context after the recorder so the recorder doesn't try to // 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 // Finally do our post-processing and clean up
this.recording = false; this.recording = false;
this.recorderProcessor.removeEventListener("audioprocess", this.processAudioUpdate);
await this.recorder.close(); await this.recorder.close();
this.emit(RecordingState.Ended); this.emit(RecordingState.Ended);
@ -240,7 +275,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
this.emit(RecordingState.Uploading); this.emit(RecordingState.Uploading);
this.mxc = await this.client.uploadContent(new Blob([this.buffer], { 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 onlyContentUri: false, // to stop the warnings in the console
}).then(r => r['content_uri']); }).then(r => r['content_uri']);

37
src/voice/consts.ts Normal file
View 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;
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {Singleflight} from "../src/utils/Singleflight"; import {Singleflight} from "../../src/utils/Singleflight";
describe('Singleflight', () => { describe('Singleflight', () => {
afterEach(() => { afterEach(() => {

294
test/utils/arrays-test.ts Normal file
View 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
View 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);
});
});
});

View 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
View 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
View 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
View 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
View 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);
});
});
});