Merge branch 'develop' into export-conversations

This commit is contained in:
Jaiwanth 2021-08-12 17:10:13 +05:30 committed by GitHub
commit e88edba650
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 4403 additions and 1194 deletions

View file

@ -61,7 +61,9 @@ export default class AutoHideScrollbar extends React.Component<IProps> {
style={style}
className={["mx_AutoHideScrollbar", className].join(" ")}
onWheel={onWheel}
tabIndex={tabIndex}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex={tabIndex ?? -1}
>
{ children }
</div>);

View file

@ -41,6 +41,9 @@ import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar";
import { mediaFromMxc } from "../../customisations/Media";
import { replaceableComponent } from "../../utils/replaceableComponent";
import { createSpaceFromCommunity } from "../../utils/space";
import { Action } from "../../dispatcher/actions";
import { RightPanelPhases } from "../../stores/RightPanelStorePhases";
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
@ -399,6 +402,8 @@ class FeaturedUser extends React.Component {
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
const UPGRADE_NOTICE_LS_KEY = "mx_hide_community_upgrade_notice";
@replaceableComponent("structures.GroupView")
export default class GroupView extends React.Component {
static propTypes = {
@ -422,6 +427,7 @@ export default class GroupView extends React.Component {
publicityBusy: false,
inviterProfile: null,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup,
showUpgradeNotice: !localStorage.getItem(UPGRADE_NOTICE_LS_KEY),
};
componentDidMount() {
@ -807,6 +813,22 @@ export default class GroupView extends React.Component {
showGroupAddRoomDialog(this.props.groupId);
};
_dismissUpgradeNotice = () => {
localStorage.setItem(UPGRADE_NOTICE_LS_KEY, "true");
this.setState({ showUpgradeNotice: false });
}
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this._matrixClient, this.props.groupId);
};
_onAdminsLinkClick = () => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.GroupMemberList,
});
};
_getGroupSection() {
const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
@ -843,10 +865,46 @@ export default class GroupView extends React.Component {
},
) }
</div> : <div />;
let communitiesUpgradeNotice;
if (this.state.showUpgradeNotice) {
let text;
if (this.state.isUserPrivileged) {
text = _t("You can create a Space from this community <a>here</a>.", {}, {
a: sub => <AccessibleButton onClick={this._onCreateSpaceClick} kind="link">
{ sub }
</AccessibleButton>,
});
} else {
text = _t("Ask the <a>admins</a> of this community to make it into a Space " +
"and keep a look out for the invite.", {}, {
a: sub => <AccessibleButton onClick={this._onAdminsLinkClick} kind="link">
{ sub }
</AccessibleButton>,
});
}
communitiesUpgradeNotice = <div className="mx_GroupView_spaceUpgradePrompt">
<h2>{ _t("Communities can now be made into Spaces") }</h2>
<p>
{ _t("Spaces are a new way to make a community, with new features coming.") }
&nbsp;
{ text }
&nbsp;
{ _t("Communities won't receive further updates.") }
</p>
<AccessibleButton
className="mx_GroupView_spaceUpgradePrompt_close"
onClick={this._dismissUpgradeNotice}
/>
</div>;
}
return <div className={groupSettingsSectionClasses}>
{ header }
{ hostingSignup }
{ changeDelayWarning }
{ communitiesUpgradeNotice }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }

View file

