Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/18071
Conflicts: res/css/views/dialogs/_AddExistingToSpaceDialog.scss src/utils/space.tsx
This commit is contained in:
commit
493cd01ed3
56 changed files with 2111 additions and 874 deletions
|
@ -16,7 +16,6 @@ limitations under the License.
|
|||
|
||||
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";
|
||||
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
|
||||
import classNames from "classnames";
|
||||
|
@ -44,11 +43,13 @@ import { getChildOrder } from "../../stores/SpaceStore";
|
|||
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
|
||||
import { linkifyElement } from "../../HtmlUtils";
|
||||
import { getDisplayAliasForAliasSet } from "../../Rooms";
|
||||
import { useDispatcher } from "../../hooks/useDispatcher";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
interface IHierarchyProps {
|
||||
space: Room;
|
||||
initialText?: string;
|
||||
refreshToken?: any;
|
||||
additionalButtons?: ReactNode;
|
||||
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
|
||||
}
|
||||
|
@ -315,18 +316,25 @@ export const HierarchyLevel = ({
|
|||
</React.Fragment>;
|
||||
};
|
||||
|
||||
// mutate argument refreshToken to force a reload
|
||||
export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [
|
||||
export const useSpaceSummary = (space: Room): [
|
||||
null,
|
||||
ISpaceSummaryRoom[],
|
||||
Map<string, Map<string, ISpaceSummaryEvent>>?,
|
||||
Map<string, Set<string>>?,
|
||||
Map<string, Set<string>>?,
|
||||
] | [Error] => {
|
||||
// crude temporary refresh token approach until we have pagination and rework the data flow here
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
useDispatcher(defaultDispatcher, (payload => {
|
||||
if (payload.action === Action.UpdateSpaceHierarchy) {
|
||||
setRefreshToken(t => t + 1);
|
||||
}
|
||||
}));
|
||||
|
||||
// TODO pagination
|
||||
return useAsyncMemo(async () => {
|
||||
try {
|
||||
const data = await cli.getSpaceSummary(space.roomId);
|
||||
const data = await space.client.getSpaceSummary(space.roomId);
|
||||
|
||||
const parentChildRelations = new EnhancedMap<string, Map<string, ISpaceSummaryEvent>>();
|
||||
const childParentRelations = new EnhancedMap<string, Set<string>>();
|
||||
|
@ -354,7 +362,6 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
space,
|
||||
initialText = "",
|
||||
showRoom,
|
||||
refreshToken,
|
||||
additionalButtons,
|
||||
children,
|
||||
}) => {
|
||||
|
@ -364,7 +371,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
|
|||
|
||||
const [selected, setSelected] = useState(new Map<string, Set<string>>()); // Map<parentId, Set<childId>>
|
||||
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken);
|
||||
const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(space);
|
||||
|
||||
const roomsMap = useMemo(() => {
|
||||
if (!rooms) return null;
|
||||
|
|
|
@ -47,13 +47,23 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
|
|||
import { SetRightPanelPhasePayload } from "../../dispatcher/payloads/SetRightPanelPhasePayload";
|
||||
import { useStateArray } from "../../hooks/useStateArray";
|
||||
import SpacePublicShare from "../views/spaces/SpacePublicShare";
|
||||
import { shouldShowSpaceSettings, showAddExistingRooms, showCreateNewRoom, showSpaceSettings } from "../../utils/space";
|
||||
import {
|
||||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceSettings,
|
||||
} from "../../utils/space";
|
||||
import { showRoom, SpaceHierarchy } from "./SpaceRoomDirectory";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import { useStateToggle } from "../../hooks/useStateToggle";
|
||||
import SpaceStore from "../../stores/SpaceStore";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import { AddExistingToSpace } from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
defaultRoomsRenderer,
|
||||
defaultSpacesRenderer,
|
||||
} from "../views/dialogs/AddExistingToSpaceDialog";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
|
@ -307,7 +317,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
|
|||
</div>;
|
||||
};
|
||||
|
||||
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
||||
const SpaceLandingAddButton = ({ space }) => {
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
let contextMenu;
|
||||
|
@ -331,24 +341,32 @@ const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
|
|||
closeMenu();
|
||||
|
||||
if (await showCreateNewRoom(space)) {
|
||||
onNewRoomAdded();
|
||||
defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add existing room")}
|
||||
iconClassName="mx_RoomList_iconHash"
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
const [added] = await showAddExistingRooms(space);
|
||||
if (added) {
|
||||
onNewRoomAdded();
|
||||
}
|
||||
showAddExistingRooms(space);
|
||||
}}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Add space")}
|
||||
iconClassName="mx_RoomList_iconPlus"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeMenu();
|
||||
showCreateNewSubspace(space);
|
||||
}}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
@ -389,11 +407,9 @@ const SpaceLanding = ({ space }) => {
|
|||
|
||||
const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
|
||||
|
||||
const [refreshToken, forceUpdate] = useStateToggle(false);
|
||||
|
||||
let addRoomButton;
|
||||
if (canAddRooms) {
|
||||
addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
|
||||
addRoomButton = <SpaceLandingAddButton space={space} />;
|
||||
}
|
||||
|
||||
let settingsButton;
|
||||
|
@ -443,12 +459,7 @@ const SpaceLanding = ({ space }) => {
|
|||
<SpaceFeedbackPrompt />
|
||||
<hr />
|
||||
|
||||
<SpaceHierarchy
|
||||
space={space}
|
||||
showRoom={showRoom}
|
||||
refreshToken={refreshToken}
|
||||
additionalButtons={addRoomButton}
|
||||
/>
|
||||
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
@ -550,10 +561,12 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
|
|||
{ _t("Skip for now") }
|
||||
</AccessibleButton>
|
||||
}
|
||||
filterPlaceholder={_t("Search for rooms or spaces")}
|
||||
onFinished={onFinished}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
|
||||
<div className="mx_SpaceRoomView_buttons" />
|
||||
<SpaceFeedbackPrompt />
|
||||
</div>;
|
||||
};
|
||||
|
|
|
@ -103,8 +103,8 @@ export default class CaptchaForm extends React.Component<ICaptchaFormProps, ICap
|
|||
}
|
||||
|
||||
private resetRecaptcha() {
|
||||
if (this.captchaWidgetId !== null) {
|
||||
global.grecaptcha.reset(this.captchaWidgetId);
|
||||
if (this.captchaWidgetId) {
|
||||
global?.grecaptcha?.reset(this.captchaWidgetId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -90,10 +90,11 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
|
|||
</MenuItemCheckbox>;
|
||||
};
|
||||
|
||||
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, ...props }) => {
|
||||
export const IconizedContextMenuOption: React.FC<IOptionProps> = ({ label, iconClassName, children, ...props }) => {
|
||||
return <MenuItem {...props} label={label}>
|
||||
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
|
||||
<span className="mx_IconizedContextMenu_label">{ label }</span>
|
||||
{ children }
|
||||
</MenuItem>;
|
||||
};
|
||||
|
||||
|
|
70
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
70
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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, { useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onCreateSubspaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Add existing space")}
|
||||
space={space}
|
||||
value={selectedSpace}
|
||||
onChange={setSelectedSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<AddExistingToSpace
|
||||
space={space}
|
||||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new space instead?") }</div>
|
||||
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
|
||||
{ _t("Create a new space") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
filterPlaceholder={_t("Search for spaces")}
|
||||
spacesRenderer={defaultSpacesRenderer}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
|
||||
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default AddExistingSubspaceDialog;
|
||||
|
|
@ -18,9 +18,9 @@ import React, { ReactNode, useContext, useMemo, useState } from "react";
|
|||
import classNames from "classnames";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { sleep } from "matrix-js-sdk/src/utils";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SearchBox from "../../structures/SearchBox";
|
||||
|
@ -42,9 +42,11 @@ import TruncatedList from "../elements/TruncatedList";
|
|||
import EntityTile from "../rooms/EntityTile";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onCreateRoomClick(space: Room): void;
|
||||
onCreateRoomClick(): void;
|
||||
onAddSubspaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
export const Entry = ({ room, checked, onChange }) => {
|
||||
|
@ -65,14 +67,36 @@ export const Entry = ({ room, checked, onChange }) => {
|
|||
interface IAddExistingToSpaceProps {
|
||||
space: Room;
|
||||
footerPrompt?: ReactNode;
|
||||
filterPlaceholder: string;
|
||||
emptySelectionButton?: ReactNode;
|
||||
onFinished(added: boolean): void;
|
||||
roomsRenderer?(
|
||||
rooms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange: undefined | ((checked: boolean, room: Room) => void),
|
||||
truncateAt: number,
|
||||
overflowTile: (overflowCount: number, totalCount: number) => JSX.Element,
|
||||
): ReactNode;
|
||||
spacesRenderer?(
|
||||
spaces: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange?: (checked: boolean, room: Room) => void,
|
||||
): ReactNode;
|
||||
dmsRenderer?(
|
||||
dms: Room[],
|
||||
selectedToAdd: Set<Room>,
|
||||
onChange?: (checked: boolean, room: Room) => void,
|
||||
): ReactNode;
|
||||
}
|
||||
|
||||
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
||||
space,
|
||||
footerPrompt,
|
||||
emptySelectionButton,
|
||||
filterPlaceholder,
|
||||
roomsRenderer,
|
||||
dmsRenderer,
|
||||
spacesRenderer,
|
||||
onFinished,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
@ -196,7 +220,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</>;
|
||||
}
|
||||
|
||||
const onChange = !busy && !error ? (checked, room) => {
|
||||
const onChange = !busy && !error ? (checked: boolean, room: Room) => {
|
||||
if (checked) {
|
||||
selectedToAdd.add(room);
|
||||
} else {
|
||||
|
@ -206,7 +230,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
} : null;
|
||||
|
||||
const [truncateAt, setTruncateAt] = useState(20);
|
||||
function overflowTile(overflowCount, totalCount) {
|
||||
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
|
||||
const text = _t("and %(count)s others...", { count: overflowCount });
|
||||
return (
|
||||
<EntityTile
|
||||
|
@ -222,73 +246,36 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
let noResults = true;
|
||||
if ((roomsRenderer && rooms.length > 0) ||
|
||||
(dmsRenderer && dms.length > 0) ||
|
||||
(!roomsRenderer && !dmsRenderer && spacesRenderer && spaces.length > 0) // only count spaces when alone
|
||||
) {
|
||||
noResults = false;
|
||||
}
|
||||
|
||||
return <div className="mx_AddExistingToSpace">
|
||||
<SearchBox
|
||||
className="mx_textinput_icon mx_textinput_search"
|
||||
placeholder={_t("Filter your rooms and spaces")}
|
||||
placeholder={filterPlaceholder}
|
||||
onSearch={setQuery}
|
||||
autoComplete={true}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<AutoHideScrollbar className="mx_AddExistingToSpace_content">
|
||||
{ rooms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
{ rooms.length > 0 && roomsRenderer ? (
|
||||
roomsRenderer(rooms, selectedToAdd, onChange, truncateAt, overflowTile)
|
||||
) : undefined }
|
||||
|
||||
{ spaces.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section mx_AddExistingToSpace_section_spaces">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<div className="mx_AddExistingToSpace_section_experimental">
|
||||
<div>{ _t("Feeling experimental?") }</div>
|
||||
<div>{ _t("You can add existing spaces to a space.") }</div>
|
||||
</div>
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ spaces.length > 0 && spacesRenderer ? (
|
||||
spacesRenderer(spaces, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ dms.length > 0 ? (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
{ dms.length > 0 && dmsRenderer ? (
|
||||
dmsRenderer(dms, selectedToAdd, onChange)
|
||||
) : null }
|
||||
|
||||
{ spaces.length + rooms.length + dms.length < 1 ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ noResults ? <span className="mx_AddExistingToSpace_noResults">
|
||||
{ _t("No results") }
|
||||
</span> : undefined }
|
||||
</AutoHideScrollbar>
|
||||
|
@ -299,50 +286,126 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
|
|||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId);
|
||||
export const defaultRoomsRenderer: IAddExistingToSpaceProps["roomsRenderer"] = (
|
||||
rooms, selectedToAdd, onChange, truncateAt, overflowTile,
|
||||
) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Rooms") }</h3>
|
||||
<TruncatedList
|
||||
truncateAt={truncateAt}
|
||||
createOverflowElement={overflowTile}
|
||||
getChildren={(start, end) => rooms.slice(start, end).map(room =>
|
||||
<Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>,
|
||||
)}
|
||||
getChildCount={() => rooms.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
let spaceOptionSection;
|
||||
if (existingSubspaces.length > 0) {
|
||||
const options = [space, ...existingSubspaces].map((space) => {
|
||||
const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", {
|
||||
mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
});
|
||||
export const defaultSpacesRenderer: IAddExistingToSpaceProps["spacesRenderer"] = (spaces, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
{ spaces.map(space => {
|
||||
return <Entry
|
||||
key={space.roomId}
|
||||
room={space}
|
||||
checked={selectedToAdd.has(space)}
|
||||
onChange={onChange ? (checked) => {
|
||||
onChange(checked, space);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
|
||||
spaceOptionSection = (
|
||||
export const defaultDmsRenderer: IAddExistingToSpaceProps["dmsRenderer"] = (dms, selectedToAdd, onChange) => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Direct Messages") }</h3>
|
||||
{ dms.map(room => {
|
||||
return <Entry
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
checked={selectedToAdd.has(room)}
|
||||
onChange={onChange ? (checked: boolean) => {
|
||||
onChange(checked, room);
|
||||
} : null}
|
||||
/>;
|
||||
}) }
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ISubspaceSelectorProps {
|
||||
title: string;
|
||||
space: Room;
|
||||
value: Room;
|
||||
onChange(space: Room): void;
|
||||
}
|
||||
|
||||
export const SubspaceSelector = ({ title, space, value, onChange }: ISubspaceSelectorProps) => {
|
||||
const options = useMemo(() => {
|
||||
return [space, ...SpaceStore.instance.getChildSpaces(space.roomId).filter(space => {
|
||||
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.credentials.userId);
|
||||
})];
|
||||
}, [space]);
|
||||
|
||||
let body;
|
||||
if (options.length > 1) {
|
||||
body = (
|
||||
<Dropdown
|
||||
id="mx_SpaceSelectDropdown"
|
||||
className="mx_SpaceSelectDropdown"
|
||||
onOptionChange={(key: string) => {
|
||||
setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space);
|
||||
onChange(options.find(space => space.roomId === key) || space);
|
||||
}}
|
||||
value={selectedSpace.roomId}
|
||||
value={value.roomId}
|
||||
label={_t("Space selection")}
|
||||
>
|
||||
{ options }
|
||||
{ options.map((space) => {
|
||||
const classes = classNames({
|
||||
mx_SubspaceSelector_dropdownOptionActive: space === value,
|
||||
});
|
||||
return <div key={space.roomId} className={classes}>
|
||||
<RoomAvatar room={space} width={24} height={24} />
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
}) }
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
spaceOptionSection = <div className="mx_AddExistingToSpaceDialog_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>;
|
||||
body = (
|
||||
<div className="mx_SubspaceSelector_onlySpace">
|
||||
{ space.name || getDisplayAliasForRoom(space) || space.roomId }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = <React.Fragment>
|
||||
<RoomAvatar room={selectedSpace} height={40} width={40} />
|
||||
return <div className="mx_SubspaceSelector">
|
||||
<RoomAvatar room={value} height={40} width={40} />
|
||||
<div>
|
||||
<h1>{ _t("Add existing rooms") }</h1>
|
||||
{ spaceOptionSection }
|
||||
<h1>{ title }</h1>
|
||||
{ body }
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
</div>;
|
||||
};
|
||||
|
||||
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
|
||||
const [selectedSpace, setSelectedSpace] = useState(space);
|
||||
|
||||
return <BaseDialog
|
||||
title={title}
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Add existing rooms")}
|
||||
space={space}
|
||||
value={selectedSpace}
|
||||
onChange={setSelectedSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_AddExistingToSpaceDialog"
|
||||
contentId="mx_AddExistingToSpace"
|
||||
onFinished={onFinished}
|
||||
|
@ -354,10 +417,33 @@ const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick,
|
|||
onFinished={onFinished}
|
||||
footerPrompt={<>
|
||||
<div>{ _t("Want to add a new room instead?") }</div>
|
||||
<AccessibleButton onClick={() => onCreateRoomClick(space)} kind="link">
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onCreateRoomClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Create a new room") }
|
||||
</AccessibleButton>
|
||||
</>}
|
||||
filterPlaceholder={_t("Search for rooms")}
|
||||
roomsRenderer={defaultRoomsRenderer}
|
||||
spacesRenderer={() => (
|
||||
<div className="mx_AddExistingToSpace_section">
|
||||
<h3>{ _t("Spaces") }</h3>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onAddSubspaceClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Adding spaces has moved.") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
)}
|
||||
dmsRenderer={defaultDmsRenderer}
|
||||
/>
|
||||
</MatrixClientContext.Provider>
|
||||
|
||||
|
|
|
@ -32,8 +32,8 @@ import RoomAliasField from "../elements/RoomAliasField";
|
|||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import Dropdown from "../elements/Dropdown";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
||||
interface IProps {
|
||||
defaultPublic?: boolean;
|
||||
|
@ -250,7 +250,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
|
||||
{ _t("You can change this at any time from room settings.") }
|
||||
</p>;
|
||||
} else if (this.state.joinRule === JoinRule.Public) {
|
||||
} else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) {
|
||||
publicPrivateLabel = <p>
|
||||
{ _t(
|
||||
"Anyone will be able to find and join this room, not just members of <SpaceName/>.", {}, {
|
||||
|
@ -260,6 +260,12 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
|
||||
{ _t("You can change this at any time from room settings.") }
|
||||
</p>;
|
||||
} else if (this.state.joinRule === JoinRule.Public) {
|
||||
publicPrivateLabel = <p>
|
||||
{ _t("Anyone will be able to find and join this room.") }
|
||||
|
||||
{ _t("You can change this at any time from room settings.") }
|
||||
</p>;
|
||||
} else if (this.state.joinRule === JoinRule.Invite) {
|
||||
publicPrivateLabel = <p>
|
||||
{ _t(
|
||||
|
@ -316,21 +322,6 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
title = this.state.joinRule === JoinRule.Public ? _t('Create a public room') : _t('Create a private room');
|
||||
}
|
||||
|
||||
const options = [
|
||||
<div key={JoinRule.Invite} className="mx_CreateRoomDialog_dropdown_invite">
|
||||
{ _t("Private room (invite only)") }
|
||||
</div>,
|
||||
<div key={JoinRule.Public} className="mx_CreateRoomDialog_dropdown_public">
|
||||
{ _t("Public room") }
|
||||
</div>,
|
||||
];
|
||||
|
||||
if (this.supportsRestricted) {
|
||||
options.unshift(<div key={JoinRule.Restricted} className="mx_CreateRoomDialog_dropdown_restricted">
|
||||
{ _t("Visible to space members") }
|
||||
</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} title={title}>
|
||||
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
|
||||
|
@ -350,16 +341,14 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
|
|||
className="mx_CreateRoomDialog_topic"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
id="mx_CreateRoomDialog_typeDropdown"
|
||||
className="mx_CreateRoomDialog_typeDropdown"
|
||||
onOptionChange={this.onJoinRuleChange}
|
||||
menuWidth={448}
|
||||
value={this.state.joinRule}
|
||||
<JoinRuleDropdown
|
||||
label={_t("Room visibility")}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>
|
||||
labelInvite={_t("Private room (invite only)")}
|
||||
labelPublic={_t("Public room")}
|
||||
labelRestricted={this.supportsRestricted ? _t("Visible to space members") : undefined}
|
||||
value={this.state.joinRule}
|
||||
onChange={this.onJoinRuleChange}
|
||||
/>
|
||||
|
||||
{ publicPrivateLabel }
|
||||
{ e2eeSection }
|
||||
|
|
210
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal file
210
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal file
|
@ -0,0 +1,210 @@
|
|||
/*
|
||||
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, { useRef, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import Field from "../elements/Field";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
import SpaceStore from "../../../stores/SpaceStore";
|
||||
import { SpaceCreateForm } from "../spaces/SpaceCreateMenu";
|
||||
import createRoom from "../../../createRoom";
|
||||
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
|
||||
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
|
||||
|
||||
interface IProps {
|
||||
space: Room;
|
||||
onAddExistingSpaceClick(): void;
|
||||
onFinished(added?: boolean): void;
|
||||
}
|
||||
|
||||
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
|
||||
const [parentSpace, setParentSpace] = useState(space);
|
||||
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
const [name, setName] = useState("");
|
||||
const spaceNameField = useRef<Field>();
|
||||
const [alias, setAlias] = useState("");
|
||||
const spaceAliasField = useRef<RoomAliasField>();
|
||||
const [avatar, setAvatar] = useState<File>(null);
|
||||
const [topic, setTopic] = useState<string>("");
|
||||
|
||||
const supportsRestricted = !!SpaceStore.instance.restrictedJoinRuleSupport?.preferred;
|
||||
|
||||
const spaceJoinRule = space.getJoinRule();
|
||||
let defaultJoinRule = JoinRule.Invite;
|
||||
if (spaceJoinRule === JoinRule.Public) {
|
||||
defaultJoinRule = JoinRule.Public;
|
||||
} else if (supportsRestricted) {
|
||||
defaultJoinRule = JoinRule.Restricted;
|
||||
}
|
||||
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
|
||||
|
||||
const onCreateSubspaceClick = async (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
|
||||
setBusy(true);
|
||||
// require & validate the space name field
|
||||
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
|
||||
spaceNameField.current.focus();
|
||||
spaceNameField.current.validate({ allowEmpty: false, focused: true });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
// validate the space name alias field but do not require it
|
||||
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
|
||||
spaceAliasField.current.focus();
|
||||
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRoom({
|
||||
createOpts: {
|
||||
preset: joinRule === JoinRule.Public ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...joinRule === JoinRule.Public ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: joinRule === JoinRule.Public && alias
|
||||
? alias.substr(1, alias.indexOf(":") - 1)
|
||||
: undefined,
|
||||
topic,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
parentSpace,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
inlineErrors: true,
|
||||
});
|
||||
|
||||
onFinished(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
let joinRuleMicrocopy: JSX.Element;
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
joinRuleMicrocopy = <p>
|
||||
{ _t(
|
||||
"Anyone in <SpaceName/> will be able to find and join.", {}, {
|
||||
SpaceName: () => <b>{ parentSpace.name }</b>,
|
||||
},
|
||||
) }
|
||||
</p>;
|
||||
} else if (joinRule === JoinRule.Public) {
|
||||
joinRuleMicrocopy = <p>
|
||||
{ _t(
|
||||
"Anyone will be able to find and join this space, not just members of <SpaceName/>.", {}, {
|
||||
SpaceName: () => <b>{ parentSpace.name }</b>,
|
||||
},
|
||||
) }
|
||||
</p>;
|
||||
} else if (joinRule === JoinRule.Invite) {
|
||||
joinRuleMicrocopy = <p>
|
||||
{ _t("Only people invited will be able to find and join this space.") }
|
||||
</p>;
|
||||
}
|
||||
|
||||
return <BaseDialog
|
||||
title={(
|
||||
<SubspaceSelector
|
||||
title={_t("Create a space")}
|
||||
space={space}
|
||||
value={parentSpace}
|
||||
onChange={setParentSpace}
|
||||
/>
|
||||
)}
|
||||
className="mx_CreateSubspaceDialog"
|
||||
contentId="mx_CreateSubspaceDialog"
|
||||
onFinished={onFinished}
|
||||
fixedWidth={false}
|
||||
>
|
||||
<MatrixClientContext.Provider value={space.client}>
|
||||
<div className="mx_CreateSubspaceDialog_content">
|
||||
<div className="mx_CreateSubspaceDialog_betaNotice">
|
||||
<BetaPill />
|
||||
{ _t("Add a space to a space you manage.") }
|
||||
</div>
|
||||
|
||||
<SpaceCreateForm
|
||||
busy={busy}
|
||||
onSubmit={onCreateSubspaceClick}
|
||||
setAvatar={setAvatar}
|
||||
name={name}
|
||||
setName={setName}
|
||||
nameFieldRef={spaceNameField}
|
||||
topic={topic}
|
||||
setTopic={setTopic}
|
||||
alias={alias}
|
||||
setAlias={setAlias}
|
||||
showAliasField={joinRule === JoinRule.Public}
|
||||
aliasFieldRef={spaceAliasField}
|
||||
>
|
||||
<JoinRuleDropdown
|
||||
label={_t("Space visibility")}
|
||||
labelInvite={_t("Private space (invite only)")}
|
||||
labelPublic={_t("Public space")}
|
||||
labelRestricted={supportsRestricted ? _t("Visible to space members") : undefined}
|
||||
width={478}
|
||||
value={joinRule}
|
||||
onChange={setJoinRule}
|
||||
/>
|
||||
{ joinRuleMicrocopy }
|
||||
</SpaceCreateForm>
|
||||
</div>
|
||||
|
||||
<div className="mx_CreateSubspaceDialog_footer">
|
||||
<div className="mx_CreateSubspaceDialog_footer_prompt">
|
||||
<div>{ _t("Want to add an existing space instead?") }</div>
|
||||
<AccessibleButton
|
||||
kind="link"
|
||||
onClick={() => {
|
||||
onAddExistingSpaceClick();
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Add existing space") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
|
||||
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
|
||||
{ _t("Cancel") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
|
||||
{ busy ? _t("Adding...") : _t("Add") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</MatrixClientContext.Provider>
|
||||
</BaseDialog>;
|
||||
};
|
||||
|
||||
export default CreateSubspaceDialog;
|
||||
|
|
@ -17,9 +17,12 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import BaseDialog from "..//dialogs/BaseDialog";
|
||||
import DialogButtons from "./DialogButtons";
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
|
||||
export interface DesktopCapturerSource {
|
||||
id: string;
|
||||
|
@ -28,62 +31,70 @@ export interface DesktopCapturerSource {
|
|||
}
|
||||
|
||||
export enum Tabs {
|
||||
Screens = "screens",
|
||||
Windows = "windows",
|
||||
Screens = "screen",
|
||||
Windows = "window",
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourceIProps {
|
||||
export interface ExistingSourceIProps {
|
||||
source: DesktopCapturerSource;
|
||||
onSelect(source: DesktopCapturerSource): void;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export class ExistingSource extends React.Component<DesktopCapturerSourceIProps> {
|
||||
constructor(props) {
|
||||
export class ExistingSource extends React.Component<ExistingSourceIProps> {
|
||||
constructor(props: ExistingSourceIProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onClick = (ev) => {
|
||||
private onClick = (): void => {
|
||||
this.props.onSelect(this.props.source);
|
||||
};
|
||||
|
||||
render() {
|
||||
const thumbnailClasses = classNames({
|
||||
mx_desktopCapturerSourcePicker_source_thumbnail: true,
|
||||
mx_desktopCapturerSourcePicker_source_thumbnail_selected: this.props.selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
className="mx_desktopCapturerSourcePicker_stream_button"
|
||||
className="mx_desktopCapturerSourcePicker_source"
|
||||
title={this.props.source.name}
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<img
|
||||
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
|
||||
className={thumbnailClasses}
|
||||
src={this.props.source.thumbnailURL}
|
||||
/>
|
||||
<span className="mx_desktopCapturerSourcePicker_stream_name">{ this.props.source.name }</span>
|
||||
<span className="mx_desktopCapturerSourcePicker_source_name">{ this.props.source.name }</span>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopCapturerSourcePickerIState {
|
||||
export interface PickerIState {
|
||||
selectedTab: Tabs;
|
||||
sources: Array<DesktopCapturerSource>;
|
||||
selectedSource: DesktopCapturerSource | null;
|
||||
}
|
||||
export interface DesktopCapturerSourcePickerIProps {
|
||||
export interface PickerIProps {
|
||||
onFinished(source: DesktopCapturerSource): void;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.elements.DesktopCapturerSourcePicker")
|
||||
export default class DesktopCapturerSourcePicker extends React.Component<
|
||||
DesktopCapturerSourcePickerIProps,
|
||||
DesktopCapturerSourcePickerIState
|
||||
> {
|
||||
interval;
|
||||
PickerIProps,
|
||||
PickerIState
|
||||
> {
|
||||
interval: number;
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: PickerIProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedTab: Tabs.Screens,
|
||||
sources: [],
|
||||
selectedSource: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -107,69 +118,61 @@ export default class DesktopCapturerSourcePicker extends React.Component<
|
|||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
onSelect = (source) => {
|
||||
this.props.onFinished(source);
|
||||
private onSelect = (source: DesktopCapturerSource): void => {
|
||||
this.setState({ selectedSource: source });
|
||||
};
|
||||
|
||||
onScreensClick = (ev) => {
|
||||
this.setState({ selectedTab: Tabs.Screens });
|
||||
private onShare = (): void => {
|
||||
this.props.onFinished(this.state.selectedSource);
|
||||
};
|
||||
|
||||
onWindowsClick = (ev) => {
|
||||
this.setState({ selectedTab: Tabs.Windows });
|
||||
private onTabChange = (): void => {
|
||||
this.setState({ selectedSource: null });
|
||||
};
|
||||
|
||||
onCloseClick = (ev) => {
|
||||
private onCloseClick = (): void => {
|
||||
this.props.onFinished(null);
|
||||
};
|
||||
|
||||
render() {
|
||||
let sources;
|
||||
if (this.state.selectedTab === Tabs.Screens) {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("screen");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
} else {
|
||||
sources = this.state.sources
|
||||
.filter((source) => {
|
||||
return source.id.startsWith("window");
|
||||
})
|
||||
.map((source) => {
|
||||
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
|
||||
});
|
||||
}
|
||||
private getTab(type: "screen" | "window", label: string): Tab {
|
||||
const sources = this.state.sources.filter((source) => source.id.startsWith(type)).map((source) => {
|
||||
return (
|
||||
<ExistingSource
|
||||
selected={this.state.selectedSource?.id === source.id}
|
||||
source={source}
|
||||
onSelect={this.onSelect}
|
||||
key={source.id}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
|
||||
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
|
||||
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
|
||||
return new Tab(type, label, null, (
|
||||
<div className="mx_desktopCapturerSourcePicker_tab">
|
||||
{ sources }
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
render() {
|
||||
const tabs = [
|
||||
this.getTab("screen", _t("Share entire screen")),
|
||||
this.getTab("window", _t("Application window")),
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseDialog
|
||||
className="mx_desktopCapturerSourcePicker"
|
||||
onFinished={this.onCloseClick}
|
||||
title={_t("Share your screen")}
|
||||
title={_t("Share content")}
|
||||
>
|
||||
<div className="mx_desktopCapturerSourcePicker_tabLabels">
|
||||
<AccessibleButton
|
||||
className={screensButtonStyle}
|
||||
onClick={this.onScreensClick}
|
||||
>
|
||||
{ _t("Screens") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
className={windowsButtonStyle}
|
||||
onClick={this.onWindowsClick}
|
||||
>
|
||||
{ _t("Windows") }
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
<div className="mx_desktopCapturerSourcePicker_panel">
|
||||
{ sources }
|
||||
</div>
|
||||
<TabbedView tabs={tabs} tabLocation={TabLocation.TOP} onChange={this.onTabChange} />
|
||||
<DialogButtons
|
||||
primaryButton={_t("Share")}
|
||||
hasCancel={true}
|
||||
onCancel={this.onCloseClick}
|
||||
onPrimaryButtonClick={this.onShare}
|
||||
primaryDisabled={!this.state.selectedSource}
|
||||
/>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
|
68
src/components/views/elements/JoinRuleDropdown.tsx
Normal file
68
src/components/views/elements/JoinRuleDropdown.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
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 { JoinRule } from 'matrix-js-sdk/src/@types/partials';
|
||||
|
||||
import Dropdown from "./Dropdown";
|
||||
|
||||
interface IProps {
|
||||
value: JoinRule;
|
||||
label: string;
|
||||
width?: number;
|
||||
labelInvite: string;
|
||||
labelPublic: string;
|
||||
labelRestricted?: string; // if omitted then this option will be hidden, e.g if unsupported
|
||||
onChange(value: JoinRule): void;
|
||||
}
|
||||
|
||||
const JoinRuleDropdown = ({
|
||||
label,
|
||||
labelInvite,
|
||||
labelPublic,
|
||||
labelRestricted,
|
||||
value,
|
||||
width = 448,
|
||||
onChange,
|
||||
}: IProps) => {
|
||||
const options = [
|
||||
<div key={JoinRule.Invite} className="mx_JoinRuleDropdown_invite">
|
||||
{ labelInvite }
|
||||
</div>,
|
||||
<div key={JoinRule.Public} className="mx_JoinRuleDropdown_public">
|
||||
{ labelPublic }
|
||||
</div>,
|
||||
];
|
||||
|
||||
if (labelRestricted) {
|
||||
options.unshift(<div key={JoinRule.Restricted} className="mx_JoinRuleDropdown_restricted">
|
||||
{ labelRestricted }
|
||||
</div>);
|
||||
}
|
||||
|
||||
return <Dropdown
|
||||
id="mx_JoinRuleDropdown"
|
||||
className="mx_JoinRuleDropdown"
|
||||
onOptionChange={onChange}
|
||||
menuWidth={width}
|
||||
value={value}
|
||||
label={label}
|
||||
>
|
||||
{ options }
|
||||
</Dropdown>;
|
||||
};
|
||||
|
||||
export default JoinRuleDropdown;
|
|
@ -15,18 +15,21 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src';
|
||||
import classNames from 'classnames';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
|
||||
@replaceableComponent("views.messages.ViewSourceEvent")
|
||||
export default class ViewSourceEvent extends React.PureComponent {
|
||||
static propTypes = {
|
||||
/* the MatrixEvent to show */
|
||||
mxEvent: PropTypes.object.isRequired,
|
||||
};
|
||||
interface IProps {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.ViewSourceEvent")
|
||||
export default class ViewSourceEvent extends React.PureComponent<IProps, IState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
@ -35,7 +38,7 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
public componentDidMount(): void {
|
||||
const { mxEvent } = this.props;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
|
@ -46,15 +49,15 @@ export default class ViewSourceEvent extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
onToggle = (ev) => {
|
||||
private onToggle = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
const { expanded } = this.state;
|
||||
this.setState({
|
||||
expanded: !expanded,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
public render(): React.ReactNode {
|
||||
const { mxEvent } = this.props;
|
||||
const { expanded } = this.state;
|
||||
|
|
@ -342,8 +342,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
private onVoiceStoreUpdate = () => {
|
||||
const recording = VoiceRecordingStore.instance.activeRecording;
|
||||
this.setState({ haveRecording: !!recording });
|
||||
if (recording) {
|
||||
// Delay saying we have a recording until it is started, as we might not yet have A/V permissions
|
||||
recording.on(RecordingState.Started, () => {
|
||||
this.setState({ haveRecording: !!VoiceRecordingStore.instance.activeRecording });
|
||||
});
|
||||
// We show a little heads up that the recording is about to automatically end soon. The 3s
|
||||
// display time is completely arbitrary. Note that we don't need to deregister the listener
|
||||
// because the recording instance will clean that up for us.
|
||||
|
@ -351,6 +354,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
this.setState({ recordingTimeLeftSeconds: secondsLeft });
|
||||
setTimeout(() => this.setState({ recordingTimeLeftSeconds: null }), 3000);
|
||||
});
|
||||
} else {
|
||||
this.setState({ haveRecording: false });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -67,15 +67,21 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
|||
};
|
||||
|
||||
private onClick = (e: React.MouseEvent): void => {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Riot when clicked.
|
||||
e.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
});
|
||||
const clickTarget = e.target as HTMLElement;
|
||||
// Following a link within a reply should not dispatch the `view_room` action
|
||||
// so that the browser can direct the user to the correct location
|
||||
// The exception being the link wrapping the reply
|
||||
if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) {
|
||||
// This allows the permalink to be opened in a new tab/window or copied as
|
||||
// matrix.to, but also for it to enable routing within Riot when clicked.
|
||||
e.preventDefault();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
event_id: this.props.mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.mxEvent.getRoomId(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
@ -29,6 +29,8 @@ import RoomTopic from "../elements/RoomTopic";
|
|||
import RoomName from "../elements/RoomName";
|
||||
import { PlaceCallType } from "../../../CallHandler";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import Modal from '../../../Modal';
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
import { throttle } from 'lodash';
|
||||
import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src';
|
||||
import { E2EStatus } from '../../../utils/ShieldUtils';
|
||||
|
@ -87,6 +89,14 @@ export default class RoomHeader extends React.Component<IProps> {
|
|||
this.forceUpdate();
|
||||
}, 500, { leading: true, trailing: true });
|
||||
|
||||
private displayInfoDialogAboutScreensharing() {
|
||||
Modal.createDialog(InfoDialog, {
|
||||
title: _t("Screen sharing is here!"),
|
||||
description: _t("You can now share your screen by pressing the \"screen share\" " +
|
||||
"button during a call. You can even do this in audio calls if both sides support it!"),
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
let searchStatus = null;
|
||||
|
||||
|
@ -185,8 +195,8 @@ export default class RoomHeader extends React.Component<IProps> {
|
|||
videoCallButton =
|
||||
<AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
|
||||
onClick={(ev) => this.props.onCallPlaced(
|
||||
ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)}
|
||||
onClick={(ev) => ev.shiftKey ?
|
||||
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
|
||||
title={_t("Video call")} />;
|
||||
}
|
||||
|
||||
|
|
|
@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useContext, useRef, useState } from "react";
|
||||
import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { EventType, RoomType, RoomCreateTypeField } from "matrix-js-sdk/src/@types/event";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import FocusLock from "react-focus-lock";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
@ -24,7 +24,7 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
|||
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
|
||||
import createRoom from "../../../createRoom";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { SpaceAvatar } from "./SpaceBasicSettings";
|
||||
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
|
@ -33,8 +33,7 @@ import { UserTab } from "../dialogs/UserSettingsDialog";
|
|||
import Field from "../elements/Field";
|
||||
import withValidation from "../elements/Validation";
|
||||
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
|
||||
import { Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests";
|
||||
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||
import RoomAliasField from "../elements/RoomAliasField";
|
||||
|
||||
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
|
||||
|
@ -66,8 +65,83 @@ const nameToAlias = (name: string, domain: string): string => {
|
|||
return `#${localpart}:${domain}`;
|
||||
};
|
||||
|
||||
const SpaceCreateMenu = ({ onFinished }) => {
|
||||
type BProps = Pick<ComponentProps<typeof SpaceBasicSettings>, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
|
||||
interface ISpaceCreateFormProps extends BProps {
|
||||
busy: boolean;
|
||||
alias: string;
|
||||
nameFieldRef: RefObject<Field>;
|
||||
aliasFieldRef: RefObject<RoomAliasField>;
|
||||
showAliasField?: boolean;
|
||||
onSubmit(e: SyntheticEvent): void;
|
||||
setAlias(alias: string): void;
|
||||
}
|
||||
|
||||
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
|
||||
busy,
|
||||
onSubmit,
|
||||
setAvatar,
|
||||
name,
|
||||
setName,
|
||||
nameFieldRef,
|
||||
alias,
|
||||
aliasFieldRef,
|
||||
setAlias,
|
||||
showAliasField,
|
||||
topic,
|
||||
setTopic,
|
||||
children,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const domain = cli.getDomain();
|
||||
|
||||
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
|
||||
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
|
||||
<Field
|
||||
name="spaceName"
|
||||
label={_t("Name")}
|
||||
autoFocus={true}
|
||||
value={name}
|
||||
onChange={ev => {
|
||||
const newName = ev.target.value;
|
||||
if (!alias || alias === nameToAlias(name, domain)) {
|
||||
setAlias(nameToAlias(newName, domain));
|
||||
}
|
||||
setName(newName);
|
||||
}}
|
||||
ref={nameFieldRef}
|
||||
onValidate={spaceNameValidator}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{ showAliasField
|
||||
? <RoomAliasField
|
||||
ref={aliasFieldRef}
|
||||
onChange={setAlias}
|
||||
domain={domain}
|
||||
value={alias}
|
||||
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
|
||||
label={_t("Address")}
|
||||
disabled={busy}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
<Field
|
||||
name="spaceTopic"
|
||||
element="textarea"
|
||||
label={_t("Description")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{ children }
|
||||
</form>;
|
||||
};
|
||||
|
||||
const SpaceCreateMenu = ({ onFinished }) => {
|
||||
const [visibility, setVisibility] = useState<Visibility>(null);
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
|
||||
|
@ -98,42 +172,26 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const initialState: ICreateRoomStateEvent[] = [
|
||||
{
|
||||
type: EventType.RoomHistoryVisibility,
|
||||
content: {
|
||||
"history_visibility": visibility === Visibility.Public ? "world_readable" : "invited",
|
||||
},
|
||||
},
|
||||
];
|
||||
if (avatar) {
|
||||
const url = await cli.uploadContent(avatar);
|
||||
|
||||
initialState.push({
|
||||
type: EventType.RoomAvatar,
|
||||
content: { url },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await createRoom({
|
||||
createOpts: {
|
||||
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
|
||||
name,
|
||||
creation_content: {
|
||||
[RoomCreateTypeField]: RoomType.Space,
|
||||
},
|
||||
initial_state: initialState,
|
||||
power_level_content_override: {
|
||||
// Only allow Admins to write to the timeline to prevent hidden sync spam
|
||||
events_default: 100,
|
||||
...Visibility.Public ? { invite: 0 } : {},
|
||||
...visibility === Visibility.Public ? { invite: 0 } : {},
|
||||
},
|
||||
room_alias_name: visibility === Visibility.Public && alias
|
||||
? alias.substr(1, alias.indexOf(":") - 1)
|
||||
: undefined,
|
||||
topic,
|
||||
},
|
||||
avatar,
|
||||
roomType: RoomType.Space,
|
||||
historyVisibility: visibility === Visibility.Public
|
||||
? HistoryVisibility.WorldReadable
|
||||
: HistoryVisibility.Invited,
|
||||
spinner: false,
|
||||
encryption: false,
|
||||
andView: true,
|
||||
|
@ -171,7 +229,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
<SpaceFeedbackPrompt onClick={onFinished} />
|
||||
</React.Fragment>;
|
||||
} else {
|
||||
const domain = cli.getDomain();
|
||||
body = <React.Fragment>
|
||||
<AccessibleTooltipButton
|
||||
className="mx_SpaceCreateMenu_back"
|
||||
|
@ -192,49 +249,20 @@ const SpaceCreateMenu = ({ onFinished }) => {
|
|||
}
|
||||
</p>
|
||||
|
||||
<form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}>
|
||||
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
|
||||
|
||||
<Field
|
||||
name="spaceName"
|
||||
label={_t("Name")}
|
||||
autoFocus={true}
|
||||
value={name}
|
||||
onChange={ev => {
|
||||
const newName = ev.target.value;
|
||||
if (!alias || alias === nameToAlias(name, domain)) {
|
||||
setAlias(nameToAlias(newName, domain));
|
||||
}
|
||||
setName(newName);
|
||||
}}
|
||||
ref={spaceNameField}
|
||||
onValidate={spaceNameValidator}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{ visibility === Visibility.Public
|
||||
? <RoomAliasField
|
||||
ref={spaceAliasField}
|
||||
onChange={setAlias}
|
||||
domain={domain}
|
||||
value={alias}
|
||||
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")}
|
||||
label={_t("Address")}
|
||||
disabled={busy}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
||||
<Field
|
||||
name="spaceTopic"
|
||||
element="textarea"
|
||||
label={_t("Description")}
|
||||
value={topic}
|
||||
onChange={ev => setTopic(ev.target.value)}
|
||||
rows={3}
|
||||
disabled={busy}
|
||||
/>
|
||||
</form>
|
||||
<SpaceCreateForm
|
||||
busy={busy}
|
||||
onSubmit={onSpaceCreateClick}
|
||||
setAvatar={setAvatar}
|
||||
name={name}
|
||||
setName={setName}
|
||||
nameFieldRef={spaceNameField}
|
||||
topic={topic}
|
||||
setTopic={setTopic}
|
||||
alias={alias}
|
||||
setAlias={setAlias}
|
||||
showAliasField={visibility === Visibility.Public}
|
||||
aliasFieldRef={spaceAliasField}
|
||||
/>
|
||||
|
||||
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
|
||||
{ busy ? _t("Creating...") : _t("Create") }
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
shouldShowSpaceSettings,
|
||||
showAddExistingRooms,
|
||||
showCreateNewRoom,
|
||||
showCreateNewSubspace,
|
||||
showSpaceInvite,
|
||||
showSpaceSettings,
|
||||
} from "../../../utils/space";
|
||||
|
@ -49,6 +50,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
|
|||
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
|
||||
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
|
||||
space?: Room;
|
||||
|
@ -232,6 +234,14 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onNewSubspaceClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
showCreateNewSubspace(this.props.space);
|
||||
this.setState({ contextMenuPosition: null }); // also close the menu
|
||||
};
|
||||
|
||||
private onMembersClick = (ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
@ -316,6 +326,13 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
|
|||
label={_t("Add existing room")}
|
||||
onClick={this.onAddExistingRoomClick}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_SpacePanel_iconPlus"
|
||||
label={_t("Add space")}
|
||||
onClick={this.onNewSubspaceClick}
|
||||
>
|
||||
<BetaPill />
|
||||
</IconizedContextMenuOption>
|
||||
</IconizedContextMenuOptionList>;
|
||||
}
|
||||
|
||||
|
|
|
@ -23,9 +23,21 @@ interface IProps {
|
|||
feed: CallFeed;
|
||||
}
|
||||
|
||||
export default class AudioFeed extends React.Component<IProps> {
|
||||
interface IState {
|
||||
audioMuted: boolean;
|
||||
}
|
||||
|
||||
export default class AudioFeed extends React.Component<IProps, IState> {
|
||||
private element = createRef<HTMLAudioElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
MediaDeviceHandler.instance.addListener(
|
||||
MediaDeviceHandlerEvent.AudioOutputChanged,
|
||||
|
@ -62,6 +74,7 @@ export default class AudioFeed extends React.Component<IProps> {
|
|||
|
||||
private playMedia() {
|
||||
const element = this.element.current;
|
||||
if (!element) return;
|
||||
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
|
||||
element.muted = false;
|
||||
element.srcObject = this.props.feed.stream;
|
||||
|
@ -85,6 +98,7 @@ export default class AudioFeed extends React.Component<IProps> {
|
|||
|
||||
private stopMedia() {
|
||||
const element = this.element.current;
|
||||
if (!element) return;
|
||||
|
||||
element.pause();
|
||||
element.src = null;
|
||||
|
@ -96,10 +110,16 @@ export default class AudioFeed extends React.Component<IProps> {
|
|||
}
|
||||
|
||||
private onNewStream = () => {
|
||||
this.setState({
|
||||
audioMuted: this.props.feed.isAudioMuted(),
|
||||
});
|
||||
this.playMedia();
|
||||
};
|
||||
|
||||
render() {
|
||||
// Do not render the audio element if there is no audio track
|
||||
if (this.state.audioMuted) return null;
|
||||
|
||||
return (
|
||||
<audio ref={this.element} />
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
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.
|
||||
|
@ -32,6 +33,10 @@ import { avatarUrlForMember } from '../../../Avatar';
|
|||
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
|
||||
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
|
||||
import Modal from '../../../Modal';
|
||||
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
|
||||
import CallViewSidebar from './CallViewSidebar';
|
||||
|
||||
interface IProps {
|
||||
// The call for us to display
|
||||
|
@ -59,11 +64,15 @@ interface IState {
|
|||
isRemoteOnHold: boolean;
|
||||
micMuted: boolean;
|
||||
vidMuted: boolean;
|
||||
screensharing: boolean;
|
||||
callState: CallState;
|
||||
controlsVisible: boolean;
|
||||
hoveringControls: boolean;
|
||||
showMoreMenu: boolean;
|
||||
showDialpad: boolean;
|
||||
feeds: CallFeed[];
|
||||
primaryFeed: CallFeed;
|
||||
secondaryFeeds: Array<CallFeed>;
|
||||
sidebarShown: boolean;
|
||||
}
|
||||
|
||||
function getFullScreenElement() {
|
||||
|
@ -94,7 +103,7 @@ function exitFullscreen() {
|
|||
if (exitMethod) exitMethod.call(document);
|
||||
}
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 1000;
|
||||
const CONTROLS_HIDE_DELAY = 2000;
|
||||
// Height of the header duplicated from CSS because we need to subtract it from our max
|
||||
// height to get the max height of the video
|
||||
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
|
||||
|
@ -110,16 +119,22 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const { primary, secondary } = this.getOrderedFeeds(this.props.call.getFeeds());
|
||||
|
||||
this.state = {
|
||||
isLocalOnHold: this.props.call.isLocalOnHold(),
|
||||
isRemoteOnHold: this.props.call.isRemoteOnHold(),
|
||||
micMuted: this.props.call.isMicrophoneMuted(),
|
||||
vidMuted: this.props.call.isLocalVideoMuted(),
|
||||
screensharing: this.props.call.isScreensharing(),
|
||||
callState: this.props.call.state,
|
||||
controlsVisible: true,
|
||||
hoveringControls: false,
|
||||
showMoreMenu: false,
|
||||
showDialpad: false,
|
||||
feeds: this.props.call.getFeeds(),
|
||||
primaryFeed: primary,
|
||||
secondaryFeeds: secondary,
|
||||
sidebarShown: true,
|
||||
};
|
||||
|
||||
this.updateCallListeners(null, this.props.call);
|
||||
|
@ -194,7 +209,11 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
|
||||
this.setState({ feeds: newFeeds });
|
||||
const { primary, secondary } = this.getOrderedFeeds(newFeeds);
|
||||
this.setState({
|
||||
primaryFeed: primary,
|
||||
secondaryFeeds: secondary,
|
||||
});
|
||||
};
|
||||
|
||||
private onCallLocalHoldUnhold = () => {
|
||||
|
@ -227,6 +246,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private onControlsHideTimer = () => {
|
||||
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
|
||||
this.controlsHideTimer = null;
|
||||
this.setState({
|
||||
controlsVisible: false,
|
||||
|
@ -237,7 +257,30 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
this.showControls();
|
||||
};
|
||||
|
||||
private showControls() {
|
||||
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
|
||||
let primary;
|
||||
|
||||
// Try to use a screensharing as primary, a remote one if possible
|
||||
const screensharingFeeds = feeds.filter((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
|
||||
primary = screensharingFeeds.find((feed) => !feed.isLocal()) || screensharingFeeds[0];
|
||||
// If we didn't find remote screen-sharing stream, try to find any remote stream
|
||||
if (!primary) {
|
||||
primary = feeds.find((feed) => !feed.isLocal());
|
||||
}
|
||||
|
||||
const secondary = [...feeds];
|
||||
// Remove the primary feed from the array
|
||||
if (primary) secondary.splice(secondary.indexOf(primary), 1);
|
||||
secondary.sort((a, b) => {
|
||||
if (a.isLocal() && !b.isLocal()) return -1;
|
||||
if (!a.isLocal() && b.isLocal()) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
|
||||
private showControls(): void {
|
||||
if (this.state.showMoreMenu || this.state.showDialpad) return;
|
||||
|
||||
if (!this.state.controlsVisible) {
|
||||
|
@ -251,73 +294,62 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
private onDialpadClick = () => {
|
||||
private onDialpadClick = (): void => {
|
||||
if (!this.state.showDialpad) {
|
||||
if (this.controlsHideTimer) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
showDialpad: true,
|
||||
controlsVisible: true,
|
||||
});
|
||||
this.setState({ showDialpad: true });
|
||||
this.showControls();
|
||||
} else {
|
||||
if (this.controlsHideTimer !== null) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
}
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
|
||||
this.setState({
|
||||
showDialpad: false,
|
||||
});
|
||||
this.setState({ showDialpad: false });
|
||||
}
|
||||
};
|
||||
|
||||
private onMicMuteClick = () => {
|
||||
private onMicMuteClick = (): void => {
|
||||
const newVal = !this.state.micMuted;
|
||||
|
||||
this.props.call.setMicrophoneMuted(newVal);
|
||||
this.setState({ micMuted: newVal });
|
||||
};
|
||||
|
||||
private onVidMuteClick = () => {
|
||||
private onVidMuteClick = (): void => {
|
||||
const newVal = !this.state.vidMuted;
|
||||
|
||||
this.props.call.setLocalVideoMuted(newVal);
|
||||
this.setState({ vidMuted: newVal });
|
||||
};
|
||||
|
||||
private onMoreClick = () => {
|
||||
if (this.controlsHideTimer) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
this.controlsHideTimer = null;
|
||||
}
|
||||
private onScreenshareClick = async (): Promise<void> => {
|
||||
const isScreensharing = await this.props.call.setScreensharingEnabled(
|
||||
!this.state.screensharing,
|
||||
async (): Promise<DesktopCapturerSource> => {
|
||||
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
|
||||
const [source] = await finished;
|
||||
return source;
|
||||
},
|
||||
);
|
||||
|
||||
this.setState({
|
||||
showMoreMenu: true,
|
||||
controlsVisible: true,
|
||||
sidebarShown: true,
|
||||
screensharing: isScreensharing,
|
||||
});
|
||||
};
|
||||
|
||||
private closeDialpad = () => {
|
||||
this.setState({
|
||||
showDialpad: false,
|
||||
});
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
private onMoreClick = (): void => {
|
||||
this.setState({ showMoreMenu: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private closeContextMenu = () => {
|
||||
this.setState({
|
||||
showMoreMenu: false,
|
||||
});
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
private closeDialpad = (): void => {
|
||||
this.setState({ showDialpad: false });
|
||||
};
|
||||
|
||||
private closeContextMenu = (): void => {
|
||||
this.setState({ showMoreMenu: false });
|
||||
};
|
||||
|
||||
// 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
|
||||
// CallHandler would probably be a better place for this
|
||||
private onNativeKeyDown = ev => {
|
||||
private onNativeKeyDown = (ev): void => {
|
||||
let handled = false;
|
||||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
|
||||
|
@ -347,7 +379,16 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onRoomAvatarClick = () => {
|
||||
private onCallControlsMouseEnter = (): void => {
|
||||
this.setState({ hoveringControls: true });
|
||||
this.showControls();
|
||||
};
|
||||
|
||||
private onCallControlsMouseLeave = (): void => {
|
||||
this.setState({ hoveringControls: false });
|
||||
};
|
||||
|
||||
private onRoomAvatarClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
|
@ -355,7 +396,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onSecondaryRoomAvatarClick = () => {
|
||||
private onSecondaryRoomAvatarClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
|
||||
dis.dispatch({
|
||||
|
@ -364,50 +405,30 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onCallResumeClick = () => {
|
||||
private onCallResumeClick = (): void => {
|
||||
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
||||
};
|
||||
|
||||
private onTransferClick = () => {
|
||||
private onTransferClick = (): void => {
|
||||
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
|
||||
this.props.call.transferToCall(transfereeCall);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
const callRoom = client.getRoom(callRoomId);
|
||||
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
|
||||
private onHangupClick = (): void => {
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
room_id: CallHandler.sharedInstance().roomIdForCall(this.props.call),
|
||||
});
|
||||
};
|
||||
|
||||
let dialPad;
|
||||
let contextMenu;
|
||||
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
private onToggleSidebar = (): void => {
|
||||
this.setState({
|
||||
sidebarShown: !this.state.sidebarShown,
|
||||
});
|
||||
};
|
||||
|
||||
private renderCallControls(): JSX.Element {
|
||||
const micClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_micOn: !this.state.micMuted,
|
||||
|
@ -420,6 +441,18 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
|
||||
});
|
||||
|
||||
const screensharingClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
|
||||
mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
|
||||
});
|
||||
|
||||
const sidebarButtonClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
|
||||
mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
|
||||
});
|
||||
|
||||
// Put the other states of the mic/video icons in the document to make sure they're cached
|
||||
// (otherwise the icon disappears briefly when toggled)
|
||||
const micCacheClasses = classNames({
|
||||
|
@ -441,59 +474,121 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_callControls_hidden: !this.state.controlsVisible,
|
||||
});
|
||||
|
||||
const vidMuteButton = this.props.call.type === CallType.Video ? <AccessibleButton
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
/> : null;
|
||||
// We don't support call upgrades (yet) so hide the video mute button in voice calls
|
||||
let vidMuteButton;
|
||||
if (this.props.call.type === CallType.Video) {
|
||||
vidMuteButton = (
|
||||
<AccessibleButton
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Screensharing is possible, if we can send a second stream and
|
||||
// identify it using SDPStreamMetadata or if we can replace the already
|
||||
// existing usermedia track by a screensharing track. We also need to be
|
||||
// connected to know the state of the other side
|
||||
let screensharingButton;
|
||||
if (
|
||||
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
|
||||
this.props.call.state === CallState.Connected
|
||||
) {
|
||||
screensharingButton = (
|
||||
<AccessibleButton
|
||||
className={screensharingClasses}
|
||||
onClick={this.onScreenshareClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// To show the sidebar we need secondary feeds, if we don't have them,
|
||||
// we can hide this button. If we are in PiP, sidebar is also hidden, so
|
||||
// we can hide the button too
|
||||
let sidebarButton;
|
||||
if (
|
||||
!this.props.pipMode &&
|
||||
(
|
||||
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
|
||||
this.props.call.isScreensharing()
|
||||
)
|
||||
) {
|
||||
sidebarButton = (
|
||||
<AccessibleButton
|
||||
className={sidebarButtonClasses}
|
||||
onClick={this.onToggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The dial pad & 'more' button actions are only relevant in a connected call
|
||||
// When not connected, we have to put something there to make the flexbox alignment correct
|
||||
const dialpadButton = this.state.callState === CallState.Connected ? <ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_dialpad_hidden" />;
|
||||
let contextMenuButton;
|
||||
if (this.state.callState === CallState.Connected) {
|
||||
contextMenuButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let dialpadButton;
|
||||
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
|
||||
dialpadButton = (
|
||||
<ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
|
||||
inputRef={this.dialpadButton}
|
||||
onClick={this.onDialpadClick}
|
||||
isExpanded={this.state.showDialpad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
|
||||
onClick={this.onMoreClick}
|
||||
inputRef={this.contextMenuButton}
|
||||
isExpanded={this.state.showMoreMenu}
|
||||
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
|
||||
|
||||
// in the near future, the dial pad button will go on the left. For now, it's the nothing button
|
||||
// because something needs to have margin-right: auto to make the alignment correct.
|
||||
const callControls = <div className={callControlsClasses}>
|
||||
{ dialpadButton }
|
||||
<AccessibleButton
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
/>
|
||||
<AccessibleButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
room_id: callRoomId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
{ contextMenuButton }
|
||||
</div>;
|
||||
return (
|
||||
<div
|
||||
className={callControlsClasses}
|
||||
onMouseEnter={this.onCallControlsMouseEnter}
|
||||
onMouseLeave={this.onCallControlsMouseLeave}
|
||||
>
|
||||
{ dialpadButton }
|
||||
<AccessibleButton
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
/>
|
||||
{ vidMuteButton }
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
{ screensharingButton }
|
||||
{ sidebarButton }
|
||||
{ contextMenuButton }
|
||||
<AccessibleButton
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={this.onHangupClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const client = MatrixClientPeg.get();
|
||||
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
|
||||
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
|
||||
const callRoom = client.getRoom(callRoomId);
|
||||
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
|
||||
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;
|
||||
|
||||
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
|
||||
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
|
||||
const isScreensharing = this.props.call.isScreensharing();
|
||||
const sidebarShown = this.state.sidebarShown;
|
||||
const someoneIsScreensharing = this.props.call.getFeeds().some((feed) => {
|
||||
return feed.purpose === SDPStreamMetadataPurpose.Screenshare;
|
||||
});
|
||||
const isVideoCall = this.props.call.type === CallType.Video;
|
||||
|
||||
let contentView: React.ReactNode;
|
||||
let holdTransferContent;
|
||||
|
||||
if (transfereeCall) {
|
||||
const transferTargetRoom = MatrixClientPeg.get().getRoom(
|
||||
CallHandler.sharedInstance().roomIdForCall(this.props.call),
|
||||
|
@ -539,9 +634,25 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
</div>;
|
||||
}
|
||||
|
||||
let sidebar;
|
||||
if (
|
||||
!isOnHold &&
|
||||
!transfereeCall &&
|
||||
sidebarShown &&
|
||||
(isVideoCall || someoneIsScreensharing)
|
||||
) {
|
||||
sidebar = (
|
||||
<CallViewSidebar
|
||||
feeds={this.state.secondaryFeeds}
|
||||
call={this.props.call}
|
||||
pipMode={this.props.pipMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (isVideoCall) {
|
||||
const containerClasses = classNames({
|
||||
mx_CallView_content: true,
|
||||
mx_CallView_video: true,
|
||||
|
@ -560,7 +671,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
<div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
|
||||
{ onHoldBackground }
|
||||
{ holdTransferContent }
|
||||
{ callControls }
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
@ -585,7 +696,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
</div>
|
||||
</div>
|
||||
{ holdTransferContent }
|
||||
{ callControls }
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -599,77 +710,91 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
mx_CallView_voice: true,
|
||||
});
|
||||
|
||||
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
|
||||
room={callRoom}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
contentView = (
|
||||
<div
|
||||
className={classes}
|
||||
onMouseMove={this.onMouseMove}
|
||||
>
|
||||
{ sidebar }
|
||||
<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>
|
||||
<div className="mx_CallView_holdTransferContent">{ _t("Connecting") }</div>
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
<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 (
|
||||
let toast;
|
||||
if (someoneIsScreensharing) {
|
||||
const presentingClasses = classNames({
|
||||
mx_CallView_presenting: true,
|
||||
mx_CallView_presenting_hidden: !this.state.controlsVisible,
|
||||
});
|
||||
const sharerName = this.state.primaryFeed.getMember().name;
|
||||
let text = isScreensharing
|
||||
? _t("You are presenting")
|
||||
: _t('%(sharerName)s is presenting', { sharerName });
|
||||
if (!this.state.sidebarShown && isVideoCall) {
|
||||
text += " • " + (this.props.call.isLocalVideoMuted()
|
||||
? _t("Your camera is turned off")
|
||||
: _t("Your camera is still enabled"));
|
||||
}
|
||||
|
||||
toast = (
|
||||
<div className={presentingClasses}>
|
||||
{ text }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
contentView = (
|
||||
<div
|
||||
className={containerClasses}
|
||||
ref={this.contentRef}
|
||||
onMouseMove={this.onMouseMove}
|
||||
>
|
||||
{ toast }
|
||||
{ sidebar }
|
||||
<VideoFeed
|
||||
key={i}
|
||||
feed={feed}
|
||||
feed={this.state.primaryFeed}
|
||||
call={this.props.call}
|
||||
pipMode={this.props.pipMode}
|
||||
onResize={this.props.onResize}
|
||||
primary={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
|
||||
{ feeds }
|
||||
{ callControls }
|
||||
</div>;
|
||||
{ this.renderCallControls() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const callTypeText = this.props.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
|
||||
const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call");
|
||||
let myClassName;
|
||||
|
||||
let fullScreenButton;
|
||||
if (this.props.call.type === CallType.Video && !this.props.pipMode) {
|
||||
fullScreenButton = <div
|
||||
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
/>;
|
||||
if (!this.props.pipMode) {
|
||||
fullScreenButton = (
|
||||
<div
|
||||
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick}
|
||||
title={_t("Fill Screen")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let expandButton;
|
||||
|
@ -686,10 +811,15 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
{ expandButton }
|
||||
</div>;
|
||||
|
||||
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
|
||||
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
|
||||
"mx_CallView_header_callTypeIcon_video": isVideoCall,
|
||||
});
|
||||
|
||||
let header: React.ReactNode;
|
||||
if (!this.props.pipMode) {
|
||||
header = <div className="mx_CallView_header">
|
||||
<div className="mx_CallView_header_phoneIcon" />
|
||||
<div className={callTypeIconClassName} />
|
||||
<span className="mx_CallView_header_callType">{ callTypeText }</span>
|
||||
{ headerControls }
|
||||
</div>;
|
||||
|
@ -728,6 +858,32 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
myClassName = 'mx_CallView_pip';
|
||||
}
|
||||
|
||||
let dialPad;
|
||||
if (this.state.showDialpad) {
|
||||
dialPad = <DialpadContextMenu
|
||||
{...alwaysAboveRightOf(
|
||||
this.dialpadButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeDialpad}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
let contextMenu;
|
||||
if (this.state.showMoreMenu) {
|
||||
contextMenu = <CallContextMenu
|
||||
{...alwaysAboveLeftOf(
|
||||
this.contextMenuButton.current.getBoundingClientRect(),
|
||||
ChevronFace.None,
|
||||
CONTEXT_MENU_VPADDING,
|
||||
)}
|
||||
onFinished={this.closeContextMenu}
|
||||
call={this.props.call}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className={"mx_CallView " + myClassName}>
|
||||
{ header }
|
||||
{ contentView }
|
||||
|
|
53
src/components/views/voip/CallViewSidebar.tsx
Normal file
53
src/components/views/voip/CallViewSidebar.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
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 { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||
import VideoFeed from "./VideoFeed";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface IProps {
|
||||
feeds: Array<CallFeed>;
|
||||
call: MatrixCall;
|
||||
pipMode: boolean;
|
||||
}
|
||||
|
||||
export default class CallViewSidebar extends React.Component<IProps> {
|
||||
render() {
|
||||
const feeds = this.props.feeds.map((feed) => {
|
||||
return (
|
||||
<VideoFeed
|
||||
key={feed.stream.id}
|
||||
feed={feed}
|
||||
call={this.props.call}
|
||||
primary={false}
|
||||
pipMode={this.props.pipMode}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const className = classNames("mx_CallViewSidebar", {
|
||||
mx_CallViewSidebar_pipMode: this.props.pipMode,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{ feeds }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import classnames from 'classnames';
|
||||
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import React, { createRef } from 'react';
|
||||
import React 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';
|
||||
|
@ -37,6 +37,8 @@ interface IProps {
|
|||
// a callback which is called when the video element is resized
|
||||
// due to a change in video metadata
|
||||
onResize?: (e: Event) => void;
|
||||
|
||||
primary: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -46,7 +48,7 @@ interface IState {
|
|||
|
||||
@replaceableComponent("views.voip.VideoFeed")
|
||||
export default class VideoFeed extends React.Component<IProps, IState> {
|
||||
private element = createRef<HTMLVideoElement>();
|
||||
private element: HTMLVideoElement;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -58,19 +60,50 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.element.current?.addEventListener('resize', this.onResize);
|
||||
this.updateFeed(null, this.props.feed);
|
||||
this.playMedia();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.element.current?.removeEventListener('resize', this.onResize);
|
||||
this.stopMedia();
|
||||
this.updateFeed(this.props.feed, null);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IProps) {
|
||||
this.updateFeed(prevProps.feed, this.props.feed);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: IProps) {
|
||||
return {
|
||||
audioMuted: props.feed.isAudioMuted(),
|
||||
videoMuted: props.feed.isVideoMuted(),
|
||||
};
|
||||
}
|
||||
|
||||
private setElementRef = (element: HTMLVideoElement): void => {
|
||||
if (!element) {
|
||||
this.element?.removeEventListener('resize', this.onResize);
|
||||
return;
|
||||
}
|
||||
|
||||
this.element = element;
|
||||
element.addEventListener('resize', this.onResize);
|
||||
};
|
||||
|
||||
private updateFeed(oldFeed: CallFeed, newFeed: CallFeed) {
|
||||
if (oldFeed === newFeed) return;
|
||||
|
||||
if (oldFeed) {
|
||||
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.stopMedia();
|
||||
}
|
||||
if (newFeed) {
|
||||
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
|
||||
this.playMedia();
|
||||
}
|
||||
}
|
||||
|
||||
private playMedia() {
|
||||
const element = this.element.current;
|
||||
const element = this.element;
|
||||
if (!element) return;
|
||||
// We play audio in AudioFeed, not here
|
||||
element.muted = true;
|
||||
|
@ -93,7 +126,7 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
private stopMedia() {
|
||||
const element = this.element.current;
|
||||
const element = this.element;
|
||||
if (!element) return;
|
||||
|
||||
element.pause();
|
||||
|
@ -122,8 +155,6 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
render() {
|
||||
const videoClasses = {
|
||||
mx_VideoFeed: true,
|
||||
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: (
|
||||
|
@ -132,9 +163,15 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
),
|
||||
};
|
||||
|
||||
const { pipMode, primary } = this.props;
|
||||
|
||||
if (this.state.videoMuted) {
|
||||
const member = this.props.feed.getMember();
|
||||
const avatarSize = this.props.pipMode ? 76 : 160;
|
||||
let avatarSize;
|
||||
if (pipMode && primary) avatarSize = 76;
|
||||
else if (pipMode && !primary) avatarSize = 16;
|
||||
else if (!pipMode && primary) avatarSize = 160;
|
||||
else; // TBD
|
||||
|
||||
return (
|
||||
<div className={classnames(videoClasses)}>
|
||||
|
@ -147,7 +184,7 @@ export default class VideoFeed extends React.Component<IProps, IState> {
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<video className={classnames(videoClasses)} ref={this.element} />
|
||||
<video className={classnames(videoClasses)} ref={this.setElementRef} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue