Merge branch 'develop' into fix-pip-color
This commit is contained in:
commit
ec908bc8be
23 changed files with 465 additions and 157 deletions
|
@ -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;
|
||||||
|
|
||||||
|
> .mx_SpaceItem {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_AutoHideScrollbar {
|
|
||||||
padding: 8px 0 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_SpaceButton_toggleCollapse {
|
.mx_SpaceButton_toggleCollapse {
|
||||||
|
|
|
@ -224,35 +224,6 @@ $SpaceRoomViewInnerWidth: 428px;
|
||||||
|
|
||||||
.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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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: #21262c;
|
$settings-profile-placeholder-bg-color: #21262c;
|
||||||
|
|
|
@ -83,7 +83,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;
|
||||||
|
|
|
@ -127,7 +127,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);
|
||||||
|
|
|
@ -118,7 +118,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);
|
||||||
|
|
26
src/@types/global.d.ts
vendored
26
src/@types/global.d.ts
vendored
|
@ -129,4 +129,30 @@ declare global {
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
|
// 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[];
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
return <div {...props} className="mx_FacePile">
|
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
|
||||||
<div className="mx_FacePile_faces">
|
if (shownMembers.length < 1) return null;
|
||||||
{ shownMembers.map(member => {
|
|
||||||
return <TextWithTooltip key={member.userId} tooltip={member.name}>
|
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
|
||||||
<MemberAvatar member={member} width={28} height={28} />
|
// reverse members in tooltip order to make the order between the two match up.
|
||||||
</TextWithTooltip>;
|
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>
|
||||||
{ onlyKnownUsers && <span>
|
<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">
|
||||||
|
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
|
||||||
|
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
|
||||||
|
{ shownMembers.map(m =>
|
||||||
|
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" /> )}
|
||||||
|
</TextWithTooltip>
|
||||||
|
{ onlyKnownUsers && <span className="mx_FacePile_summary">
|
||||||
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
||||||
</span> }
|
</span> }
|
||||||
</div>
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FacePile;
|
export default FacePile;
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -548,6 +548,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) {
|
||||||
|
@ -568,21 +571,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
|
||||||
|
|
|
@ -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",
|
||||||
|
//"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,
|
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});
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -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) {
|
||||||
|
|
|
@ -1916,7 +1916,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",
|
||||||
|
|
|
@ -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,17 +112,23 @@ 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 = []);
|
||||||
|
|
||||||
|
if (contextSwitch) {
|
||||||
// view last selected room from space
|
// view last selected room from space
|
||||||
const roomId = window.localStorage.getItem(getLastViewedRoomsStorageKey(this.activeSpace));
|
const roomId = window.localStorage.getItem(getSpaceContextKey(this.activeSpace));
|
||||||
|
|
||||||
if (roomId && this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join") {
|
// if the space being selected is an invite then always view that invite
|
||||||
|
// else if the last viewed room in this space is joined then view that
|
||||||
|
// else view space home or home depending on what is being clicked on
|
||||||
|
if (space?.getMyMembership !== "invite" &&
|
||||||
|
this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join"
|
||||||
|
) {
|
||||||
defaultDispatcher.dispatch({
|
defaultDispatcher.dispatch({
|
||||||
action: "view_room",
|
action: "view_room",
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
|
@ -137,6 +145,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||||
action: "view_home_page",
|
action: "view_home_page",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// persist space selected
|
// persist space selected
|
||||||
if (space) {
|
if (space) {
|
||||||
|
@ -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,23 +538,13 @@ 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:
|
|
||||||
// When switching to a space home, we first view that room and
|
|
||||||
// only after that we switch to that space. This causes us to
|
|
||||||
// save the space home to be the last viewed room in the home
|
|
||||||
// space.
|
|
||||||
if (room && !room.isSpaceRoom()) {
|
|
||||||
window.localStorage.setItem(getLastViewedRoomsStorageKey(this.activeSpace), payload.room_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (room?.getMyMembership() === "join") {
|
|
||||||
if (room.isSpaceRoom()) {
|
if (room.isSpaceRoom()) {
|
||||||
this.setActiveSpace(room);
|
this.setActiveSpace(room);
|
||||||
} else if (!this.spaceFilteredRooms.get(this._activeSpace?.roomId || HOME_SPACE).has(room.roomId)) {
|
} else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) {
|
||||||
// TODO maybe reverse these first 2 clauses once space panel active is fixed
|
// 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));
|
let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId));
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
|
@ -543,11 +554,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
|
||||||
const parents = Array.from(this.parentMap.get(room.roomId) || []);
|
const parents = Array.from(this.parentMap.get(room.roomId) || []);
|
||||||
parent = parents.find(p => this.matrixClient.getRoom(p));
|
parent = parents.find(p => this.matrixClient.getRoom(p));
|
||||||
}
|
}
|
||||||
if (parent) {
|
// don't trigger a context switch when we are switching a space to match the chosen room
|
||||||
this.setActiveSpace(parent);
|
this.setActiveSpace(parent || null, false);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist last viewed room from a space
|
||||||
|
// we don't await setActiveSpace above as we only care about this.activeSpace being up to date
|
||||||
|
// synchronously for the below code - everything else can and should be async.
|
||||||
|
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "after_leave_room":
|
case "after_leave_room":
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
50
src/utils/Mouse.ts
Normal file
50
src/utils/Mouse.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Different browsers use different deltaModes. This causes different behaviour.
|
||||||
|
* To avoid that we use this function to convert any event to pixels.
|
||||||
|
* @param {WheelEvent} event to normalize
|
||||||
|
* @returns {WheelEvent} normalized event event
|
||||||
|
*/
|
||||||
|
export function normalizeWheelEvent(event: WheelEvent): WheelEvent {
|
||||||
|
const LINE_HEIGHT = 18;
|
||||||
|
|
||||||
|
let deltaX;
|
||||||
|
let deltaY;
|
||||||
|
let deltaZ;
|
||||||
|
|
||||||
|
if (event.deltaMode === 1) { // Units are lines
|
||||||
|
deltaX = (event.deltaX * LINE_HEIGHT);
|
||||||
|
deltaY = (event.deltaY * LINE_HEIGHT);
|
||||||
|
deltaZ = (event.deltaZ * LINE_HEIGHT);
|
||||||
|
} else {
|
||||||
|
deltaX = event.deltaX;
|
||||||
|
deltaY = event.deltaY;
|
||||||
|
deltaZ = event.deltaZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WheelEvent(
|
||||||
|
"syntheticWheel",
|
||||||
|
{
|
||||||
|
deltaMode: 0,
|
||||||
|
deltaY: deltaY,
|
||||||
|
deltaX: deltaX,
|
||||||
|
deltaZ: deltaZ,
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
|
@ -54,7 +54,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
67
src/voice/RecorderWorklet.ts
Normal file
67
src/voice/RecorderWorklet.ts
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {IAmplitudePayload, ITimingPayload, PayloadEvent, WORKLET_NAME} from "./consts";
|
||||||
|
import {percentageOf} from "../utils/numbers";
|
||||||
|
|
||||||
|
// from AudioWorkletGlobalScope: https://developer.mozilla.org/en-US/docs/Web/API/AudioWorkletGlobalScope
|
||||||
|
declare const currentTime: number;
|
||||||
|
// declare const currentFrame: number;
|
||||||
|
// declare const sampleRate: number;
|
||||||
|
|
||||||
|
class MxVoiceWorklet extends AudioWorkletProcessor {
|
||||||
|
private nextAmplitudeSecond = 0;
|
||||||
|
|
||||||
|
process(inputs, outputs, parameters) {
|
||||||
|
// We only fire amplitude updates once a second to avoid flooding the recording instance
|
||||||
|
// with useless data. Much of the data would end up discarded, so we ratelimit ourselves
|
||||||
|
// here.
|
||||||
|
const currentSecond = Math.round(currentTime);
|
||||||
|
if (currentSecond === this.nextAmplitudeSecond) {
|
||||||
|
// We're expecting exactly one mono input source, so just grab the very first frame of
|
||||||
|
// samples for the analysis.
|
||||||
|
const monoChan = inputs[0][0];
|
||||||
|
|
||||||
|
// The amplitude of the frame's samples is effectively the loudness of the frame. This
|
||||||
|
// translates into a bar which can be rendered as part of the whole recording clip's
|
||||||
|
// waveform.
|
||||||
|
//
|
||||||
|
// We translate the amplitude down to 0-1 for sanity's sake.
|
||||||
|
const minVal = Math.min(...monoChan);
|
||||||
|
const maxVal = Math.max(...monoChan);
|
||||||
|
const amplitude = percentageOf(maxVal, -1, 1) - percentageOf(minVal, -1, 1);
|
||||||
|
|
||||||
|
this.port.postMessage(<IAmplitudePayload>{
|
||||||
|
ev: PayloadEvent.AmplitudeMark,
|
||||||
|
amplitude: amplitude,
|
||||||
|
forSecond: currentSecond,
|
||||||
|
});
|
||||||
|
this.nextAmplitudeSecond++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We mostly use this worklet to fire regular clock updates through to components
|
||||||
|
this.port.postMessage(<ITimingPayload>{ev: PayloadEvent.Timekeep, timeSeconds: currentTime});
|
||||||
|
|
||||||
|
// We're supposed to return false when we're "done" with the audio clip, but seeing as
|
||||||
|
// we are acting as a passive processor we are never truly "done". The browser will clean
|
||||||
|
// us up when it is done with us.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor(WORKLET_NAME, MxVoiceWorklet);
|
||||||
|
|
||||||
|
export default null; // to appease module loaders (we never use the export)
|
|
@ -23,6 +23,8 @@ import {clamp} from "../utils/numbers";
|
||||||
import EventEmitter from "events";
|
import 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
37
src/voice/consts.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WORKLET_NAME = "mx-voice-worklet";
|
||||||
|
|
||||||
|
export enum PayloadEvent {
|
||||||
|
Timekeep = "timekeep",
|
||||||
|
AmplitudeMark = "amplitude_mark",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPayload {
|
||||||
|
ev: PayloadEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITimingPayload extends IPayload {
|
||||||
|
ev: PayloadEvent.Timekeep;
|
||||||
|
timeSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAmplitudePayload extends IPayload {
|
||||||
|
ev: PayloadEvent.AmplitudeMark;
|
||||||
|
forSecond: number;
|
||||||
|
amplitude: number;
|
||||||
|
}
|
Loading…
Reference in a new issue