@ -392,9 +392,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
<IndicatorScrollbar
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useMemo, useState } from "react";
import React, { ReactNode, KeyboardEvent, useMemo, useState, KeyboardEventHandler } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces";
@ -46,6 +46,8 @@ import { getDisplayAliasForAliasSet } from "../../Rooms";
import { useDispatcher } from "../../hooks/useDispatcher";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
interface IHierarchyProps {
space: Room;
@ -80,6 +82,7 @@ const Tile: React.FC<ITileProps> = ({
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex();
const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault();
@ -94,11 +97,21 @@ const Tile: React.FC<ITileProps> = ({
let button;
if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
button = <AccessibleButton
onClick={onPreviewClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("View") }
</AccessibleButton>;
} else if (onJoinClick) {
button = <AccessibleButton onClick={onJoinClick} kind="primary">
button = <AccessibleButton
onClick={onJoinClick}
kind="primary"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ _t("Join") }
</AccessibleButton>;
}
@ -106,13 +119,13 @@ const Tile: React.FC<ITileProps> = ({
let checkbox;
if (onToggleClick) {
if (hasPermissions) {
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} />;
checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
} else {
checkbox = <TextWithTooltip
tooltip={_t("You don't have permission")}
onClick={ev => { ev.stopPropagation(); }}
>
<StyledCheckbox disabled={true} />
<StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
</TextWithTooltip>;
}
}
@ -172,8 +185,9 @@ const Tile: React.FC<ITileProps> = ({
</div>
</React.Fragment>;
let childToggle;
let childSection;
let childToggle: JSX.Element;
let childSection: JSX.Element;
let onKeyDown: KeyboardEventHandler;
if (children) {
// the chevron is purposefully a div rather than a button as it should be ignored for a11y
childToggle = <div
@ -185,25 +199,74 @@ const Tile: React.FC<ITileProps> = ({
toggleShowChildren();
}}
/>;
if (showChildren) {
childSection = <div className="mx_SpaceRoomDirectory_subspace_children">
const onChildrenKeyDown = (e) => {
if (e.key === Key.ARROW_LEFT) {
e.preventDefault();
e.stopPropagation();
ref.current?.focus();
}
};
childSection = <div
className="mx_SpaceRoomDirectory_subspace_children"
onKeyDown={onChildrenKeyDown}
role="group"
>
{ children }
</div>;
}
onKeyDown = (e) => {
let handled = false;
switch (e.key) {
case Key.ARROW_LEFT:
if (showChildren) {
handled = true;
toggleShowChildren();
}
break;
case Key.ARROW_RIGHT:
handled = true;
if (showChildren) {
const childSection = ref.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceRoomDirectory_roomTile")?.focus();
} else {
toggleShowChildren();
}
break;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
}
return <>
return <li
className="mx_SpaceRoomDirectory_roomTileWrapper"
role="treeitem"
aria-expanded={children ? showChildren : undefined}
>
<AccessibleButton
className={classNames("mx_SpaceRoomDirectory_roomTile", {
mx_SpaceRoomDirectory_subspace: room.room_type === RoomType.Space,
})}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown}
inputRef={ref}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
>
{ content }
{ childToggle }
</AccessibleButton>
{ childSection }
</>;
</li>;
};
export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => {
@ -414,176 +477,196 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
return <p>{ _t("Your server does not support showing space hierarchies.") }</p>;
}
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
const onKeyDown = (ev: KeyboardEvent, state: IState) => {
if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceRoomDirectory_search")) {
state.refs[0]?.current?.focus();
}
let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar className="mx_SpaceRoomDirectory_list">
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else {
content = <Spinner />;
}
};
// TODO loading state/error state
return <>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
/>
return <RovingTabIndexProvider onKeyDown={onKeyDown} handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => {
let content;
if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
{ content }
</>;
let countsStr;
if (numSpaces > 1) {
countsStr = _t("%(count)s rooms and %(numSpaces)s spaces", { count: numRooms, numSpaces });
} else if (numSpaces > 0) {
countsStr = _t("%(count)s rooms and 1 space", { count: numRooms, numSpaces });
} else {
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
}
let manageButtons;
if (space.getMyMembership() === "join" &&
space.currentState.maySendStateEvent(EventType.SpaceChild, userId)
) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [
...selected.get(parentId).values(),
].map(childId => [parentId, childId]) as [string, string][];
});
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
});
const disabled = !selectedRelations.length || removing || saving;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => {
setRemoving(true);
try {
for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId);
parentChildMap.get(parentId).delete(childId);
if (parentChildMap.get(parentId).size > 0) {
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
} else {
parentChildMap.delete(parentId);
}
}
} catch (e) {
setError(_t("Failed to remove some rooms. Try again later"));
}
setRemoving(false);
}}
kind="danger_outline"
disabled={disabled}
>
{ removing ? _t("Removing...") : _t("Remove") }
</Button>
<Button
{...props}
onClick={async () => {
setSaving(true);
try {
for (const [parentId, childId] of selectedRelations) {
const suggested = !selectionAllSuggested;
const existingContent = parentChildMap.get(parentId)?.get(childId)?.content;
if (!existingContent || existingContent.suggested === suggested) continue;
const content = {
...existingContent,
suggested: !selectionAllSuggested,
};
await cli.sendStateEvent(parentId, EventType.SpaceChild, content, childId);
parentChildMap.get(parentId).get(childId).content = content;
parentChildMap.set(parentId, new Map(parentChildMap.get(parentId)));
}
} catch (e) {
setError("Failed to update some suggestions. Try again later");
}
setSaving(false);
setSelected(new Map());
}}
kind="primary_outline"
disabled={disabled}
>
{ saving
? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
}
</Button>
</>;
}
let results;
if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
results = <>
<HierarchyLevel
spaceId={space.roomId}
rooms={roomsMap}
relations={parentChildMap}
parents={new Set()}
selectedMap={selected}
onToggleClick={hasPermissions ? (parentId, childId) => {
setError("");
if (!selected.has(parentId)) {
setSelected(new Map(selected.set(parentId, new Set([childId]))));
return;
}
const parentSet = selected.get(parentId);
if (!parentSet.has(childId)) {
setSelected(new Map(selected.set(parentId, new Set([...parentSet, childId]))));
return;
}
parentSet.delete(childId);
setSelected(new Map(selected.set(parentId, new Set(parentSet))));
} : undefined}
onViewRoomClick={(roomId, autoJoin) => {
showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), autoJoin);
}}
/>
{ children && <hr /> }
</>;
} else {
results = <div className="mx_SpaceRoomDirectory_noResults">
<h3>{ _t("No results found") }</h3>
<div>{ _t("You may want to try a different search or check for typos.") }</div>
</div>;
}
content = <>
<div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr }
<span>
{ additionalButtons }
{ manageButtons }
</span>
</div>
{ error && <div className="mx_SpaceRoomDirectory_error">
{ error }
</div> }
<AutoHideScrollbar
className="mx_SpaceRoomDirectory_list"
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Space")}
>
{ results }
{ children }
</AutoHideScrollbar>
</>;
} else {
content = <Spinner />;
}
return <>
<SearchBox
className="mx_SpaceRoomDirectory_search mx_textinput_icon mx_textinput_search"
placeholder={_t("Search names and descriptions")}
onSearch={setQuery}
autoFocus={true}
initialValue={initialText}
onKeyDown={onKeyDownHandler}
/>
{ content }
</>;
} }
</RovingTabIndexProvider>;
};
interface IProps {

View file

@ -74,6 +74,10 @@ import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog";
import { EffectiveMembership, getEffectiveMembership } from "../../utils/membership";
import { SpaceFeedbackPrompt } from "../views/spaces/SpaceCreateMenu";
import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFromCommunityDialog";
import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar";
interface IProps {
space: Room;
@ -158,7 +162,33 @@ const onBetaClick = () => {
});
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
// XXX: temporary community migration component
const GroupTile = ({ groupId }: { groupId: string }) => {
const cli = useContext(MatrixClientContext);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [cli, groupId]);
if (!groupSummary) return <Spinner />;
return <>
<GroupAvatar
groupId={groupId}
groupName={groupSummary.profile.name}
groupAvatarUrl={groupSummary.profile.avatar_url}
width={16}
height={16}
resizeMethod='crop'
/>
{ groupSummary.profile.name }
</>;
};
interface ISpacePreviewProps {
space: Room;
onJoinButtonClicked(): void;
onRejectButtonClicked(): void;
}
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space);
@ -270,8 +300,18 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
</div>;
}
let migratedCommunitySection: JSX.Element;
const createContent = space.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
if (createContent[CreateEventField]) {
migratedCommunitySection = <div className="mx_SpaceRoomView_preview_migratedCommunity">
{ _t("Created from <Community />", {}, {
Community: () => <GroupTile groupId={createContent[CreateEventField]} />,
}) }
</div>;
}
return <div className="mx_SpaceRoomView_preview">
<BetaPill onClick={onBetaClick} />
{ migratedCommunitySection }
{ inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name">

View file

@ -24,6 +24,8 @@ import { MenuItem } from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
import { createSpaceFromCommunity } from "../../../utils/space";
import GroupStore from "../../../stores/GroupStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
@ -49,6 +51,11 @@ export default class TagTileContextMenu extends React.Component {
this.props.onFinished();
};
_onCreateSpaceClick = () => {
createSpaceFromCommunity(this.context, this.props.tag);
this.props.onFinished();
};
_onMoveUp = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index - 1));
this.props.onFinished();
@ -77,6 +84,16 @@ export default class TagTileContextMenu extends React.Component {
);
}
let createSpaceOption;
if (GroupStore.isUserPrivileged(this.props.tag)) {
createSpaceOption = <>
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_createSpace" onClick={this._onCreateSpaceClick}>
{ _t("Create Space") }
</MenuItem>
</>;
}
return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
@ -88,6 +105,7 @@ export default class TagTileContextMenu extends React.Component {
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t("Unpin") }
</MenuItem>
{ createSpaceOption }
</div>;
}
}

