Merge branch 'develop' into fix/17164/remember-collapsed-state

This commit is contained in:
Šimon Brandner 2021-05-11 07:38:08 +02:00
commit bc0ff5bbea
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
107 changed files with 2956 additions and 1051 deletions

View file

@ -416,7 +416,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const roomList = <RoomList
onKeyDown={this.onKeyDown}
resizeNotifier={null}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}

View file

@ -59,6 +59,9 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
@ -119,6 +122,7 @@ interface IState {
usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number;
useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
}
/**
@ -160,6 +164,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: [],
};
// stash the MatrixClient in case we log out before we are unmounted
@ -175,6 +180,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._updateServerNoticeEvents();
@ -199,6 +205,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -206,6 +213,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach();
}
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
};
// Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy.
//
@ -661,6 +674,12 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout';
}
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
);
});
return (
<MatrixClientContext.Provider value={this._matrixClient}>
<div
@ -685,6 +704,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<CallContainer />
<NonUrgentToastContainer />
<HostSignupContainer />
{audioFeedArraysForCalls}
</MatrixClientContext.Provider>
);
}

View file

@ -1094,7 +1094,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications.
const warnings = [];
@ -1133,7 +1133,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"),
description: (

View file

@ -544,11 +544,13 @@ export default class MessagePanel extends React.Component {
}
if (!grouper) {
const wantTile = this._shouldShowEvent(mxEv);
const isGrouped = false;
if (wantTile) {
// make sure we unpack the array returned by _getTilesForEvent,
// otherwise react will auto-generate keys and we will end up
// replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile));
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped,
nextEvent, nextTile));
prevEvent = mxEv;
}
@ -564,7 +566,7 @@ export default class MessagePanel extends React.Component {
return ret;
}
_getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) {
_getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) {
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
@ -584,7 +586,7 @@ export default class MessagePanel extends React.Component {
// do we need a date separator since the last event?
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
if (wantsDateSeparator) {
if (wantsDateSeparator && !isGrouped) {
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
ret.push(dateSeparator);
}
@ -968,9 +970,9 @@ class CreationGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const panel = this.panel;
const ret = [];
const isGrouped = true;
const createEvent = this.createEvent;
const lastShownEvent = this.lastShownEvent;
@ -984,12 +986,12 @@ class CreationGrouper {
// If this m.room.create event should be shown (room upgrade) then show it before the summary
if (panel._shouldShowEvent(createEvent)) {
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
ret.push(...panel._getTilesForEvent(createEvent, createEvent, false));
ret.push(...panel._getTilesForEvent(createEvent, createEvent));
}
for (const ejected of this.ejectedEvents) {
ret.push(...panel._getTilesForEvent(
createEvent, ejected, createEvent === lastShownEvent,
createEvent, ejected, createEvent === lastShownEvent, isGrouped,
));
}
@ -998,7 +1000,7 @@ class CreationGrouper {
// of EventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted.
return panel._getTilesForEvent(e, e, e === lastShownEvent);
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1];
@ -1083,7 +1085,7 @@ class RedactionGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const isGrouped = true;
const panel = this.panel;
const ret = [];
const lastShownEvent = this.lastShownEvent;
@ -1103,7 +1105,8 @@ class RedactionGrouper {
let eventTiles = this.events.map((e, i) => {
senders.add(e.sender);
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile);
return panel._getTilesForEvent(
prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile);
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {
@ -1182,7 +1185,7 @@ class MemberGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
const isGrouped = true;
const panel = this.panel;
const lastShownEvent = this.lastShownEvent;
const ret = [];
@ -1215,7 +1218,7 @@ class MemberGrouper {
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted.
return panel._getTilesForEvent(e, e, e === lastShownEvent);
return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) {

View file

@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore";
@replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component {
@ -85,7 +86,9 @@ export default class RightPanel extends React.Component {
return RightPanelPhases.GroupMemberList;
}
return rps.groupPanelPhase;
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) {
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) {
return RightPanelPhases.SpaceMemberList;
} else if (userForPanel) {
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state

View file

@ -27,8 +27,8 @@ import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import {replaceableComponent} from "../../utils/replaceableComponent";
import SpaceStore, {UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../stores/SpaceStore";
import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
interface IProps {
isMinimized: boolean;

View file

@ -1750,7 +1750,10 @@ export default class RoomView extends React.Component<IProps, IState> {
}
const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself
if (myMembership === "invite"
// SpaceRoomView handles invites itself
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
) {
if (this.state.joining || this.state.rejecting) {
return (
<ErrorBoundary>
@ -1892,7 +1895,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room}
/>
);
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) {
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
return (
<div className="mx_RoomView">
{ previewBar }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useMemo, useState} from "react";
import React, {ReactNode, useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
@ -24,7 +24,7 @@ import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox";
@ -39,11 +39,14 @@ import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
interface IHierarchyProps {
space: Room;
initialText?: string;
refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
}
@ -106,8 +109,16 @@ const Tile: React.FC<ITileProps> = ({
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
const onPreviewClick = () => onViewRoomClick(false);
const onJoinClick = () => onViewRoomClick(true);
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
}
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
}
let button;
if (myMembership === "join") {
@ -254,7 +265,11 @@ export const HierarchyLevel = ({
const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null);
const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key;
if (!rooms.has(roomId)) return result;
@ -350,6 +365,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
initialText = "",
showRoom,
refreshToken,
additionalButtons,
children,
}) => {
const cli = MatrixClientPeg.get();
@ -415,78 +431,83 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let editSection;
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
let buttons;
if (selectedRelations.length) {
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = removing || saving;
const disabled = !selectedRelations.length || removing || saving;
buttons = <>
<AccessibleButton
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</AccessibleButton>
<AccessibleButton
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</AccessibleButton>
</>;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
editSection = <span>
{ buttons }
</span>;
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).get(childId).content = {};
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
@ -532,7 +553,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
{ editSection }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }

View file

@ -52,8 +52,14 @@ import {useStateToggle} from "../../hooks/useStateToggle";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {allSettled} from "../../utils/promise";
import {sleep} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
interface IProps {
space: Room;
@ -217,6 +223,67 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
};
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const cli = useContext(MatrixClientContext);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect();
contextMenu = <IconizedContextMenu
left={rect.left + window.pageXOffset + 0}
top={rect.bottom + window.pageYOffset + 8}
chevronFace={ChevronFace.None}
onFinished={closeMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
if (await showCreateNewRoom(cli, space)) {
onNewRoomAdded();
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
const [added] = await showAddExistingRooms(cli, space);
if (added) {
onNewRoomAdded();
}
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<ContextMenuButton
kind="primary"
inputRef={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("Add")}
>
{ _t("Add") }
</ContextMenuButton>
{ contextMenu }
</>;
};
const SpaceLanding = ({ space }) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
@ -241,32 +308,20 @@ const SpaceLanding = ({ space }) => {
const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButtons;
let addRoomButton;
if (canAddRooms) {
addRoomButtons = <React.Fragment>
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
const [added] = await showAddExistingRooms(cli, space);
if (added) {
forceUpdate();
}
}}>
{ _t("Add existing rooms & spaces") }
</AccessibleButton>
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
showCreateNewRoom(cli, space);
}}>
{ _t("Create a new room") }
</AccessibleButton>
</React.Fragment>;
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
}
let settingsButton;
if (shouldShowSpaceSettings(cli, space)) {
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => {
showSpaceSettings(cli, space);
}}>
{ _t("Settings") }
</AccessibleButton>;
settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(cli, space);
}}
title={_t("Settings")}
/>;
}
const onMembersClick = () => {
@ -293,17 +348,19 @@ const SpaceLanding = ({ space }) => {
<SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
{ settingsButton }
</div>
<div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} />
</div>
<hr />
<div className="mx_SpaceRoomView_landing_adminButtons">
{ addRoomButtons }
{ settingsButton }
</div>
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} />
<SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
</div>;
};
@ -389,15 +446,24 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
let buttonLabel = _t("Skip for now");
if (selectedToAdd.size > 0) {
onClick = async () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
break;
}
}
setBusy(false);
};

View file

@ -78,8 +78,10 @@ export default class MessageContextMenu extends React.Component {
// We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
// Similarly for encryption events, since redacting them "breaks everything"
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl;
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {useContext, useState} from "react";
import React, {useContext, useMemo, useState} from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client";
@ -29,11 +29,13 @@ import RoomAvatar from "../avatars/RoomAvatar";
import {getDisplayAliasForRoom} from "../../../Rooms";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {allSettled} from "../../../utils/promise";
import {sleep} from "../../../utils/promise";
import DMRoomMap from "../../../utils/DMRoomMap";
import {calculateRoomVia} from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {sortRooms} from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -45,7 +47,11 @@ const Entry = ({ room, checked, onChange }) => {
return <label className="mx_AddExistingToSpace_entry">
<RoomAvatar room={room} height={32} width={32} />
<span className="mx_AddExistingToSpace_entry_name">{ room.name }</span>
<StyledCheckbox onChange={(e) => onChange(e.target.checked)} checked={checked} />
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : null}
checked={checked}
disabled={!onChange}
/>
</label>;
};
@ -57,6 +63,8 @@ interface IAddExistingToSpaceProps {
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space, selected, onChange }) => {
const cli = useContext(MatrixClientContext);
const visibleRooms = useMemo(() => sortRooms(cli.getVisibleRooms()), [cli]);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
@ -65,7 +73,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
const existingRoomsSet = new Set(SpaceStore.instance.getChildRooms(space.roomId));
const joinRule = space.getJoinRule();
const [spaces, rooms, dms] = cli.getVisibleRooms().reduce((arr, room) => {
const [spaces, rooms, dms] = visibleRooms.reduce((arr, room) => {
if (room.getMyMembership() !== "join") return arr;
if (!room.name.toLowerCase().includes(lcQuery)) return arr;
@ -101,9 +109,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange={onChange ? (checked) => {
onChange(checked, room);
}}
} : null}
/>;
}) }
</div>
@ -117,9 +125,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
key={space.roomId}
room={space}
checked={selected.has(space)}
onChange={(checked) => {
onChange={onChange ? (checked) => {
onChange(checked, space);
}}
} : null}
/>;
}) }
</div>
@ -133,9 +141,9 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({ space,
key={room.roomId}
room={room}
checked={selected.has(room)}
onChange={(checked) => {
onChange={onChange ? (checked) => {
onChange(checked, room);
}}
} : null}
/>;
}) }
</div>
@ -153,8 +161,8 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [progress, setProgress] = useState<number>(null);
const [error, setError] = useState<Error>(null);
let spaceOptionSection;
if (existingSubspaces.length > 0) {
@ -194,6 +202,82 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
</div>
</React.Fragment>;
const addRooms = async () => {
setError(null);
setProgress(0);
let error;
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async e => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
return SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
}
throw e;
});
setProgress(i => i + 1);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(error = e);
break;
}
}
if (!error) {
onFinished(true);
}
};
const busy = progress !== null;
let footer;
if (error) {
footer = <>
<img
src={require("../../../../res/img/element-icons/warning-badge.svg")}
height="24"
width="24"
alt=""
/>
<span className="mx_AddExistingToSpaceDialog_error">
<div className="mx_AddExistingToSpaceDialog_errorHeading">{ _t("Not all selected were added") }</div>
<div className="mx_AddExistingToSpaceDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else if (busy) {
footer = <span>
<ProgressBar value={progress} max={selectedToAdd.size} />
<div className="mx_AddExistingToSpaceDialog_progressText">
{ _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: selectedToAdd.size,
progress,
}) }
</div>
</span>;
} else {
footer = <>
<span>
<div>{ _t("Want to add a new room instead?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{ _t("Add") }
</AccessibleButton>
</>;
}
return <BaseDialog
title={title}
className="mx_AddExistingToSpaceDialog"
@ -201,50 +285,23 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ matrixClient: cli, space,
onFinished={onFinished}
fixedWidth={false}
>
{ error && <div className="mx_AddExistingToSpaceDialog_errorText">{ error }</div> }
<MatrixClientContext.Provider value={cli}>
<AddExistingToSpace
space={space}
selected={selectedToAdd}
onChange={(checked, room) => {
onChange={!busy && !error ? (checked, room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}}
} : null}
/>
</MatrixClientContext.Provider>
<div className="mx_AddExistingToSpaceDialog_footer">
<span>
<div>{ _t("Don't want to add an existing room?") }</div>
<AccessibleButton onClick={() => onCreateRoomClick(cli, space)} kind="link">
{ _t("Create a new room") }
</AccessibleButton>
</span>
<AccessibleButton
kind="primary"
disabled={busy || selectedToAdd.size < 1}
onClick={async () => {
// TODO rate limiting
setBusy(true);
try {
await allSettled(Array.from(selectedToAdd).map((room) =>
SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room))));
onFinished(true);
} catch (e) {
console.error("Failed to add rooms to space", e);
setError(_t("Failed to add rooms to space"));
}
setBusy(false);
}}
>
{ busy ? _t("Adding...") : _t("Add") }
</AccessibleButton>
{ footer }
</div>
</BaseDialog>;
};

View file

@ -1312,7 +1312,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
goButtonFn = this._startDm;
} else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = room?.isSpaceRoom();
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
title = isSpace
? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"),

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@ -16,20 +16,23 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import filesize from "filesize";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getBlobSafeMimeType } from '../../../utils/blobs';
interface IProps {
file: File;
currentIndex: number;
totalFiles?: number;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
}
@replaceableComponent("views.dialogs.UploadConfirmDialog")
export default class UploadConfirmDialog extends React.Component {
static propTypes = {
file: PropTypes.object.isRequired,
currentIndex: PropTypes.number,
totalFiles: PropTypes.number,
onFinished: PropTypes.func.isRequired,
}
export default class UploadConfirmDialog extends React.Component<IProps> {
private objectUrl: string;
private mimeType: string;
static defaultProps = {
totalFiles: 1,
@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component {
constructor(props) {
super(props);
this._objectUrl = URL.createObjectURL(props.file);
// Create a fresh `Blob` for previewing (even though `File` already is
// one) so we can adjust the MIME type if needed.
this.mimeType = getBlobSafeMimeType(props.file.type);
const blob = new Blob([props.file], { type:
this.mimeType,
});
this.objectUrl = URL.createObjectURL(blob);
}
componentWillUnmount() {
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl);
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
}
_onCancelClick = () => {
private onCancelClick = () => {
this.props.onFinished(false);
}
_onUploadClick = () => {
private onUploadClick = () => {
this.props.onFinished(true);
}
_onUploadAllClick = () => {
private onUploadAllClick = () => {
this.props.onFinished(true, true);
}
@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component {
}
let preview;
if (this.props.file.type.startsWith('image/')) {
if (this.mimeType.startsWith('image/')) {
preview = <div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner">
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} /></div>
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
</div>
</div>;
@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component {
let uploadAllButton;
if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this._onUploadAllClick}>
uploadAllButton = <button onClick={this.onUploadAllClick}>
{_t("Upload all")}
</button>;
}
@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component {
return (
<BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false}
onFinished={this._onCancelClick}
onFinished={this.onCancelClick}
title={title}
contentId='mx_Dialog_content'
>
@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component {
<DialogButtons primaryButton={_t('Upload')}
hasCancel={false}
onPrimaryButtonClick={this._onUploadClick}
onPrimaryButtonClick={this.onUploadClick}
focus={true}
>
{uploadAllButton}

View file

@ -108,23 +108,20 @@ export default class ImageView extends React.Component<IProps, IState> {
window.addEventListener("resize", this.calculateZoom);
// After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom);
// Try to precalculate the zoom from width and height props
this.calculateZoom();
}
componentWillUnmount() {
this.focusLock.current.removeEventListener('wheel', this.onWheel);
window.removeEventListener("resize", this.calculateZoom);
this.image.current.removeEventListener("load", this.calculateZoom);
}
private calculateZoom = () => {
const image = this.image.current;
const imageWrapper = this.imageWrapper.current;
const width = this.props.width || image.naturalWidth;
const height = this.props.height || image.naturalHeight;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
const zoomX = imageWrapper.clientWidth / image.naturalWidth;
const zoomY = imageWrapper.clientHeight / image.naturalHeight;
// If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size

View file

@ -0,0 +1,106 @@
/*
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 React from "react";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {Playback} from "../../../voice/Playback";
import MFileBody from "./MFileBody";
import InlineSpinner from '../elements/InlineSpinner';
import {_t} from "../../../languageHandler";
import {mediaFromContent} from "../../../customisations/Media";
import {decryptFile} from "../../../utils/DecryptFile";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
interface IProps {
mxEvent: MatrixEvent;
}
interface IState {
error?: Error;
playback?: Playback;
decryptedBlob?: Blob;
}
@replaceableComponent("views.messages.MVoiceMessageBody")
export default class MVoiceMessageBody extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {};
}
public async componentDidMount() {
let buffer: ArrayBuffer;
const content = this.props.mxEvent.getContent();
const media = mediaFromContent(content);
if (media.isEncrypted) {
try {
const blob = await decryptFile(content.file);
buffer = await blob.arrayBuffer();
this.setState({decryptedBlob: blob});
} catch (e) {
this.setState({error: e});
console.warn("Unable to decrypt voice message", e);
return; // stop processing the audio file
}
} else {
try {
buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer());
} catch (e) {
this.setState({error: e});
console.warn("Unable to download voice message", e);
return; // stop processing the audio file
}
}
const waveform = content?.["org.matrix.msc1767.audio"]?.waveform?.map(p => p / 1024);
// We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer, waveform);
this.setState({playback});
// Note: the RecordingPlayback component will handle preparing the Playback class for us.
}
public render() {
if (this.state.error) {
// TODO: @@TR: Verify error state
return (
<span className="mx_MVoiceMessageBody">
<img src={require("../../../../res/img/warning.svg")} width="16" height="16" />
{ _t("Error processing voice message") }
</span>
);
}
if (!this.state.playback) {
// TODO: @@TR: Verify loading/decrypting state
return (
<span className="mx_MVoiceMessageBody">
<InlineSpinner />
</span>
);
}
// At this point we should have a playable state
return (
<span className="mx_MVoiceMessageBody">
<RecordingPlayback playback={this.state.playback} />
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />
</span>
)
}
}

View file

@ -0,0 +1,39 @@
/*
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 React from "react";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import MAudioBody from "./MAudioBody";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody";
interface IProps {
mxEvent: MatrixEvent;
}
@replaceableComponent("views.messages.MVoiceOrAudioBody")
export default class MVoiceOrAudioBody extends React.PureComponent<IProps> {
public render() {
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages");
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />;
} else {
return <MAudioBody {...this.props} />;
}
}
}

View file

@ -72,12 +72,8 @@ export default class MessageEvent extends React.Component {
'm.emote': sdk.getComponent('messages.TextualBody'),
'm.image': sdk.getComponent('messages.MImageBody'),
'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MAudioBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
// TODO: @@ TravisR: Use labs flag determination.
// MSC: https://github.com/matrix-org/matrix-doc/pull/2516
'org.matrix.msc2516.voice': sdk.getComponent('messages.MAudioBody'),
};
const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'),

View file

@ -187,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
verifyDevice(cli.getUser(userId), device);
};
const deviceName = device.ambiguous ?
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
device.getDisplayName();
let deviceName;
if (!device.getDisplayName()?.trim()) {
deviceName = device.deviceId;
} else {
deviceName = device.ambiguous ?
device.getDisplayName() + " (" + device.deviceId + ")" :
device.getDisplayName();
}
let trustedLabel = null;
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
@ -440,7 +446,7 @@ const UserOptionsSection: React.FC<{
);
};
const warnSelfDemote = async (isSpace) => {
const warnSelfDemote = async (isSpace: boolean) => {
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"),
description:
@ -727,7 +733,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels,
// if muting self, warn as it may be irreversible
if (target === cli.getUserId()) {
try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
return;
@ -816,7 +822,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
}
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
@ -1095,7 +1101,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try {
if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return;
} catch (e) {
console.error("Failed to warn about self demotion: ", e);
}
@ -1325,10 +1331,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) {
if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption.");
} else if (room && !room.isSpaceRoom()) {
} else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
text = _t("Messages in this room are not end-to-end encrypted.");
}
} else if (!room.isSpaceRoom()) {
} else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted.");
}
@ -1405,7 +1411,7 @@ const BasicUserInfo: React.FC<{
canInvite={roomPermissions.canInvite}
isIgnored={isIgnored}
member={member}
isSpace={room?.isSpaceRoom()}
isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()}
/>
{ adminToolsContainer }
@ -1567,7 +1573,7 @@ const UserInfo: React.FC<Props> = ({
previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member};
} else if (room) {
previousPhase = previousPhase = room.isSpaceRoom()
previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList;
}
@ -1616,7 +1622,7 @@ const UserInfo: React.FC<Props> = ({
}
let scopeHeader;
if (room?.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import {_t} from '../../../languageHandler';
import {_t, _td} from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model';
@ -24,16 +24,18 @@ import {getCaretOffsetAndText} from '../../../editor/dom';
import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize';
import {findEditableEvent} from '../../../utils/EventUtils';
import {parseEvent} from '../../../editor/deserialize';
import {PartCreator} from '../../../editor/parts';
import {CommandPartCreator} from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import classNames from 'classnames';
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {CommandCategories, getCommand} from '../../../SlashCommands';
import {Action} from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Modal from '../../../Modal';
function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -178,6 +180,22 @@ export default class EditMessageComposer extends React.Component {
dis.fire(Action.FocusComposer);
}
_isSlashCommand() {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
return true;
}
}
return false;
}
_isContentModified(newContent) {
// if nothing has changed then bail
const oldContent = this.props.editState.getEvent().getContent();
@ -190,19 +208,112 @@ export default class EditMessageComposer extends React.Component {
return true;
}
_sendEdit = () => {
_getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
return text + part.resourceId;
}
return text + part.text;
}, "");
const {cmd, args} = getCommand(commandText);
return [cmd, args, commandText];
}
async _runSlashCommand(cmd, args, roomId) {
const result = cmd.run(roomId, args);
let messageContent;
let error = result.error;
if (result.promise) {
try {
if (cmd.category === CommandCategories.messages) {
messageContent = await result.promise;
} else {
await result.promise;
}
} catch (err) {
error = err;
}
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!result.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
let errText;
if (typeof error === 'string') {
errText = error;
} else if (error.message) {
errText = error.message;
} else {
errText = _t("Server unavailable, overloaded, or something else went wrong.");
}
Modal.createTrackedDialog(title, '', ErrorDialog, {
title: _t(title),
description: errText,
});
} else {
console.log("Command success.");
if (messageContent) return messageContent;
}
}
_sendEdit = async () => {
const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"];
let shouldSend = true;
// If content is modified then send an updated event into the room
if (this._isContentModified(newContent)) {
const roomId = editedEvent.getRoomId();
this._cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
if (!containsEmote(this.model) && this._isSlashCommand()) {
const [cmd, args, commandText] = this._getSlashCommand();
if (cmd) {
if (cmd.category === CommandCategories.messages) {
editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId);
} else {
this._runSlashCommand(cmd, args, roomId);
shouldSend = false;
}
} else {
// ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"),
description: <div>
<p>
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
</p>
<p>
{ _t("You can use <code>/help</code> to list available commands. " +
"Did you mean to send this as a message?", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
<p>
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
</div>,
button: _t('Send as message'),
});
const [sendAnyway] = await finished;
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
this._cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
}
}
// close the event editing and focus composer
@ -240,7 +351,7 @@ export default class EditMessageComposer extends React.Component {
_createEditorModel() {
const {editState} = this.props;
const room = this._getRoom();
const partCreator = new PartCreator(room, this.context);
const partCreator = new CommandPartCreator(room, this.context);
let parts;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,

View file

@ -30,6 +30,7 @@ import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -460,7 +461,7 @@ export default class MemberList extends React.Component {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) {
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
@ -492,7 +493,7 @@ export default class MemberList extends React.Component {
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (room?.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />

View file

@ -539,6 +539,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}
alwaysVisible={ALWAYS_VISIBLE_TAGS.includes(orderedTagId)}
/>
});

View file

@ -44,6 +44,7 @@ import { ActionPayload } from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
@ -75,7 +76,7 @@ interface IProps {
onResize: () => void;
showSkeleton?: boolean;
alwaysVisible?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[];
// TODO: Account for https://github.com/vector-im/element-web/issues/14179
@ -528,6 +529,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
tiles.push(<RoomTile
room={room}
key={`room-${room.roomId}`}
resizeNotifier={this.props.resizeNotifier}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}

View file

@ -53,12 +53,14 @@ import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/Community
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
interface IProps {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
resizeNotifier: ResizeNotifier;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
@ -102,6 +104,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
};
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
}
private countUnsentEvents(): number {
@ -116,6 +121,12 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update
};
private onResize = () => {
if (this.showMessagePreview && !this.state.messagePreview) {
this.setState({messagePreview: this.generatePreview()});
}
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (!room?.roomId === this.props.room.roomId) return;
this.setState({hasUnsentEvents: this.countUnsentEvents() > 0});
@ -195,6 +206,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
this.props.room.off("Room.name", this.onRoomNameUpdate);
}
if (this.props.resizeNotifier) {
this.props.resizeNotifier.off("middlePanelResized", this.onResize);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);

View file

@ -26,6 +26,7 @@ import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps {
event: MatrixEvent;
@ -135,7 +136,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />

View file

@ -27,6 +27,10 @@ import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
import {MsgType} from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import CallMediaHandler from "../../../CallMediaHandler";
interface IProps {
room: Room;
@ -64,8 +68,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message",
"msgtype": "org.matrix.msc2516.voice",
//"msgtype": MsgType.Audio,
//"msgtype": "org.matrix.msc2516.voice",
"msgtype": MsgType.Audio,
"url": mxc,
"info": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
@ -83,10 +87,6 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
},
"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
@ -95,6 +95,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
// We're expecting about one data point per second of audio.
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
},
"org.matrix.msc2516.voice": {}, // No content, this is a rendering hint
});
await this.disposeRecording();
}
@ -115,16 +116,59 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
await this.state.recorder.stop();
return;
}
const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start();
// We don't need to remove the listener: the recorder will clean that up for us.
recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
this.setState({recordingPhase: ev});
});
// The "microphone access error" dialogs are used a lot, so let's functionify them
const accessError = () => {
Modal.createTrackedDialog('Microphone Access Error', '', ErrorDialog, {
title: _t("Unable to access your microphone"),
description: <>
<p>{_t(
"We were unable to access your microphone. Please check your browser settings and try again.",
)}</p>
</>,
});
};
this.setState({recorder, recordingPhase: RecordingState.Started});
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried.
try {
const devices = await CallMediaHandler.getDevices();
if (!devices?.['audioinput']?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"),
description: <>
<p>{_t(
"We didn't find a microphone on your device. Please check your settings and try again.",
)}</p>
</>,
});
return;
}
// else we probably have a device that is good enough
} catch (e) {
console.error("Error getting devices: ", e);
accessError();
return;
}
try {
const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start();
// We don't need to remove the listener: the recorder will clean that up for us.
recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
this.setState({recordingPhase: ev});
});
this.setState({recorder, recordingPhase: RecordingState.Started});
} catch (e) {
console.error("Error starting recording: ", e);
accessError();
// noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack
VoiceRecordingStore.instance.disposeRecording();
}
};
private renderWaveformArea(): ReactNode {

View file

@ -18,6 +18,7 @@ import React from 'react';
import {_t, getCurrentLanguage} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
import SdkConfig from "../../../../../SdkConfig";
import createRoom from "../../../../../createRoom";
import Modal from "../../../../../Modal";
@ -26,6 +27,9 @@ import PlatformPeg from "../../../../../PlatformPeg";
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import UpdateCheckButton from "../../UpdateCheckButton";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import { copyPlaintext } from "../../../../../utils/strings";
import * as ContextMenu from "../../../../structures/ContextMenu";
import { toRightOf } from "../../../../structures/ContextMenu";
interface IProps {
closeSettingsFn: () => {};
@ -38,6 +42,8 @@ interface IState {
@replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab")
export default class HelpUserSettingsTab extends React.Component<IProps, IState> {
protected closeCopiedTooltip: () => void;
constructor(props) {
super(props);
@ -56,6 +62,12 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
});
}
componentWillUnmount() {
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
private onClearCacheAndReload = (e) => {
if (!PlatformPeg.get()) return;
@ -153,6 +165,20 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
);
}
onAccessTokenCopyClick = async (e) => {
e.preventDefault();
const target = e.target; // copy target before we go async and React throws it away
const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken());
const buttonRect = target.getBoundingClientRect();
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const {close} = ContextMenu.createMenu(GenericTextContextMenu, {
...toRightOf(buttonRect, 2),
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
this.closeCopiedTooltip = target.onmouseleave = close;
}
render() {
const brand = SdkConfig.get().brand;
@ -269,12 +295,20 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
<div className='mx_SettingsTab_subsectionText'>
{_t("Homeserver is")} <code>{MatrixClientPeg.get().getHomeserverUrl()}</code><br />
{_t("Identity Server is")} <code>{MatrixClientPeg.get().getIdentityServerUrl()}</code><br />
{_t("Access Token:") + ' '}
<AccessibleButton element="span" onClick={this.showSpoiler}
data-spoiler={MatrixClientPeg.get().getAccessToken()}
>
&lt;{ _t("click to reveal") }&gt;
</AccessibleButton>
<br />
<details>
<summary>{_t("Access Token")}</summary><br />
<b>{_t("Your access token gives full access to your account."
+ " Do not share it with anyone." )}</b>
<div className="mx_HelpUserSettingsTab_accessToken">
<code>{MatrixClientPeg.get().getAccessToken()}</code>
<AccessibleTooltipButton
title={_t("Copy")}
onClick={this.onAccessTokenCopyClick}
className="mx_HelpUserSettingsTab_accessToken_copy"
/>
</div>
</details><br />
<div className='mx_HelpUserSettingsTab_debugButton'>
<AccessibleButton onClick={this.onClearCacheAndReload} kind='danger'>
{_t("Clear cache and reload")}

View file

@ -26,13 +26,11 @@ import {SpaceItem} from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import SpaceStore, {
HOME_SPACE,
UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES,
} from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
import NotificationBadge from "../rooms/NotificationBadge";
import {
RovingAccessibleButton,
@ -40,13 +38,15 @@ import {
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import {Key} from "../../../Keyboard";
import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore";
import {NotificationState} from "../../../stores/notifications/NotificationState";
interface IButtonProps {
space?: Room;
className?: string;
selected?: boolean;
tooltip?: string;
notificationState?: SpaceNotificationState;
notificationState?: NotificationState;
isNarrow?: boolean;
onClick(): void;
}
@ -212,8 +212,8 @@ const SpacePanel = () => {
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={_t("Home")}
notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)}
tooltip={_t("All rooms")}
notificationState={RoomNotificationStateStore.instance.globalState}
isNarrow={isPanelCollapsed}
/>
{ invites.map(s => <SpaceItem

View file

@ -307,15 +307,19 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
const isActive = activeSpaces.includes(space);
const itemClasses = classNames({
"mx_SpaceItem": true,
"mx_SpaceItem_narrow": isNarrow,
"collapsed": collapsed,
"hasSubSpaces": childSpaces && childSpaces.length,
});
const isInvite = space.getMyMembership() === "invite";
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isNarrow,
mx_SpaceButton_invite: isInvite,
});
const notificationState = space.getMyMembership() === "invite"
const notificationState = isInvite
? StaticNotificationState.forSymbol("!", NotificationColor.Red)
: SpaceStore.instance.getNotificationState(space.roomId);

View file

@ -15,12 +15,11 @@ limitations under the License.
*/
import React from "react";
import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording";
import {IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording} from "../../../voice/VoiceRecording";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arrayFastResample, arraySeed} from "../../../utils/arrays";
import {percentageOf} from "../../../utils/numbers";
import Waveform from "./Waveform";
import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
interface IProps {
recorder: VoiceRecording;
@ -38,14 +37,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
public constructor(props) {
super(props);
this.state = {heights: arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES)};
this.state = {heights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES)};
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
}
private onRecordingUpdate = (update: IRecordingUpdate) => {
// The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample.
const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES);
const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
this.setState({
// The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the

View file

@ -0,0 +1,97 @@
/*
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.
*/
import React, {createRef} from 'react';
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import CallMediaHandler from "../../../CallMediaHandler";
interface IProps {
feed: CallFeed,
}
export default class AudioFeed extends React.Component<IProps> {
private element = createRef<HTMLAudioElement>();
componentDidMount() {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia();
}
componentWillUnmount() {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.stopMedia();
}
private playMedia() {
const element = this.element.current;
const audioOutput = CallMediaHandler.getAudioOutput();
if (audioOutput) {
try {
// This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where
// it fails.
// It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID
// back to the default after the call is over - Dave
element.setSinkId(audioOutput);
} catch (e) {
console.error("Couldn't set requested audio output device: using default", e);
logger.warn("Couldn't set requested audio output device: using default", e);
}
}
element.muted = false;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play()
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
}
}
private stopMedia() {
const element = this.element.current;
element.pause();
element.src = null;
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = () => {
this.playMedia();
};
render() {
return (
<audio ref={this.element} />
);
}
}

View file

@ -0,0 +1,60 @@
/*
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.
*/
import React from "react";
import AudioFeed from "./AudioFeed"
import { CallEvent, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
interface IProps {
call: MatrixCall;
}
interface IState {
feeds: Array<CallFeed>;
}
export default class AudioFeedArrayForCall extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
feeds: [],
};
}
componentDidMount() {
this.props.call.addListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
componentWillUnmount() {
this.props.call.removeListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
onFeedsChanged = () => {
this.setState({
feeds: this.props.call.getRemoteFeeds(),
});
}
render() {
return this.state.feeds.map((feed, i) => {
return (
<AudioFeed feed={feed} key={i} />
);
});
}
}

View file

@ -19,7 +19,7 @@ import React from 'react';
import CallView from "./CallView";
import RoomViewStore from '../../../stores/RoomViewStore';
import CallHandler from '../../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from '../../../dispatcher/payloads';
import PersistentApp from "../elements/PersistentApp";
@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
const SHOW_CALL_IN_STATES = [
CallState.Connected,
@ -110,12 +109,14 @@ export default class CallPreview extends React.Component<IProps, IState> {
}
public componentDidMount() {
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
}
public componentWillUnmount() {
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
if (this.roomStoreToken) {
this.roomStoreToken.remove();
@ -143,21 +144,24 @@ export default class CallPreview extends React.Component<IProps, IState> {
switch (payload.action) {
// listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead
case Action.CallChangeRoom:
case 'call_state': {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
);
this.setState({
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
this.updateCalls();
break;
}
}
};
private updateCalls = () => {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
);
this.setState({
primaryCall: primaryCall,
secondaryCall: secondaryCalls[0],
});
};
private onCallRemoteHold = () => {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),

View file

@ -20,10 +20,9 @@ import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler';
import VideoFeed, { VideoFeedType } from "./VideoFeed";
import VideoFeed from './VideoFeed';
import RoomAvatar from "../avatars/RoomAvatar";
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
@ -31,6 +30,7 @@ import {alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton} f
import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
@ -40,11 +40,11 @@ interface IProps {
// Another ongoing call to display information about
secondaryCall?: MatrixCall,
// a callback which is called when the content in the callview changes
// a callback which is called when the content in the CallView changes
// in a way that is likely to cause a resize.
onResize?: any;
// Whether this call view is for picture-in-pictue mode
// Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
@ -60,6 +60,7 @@ interface IState {
controlsVisible: boolean,
showMoreMenu: boolean,
showDialpad: boolean,
feeds: CallFeed[],
}
function getFullScreenElement() {
@ -115,6 +116,7 @@ export default class CallView extends React.Component<IProps, IState> {
controlsVisible: true,
showMoreMenu: false,
showDialpad: false,
feeds: this.props.call.getFeeds(),
}
this.updateCallListeners(null, this.props.call);
@ -172,11 +174,13 @@ export default class CallView extends React.Component<IProps, IState> {
oldCall.removeListener(CallEvent.State, this.onCallState);
oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
oldCall.removeListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
if (newCall) {
newCall.on(CallEvent.State, this.onCallState);
newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
newCall.on(CallEvent.FeedsChanged, this.onFeedsChanged);
}
}
@ -186,6 +190,10 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
this.setState({feeds: newFeeds});
};
private onCallLocalHoldUnhold = () => {
this.setState({
isLocalOnHold: this.props.call.isLocalOnHold(),
@ -304,7 +312,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a callview on screen at any given time
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
private onNativeKeyDown = ev => {
let handled = false;
@ -474,6 +482,8 @@ export default class CallView extends React.Component<IProps, IState> {
{contextMenuButton}
</div>;
const avatarSize = this.props.pipMode ? 76 : 160;
// The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg)
let contentView: React.ReactNode;
@ -524,41 +534,85 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
}
if (this.props.call.type === CallType.Video) {
let localVideoFeed = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const containerClasses = classNames({
mx_CallView_video: true,
mx_CallView_video_hold: isOnHold,
});
if (isOnHold) {
// This is a bit messy. I can't see a reason to have two onHold/transfer screens
if (isOnHold || transfereeCall) {
if (this.props.call.type === CallType.Video) {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
mx_CallView_video_hold: isOnHold,
});
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const backgroundAvatarUrl = avatarUrlForMember(
// is it worth getting the size of the div to pass here?
// is it worth getting the size of the div to pass here?
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
);
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />;
}
if (!this.state.vidMuted) {
localVideoFeed = <VideoFeed type={VideoFeedType.Local} call={this.props.call} />;
}
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{onHoldBackground}
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize} />
{localVideoFeed}
{holdTransferContent}
{callControls}
</div>;
} else {
const avatarSize = this.props.pipMode ? 76 : 160;
contentView = (
<div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{onHoldBackground}
{holdTransferContent}
{callControls}
</div>
);
} else {
const classes = classNames({
mx_CallView_content: true,
mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold,
});
contentView =(
<div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
<div
className="mx_CallView_voice_avatarContainer"
style={{width: avatarSize, height: avatarSize}}
>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
</div>
</div>
{holdTransferContent}
{callControls}
</div>
);
}
} else if (this.props.call.noIncomingFeeds()) {
// Here we're reusing the css classes from voice on hold, because
// I am lazy. If this gets merged, the CallView might be subject
// to change anyway - I might take an axe to this file in order to
// try to get other things working
const classes = classNames({
mx_CallView_content: true,
mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold,
});
const feeds = this.props.call.getLocalFeeds().map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
// Saying "Connecting" here isn't really true, but the best thing
// I can come up with, but this might be subject to change as well
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
{feeds}
<div className="mx_CallView_voice_avatarsContainer">
<div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}>
<RoomAvatar
@ -568,7 +622,35 @@ export default class CallView extends React.Component<IProps, IState> {
/>
</div>
</div>
{holdTransferContent}
<div className="mx_CallView_holdTransferContent">{_t("Connecting")}</div>
{callControls}
</div>;
} else {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
});
// TODO: Later the CallView should probably be reworked to support
// any number of feeds but now we can always expect there to be two
// feeds. This is because the js-sdk ignores any new incoming streams
const feeds = this.state.feeds.map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted() && feed.isLocal()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{feeds}
{callControls}
</div>;
}

View file

@ -16,13 +16,12 @@ limitations under the License.
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react';
import CallHandler from '../../../CallHandler';
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import CallView from './CallView';
import dis from '../../../dispatcher/dispatcher';
import {Resizable} from "re-resizable";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
interface IProps {
// What room we should display the call for
@ -55,25 +54,30 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
}
public componentWillUnmount() {
dis.unregister(this.dispatcherRef);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
}
private onAction = (payload) => {
switch (payload.action) {
case Action.CallChangeRoom:
case 'call_state': {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.setState({call: newCall});
}
this.updateCall();
break;
}
}
};
private updateCall = () => {
const newCall = this.getCall();
if (newCall !== this.state.call) {
this.setState({call: newCall});
}
};
private getCall(): MatrixCall {
const call = CallHandler.sharedInstance().getCallForRoom(this.props.roomId);

View file

@ -18,52 +18,102 @@ import classnames from 'classnames';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React, {createRef} from 'react';
import SettingsStore from "../../../settings/SettingsStore";
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import MemberAvatar from "../avatars/MemberAvatar"
import {replaceableComponent} from "../../../utils/replaceableComponent";
export enum VideoFeedType {
Local,
Remote,
}
interface IProps {
call: MatrixCall,
type: VideoFeedType,
feed: CallFeed,
// Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize?: (e: Event) => void,
}
@replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps> {
private vid = createRef<HTMLVideoElement>();
interface IState {
audioMuted: boolean;
videoMuted: boolean;
}
componentDidMount() {
this.vid.current.addEventListener('resize', this.onResize);
this.setVideoElement();
@replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps, IState> {
private element = createRef<HTMLVideoElement>();
constructor(props: IProps) {
super(props);
this.state = {
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
};
}
componentDidUpdate(prevProps) {
if (this.props.call !== prevProps.call) {
this.setVideoElement();
}
componentDidMount() {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia();
}
componentWillUnmount() {
this.vid.current.removeEventListener('resize', this.onResize);
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.element.current?.removeEventListener('resize', this.onResize);
this.stopMedia();
}
private setVideoElement() {
if (this.props.type === VideoFeedType.Local) {
this.props.call.setLocalVideoElement(this.vid.current);
} else {
this.props.call.setRemoteVideoElement(this.vid.current);
private playMedia() {
const element = this.element.current;
if (!element) return;
// We play audio in AudioFeed, not here
element.muted = true;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play()
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
}
}
onResize = (e) => {
if (this.props.onResize) {
private stopMedia() {
const element = this.element.current;
if (!element) return;
element.pause();
element.src = null;
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
this.playMedia();
};
private onResize = (e) => {
if (this.props.onResize && !this.props.feed.isLocal()) {
this.props.onResize(e);
}
};
@ -71,14 +121,33 @@ export default class VideoFeed extends React.Component<IProps> {
render() {
const videoClasses = {
mx_VideoFeed: true,
mx_VideoFeed_local: this.props.type === VideoFeedType.Local,
mx_VideoFeed_remote: this.props.type === VideoFeedType.Remote,
mx_VideoFeed_local: this.props.feed.isLocal(),
mx_VideoFeed_remote: !this.props.feed.isLocal(),
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted,
mx_VideoFeed_mirror: (
this.props.type === VideoFeedType.Local &&
this.props.feed.isLocal() &&
SettingsStore.getValue('VideoView.flipVideoHorizontally')
),
};
return <video className={classnames(videoClasses)} ref={this.vid} />;
if (this.state.videoMuted) {
const member = this.props.feed.getMember();
const avatarSize = this.props.pipMode ? 76 : 160;
return (
<div className={classnames(videoClasses)} >
<MemberAvatar
member={member}
height={avatarSize}
width={avatarSize}
/>
</div>
);
} else {
return (
<video className={classnames(videoClasses)} ref={this.element} />
);
}
}
}