View file

@ -116,8 +116,8 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId();
}
opts.parentSpace = this.props.parentSpace;
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.parentSpace = this.props.parentSpace;
opts.joinRule = JoinRule.Restricted;
}

View file

@ -0,0 +1,340 @@
/*
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, { useEffect, useRef, useState } from "react";
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import { GroupMember } from "../right_panel/UserInfo";
import { parseMembersResponse, parseRoomsResponse } from "../../../stores/GroupStore";
import { calculateRoomVia, makeRoomPermalink } from "../../../utils/permalinks/Permalinks";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import Spinner from "../elements/Spinner";
import { mediaFromMxc } from "../../../customisations/Media";
import SpaceStore from "../../../stores/SpaceStore";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions";
interface IProps {
matrixClient: MatrixClient;
groupId: string;
onFinished(spaceId?: string): void;
}
export const CreateEventField = "io.element.migrated_from_community";
interface IGroupRoom {
displayname: string;
name?: string;
roomId: string;
canonicalAlias?: string;
avatarUrl?: string;
topic?: string;
numJoinedMembers?: number;
worldReadable?: boolean;
guestCanJoin?: boolean;
isPublic?: boolean;
}
/* eslint-disable camelcase */
export interface IGroupSummary {
profile: {
avatar_url?: string;
is_openly_joinable?: boolean;
is_public?: boolean;
long_description: string;
name: string;
short_description: string;
};
rooms_section: {
rooms: unknown[];
categories: Record<string, unknown>;
total_room_count_estimate: number;
};
user: {
is_privileged: boolean;
is_public: boolean;
is_publicised: boolean;
membership: string;
};
users_section: {
users: unknown[];
roles: Record<string, unknown>;
total_user_count_estimate: number;
};
}
/* eslint-enable camelcase */
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [alias, setAlias] = useState("#" + groupId.substring(1, groupId.indexOf(":")) + ":" + cli.getDomain());
const spaceAliasField = useRef<RoomAliasField>();
const [topic, setTopic] = useState("");
const [joinRule, setJoinRule] = useState<JoinRule>(JoinRule.Public);
const groupSummary = useAsyncMemo<IGroupSummary>(() => cli.getGroupSummary(groupId), [groupId]);
useEffect(() => {
if (groupSummary) {
setName(groupSummary.profile.name || "");
setTopic(groupSummary.profile.short_description || "");
setJoinRule(groupSummary.profile.is_openly_joinable ? JoinRule.Public : JoinRule.Invite);
setLoading(false);
}
}, [groupSummary]);
if (loading) {
return <Spinner />;
}
const onCreateSpaceClick = async (e) => {
e.preventDefault();
if (busy) return;
setError(null);
setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
setBusy(false);
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
return;
}
// validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
setBusy(false);
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return;
}
try {
const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]);
const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId);
if (room) {
viaMap.set(roomId, calculateRoomVia(room));
} else if (canonicalAlias) {
try {
const { servers } = await cli.getRoomIdForAlias(canonicalAlias);
viaMap.set(roomId, servers);
} catch (e) {
console.warn("Failed to resolve alias during community migration", e);
}
}
if (!viaMap.get(roomId)?.length) {
// XXX: lets guess the via, this might end up being incorrect.
const str = canonicalAlias || roomId;
viaMap.set(roomId, [str.substring(1, str.indexOf(":"))]);
}
}
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: {
[CreateEventField]: groupId,
},
initial_state: rooms.map(({ roomId }) => ({
type: EventType.SpaceChild,
state_key: roomId,
content: {
via: viaMap.get(roomId) || [],
},
})),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()),
}, {
andView: false,
});
// eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId));
// don't bother awaiting this, as we don't hugely care if it fails
cli.setGroupProfile(groupId, {
...groupSummary.profile,
long_description: `<a href="${makeRoomPermalink(roomId)}"><h1>` +
_t("This community has been upgraded into a Space") + `</h1></a><br />`
+ groupSummary.profile.long_description,
} as IGroupSummary["profile"]).catch(e => {
console.warn("Failed to update community profile during migration", e);
});
onFinished(roomId);
const onSpaceClick = () => {
dis.dispatch({
action: "view_room",
room_id: roomId,
});
};
const onPreferencesClick = () => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
};
let spacesDisabledCopy;
if (!SpaceStore.spacesEnabled) {
spacesDisabledCopy = _t("To view Spaces, hide communities in <a>Preferences</a>", {}, {
a: sub => <AccessibleButton onClick={onPreferencesClick} kind="link">{ sub }</AccessibleButton>,
});
}
Modal.createDialog(InfoDialog, {
title: _t("Space created"),
description: <>
<div className="mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog_checkmark" />
<p>
{ _t("<SpaceName/> has been made and everyone who was a part of the community has " +
"been invited to it.", {}, {
SpaceName: () => <AccessibleButton onClick={onSpaceClick} kind="link">
{ name }
</AccessibleButton>,
}) }
&nbsp;
{ spacesDisabledCopy }
</p>
<p>
{ _t("To create a Space from another community, just pick the community in Preferences.") }
</p>
</>,
button: _t("Preferences"),
onFinished: (openPreferences: boolean) => {
if (openPreferences) {
onPreferencesClick();
}
},
}, "mx_CreateSpaceFromCommunityDialog_SuccessInfoDialog");
} catch (e) {
console.error(e);
setError(e);
}
setBusy(false);
};
let footer;
if (error) {
footer = <>
<img src={require("../../../../res/img/element-icons/warning-badge.svg")} height="24" width="24" alt="" />
<span className="mx_CreateSpaceFromCommunityDialog_error">
<div className="mx_CreateSpaceFromCommunityDialog_errorHeading">{ _t("Failed to migrate community") }</div>
<div className="mx_CreateSpaceFromCommunityDialog_errorCaption">{ _t("Try again") }</div>
</span>
<AccessibleButton className="mx_CreateSpaceFromCommunityDialog_retryButton" onClick={onCreateSpaceClick}>
{ _t("Retry") }
</AccessibleButton>
</>;
} else {
footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") }
</AccessibleButton>
</>;
}
return <BaseDialog
title={_t("Create Space from community")}
className="mx_CreateSpaceFromCommunityDialog"
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_CreateSpaceFromCommunityDialog_content">
<p>
{ _t("A link to the Space will be put in your community description.") }
&nbsp;
{ _t("All rooms will be added and all community members will be invited.") }
</p>
<p className="mx_CreateSpaceFromCommunityDialog_flairNotice">
{ _t("Flair won't be available in Spaces for the foreseeable future.") }
</p>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSpaceClick}
avatarUrl={groupSummary.profile.avatar_url
? mediaFromMxc(groupSummary.profile.avatar_url).getThumbnailOfSourceHttp(80, 80, "crop")
: undefined
}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<p>{ _t("This description will be shown to people when they view your space") }</p>
<JoinRuleDropdown
label={_t("Space visibility")}
labelInvite={_t("Private space (invite only)")}
labelPublic={_t("Public space")}
value={joinRule}
onChange={setJoinRule}
/>
<p>{ joinRule === JoinRule.Public
? _t("Open space for anyone, best for communities")
: _t("Invite only, best for yourself or teams")
}</p>
{ joinRule !== JoinRule.Public &&
<div className="mx_CreateSpaceFromCommunityDialog_nonPublicSpacer" />
}
</SpaceCreateForm>
</div>
<div className="mx_CreateSpaceFromCommunityDialog_footer">
{ footer }
</div>
</BaseDialog>;
};
export default CreateSpaceFromCommunityDialog;

View file

@ -16,8 +16,7 @@ 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 { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t } from '../../../languageHandler';
import BaseDialog from "./BaseDialog";
@ -27,8 +26,7 @@ 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 { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
@ -81,28 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
}
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,
});
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace });
onFinished(true);
} catch (e) {

View file

@ -114,7 +114,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
UserTab.Preferences,
_td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon",
<PreferencesUserSettingsTab />,
<PreferencesUserSettingsTab closeSettingsFn={this.props.onFinished} />,
));
if (SettingsStore.getValue(UIFeature.Voip)) {

View file

@ -67,7 +67,9 @@ export default function AccessibleButton({
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
if (disabled) {
newProps["aria-disabled"] = true;
} else {
newProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
@ -118,7 +120,7 @@ export default function AccessibleButton({
);
// React.createElement expects InputHTMLAttributes
return React.createElement(element, restProps, children);
return React.createElement(element, newProps, children);
}
AccessibleButton.defaultProps = {

View file

@ -18,7 +18,7 @@ limitations under the License.
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
import classnames from 'classnames';
import AccessibleButton from './AccessibleButton';
import AccessibleButton, { ButtonEvent } from './AccessibleButton';
import { _t } from '../../../languageHandler';
import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -178,7 +178,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.ignoreEvent = ev;
};
private onInputClick = (ev: React.MouseEvent) => {
private onAccessibleButtonClick = (ev: ButtonEvent) => {
if (this.props.disabled) return;
if (!this.state.expanded) {
@ -186,6 +186,10 @@ export default class Dropdown extends React.Component<IProps, IState> {
expanded: true,
});
ev.preventDefault();
} else if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
this.props.onOptionChange(this.state.highlightedOption);
this.close();
}
};
@ -204,7 +208,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
this.props.onOptionChange(dropdownKey);
};
private onInputKeyDown = (e: React.KeyboardEvent) => {
private onKeyDown = (e: React.KeyboardEvent) => {
let handled = true;
// These keys don't generate keypress events and so needs to be on keyup
@ -269,7 +273,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
private prevOption(optionKey: string): string {
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[(index - 1) % keys.length];
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
}
private scrollIntoView(node: Element) {
@ -320,7 +324,6 @@ export default class Dropdown extends React.Component<IProps, IState> {
type="text"
autoFocus={true}
className="mx_Dropdown_option"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}
value={this.state.searchQuery}
role="combobox"
@ -329,6 +332,7 @@ export default class Dropdown extends React.Component<IProps, IState> {
aria-owns={`${this.props.id}_listbox`}
aria-disabled={this.props.disabled}
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
/>
);
}
@ -361,13 +365,14 @@ export default class Dropdown extends React.Component<IProps, IState> {
return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
<AccessibleButton
className="mx_Dropdown_input mx_no_textinput"
onClick={this.onInputClick}
onClick={this.onAccessibleButtonClick}
aria-haspopup="listbox"
aria-expanded={this.state.expanded}
disabled={this.props.disabled}
inputRef={this.buttonRef}
aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`}
onKeyDown={this.onKeyDown}
>
{ currentValue }
<span className="mx_Dropdown_arrow" />

View file

@ -25,12 +25,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media";
import { Media, mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages";
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
import ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
import { IBodyProps } from "./IBodyProps";
import classNames from 'classnames';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
interface IState {
decryptedUrl?: string;
@ -157,21 +159,23 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// this is only used as a fallback in case content.info.w/h is missing
loadedImageDimensions = { naturalWidth, naturalHeight };
}
this.setState({ imgLoaded: true, loadedImageDimensions });
};
protected getContentUrl(): string {
const content: IMediaEventContent= this.props.mxEvent.getContent();
const content: IMediaEventContent = this.props.mxEvent.getContent();
if (this.props.forExport) return content.url || content.file.url;
const media = mediaFromContent(content);
if (media.isEncrypted) {
if (this.media.isEncrypted) {
return this.state.decryptedUrl;
} else {
return media.srcHttp;
return this.media.srcHttp;
}
}
private get media(): Media {
return mediaFromContent(this.props.mxEvent.getContent());
}
protected getThumbUrl(): string {
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
@ -227,7 +231,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
info.w > thumbWidth ||
info.h > thumbHeight
);
const isLargeFileSize = info.size > 1*1024*1024; // 1mb
const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb
if (isLargeFileSize && isLargerThanThumbnail) {
// image is too large physically and bytewise to clutter our timeline so
@ -349,12 +353,21 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
className="mx_MImageBody_thumbnail"
src={thumbUrl}
ref={this.image}
style={{ maxWidth: `min(100%, ${maxWidth}px)` }}
// Force the image to be the full size of the container, even if the
// pixel size is smaller. The problem here is that we don't know what
// thumbnail size the HS is going to give us, but we have to commit to
// a container size immediately and not change it when the image loads
// or we'll get a scroll jump (or have to leave blank space).
// This will obviously result in an upscaled image which will be a bit
// blurry. The best fix would be for the HS to advertise what size thumbnails
// it guarantees to produce.
style={{ height: '100%' }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
onMouseLeave={this.onImageLeave}
/>
);
}
@ -367,21 +380,41 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
}
const classes = classNames({
'mx_MImageBody_thumbnail': true,
'mx_MImageBody_thumbnail--blurhash': this.props.mxEvent.getContent().info[BLURHASH_FIELD],
});
// This has incredibly broken types.
const C = CSSTransition as any;
const thumbnail = (
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
{ showPlaceholder &&
<div
className="mx_MImageBody_thumbnail"
style={{
// Constrain width here so that spinner appears central to the loaded thumbnail
maxWidth: `min(100%, ${infoWidth}px)`,
}}
<SwitchTransition mode="out-in">
<C
classNames="mx_rtg--fade"
key={`img-${showPlaceholder}`}
timeout={300}
>
{ placeholder }
</div>
}
{ /* This weirdly looking div is necessary here, otherwise SwitchTransition fails */ }
<div>
{ showPlaceholder && <div
className={classes}
style={{
// Constrain width here so that spinner appears central to the loaded thumbnail
maxWidth: `min(100%, ${infoWidth}px)`,
maxHeight: maxHeight,
aspectRatio: `${infoWidth}/${infoHeight}`,
}}
>
{ placeholder }
</div> }
</div>
</C>
</SwitchTransition>
<div style={{ display: !showPlaceholder ? undefined : 'none' }}>
<div style={{
height: '100%',
}}>
{ img }
{ gifLabel }
</div>
@ -403,7 +436,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
// Overidden by MStickerBody
protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
if (blurhash) return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
return (
<InlineSpinner w={32} h={32} />
);
@ -446,10 +479,12 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
const fileBody = this.getFileBody();
return <div className="mx_MImageBody">
{ thumbnail }
{ fileBody }
</div>;
return (
<div className="mx_MImageBody">
{ thumbnail }
{ fileBody }
</div>
);
}
}

View file

@ -851,7 +851,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
return <div />;
};
interface GroupMember {
export interface GroupMember {
userId: string;
displayname?: string; // XXX: GroupMember objects are inconsistent :((
avatarUrl?: string;

View file

@ -31,7 +31,7 @@ import {
} from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
import { getAutoCompleteCreator } from '../../../editor/parts';
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
import { renderModel } from '../../../editor/render';
import TypingStore from "../../../stores/TypingStore";
@ -169,7 +169,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate);
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
@ -558,9 +558,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && part.text[offset] !== "+" && (
part.type === "plain" ||
part.type === "pill-candidate" ||
part.type === "command"
part.type === Type.Plain ||
part.type === Type.PillCandidate ||
part.type === Type.Command
);
});
const { partCreator } = model;

View file

@ -25,7 +25,7 @@ import { getCaretOffsetAndText } from '../../../editor/dom';
import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize';
import { findEditableEvent } from '../../../utils/EventUtils';
import { parseEvent } from '../../../editor/deserialize';
import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, Type } from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -242,12 +242,12 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}
@ -268,7 +268,7 @@ export default class EditMessageComposer extends React.Component<IProps, IState>
private getSlashCommand(): [Command, string, string] {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
if (part.type === Type.UserPill) {
return text + part.resourceId;
}
return text + part.text;

View file

@ -31,7 +31,7 @@ import {
textSerialize,
unescapeMessage,
} from '../../../editor/serialize';
import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts';
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils';
@ -240,14 +240,14 @@ export default class SendMessageComposer extends React.Component<IProps> {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
if (firstPart.type === Type.Command && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
// be extra resilient when somehow the AutocompleteWrapperModel or
// CommandPartCreator fails to insert a command part, so we don't send
// a command as a message
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
&& (firstPart.type === Type.Plain || firstPart.type === Type.PillCandidate)) {
return true;
}
}

View file

@ -79,7 +79,7 @@ export default class LabsUserSettingsTab extends React.Component {
let hiddenReadReceipts;
if (this.state.showHiddenReadReceipts) {
hiddenReadReceipts = (
<SettingsFlag name="feature_hidden_read_receipts" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="feature_hidden_read_receipts" level={SettingLevel.DEVICE} />
);
}

View file

@ -15,7 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import SettingsStore from "../../../../../settings/SettingsStore";
@ -27,6 +29,18 @@ import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
import GroupAvatar from "../../../avatars/GroupAvatar";
import dis from "../../../../../dispatcher/dispatcher";
import GroupActions from "../../../../../actions/GroupActions";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { useDispatcher } from "../../../../../hooks/useDispatcher";
import { CreateEventField, IGroupSummary } from "../../../dialogs/CreateSpaceFromCommunityDialog";
import { createSpaceFromCommunity } from "../../../../../utils/space";
import Spinner from "../../../elements/Spinner";
interface IProps {
closeSettingsFn(success: boolean): void;
}
interface IState {
autoLaunch: boolean;
@ -42,8 +56,86 @@ interface IState {
readMarkerOutOfViewThresholdMs: string;
}
type Community = IGroupSummary & {
groupId: string;
spaceId?: string;
};
const CommunityMigrator = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [communities, setCommunities] = useState<Community[]>(null);
useEffect(() => {
dis.dispatch(GroupActions.fetchJoinedGroups(cli));
}, [cli]);
useDispatcher(dis, async payload => {
if (payload.action === "GroupActions.fetchJoinedGroups.success") {
const communities: Community[] = [];
const migratedSpaceMap = new Map(cli.getRooms().map(room => {
const createContent = room.currentState.getStateEvents(EventType.RoomCreate, "")?.getContent();
if (createContent?.[CreateEventField]) {
return [createContent[CreateEventField], room.roomId] as [string, string];
}
}).filter(Boolean));
for (const groupId of payload.result.groups) {
const summary = await cli.getGroupSummary(groupId) as IGroupSummary;
if (summary.user.is_privileged) {
communities.push({
...summary,
groupId,
spaceId: migratedSpaceMap.get(groupId),
});
}
}
setCommunities(communities);
}
});
if (!communities) {
return <Spinner />;
}
return <div className="mx_PreferencesUserSettingsTab_CommunityMigrator">
{ communities.map(community => (
<div key={community.groupId}>
<GroupAvatar
groupId={community.groupId}
groupAvatarUrl={community.profile.avatar_url}
groupName={community.profile.name}
width={32}
height={32}
/>
{ community.profile.name }
<AccessibleButton
kind="primary_outline"
onClick={() => {
if (community.spaceId) {
dis.dispatch({
action: "view_room",
room_id: community.spaceId,
});
onFinished();
} else {
createSpaceFromCommunity(cli, community.groupId).then(([spaceId]) => {
if (spaceId) {
community.spaceId = spaceId;
setCommunities([...communities]); // force component re-render
}
});
}
}}
>
{ community.spaceId ? _t("Open Space") : _t("Create Space") }
</AccessibleButton>
</div>
)) }
</div>;
};
@replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab")
export default class PreferencesUserSettingsTab extends React.Component<{}, IState> {
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
static ROOM_LIST_SETTINGS = [
'breadcrumbs',
];
@ -52,6 +144,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
"Spaces.allRoomsInHome",
];
static COMMUNITIES_SETTINGS = [
// TODO: part of delabsing move the toggle here - https://github.com/vector-im/element-web/issues/18088
];
static KEYBINDINGS_SETTINGS = [
'ctrlFForSearch',
];
@ -242,6 +338,19 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
</div> }
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Communities") }</span>
<p>{ _t("Communities have been archived to make way for Spaces but you can convert your " +
"communities into Spaces below. Converting will ensure your conversations get the latest " +
"features.") }</p>
<details>
<summary>{ _t("Show my Communities") }</summary>
<p>{ _t("If a community isn't shown you may not have permission to convert it.") }</p>
<CommunityMigrator onFinished={this.props.closeSettingsFn} />
</details>
{ this.renderGroup(PreferencesUserSettingsTab.COMMUNITIES_SETTINGS) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>

View file

@ -65,6 +65,7 @@ export const SpaceAvatar = ({
}}
kind="link"
className="mx_SpaceBasicSettings_avatar_remove"
aria-label={_t("Delete avatar")}
>
{ _t("Delete") }
</AccessibleButton>
@ -72,7 +73,11 @@ export const SpaceAvatar = ({
} else {
avatarSection = <React.Fragment>
<div className="mx_SpaceBasicSettings_avatar" onClick={() => avatarUploadRef.current?.click()} />
<AccessibleButton onClick={() => avatarUploadRef.current?.click()} kind="link">
<AccessibleButton
onClick={() => avatarUploadRef.current?.click()}
kind="link"
aria-label={_t("Upload avatar")}
>
{ _t("Upload") }
</AccessibleButton>
</React.Fragment>;

View file

@ -18,22 +18,59 @@ import React, { ComponentProps, RefObject, SyntheticEvent, useContext, useRef, u
import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { ChevronFace, ContextMenu } from "../../structures/ContextMenu";
import createRoom from "../../../createRoom";
import createRoom, { IOpts as ICreateOpts } from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import SpaceBasicSettings, { SpaceAvatar } from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import RoomAliasField from "../elements/RoomAliasField";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import GenericFeatureFeedbackDialog from "../dialogs/GenericFeatureFeedbackDialog";
import SettingsStore from "../../../settings/SettingsStore";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserSettingsDialog";
export const createSpace = async (
name: string,
isPublic: boolean,
alias?: string,
topic?: string,
avatar?: string | File,
createOpts: Partial<ICreateRoomOpts> = {},
otherOpts: Partial<Omit<ICreateOpts, "createOpts">> = {},
) => {
return createRoom({
createOpts: {
name,
preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100,
...isPublic ? { invite: 0 } : {},
},
room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
topic,
...createOpts,
},
avatar,
roomType: RoomType.Space,
historyVisibility: isPublic ? HistoryVisibility.WorldReadable : HistoryVisibility.Invited,
spinner: false,
encryption: false,
andView: true,
inlineErrors: true,
...otherOpts,
});
};
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
@ -92,7 +129,7 @@ export const SpaceFeedbackPrompt = ({ onClick }: { onClick?: () => void }) => {
</div>;
};
type BProps = Pick<ComponentProps<typeof SpaceBasicSettings>, "setAvatar" | "name" | "setName" | "topic" | "setTopic">;
type BProps = Omit<ComponentProps<typeof SpaceBasicSettings>, "nameDisabled" | "topicDisabled" | "avatarDisabled">;
interface ISpaceCreateFormProps extends BProps {
busy: boolean;
alias: string;
@ -106,6 +143,7 @@ interface ISpaceCreateFormProps extends BProps {
export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
busy,
onSubmit,
avatarUrl,
setAvatar,
name,
setName,
@ -122,7 +160,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
const domain = cli.getDomain();
return <form className="mx_SpaceBasicSettings" onSubmit={onSubmit}>
<SpaceAvatar setAvatar={setAvatar} avatarDisabled={busy} />
<SpaceAvatar avatarUrl={avatarUrl} setAvatar={setAvatar} avatarDisabled={busy} />
<Field
name="spaceName"
@ -200,30 +238,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
}
try {
await createRoom({
createOpts: {
preset: visibility === Visibility.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,
...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,
inlineErrors: true,
});
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar);
onFinished();
} catch (e) {
@ -233,10 +248,23 @@ const SpaceCreateMenu = ({ onFinished }) => {
let body;
if (visibility === null) {
const onCreateSpaceFromCommunityClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Preferences,
});
onFinished();
};
body = <React.Fragment>
<h2>{ _t("Create a space") }</h2>
<p>{ _t("Spaces are a new way to group rooms and people. " +
"To join an existing space you'll need an invite.") }</p>
<p>
{ _t("Spaces are a new way to group rooms and people.") }
&nbsp;
{ _t("What kind of Space do you want to create?") }
&nbsp;
{ _t("You can change this later.") }
</p>
<SpaceCreateMenuType
title={_t("Public")}
@ -251,7 +279,15 @@ const SpaceCreateMenu = ({ onFinished }) => {
onClick={() => setVisibility(Visibility.Private)}
/>
<p>{ _t("You can change this later") }</p>
<p>
{ _t("You can also create a Space from a <a>community</a>.", {}, {
a: sub => <AccessibleButton kind="link" onClick={onCreateSpaceFromCommunityClick}>
{ sub }
</AccessibleButton>,
}) }
<br />
{ _t("To join an existing space you'll need an invite.") }
</p>
<SpaceFeedbackPrompt onClick={onFinished} />
</React.Fragment>;

View file

@ -100,9 +100,12 @@ const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
return SpaceStore.instance.allRoomsInHome;
});
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
return <li
className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}
role="treeitem"
>
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
@ -142,9 +145,12 @@ const CreateSpaceButton = ({
openMenu();
};
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
return <li
className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}
role="treeitem"
>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
@ -272,6 +278,8 @@ const SpacePanel = () => {
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
role="tree"
aria-label={_t("Spaces")}
>
<Droppable droppableId="top-level-spaces">
{ (provided, snapshot) => (

View file

@ -77,11 +77,17 @@ export const SpaceButton: React.FC<IButtonProps> = ({
let notifBadge;
if (notificationState) {
let ariaLabel = _t("Jump to first unread room.");
if (space?.getMyMembership() === "invite") {
ariaLabel = _t("Jump to first invite.");
}
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
aria-label={ariaLabel}
/>
</div>;
}
@ -107,7 +113,6 @@ export const SpaceButton: React.FC<IButtonProps> = ({
onClick={onClick}
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
role="treeitem"
inputRef={handle}
>
{ children }
@ -284,7 +289,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/> : null;
return (
<li {...otherProps} className={itemClasses} ref={innerRef}>
<li {...otherProps} className={itemClasses} ref={innerRef} aria-expanded={!collapsed} role="treeitem">
<SpaceButton
space={space}
className={isInvite ? "mx_SpaceButton_invite" : undefined}
@ -296,9 +301,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
avatarSize={isNested ? 24 : 32}
onClick={this.onClick}
onKeyDown={this.onKeyDown}
aria-expanded={!collapsed}
ContextMenuComponent={this.props.space.getMyMembership() === "join"
? SpaceContextMenu : undefined}
ContextMenuComponent={this.props.space.getMyMembership() === "join" ? SpaceContextMenu : undefined}
>
{ toggleCollapseButton }
</SpaceButton>
@ -322,7 +325,7 @@ const SpaceTreeLevel: React.FC<ITreeLevelProps> = ({
isNested,
parents,
}) => {
return <ul className="mx_SpaceTreeLevel">
return <ul className="mx_SpaceTreeLevel" role="group">
{ spaces.map(s => {
return (<SpaceItem
key={s.roomId}

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 - 2021 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");
@ -27,15 +27,7 @@ import { CallEvent, CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/we
import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton';
import { isOnlyCtrlOrCmdKeyEvent, Key } from '../../../Keyboard';
import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
} from '../../structures/ContextMenu';
import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
@ -43,8 +35,7 @@ import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar';
import CallViewHeader from './CallView/CallViewHeader';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Alignment } from "../elements/Tooltip";
import CallViewButtons from "./CallView/CallViewButtons";
interface IProps {
// The call for us to display
@ -83,8 +74,6 @@ interface IState {
sidebarShown: boolean;
}
const tooltipYOffset = -24;
function getFullScreenElement() {
return (
document.fullscreenElement ||
@ -113,18 +102,11 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
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)
@replaceableComponent("views.voip.CallView")
export default class CallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentRef = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private buttonsRef = createRef<CallViewButtons>();
constructor(props: IProps) {
super(props);
@ -153,7 +135,6 @@ export default class CallView extends React.Component<IProps, IState> {
public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener('keydown', this.onNativeKeyDown);
this.showControls();
}
public componentWillUnmount() {
@ -241,16 +222,8 @@ 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,
});
};
private onMouseMove = () => {
this.showControls();
this.buttonsRef.current?.showControls();
};
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
@ -276,29 +249,6 @@ export default class CallView extends React.Component<IProps, IState> {
return { primary, secondary };
}
private showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
this.setState({
controlsVisible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
this.setState({ showDialpad: true });
this.showControls();
} else {
this.setState({ showDialpad: false });
}
};
private onMicMuteClick = (): void => {
const newVal = !this.state.micMuted;
@ -329,19 +279,6 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private onMoreClick = (): void => {
this.setState({ showMoreMenu: true });
this.showControls();
};
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
@ -354,7 +291,7 @@ export default class CallView extends React.Component<IProps, IState> {
if (ctrlCmdOnly) {
this.onMicMuteClick();
// show the controls to give feedback
this.showControls();
this.buttonsRef.current?.showControls();
handled = true;
}
break;
@ -363,7 +300,7 @@ export default class CallView extends React.Component<IProps, IState> {
if (ctrlCmdOnly) {
this.onVidMuteClick();
// show the controls to give feedback
this.showControls();
this.buttonsRef.current?.showControls();
handled = true;
}
break;
@ -375,15 +312,6 @@ export default class CallView extends React.Component<IProps, IState> {
}
};
private onCallControlsMouseEnter = (): void => {
this.setState({ hoveringControls: true });
this.showControls();
};
private onCallControlsMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
@ -402,206 +330,60 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onToggleSidebar = (): void => {
this.setState({
sidebarShown: !this.state.sidebarShown,
});
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,
mx_CallView_callControls_button_micOff: this.state.micMuted,
});
const vidClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
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({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: this.state.micMuted,
mx_CallView_callControls_button_micOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const vidCacheClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_vidOn: this.state.micMuted,
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
mx_CallView_callControls_button_invisible: true,
});
const callControlsClasses = classNames({
mx_CallView_callControls: true,
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
// 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 = (
<AccessibleTooltipButton
className={vidClasses}
onClick={this.onVidMuteClick}
title={this.state.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
);
}
const vidMuteButtonShown = this.props.call.type === CallType.Video;
// 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 (
const screensharingButtonShown = (
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
this.props.call.state === CallState.Connected
) {
screensharingButton = (
<AccessibleTooltipButton
className={screensharingClasses}
onClick={this.onScreenshareClick}
title={this.state.screensharing
? _t("Stop sharing your screen")
: _t("Start sharing your screen")
}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
);
}
);
// 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}
aria-label={this.state.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
/>
);
}
const sidebarButtonShown = (
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
this.props.call.isScreensharing()
);
// The dial pad & 'more' button actions are only relevant in a connected call
let contextMenuButton;
if (this.state.callState === CallState.Connected) {
contextMenuButton = (
<ContextMenuTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
title={_t("More")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
);
}
let dialpadButton;
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
dialpadButton = (
<ContextMenuTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
);
}
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
// We mount the context menus as a as a child typically in order to include the
// context menus when fullscreening the call content.
// However, this does not work as well when the call is embedded in a
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
mountAsChild={!this.props.pipMode}
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,
)}
mountAsChild={!this.props.pipMode}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
const contextMenuButtonShown = this.state.callState === CallState.Connected;
const dialpadButtonShown = (
this.state.callState === CallState.Connected &&
this.props.call.opponentSupportsDTMF()
);
return (
<div
className={callControlsClasses}
onMouseEnter={this.onCallControlsMouseEnter}
onMouseLeave={this.onCallControlsMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton }
<AccessibleTooltipButton
className={micClasses}
onClick={this.onMicMuteClick}
title={this.state.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
<AccessibleTooltipButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={this.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
yOffset={tooltipYOffset}
/>
</div>
<CallViewButtons
ref={this.buttonsRef}
call={this.props.call}
pipMode={this.props.pipMode}
handlers={{
onToggleSidebarClick: this.onToggleSidebar,
onScreenshareClick: this.onScreenshareClick,
onHangupClick: this.onHangupClick,
onMicMuteClick: this.onMicMuteClick,
onVidMuteClick: this.onVidMuteClick,
}}
buttonsState={{
micMuted: this.state.micMuted,
vidMuted: this.state.vidMuted,
sidebarShown: this.state.sidebarShown,
screensharing: this.state.screensharing,
}}
buttonsVisibility={{
vidMute: vidMuteButtonShown,
screensharing: screensharingButtonShown,
sidebar: sidebarButtonShown,
contextMenu: contextMenuButtonShown,
dialpad: dialpadButtonShown,
}}
/>
);
}

View file

@ -0,0 +1,315 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 - 2021 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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef } from "react";
import classNames from "classnames";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import CallContextMenu from "../../context_menus/CallContextMenu";
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
import AccessibleButton from "../../elements/AccessibleButton";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { Alignment } from "../../elements/Tooltip";
import {
alwaysAboveLeftOf,
alwaysAboveRightOf,
ChevronFace,
ContextMenuTooltipButton,
} from '../../../structures/ContextMenu';
import { _t } from "../../../../languageHandler";
// 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)
const TOOLTIP_Y_OFFSET = -24;
const CONTROLS_HIDE_DELAY = 2000;
interface IProps {
call: MatrixCall;
pipMode: boolean;
handlers: {
onHangupClick: () => void;
onScreenshareClick: () => void;
onToggleSidebarClick: () => void;
onMicMuteClick: () => void;
onVidMuteClick: () => void;
};
buttonsState: {
micMuted: boolean;
vidMuted: boolean;
sidebarShown: boolean;
screensharing: boolean;
};
buttonsVisibility: {
screensharing: boolean;
vidMute: boolean;
sidebar: boolean;
dialpad: boolean;
contextMenu: boolean;
};
}
interface IState {
visible: boolean;
showDialpad: boolean;
hoveringControls: boolean;
showMoreMenu: boolean;
}
export default class CallViewButtons extends React.Component<IProps, IState> {
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
constructor(props: IProps) {
super(props);
this.state = {
showDialpad: false,
hoveringControls: false,
showMoreMenu: false,
visible: true,
};
}
public componentDidMount(): void {
this.showControls();
}
public showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.visible) {
this.setState({
visible: true,
});
}
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onControlsHideTimer = (): void => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null;
this.setState({ visible: false });
};
private onMouseEnter = (): void => {
this.setState({ hoveringControls: true });
};
private onMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
this.setState({ showDialpad: true });
this.showControls();
} else {
this.setState({ showDialpad: false });
}
};
private onMoreClick = (): void => {
this.setState({ showMoreMenu: true });
this.showControls();
};
private closeDialpad = (): void => {
this.setState({ showDialpad: false });
};
private closeContextMenu = (): void => {
this.setState({ showMoreMenu: false });
};
public render(): JSX.Element {
const micClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_micOn: !this.props.buttonsState.micMuted,
mx_CallViewButtons_button_micOff: this.props.buttonsState.micMuted,
});
const vidClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_vidOn: !this.props.buttonsState.vidMuted,
mx_CallViewButtons_button_vidOff: this.props.buttonsState.vidMuted,
});
const screensharingClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_screensharingOn: this.props.buttonsState.screensharing,
mx_CallViewButtons_button_screensharingOff: !this.props.buttonsState.screensharing,
});
const sidebarButtonClasses = classNames("mx_CallViewButtons_button", {
mx_CallViewButtons_button_sidebarOn: this.props.buttonsState.sidebarShown,
mx_CallViewButtons_button_sidebarOff: !this.props.buttonsState.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("mx_CallViewButtons_button", "mx_CallViewButtons_button_invisible", {
mx_CallViewButtons_button_micOn: this.props.buttonsState.micMuted,
mx_CallViewButtons_button_micOff: !this.props.buttonsState.micMuted,
});
const vidCacheClasses = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_button_invisible", {
mx_CallViewButtons_button_vidOn: this.props.buttonsState.micMuted,
mx_CallViewButtons_button_vidOff: !this.props.buttonsState.micMuted,
});
const callControlsClasses = classNames("mx_CallViewButtons", {
mx_CallViewButtons_hidden: !this.state.visible,
});
let vidMuteButton;
if (this.props.buttonsVisibility.vidMute) {
vidMuteButton = (
<AccessibleTooltipButton
className={vidClasses}
onClick={this.props.handlers.onVidMuteClick}
title={this.props.buttonsState.vidMuted ? _t("Start the camera") : _t("Stop the camera")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let screensharingButton;
if (this.props.buttonsVisibility.screensharing) {
screensharingButton = (
<AccessibleTooltipButton
className={screensharingClasses}
onClick={this.props.handlers.onScreenshareClick}
title={this.props.buttonsState.screensharing
? _t("Stop sharing your screen")
: _t("Start sharing your screen")
}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let sidebarButton;
if (this.props.buttonsVisibility.sidebar) {
sidebarButton = (
<AccessibleButton
className={sidebarButtonClasses}
onClick={this.props.handlers.onToggleSidebarClick}
aria-label={this.props.buttonsState.sidebarShown ? _t("Hide sidebar") : _t("Show sidebar")}
/>
);
}
let contextMenuButton;
if (this.props.buttonsVisibility.contextMenu) {
contextMenuButton = (
<ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
title={_t("More")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let dialpadButton;
if (this.props.buttonsVisibility.dialpad) {
dialpadButton = (
<ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
);
}
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
// We mount the context menus as a as a child typically in order to include the
// context menus when fullscreening the call content.
// However, this does not work as well when the call is embedded in a
// picture-in-picture frame. Thus, only mount as child when we are *not* in PiP.
mountAsChild={!this.props.pipMode}
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,
)}
mountAsChild={!this.props.pipMode}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
return (
<div
className={callControlsClasses}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton }
<AccessibleTooltipButton
className={micClasses}
onClick={this.props.handlers.onMicMuteClick}
title={this.props.buttonsState.micMuted ? _t("Unmute the microphone") : _t("Mute the microphone")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
<AccessibleTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_hangup"
onClick={this.props.handlers.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}
yOffset={TOOLTIP_Y_OFFSET}
/>
</div>
);
}
}