Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/ts/8

 Conflicts:
	src/components/structures/AutoHideScrollbar.tsx
This commit is contained in:
Michael Telatynski 2021-06-24 16:42:03 +01:00
commit c5a1da49ce
97 changed files with 2840 additions and 1523 deletions

View file

@ -18,6 +18,7 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import Field from "../elements/Field";
import Dialpad from '../voip/DialPad';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@ -44,13 +45,21 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.setState({value: this.state.value + digit});
}
onChange = (ev) => {
this.setState({value: ev.target.value});
}
render() {
return <ContextMenu {...this.props}>
<div className="mx_DialPadContextMenu_header">
<div>
<span className="mx_DialPadContextMenu_title">{_t("Dial pad")}</span>
</div>
<div className="mx_DialPadContextMenu_dialled">{this.state.value}</div>
<Field className="mx_DialPadContextMenu_dialled"
value={this.state.value} autoFocus={true}
onChange={this.onChange}
/>
</div>
<div className="mx_DialPadContextMenu_horizSep" />
<div className="mx_DialPadContextMenu_dialPad">

View file

@ -179,7 +179,7 @@ export default class MessageContextMenu extends React.Component {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids,
...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []),
eventId,
],
});

View file

@ -23,45 +23,70 @@ import TagOrderActions from '../../../actions/TagOrderActions';
import {MenuItem} from "../../structures/ContextMenu";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
@replaceableComponent("views.context_menus.TagTileContextMenu")
export default class TagTileContextMenu extends React.Component {
static propTypes = {
tag: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func.isRequired,
};
static contextType = MatrixClientContext;
constructor() {
super();
this._onViewCommunityClick = this._onViewCommunityClick.bind(this);
this._onRemoveClick = this._onRemoveClick.bind(this);
}
_onViewCommunityClick() {
_onViewCommunityClick = () => {
dis.dispatch({
action: 'view_group',
group_id: this.props.tag,
});
this.props.onFinished();
}
};
_onRemoveClick() {
_onRemoveClick = () => {
dis.dispatch(TagOrderActions.removeTag(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();
};
_onMoveDown = () => {
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.tag, this.props.index + 1));
this.props.onFinished();
};
render() {
let moveUp;
let moveDown;
if (this.props.index > 0) {
moveUp = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveUp" onClick={this._onMoveUp}>
{ _t("Move up") }
</MenuItem>
);
}
if (this.props.index < (GroupFilterOrderStore.getOrderedTags() || []).length - 1) {
moveDown = (
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_moveDown" onClick={this._onMoveDown}>
{ _t("Move down") }
</MenuItem>
);
}
return <div>
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_viewCommunity" onClick={this._onViewCommunityClick}>
{ _t('View Community') }
</MenuItem>
{ (moveUp || moveDown) ? <hr className="mx_TagTileContextMenu_separator" role="separator" /> : null }
{ moveUp }
{ moveDown }
<hr className="mx_TagTileContextMenu_separator" role="separator" />
<MenuItem className="mx_TagTileContextMenu_item mx_TagTileContextMenu_hideCommunity" onClick={this._onRemoveClick}>
{ _t('Hide') }
{ _t("Unpin") }
</MenuItem>
</div>;
}

View file

@ -39,6 +39,9 @@ import ProgressBar from "../elements/ProgressBar";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -204,6 +207,17 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
setSelectedToAdd(new Set(selectedToAdd));
} : null;
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
@ -216,16 +230,21 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
{ rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3>
{ rooms.map(room => {
return <Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={onChange ? (checked) => {
onChange(checked, room);
} : null}
/>;
}) }
<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>
) : undefined }

View file

@ -24,7 +24,7 @@ import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { addressTypes, getAddressType } from '../../../UserAddress.js';
import { addressTypes, getAddressType } from '../../../UserAddress';
import GroupStore from '../../../stores/GroupStore';
import * as Email from '../../../email';
import IdentityAuthClient from '../../../IdentityAuthClient';

View file

@ -766,7 +766,7 @@ class VerificationExplorer extends React.PureComponent<IExplorerProps> {
render() {
const cli = this.context;
const room = this.props.room;
const inRoomChannel = cli.crypto._inRoomVerificationRequests;
const inRoomChannel = cli.crypto.inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div>

View file

@ -40,6 +40,9 @@ import NotificationBadge from "../rooms/NotificationBadge";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
const AVATAR_SIZE = 30;
@ -196,6 +199,17 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}).match(lcQuery);
}
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount, totalCount) {
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg")} name="..." width={36} height={36} />
} name={text} presenceState="online" suppressOnHover={true}
onClick={() => setTruncateAt(totalCount)} />
);
}
return <BaseDialog
title={_t("Forward message")}
className="mx_ForwardDialog"
@ -228,15 +242,20 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
<AutoHideScrollbar className="mx_ForwardList_content">
{ rooms.length > 0 ? (
<div className="mx_ForwardList_results">
{ rooms.map(room =>
<Entry
key={room.roomId}
room={room}
event={event}
matrixClient={cli}
onFinished={onFinished}
/>,
) }
<TruncatedList
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) => rooms.slice(start, end).map(room =>
<Entry
key={room.roomId}
room={room}
event={event}
matrixClient={cli}
onFinished={onFinished}
/>,
)}
getChildCount={() => rooms.length}
/>
</div>
) : <span className="mx_ForwardList_noResults">
{ _t("No results") }

View file

@ -17,37 +17,45 @@ limitations under the License.
import React, { createRef } from 'react';
import classNames from 'classnames';
import {_t, _td} from "../../../languageHandler";
import { _t, _td } from "../../../languageHandler";
import * as sdk from "../../../index";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks";
import DMRoomMap from "../../../utils/DMRoomMap";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import SdkConfig from "../../../SdkConfig";
import * as Email from "../../../email";
import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils";
import {abbreviateUrl} from "../../../utils/UrlUtils";
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils";
import { abbreviateUrl } from "../../../utils/UrlUtils";
import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
import {humanizeTime} from "../../../utils/humanize";
import { humanizeTime } from "../../../utils/humanize";
import createRoom, {
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
canEncryptToAllUsers,
ensureDMExists,
findDMForUser,
privateShouldBeEncrypted,
} from "../../../createRoom";
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
import {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
import {DefaultTagID} from "../../../stores/room-list/models";
import {
IInviteResult,
inviteMultipleToRoom,
showAnyInviteErrors,
showCommunityInviteDialog,
} from "../../../RoomInvite";
import { Key } from "../../../Keyboard";
import { Action } from "../../../dispatcher/actions";
import { DefaultTagID } from "../../../stores/room-list/models";
import RoomListStore from "../../../stores/room-list/RoomListStore";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import SettingsStore from "../../../settings/SettingsStore";
import {UIFeature} from "../../../settings/UIFeature";
import { UIFeature } from "../../../settings/UIFeature";
import CountlyAnalytics from "../../../CountlyAnalytics";
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
import { getAddressType } from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
@ -74,10 +82,10 @@ export const KIND_CALL_TRANSFER = "call_transfer";
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
// This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support
// This is the interface that is expected by various components in the Invite Dialog and RoomInvite.
// It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses.
abstract class Member {
export abstract class Member {
/**
* The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email).
@ -102,7 +110,8 @@ class DirectoryMember extends Member {
private readonly displayName: string;
private readonly avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
// eslint-disable-next-line camelcase
constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) {
super();
this._userId = userDirResult.user_id;
this.displayName = userDirResult.display_name;
@ -601,19 +610,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member}));
}
private shouldAbortAfterInviteError(result): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result);
this.setState({
busy: false,
errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", {
csvUsers: failedUsers.join(", "),
}),
});
return true; // abort
}
return false;
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
this.setState({ busy: false });
const userMap = new Map<string, Member>(this.state.targets.map(member => [member.userId, member]));
return !showAnyInviteErrors(result.states, room, result.inviter, userMap);
}
private convertFilter(): Member[] {
@ -731,7 +731,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too
if (!this.shouldAbortAfterInviteError(result, room)) { // handles setting error message too
this.props.onFinished();
}

View file

@ -63,7 +63,8 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
state: IState = {
disabledButtonIds: [],
disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter(b => b.disabled)
.map(b => b.id),
};
constructor(props) {

View file

@ -108,7 +108,10 @@ export default class RoomSettingsDialog extends React.Component<IProps> {
ROOM_ADVANCED_TAB,
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
<AdvancedRoomSettingsTab
roomId={this.props.roomId}
closeSettingsFn={() => this.props.onFinished(true)}
/>,
));
}

View file

@ -14,24 +14,27 @@ 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 {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType} from "matrix-js-sdk/src/@types/event";
import React, { useMemo } from 'react';
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import {_t} from '../../../languageHandler';
import {IDialogProps} from "./IDialogProps";
import { _t, _td } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import BaseDialog from "./BaseDialog";
import DevtoolsDialog from "./DevtoolsDialog";
import SpaceBasicSettings from '../spaces/SpaceBasicSettings';
import {getTopic} from "../elements/RoomTopic";
import {avatarUrlForRoom} from "../../../Avatar";
import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {useDispatcher} from "../../../hooks/useDispatcher";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
import { useDispatcher } from "../../../hooks/useDispatcher";
import TabbedView, { Tab } from "../../structures/TabbedView";
import SpaceSettingsGeneralTab from '../spaces/SpaceSettingsGeneralTab';
import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB",
Advanced = "SPACE_ADVANCED_TAB",
}
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
@ -45,63 +48,30 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
}
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const userId = cli.getUserId();
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId);
const avatarChanged = newAvatar !== null;
const [name, setName] = useState<string>(space.name);
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
const nameChanged = name !== space.name;
const currentTopic = getTopic(space);
const [topic, setTopic] = useState<string>(currentTopic);
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
const topicChanged = topic !== currentTopic;
const currentJoinRule = space.getJoinRule();
const [joinRule, setJoinRule] = useState(currentJoinRule);
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const joinRuleChanged = joinRule !== currentJoinRule;
const onSave = async () => {
setBusy(true);
const promises = [];
if (avatarChanged) {
if (newAvatar) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
}
if (nameChanged) {
promises.push(cli.setRoomName(space.roomId, name));
}
if (topicChanged) {
promises.push(cli.setRoomTopic(space.roomId, topic));
}
if (joinRuleChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
}
const results = await Promise.allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {
console.error("Failed to save space settings: ", failures);
setError(_t("Failed to save space settings."));
}
};
const tabs = useMemo(() => {
return [
new Tab(
SpaceSettingsTab.General,
_td("General"),
"mx_SpaceSettingsDialog_generalIcon",
<SpaceSettingsGeneralTab matrixClient={cli} space={space} onFinished={onFinished} />,
),
new Tab(
SpaceSettingsTab.Visibility,
_td("Visibility"),
"mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />,
),
SettingsStore.getValue(UIFeature.AdvancedSettings)
? new Tab(
SpaceSettingsTab.Advanced,
_td("Advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={space.roomId} closeSettingsFn={onFinished} />,
)
: null,
].filter(Boolean);
}, [cli, space, onFinished]);
return <BaseDialog
title={_t("Space settings")}
@ -110,61 +80,14 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_SpaceSettingsDialog_content" id="mx_SpaceSettingsDialog">
<div>{ _t("Edit settings relating to your space.") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={busy || !canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={busy || !canSetName}
setName={setName}
topic={topic}
topicDisabled={busy || !canSetTopic}
setTopic={setTopic}
/>
<div>
{ _t("Make this space private") }
<ToggleSwitch
checked={joinRule !== "public"}
onChange={checked => setJoinRule(checked ? "invite" : "public")}
disabled={!canSetJoinRule}
aria-label={_t("Make this space private")}
/>
</div>
<AccessibleButton
kind="danger"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave Space") }
</AccessibleButton>
<div className="mx_SpaceSettingsDialog_buttons">
<AccessibleButton onClick={() => Modal.createDialog(DevtoolsDialog, {roomId: space.roomId})}>
{ _t("View dev tools") }
</AccessibleButton>
<AccessibleButton onClick={onFinished} disabled={busy} kind="link">
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton onClick={onSave} disabled={busy} kind="primary">
{ busy ? _t("Saving...") : _t("Save Changes") }
</AccessibleButton>
</div>
<div
className="mx_SpaceSettingsDialog_content"
id="mx_SpaceSettingsDialog"
title={_t("Settings - %(spaceName)s", { spaceName: space.name })}
>
<TabbedView tabs={tabs} />
</div>
</BaseDialog>;
};
export default SpaceSettingsDialog;

View file

@ -62,6 +62,8 @@ export default function AccessibleButton({
disabled,
inputRef,
className,
onKeyDown,
onKeyUp,
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
@ -83,6 +85,8 @@ export default function AccessibleButton({
if (e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyDown?.(e);
}
};
newProps.onKeyUp = (e) => {
@ -94,6 +98,8 @@ export default function AccessibleButton({
if (e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
} else {
onKeyUp?.(e);
}
};
}

View file

@ -20,9 +20,9 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from "../../../index";
import { _t } from '../../../languageHandler';
import { UserAddressType } from '../../../UserAddress.js';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
import { UserAddressType } from '../../../UserAddress';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media";
@replaceableComponent("views.elements.AddressTile")
export default class AddressTile extends React.Component {

View file

@ -18,7 +18,6 @@ limitations under the License.
import TagTile from './TagTile';
import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { ContextMenu, toRightOf, useContextMenu } from "../../structures/ContextMenu";
import * as sdk from '../../../index';
@ -31,32 +30,17 @@ export default function DNDTagTile(props) {
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
contextMenu = (
<ContextMenu {...toRightOf(elementRect)} onFinished={closeMenu}>
<TagTileContextMenu tag={props.tag} onFinished={closeMenu} />
<TagTileContextMenu tag={props.tag} onFinished={closeMenu} index={props.index} />
</ContextMenu>
);
}
return <div>
<Draggable
key={props.tag}
draggableId={props.tag}
index={props.index}
type="draggable-TagTile"
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<TagTile
{...props}
contextMenuButtonRef={handle}
menuDisplayed={menuDisplayed}
openMenu={openMenu}
/>
</div>
)}
</Draggable>
return <>
<TagTile
{...props}
contextMenuButtonRef={handle}
menuDisplayed={menuDisplayed}
openMenu={openMenu}
/>
{contextMenu}
</div>;
</>;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2017, 2019 New Vector Ltd.
Copyright 2017-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.
@ -14,48 +14,48 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler';
import React from "react";
import { _t } from '../../../languageHandler';
import Field from "./Field";
import AccessibleButton from "./AccessibleButton";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
export class EditableItem extends React.Component {
static propTypes = {
index: PropTypes.number,
value: PropTypes.string,
onRemove: PropTypes.func,
interface IItemProps {
index?: number;
value?: string;
onRemove?(index: number): void;
}
interface IItemState {
verifyRemove: boolean;
}
export class EditableItem extends React.Component<IItemProps, IItemState> {
public state = {
verifyRemove: false,
};
constructor() {
super();
this.state = {
verifyRemove: false,
};
}
_onRemove = (e) => {
private onRemove = (e) => {
e.stopPropagation();
e.preventDefault();
this.setState({verifyRemove: true});
this.setState({ verifyRemove: true });
};
_onDontRemove = (e) => {
private onDontRemove = (e) => {
e.stopPropagation();
e.preventDefault();
this.setState({verifyRemove: false});
this.setState({ verifyRemove: false });
};
_onActuallyRemove = (e) => {
private onActuallyRemove = (e) => {
e.stopPropagation();
e.preventDefault();
if (this.props.onRemove) this.props.onRemove(this.props.index);
this.setState({verifyRemove: false});
this.setState({ verifyRemove: false });
};
render() {
@ -66,14 +66,14 @@ export class EditableItem extends React.Component {
{_t("Are you sure?")}
</span>
<AccessibleButton
onClick={this._onActuallyRemove}
onClick={this.onActuallyRemove}
kind="primary_sm"
className="mx_EditableItem_confirmBtn"
>
{_t("Yes")}
</AccessibleButton>
<AccessibleButton
onClick={this._onDontRemove}
onClick={this.onDontRemove}
kind="danger_sm"
className="mx_EditableItem_confirmBtn"
>
@ -85,59 +85,68 @@ export class EditableItem extends React.Component {
return (
<div className="mx_EditableItem">
<div onClick={this._onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
<div onClick={this.onRemove} className="mx_EditableItem_delete" title={_t("Remove")} role="button" />
<span className="mx_EditableItem_item">{this.props.value}</span>
</div>
);
}
}
interface IProps {
id: string;
items: string[];
itemsLabel?: string;
noItemsLabel?: string;
placeholder?: string;
newItem?: string;
canEdit?: boolean;
canRemove?: boolean;
suggestionsListId?: string;
onItemAdded?(item: string): void;
onItemRemoved?(index: number): void;
onNewItemChanged?(item: string): void;
}
@replaceableComponent("views.elements.EditableItemList")
export default class EditableItemList extends React.Component {
static propTypes = {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.string).isRequired,
itemsLabel: PropTypes.string,
noItemsLabel: PropTypes.string,
placeholder: PropTypes.string,
newItem: PropTypes.string,
onItemAdded: PropTypes.func,
onItemRemoved: PropTypes.func,
onNewItemChanged: PropTypes.func,
canEdit: PropTypes.bool,
canRemove: PropTypes.bool,
};
_onItemAdded = (e) => {
export default class EditableItemList<P = {}> extends React.PureComponent<IProps & P> {
protected onItemAdded = (e) => {
e.stopPropagation();
e.preventDefault();
if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem);
};
_onItemRemoved = (index) => {
protected onItemRemoved = (index) => {
if (this.props.onItemRemoved) this.props.onItemRemoved(index);
};
_onNewItemChanged = (e) => {
protected onNewItemChanged = (e) => {
if (this.props.onNewItemChanged) this.props.onNewItemChanged(e.target.value);
};
_renderNewItemField() {
protected renderNewItemField() {
return (
<form
onSubmit={this._onItemAdded}
onSubmit={this.onItemAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<Field label={this.props.placeholder} type="text"
autoComplete="off" value={this.props.newItem || ""} onChange={this._onNewItemChanged}
list={this.props.suggestionsListId} />
<AccessibleButton onClick={this._onItemAdded} kind="primary" type="submit" disabled={!this.props.newItem}>
{_t("Add")}
<Field
label={this.props.placeholder}
type="text"
autoComplete="off"
value={this.props.newItem || ""}
onChange={this.onNewItemChanged}
list={this.props.suggestionsListId}
/>
<AccessibleButton
onClick={this.onItemAdded}
kind="primary"
type="submit"
disabled={!this.props.newItem}
>
{ _t("Add") }
</AccessibleButton>
</form>
);
@ -153,19 +162,21 @@ export default class EditableItemList extends React.Component {
key={item}
index={index}
value={item}
onRemove={this._onItemRemoved}
onRemove={this.onItemRemoved}
/>;
});
const editableItemsSection = this.props.canRemove ? editableItems : <ul>{editableItems}</ul>;
const label = this.props.items.length > 0 ? this.props.itemsLabel : this.props.noItemsLabel;
return (<div className="mx_EditableItemList">
<div className="mx_EditableItemList_label">
{ label }
return (
<div className="mx_EditableItemList">
<div className="mx_EditableItemList_label">
{ label }
</div>
{ editableItemsSection }
{ this.props.canEdit ? this.renderNewItemField() : <div /> }
</div>
{ editableItemsSection }
{ this.props.canEdit ? this._renderNewItemField() : <div /> }
</div>);
);
}
}

View file

@ -102,7 +102,8 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
// Fake it more
event.sender = {
name: this.props.displayName,
name: this.props.displayName || this.props.userId,
rawDisplayName: this.props.displayName,
userId: this.props.userId,
getAvatarUrl: (..._) => {
return Avatar.avatarUrlForUser(

View file

@ -29,6 +29,11 @@ function getId() {
return `${BASE_ID}_${count++}`;
}
export interface IValidateOpts {
focused?: boolean;
allowEmpty?: boolean;
}
interface IProps {
// The field's ID, which binds the input and label together. Immutable.
id?: string;
@ -180,7 +185,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
}
};
public async validate({ focused, allowEmpty = true }: {focused?: boolean, allowEmpty?: boolean}) {
public async validate({ focused, allowEmpty = true }: IValidateOpts) {
if (!this.props.onValidate) {
return;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 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.
@ -14,38 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from "prop-types";
import React from "react";
import ToggleSwitch from "./ToggleSwitch";
import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
// The value for the toggle switch
value: boolean;
// The translated label for the switch
label: string;
// Whether or not to disable the toggle switch
disabled?: boolean;
// True to put the toggle in front of the label
// Default false.
toggleInFront?: boolean;
// Additional class names to append to the switch. Optional.
className?: string;
// The function to call when the value changes
onChange(checked: boolean): void;
}
@replaceableComponent("views.elements.LabelledToggleSwitch")
export default class LabelledToggleSwitch extends React.Component {
static propTypes = {
// The value for the toggle switch
value: PropTypes.bool.isRequired,
// The function to call when the value changes
onChange: PropTypes.func.isRequired,
// The translated label for the switch
label: PropTypes.string.isRequired,
// Whether or not to disable the toggle switch
disabled: PropTypes.bool,
// True to put the toggle in front of the label
// Default false.
toggleInFront: PropTypes.bool,
// Additional class names to append to the switch. Optional.
className: PropTypes.string,
};
export default class LabelledToggleSwitch extends React.PureComponent<IProps> {
render() {
// This is a minimal version of a SettingsFlag
let firstPart = <span className="mx_SettingsFlag_label">{this.props.label}</span>;
let firstPart = <span className="mx_SettingsFlag_label">{ this.props.label }</span>;
let secondPart = <ToggleSwitch
checked={this.props.value}
disabled={this.props.disabled}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 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.
@ -13,67 +13,78 @@ 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 { _t } from '../../../languageHandler';
import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index';
import withValidation from './Validation';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field, { IValidateOpts } from "./Field";
interface IProps {
domain: string;
value: string;
label?: string;
placeholder?: string;
onChange?(value: string): void;
}
interface IState {
isValid: boolean;
}
// Controlled form component wrapping Field for inputting a room alias scoped to a given domain
@replaceableComponent("views.elements.RoomAliasField")
export default class RoomAliasField extends React.PureComponent {
static propTypes = {
domain: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string.isRequired,
};
export default class RoomAliasField extends React.PureComponent<IProps, IState> {
private fieldRef = createRef<Field>();
constructor(props) {
super(props);
this.state = {isValid: true};
constructor(props, context) {
super(props, context);
this.state = {
isValid: true,
};
}
_asFullAlias(localpart) {
private asFullAlias(localpart: string): string {
return `#${localpart}:${this.props.domain}`;
}
render() {
const Field = sdk.getComponent('views.elements.Field');
const poundSign = (<span>#</span>);
const aliasPostfix = ":" + this.props.domain;
const domain = (<span title={aliasPostfix}>{aliasPostfix}</span>);
const maxlength = 255 - this.props.domain.length - 2; // 2 for # and :
return (
<Field
label={_t("Room address")}
label={this.props.label || _t("Room address")}
className="mx_RoomAliasField"
prefixComponent={poundSign}
postfixComponent={domain}
ref={ref => this._fieldRef = ref}
onValidate={this._onValidate}
placeholder={_t("e.g. my-room")}
onChange={this._onChange}
ref={this.fieldRef}
onValidate={this.onValidate}
placeholder={this.props.placeholder || _t("e.g. my-room")}
onChange={this.onChange}
value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)}
maxLength={maxlength}
/>
);
}
_onChange = (ev) => {
private onChange = (ev) => {
if (this.props.onChange) {
this.props.onChange(this._asFullAlias(ev.target.value));
this.props.onChange(this.asFullAlias(ev.target.value));
}
};
_onValidate = async (fieldState) => {
const result = await this._validationRules(fieldState);
private onValidate = async (fieldState) => {
const result = await this.validationRules(fieldState);
this.setState({isValid: result.valid});
return result;
};
_validationRules = withValidation({
private validationRules = withValidation({
rules: [
{
key: "safeLocalpart",
@ -81,7 +92,7 @@ export default class RoomAliasField extends React.PureComponent {
if (!value) {
return true;
}
const fullAlias = this._asFullAlias(value);
const fullAlias = this.asFullAlias(value);
// XXX: FIXME https://github.com/matrix-org/matrix-doc/issues/668
return !value.includes("#") && !value.includes(":") && !value.includes(",") &&
encodeURI(fullAlias) === fullAlias;
@ -90,7 +101,7 @@ export default class RoomAliasField extends React.PureComponent {
}, {
key: "required",
test: async ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("Please provide a room address"),
invalid: () => _t("Please provide an address"),
}, {
key: "taken",
final: true,
@ -100,7 +111,7 @@ export default class RoomAliasField extends React.PureComponent {
}
const client = MatrixClientPeg.get();
try {
await client.getRoomIdForAlias(this._asFullAlias(value));
await client.getRoomIdForAlias(this.asFullAlias(value));
// we got a room id, so the alias is taken
return false;
} catch (err) {
@ -116,15 +127,15 @@ export default class RoomAliasField extends React.PureComponent {
],
});
get isValid() {
public get isValid() {
return this.state.isValid;
}
validate(options) {
return this._fieldRef.validate(options);
public validate(options: IValidateOpts) {
return this.fieldRef.current?.validate(options);
}
focus() {
this._fieldRef.focus();
public focus() {
this.fieldRef.current?.focus();
}
}

View file

@ -34,10 +34,19 @@ interface IProps<T extends string> {
definitions: IDefinition<T>[];
value?: T; // if not provided no options will be selected
outlined?: boolean;
disabled?: boolean;
onChange(newValue: T): void;
}
function StyledRadioGroup<T extends string>({name, definitions, value, className, outlined, onChange}: IProps<T>) {
function StyledRadioGroup<T extends string>({
name,
definitions,
value,
className,
outlined,
disabled,
onChange,
}: IProps<T>) {
const _onChange = e => {
onChange(e.target.value);
};
@ -50,12 +59,12 @@ function StyledRadioGroup<T extends string>({name, definitions, value, className
checked={d.checked !== undefined ? d.checked : d.value === value}
name={name}
value={d.value}
disabled={d.disabled}
disabled={disabled || d.disabled}
outlined={outlined}
>
{d.label}
{ d.label }
</StyledRadioButton>
{d.description}
{ d.description ? <span>{ d.description }</span> : null }
</React.Fragment>)}
</React.Fragment>;
}

View file

@ -16,31 +16,29 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.TruncatedList")
export default class TruncatedList extends React.Component {
static propTypes = {
// The number of elements to show before truncating. If negative, no truncation is done.
truncateAt: PropTypes.number,
// The className to apply to the wrapping div
className: PropTypes.string,
// A function that returns the children to be rendered into the element.
// function getChildren(start: number, end: number): Array<React.Node>
// The start element is included, the end is not (as in `slice`).
// If omitted, the React child elements will be used. This parameter can be used
// to avoid creating unnecessary React elements.
getChildren: PropTypes.func,
// A function that should return the total number of child element available.
// Required if getChildren is supplied.
getChildCount: PropTypes.func,
// A function which will be invoked when an overflow element is required.
// This will be inserted after the children.
createOverflowElement: PropTypes.func,
};
interface IProps {
// The number of elements to show before truncating. If negative, no truncation is done.
truncateAt?: number;
// The className to apply to the wrapping div
className?: string;
// A function that returns the children to be rendered into the element.
// The start element is included, the end is not (as in `slice`).
// If omitted, the React child elements will be used. This parameter can be used
// to avoid creating unnecessary React elements.
getChildren?: (start: number, end: number) => Array<React.ReactNode>;
// A function that should return the total number of child element available.
// Required if getChildren is supplied.
getChildCount?: () => number;
// A function which will be invoked when an overflow element is required.
// This will be inserted after the children.
createOverflowElement?: (overflowCount: number, totalCount: number) => React.ReactNode;
}
@replaceableComponent("views.elements.TruncatedList")
export default class TruncatedList extends React.Component<IProps> {
static defaultProps ={
truncateAt: 2,
createOverflowElement(overflowCount, totalCount) {
@ -50,7 +48,7 @@ export default class TruncatedList extends React.Component {
},
};
_getChildren(start, end) {
private getChildren(start: number, end: number): Array<React.ReactNode> {
if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildren(start, end);
} else {
@ -63,7 +61,7 @@ export default class TruncatedList extends React.Component {
}
}
_getChildCount() {
private getChildCount(): number {
if (this.props.getChildren && this.props.getChildCount) {
return this.props.getChildCount();
} else {
@ -73,10 +71,10 @@ export default class TruncatedList extends React.Component {
}
}
render() {
public render() {
let overflowNode = null;
const totalChildren = this._getChildCount();
const totalChildren = this.getChildCount();
let upperBound = totalChildren;
if (this.props.truncateAt >= 0) {
const overflowCount = totalChildren - this.props.truncateAt;
@ -87,7 +85,7 @@ export default class TruncatedList extends React.Component {
upperBound = this.props.truncateAt;
}
}
const childNodes = this._getChildren(0, upperBound);
const childNodes = this.getChildren(0, upperBound);
return (
<div className={this.props.className}>

View file

@ -66,9 +66,7 @@ export default class GroupPublicityToggle extends React.Component {
render() {
const GroupTile = sdk.getComponent('groups.GroupTile');
return <div className="mx_GroupPublicity_toggle">
<GroupTile groupId={this.props.groupId} showDescription={false}
avatarHeight={40} draggable={false}
/>
<GroupTile groupId={this.props.groupId} showDescription={false} avatarHeight={40} />
<ToggleSwitch checked={this.state.isGroupPublicised}
disabled={!this.state.ready || this.state.busy}
onChange={this._onPublicityToggle} />

View file

@ -16,15 +16,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { Draggable, Droppable } from 'react-beautiful-dnd';
import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher';
import FlairStore from '../../../stores/FlairStore';
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media";
function nop() {}
import { _t } from "../../../languageHandler";
import TagOrderActions from "../../../actions/TagOrderActions";
import GroupFilterOrderStore from "../../../stores/GroupFilterOrderStore";
@replaceableComponent("views.groups.GroupTile")
class GroupTile extends React.Component {
@ -34,7 +34,6 @@ class GroupTile extends React.Component {
showDescription: PropTypes.bool,
// Height of the group avatar in pixels
avatarHeight: PropTypes.number,
draggable: PropTypes.bool,
};
static contextType = MatrixClientContext;
@ -42,7 +41,6 @@ class GroupTile extends React.Component {
static defaultProps = {
showDescription: true,
avatarHeight: 50,
draggable: true,
};
state = {
@ -57,7 +55,7 @@ class GroupTile extends React.Component {
});
}
onMouseDown = e => {
onClick = e => {
e.preventDefault();
dis.dispatch({
action: 'view_group',
@ -65,6 +63,18 @@ class GroupTile extends React.Component {
});
};
onPinClick = e => {
e.preventDefault();
e.stopPropagation();
dis.dispatch(TagOrderActions.moveTag(this.context, this.props.groupId, 0));
};
onUnpinClick = e => {
e.preventDefault();
e.stopPropagation();
dis.dispatch(TagOrderActions.removeTag(this.context, this.props.groupId));
};
render() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
@ -78,7 +88,7 @@ class GroupTile extends React.Component {
? mediaFromMxc(profile.avatarUrl).getSquareThumbnailHttp(avatarHeight)
: null;
let avatarElement = (
const avatarElement = (
<div className="mx_GroupTile_avatar">
<BaseAvatar
name={name}
@ -88,46 +98,21 @@ class GroupTile extends React.Component {
height={avatarHeight} />
</div>
);
if (this.props.draggable) {
const avatarClone = avatarElement;
avatarElement = (
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}>
<Draggable
key={"GroupTile " + this.props.groupId}
draggableId={"GroupTile " + this.props.groupId}
index={this.props.groupId}
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div>
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
{avatarClone}
</div>
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ }
{ provided.placeholder ? avatarClone : <div /> }
</div>
) }
</Draggable>
</div>
) }
</Droppable>
);
}
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/element-web/issues/6156
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown} onClick={nop}>
return <AccessibleButton className="mx_GroupTile" onClick={this.onClick}>
{ avatarElement }
<div className="mx_GroupTile_profile">
<div className="mx_GroupTile_name">{ name }</div>
{ descElement }
<div className="mx_GroupTile_groupId">{ this.props.groupId }</div>
{ !(GroupFilterOrderStore.getOrderedTags() || []).includes(this.props.groupId)
? <AccessibleButton kind="link" onClick={this.onPinClick}>
{ _t("Pin") }
</AccessibleButton>
: <AccessibleButton kind="link" onClick={this.onUnpinClick}>
{ _t("Unpin") }
</AccessibleButton>
}
</div>
</AccessibleButton>;
}

View file

@ -15,7 +15,7 @@
*/
import React from 'react';
import Flair from '../elements/Flair.js';
import Flair from '../elements/Flair';
import FlairStore from '../../../stores/FlairStore';
import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext";

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, {useCallback, useContext, useEffect, useState} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from 'matrix-js-sdk/src/@types/event';
@ -28,6 +27,7 @@ import { useEventEmitter } from "../../../hooks/useEventEmitter";
import PinningUtils from "../../../utils/PinningUtils";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import PinnedEventTile from "../rooms/PinnedEventTile";
import { useRoomState } from "../../../hooks/useRoomState";
interface IProps {
room: Room;
@ -75,24 +75,6 @@ export const useReadPinnedEvents = (room: Room): Set<string> => {
return readPinnedEvents;
};
const useRoomState = <T extends any>(room: Room, mapper: (state: RoomState) => T): T => {
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);
const update = useCallback(() => {
if (!room) return;
setValue(mapper(room.currentState));
}, [room, mapper]);
useEventEmitter(room?.currentState, "RoomState.events", update);
useEffect(() => {
update();
return () => {
setValue(undefined);
};
}, [update]);
return value;
};
const PinnedMessagesCard = ({ room, onClose }: IProps) => {
const cli = useContext(MatrixClientContext);
const canUnpin = useRoomState(room, state => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));

View file

@ -503,19 +503,15 @@ const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) =>
return member.powerLevel < levelToSend;
};
const getPowerLevels = room => room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {};
export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>(getPowerLevels(room));
const update = useCallback((ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPowerLevels) return;
const event = room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
if (event) {
setPowerLevels(event.getContent());
} else {
setPowerLevels({});
}
setPowerLevels(getPowerLevels(room));
}, [room]);
useEventEmitter(cli, "RoomState.events", update);

View file

@ -1,6 +1,5 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2016 - 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.
@ -15,59 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, createRef } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import EditableItemList from "../elements/EditableItemList";
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from '../../../languageHandler';
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import ErrorDialog from "../dialogs/ErrorDialog";
import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal";
import RoomPublishSetting from "./RoomPublishSetting";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import RoomAliasField from "../elements/RoomAliasField";
class EditableAliasesList extends EditableItemList {
constructor(props) {
super(props);
interface IEditableAliasesListProps {
domain?: string;
}
this._aliasField = createRef();
}
class EditableAliasesList extends EditableItemList<IEditableAliasesListProps> {
private aliasField = createRef<RoomAliasField>();
_onAliasAdded = async () => {
await this._aliasField.current.validate({ allowEmpty: false });
private onAliasAdded = async () => {
await this.aliasField.current.validate({ allowEmpty: false });
if (this._aliasField.current.isValid) {
if (this.aliasField.current.isValid) {
if (this.props.onItemAdded) this.props.onItemAdded(this.props.newItem);
return;
}
this._aliasField.current.focus();
this._aliasField.current.validate({ allowEmpty: false, focused: true });
this.aliasField.current.focus();
this.aliasField.current.validate({ allowEmpty: false, focused: true });
};
_renderNewItemField() {
protected renderNewItemField() {
// if we don't need the RoomAliasField,
// we don't need to overriden version of _renderNewItemField
// we don't need to overriden version of renderNewItemField
if (!this.props.domain) {
return super._renderNewItemField();
return super.renderNewItemField();
}
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
const onChange = (alias) => this._onNewItemChanged({target: {value: alias}});
const onChange = (alias) => this.onNewItemChanged({target: {value: alias}});
return (
<form
onSubmit={this._onAliasAdded}
onSubmit={this.onAliasAdded}
autoComplete="off"
noValidate={true}
className="mx_EditableItemList_newItem"
>
<RoomAliasField
ref={this._aliasField}
ref={this.aliasField}
onChange={onChange}
value={this.props.newItem || ""}
domain={this.props.domain} />
<AccessibleButton onClick={this._onAliasAdded} kind="primary">
<AccessibleButton onClick={this.onAliasAdded} kind="primary">
{ _t("Add") }
</AccessibleButton>
</form>
@ -75,19 +75,30 @@ class EditableAliasesList extends EditableItemList {
}
}
@replaceableComponent("views.room_settings.AliasSettings")
export default class AliasSettings extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
canSetCanonicalAlias: PropTypes.bool.isRequired,
canSetAliases: PropTypes.bool.isRequired,
canonicalAliasEvent: PropTypes.object, // MatrixEvent
};
interface IProps {
roomId: string;
canSetCanonicalAlias: boolean;
canSetAliases: boolean;
canonicalAliasEvent?: MatrixEvent;
hidePublishSetting?: boolean;
}
interface IState {
altAliases: string[];
localAliases: string[];
canonicalAlias?: string;
updatingCanonicalAlias: boolean;
localAliasesLoading: boolean;
detailsOpen: boolean;
newAlias?: string;
newAltAlias?: string;
}
@replaceableComponent("views.room_settings.AliasSettings")
export default class AliasSettings extends React.Component<IProps, IState> {
static defaultProps = {
canSetAliases: false,
canSetCanonicalAlias: false,
aliasEvents: [],
};
constructor(props) {
@ -122,7 +133,7 @@ export default class AliasSettings extends React.Component {
}
}
async loadLocalAliases() {
private async loadLocalAliases() {
this.setState({ localAliasesLoading: true });
try {
const cli = MatrixClientPeg.get();
@ -134,12 +145,16 @@ export default class AliasSettings extends React.Component {
}
}
this.setState({ localAliases });
if (localAliases.length === 0) {
this.setState({ detailsOpen: true });
}
} finally {
this.setState({ localAliasesLoading: false });
}
}
changeCanonicalAlias(alias) {
private changeCanonicalAlias(alias: string) {
if (!this.props.canSetCanonicalAlias) return;
const oldAlias = this.state.canonicalAlias;
@ -170,7 +185,7 @@ export default class AliasSettings extends React.Component {
});
}
changeAltAliases(altAliases) {
private changeAltAliases(altAliases: string[]) {
if (!this.props.canSetCanonicalAlias) return;
this.setState({
@ -181,7 +196,7 @@ export default class AliasSettings extends React.Component {
const eventContent = {};
if (this.state.canonicalAlias) {
eventContent.alias = this.state.canonicalAlias;
eventContent["alias"] = this.state.canonicalAlias;
}
if (altAliases) {
eventContent["alt_aliases"] = altAliases;
@ -202,11 +217,11 @@ export default class AliasSettings extends React.Component {
});
}
onNewAliasChanged = (value) => {
this.setState({newAlias: value});
private onNewAliasChanged = (value: string) => {
this.setState({ newAlias: value });
};
onLocalAliasAdded = (alias) => {
private onLocalAliasAdded = (alias: string) => {
if (!alias || alias.length === 0) return; // ignore attempts to create blank aliases
const localDomain = MatrixClientPeg.get().getDomain();
@ -232,7 +247,7 @@ export default class AliasSettings extends React.Component {
});
};
onLocalAliasDeleted = (index) => {
private onLocalAliasDeleted = (index: number) => {
const alias = this.state.localAliases[index];
// TODO: In future, we should probably be making sure that the alias actually belongs
// to this room. See https://github.com/vector-im/element-web/issues/7353
@ -261,7 +276,7 @@ export default class AliasSettings extends React.Component {
});
};
onLocalAliasesToggled = (event) => {
private onLocalAliasesToggled = (event: ChangeEvent<HTMLDetailsElement>) => {
// expanded
if (event.target.open) {
// if local aliases haven't been preloaded yet at component mount
@ -269,43 +284,45 @@ export default class AliasSettings extends React.Component {
this.loadLocalAliases();
}
}
this.setState({detailsOpen: event.target.open});
this.setState({ detailsOpen: event.currentTarget.open });
};
onCanonicalAliasChange = (event) => {
private onCanonicalAliasChange = (event: ChangeEvent<HTMLSelectElement>) => {
this.changeCanonicalAlias(event.target.value);
};
onNewAltAliasChanged = (value) => {
this.setState({newAltAlias: value});
private onNewAltAliasChanged = (value: string) => {
this.setState({ newAltAlias: value });
}
onAltAliasAdded = (alias) => {
private onAltAliasAdded = (alias: string) => {
const altAliases = this.state.altAliases.slice();
if (!altAliases.some(a => a.trim() === alias.trim())) {
altAliases.push(alias.trim());
this.changeAltAliases(altAliases);
this.setState({newAltAlias: ""});
this.setState({ newAltAlias: "" });
}
}
onAltAliasDeleted = (index) => {
private onAltAliasDeleted = (index: number) => {
const altAliases = this.state.altAliases.slice();
altAliases.splice(index, 1);
this.changeAltAliases(altAliases);
}
_getAliases() {
return this.state.altAliases.concat(this._getLocalNonAltAliases());
private getAliases() {
return this.state.altAliases.concat(this.getLocalNonAltAliases());
}
_getLocalNonAltAliases() {
private getLocalNonAltAliases() {
const {altAliases} = this.state;
return this.state.localAliases.filter(alias => !altAliases.includes(alias));
}
render() {
const localDomain = MatrixClientPeg.get().getDomain();
const cli = MatrixClientPeg.get();
const localDomain = cli.getDomain();
const isSpaceRoom = cli.getRoom(this.props.roomId)?.isSpaceRoom();
let found = false;
const canonicalValue = this.state.canonicalAlias || "";
@ -320,7 +337,7 @@ export default class AliasSettings extends React.Component {
>
<option value="" key="unset">{ _t('not specified') }</option>
{
this._getAliases().map((alias, i) => {
this.getAliases().map((alias, i) => {
if (alias === this.state.canonicalAlias) found = true;
return (
<option value={alias} key={i}>
@ -340,12 +357,10 @@ export default class AliasSettings extends React.Component {
let localAliasesList;
if (this.state.localAliasesLoading) {
const Spinner = sdk.getComponent("elements.Spinner");
localAliasesList = <Spinner />;
} else {
localAliasesList = (<EditableAliasesList
id="roomAliases"
className={"mx_RoomSettings_localAliases"}
items={this.state.localAliases}
newItem={this.state.newAlias}
onNewItemChanged={this.onNewAliasChanged}
@ -353,7 +368,9 @@ export default class AliasSettings extends React.Component {
canEdit={this.props.canSetAliases}
onItemAdded={this.onLocalAliasAdded}
onItemRemoved={this.onLocalAliasDeleted}
noItemsLabel={_t('This room has no local addresses')}
noItemsLabel={isSpaceRoom
? _t("This space has no local addresses")
: _t("This room has no local addresses")}
placeholder={_t('Local address')}
domain={localDomain}
/>);
@ -362,18 +379,27 @@ export default class AliasSettings extends React.Component {
return (
<div className='mx_AliasSettings'>
<span className='mx_SettingsTab_subheading'>{_t("Published Addresses")}</span>
<p>{_t("Published addresses can be used by anyone on any server to join your room. " +
"To publish an address, it needs to be set as a local address first.")}</p>
{canonicalAliasSection}
<RoomPublishSetting roomId={this.props.roomId} canSetCanonicalAlias={this.props.canSetCanonicalAlias} />
<p>
{ isSpaceRoom
? _t("Published addresses can be used by anyone on any server to join your space.")
: _t("Published addresses can be used by anyone on any server to join your room.")}
&nbsp;
{ _t("To publish an address, it needs to be set as a local address first.") }
</p>
{ canonicalAliasSection }
{ this.props.hidePublishSetting
? null
: <RoomPublishSetting
roomId={this.props.roomId}
canSetCanonicalAlias={this.props.canSetCanonicalAlias}
/> }
<datalist id="mx_AliasSettings_altRecommendations">
{this._getLocalNonAltAliases().map(alias => {
{this.getLocalNonAltAliases().map(alias => {
return <option value={alias} key={alias} />;
})};
</datalist>
<EditableAliasesList
id="roomAltAliases"
className={"mx_RoomSettings_altAliases"}
items={this.state.altAliases}
newItem={this.state.newAltAlias}
onNewItemChanged={this.onNewAltAliasChanged}
@ -386,11 +412,19 @@ export default class AliasSettings extends React.Component {
noItemsLabel={_t('No other published addresses yet, add one below')}
placeholder={_t('New published address (e.g. #alias:server)')}
/>
<span className='mx_SettingsTab_subheading mx_AliasSettings_localAliasHeader'>{_t("Local Addresses")}</span>
<p>{_t("Set addresses for this room so users can find this room through your homeserver (%(localDomain)s)", {localDomain})}</p>
<details onToggle={this.onLocalAliasesToggled}>
<span className='mx_SettingsTab_subheading mx_AliasSettings_localAliasHeader'>
{ _t("Local Addresses") }
</span>
<p>
{ isSpaceRoom
? _t("Set addresses for this space so users can find this space " +
"through your homeserver (%(localDomain)s)", { localDomain })
: _t("Set addresses for this room so users can find this room " +
"through your homeserver (%(localDomain)s)", { localDomain }) }
</p>
<details onToggle={this.onLocalAliasesToggled} open={this.state.detailsOpen}>
<summary>{ this.state.detailsOpen ? _t('Show less') : _t("Show more")}</summary>
{localAliasesList}
{ localAliasesList }
</details>
</div>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,20 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React from "react";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import {_t} from "../../../languageHandler";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
roomId: string;
label?: string;
canSetCanonicalAlias?: boolean;
}
interface IState {
isRoomPublished: boolean;
}
@replaceableComponent("views.room_settings.RoomPublishSetting")
export default class RoomPublishSetting extends React.PureComponent {
constructor(props) {
super(props);
this.state = {isRoomPublished: false};
export default class RoomPublishSetting extends React.PureComponent<IProps, IState> {
constructor(props, context) {
super(props, context);
this.state = {
isRoomPublished: false,
};
}
onRoomPublishChange = (e) => {
private onRoomPublishChange = (e) => {
const valueBefore = this.state.isRoomPublished;
const newValue = !valueBefore;
this.setState({isRoomPublished: newValue});
@ -52,11 +66,14 @@ export default class RoomPublishSetting extends React.PureComponent {
render() {
const client = MatrixClientPeg.get();
return (<LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias}
label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(),
})} />);
return (
<LabelledToggleSwitch value={this.state.isRoomPublished}
onChange={this.onRoomPublishChange}
disabled={!this.props.canSetCanonicalAlias}
label={_t("Publish this room to the public in %(domain)s's room directory?", {
domain: client.getDomain(),
})}
/>
);
}
}

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd
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.
@ -20,17 +21,28 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher/dispatcher';
import {isValid3pidInvite} from "../../../RoomInvite";
import rate_limited_func from "../../../ratelimitedfunc";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import * as sdk from "../../../index";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import { isValid3pidInvite } from "../../../RoomInvite";
import rateLimitedFunction from "../../../ratelimitedfunc";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import BaseCard from "../right_panel/BaseCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { Room } from 'matrix-js-sdk/src/models/room';
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import { RoomState } from 'matrix-js-sdk/src/models/room-state';
import { User } from "matrix-js-sdk/src/models/user";
import TruncatedList from '../elements/TruncatedList';
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import AccessibleButton from '../elements/AccessibleButton';
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar';
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -40,41 +52,59 @@ const SHOW_MORE_INCREMENT = 100;
// matches all ASCII punctuation: !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
const SORT_REGEX = /[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+/g;
interface IProps {
roomId: string;
onClose(): void;
}
interface IState {
loading: boolean;
members: Array<RoomMember>;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
searchQuery: string;
}
@replaceableComponent("views.rooms.MemberList")
export default class MemberList extends React.Component {
export default class MemberList extends React.Component<IProps, IState> {
private showPresence = true;
private mounted = false;
private collator: Intl.Collator;
private sortNames = new Map<RoomMember, string>(); // RoomMember -> sortName
constructor(props) {
super(props);
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
// show an empty list
this.state = this._getMembersState([]);
this.state = this.getMembersState([]);
} else {
this.state = this._getMembersState(this.roomMembers());
this.state = this.getMembersState(this.roomMembers());
}
cli.on("Room", this.onRoom); // invites & joining after peek
const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"];
const hsUrl = MatrixClientPeg.get().baseUrl;
this._showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[hsUrl] !== undefined) {
this._showPresence = enablePresenceByHsUrl[hsUrl];
}
this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
}
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
const cli = MatrixClientPeg.get();
this._mounted = true;
this.mounted = true;
if (cli.hasLazyLoadMembersEnabled()) {
this._showMembersAccordingToMembershipWithLL();
this.showMembersAccordingToMembershipWithLL();
cli.on("Room.myMembership", this.onMyMembership);
} else {
this._listenForMembersChanges();
this.listenForMembersChanges();
}
}
_listenForMembersChanges() {
private listenForMembersChanges(): void {
const cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
cli.on("RoomMember.name", this.onRoomMemberName);
@ -89,7 +119,7 @@ export default class MemberList extends React.Component {
}
componentWillUnmount() {
this._mounted = false;
this.mounted = false;
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.members", this.onRoomStateMember);
@ -103,7 +133,7 @@ export default class MemberList extends React.Component {
}
// cancel any pending calls to the rate_limited_funcs
this._updateList.cancelPendingCall();
this.updateList.cancelPendingCall();
}
/**
@ -111,7 +141,7 @@ export default class MemberList extends React.Component {
* show a spinner and load the members if the user is joined,
* or show the members available so far if the user is invited
*/
async _showMembersAccordingToMembershipWithLL() {
private async showMembersAccordingToMembershipWithLL(): Promise<void> {
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
const cli = MatrixClientPeg.get();
@ -122,31 +152,31 @@ export default class MemberList extends React.Component {
try {
await room.loadMembersIfNeeded();
} catch (ex) {/* already logged in RoomView */}
if (this._mounted) {
this.setState(this._getMembersState(this.roomMembers()));
this._listenForMembersChanges();
if (this.mounted) {
this.setState(this.getMembersState(this.roomMembers()));
this.listenForMembersChanges();
}
} else {
// show the members we already have loaded
this.setState(this._getMembersState(this.roomMembers()));
this.setState(this.getMembersState(this.roomMembers()));
}
}
}
get canInvite() {
private get canInvite(): boolean {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
return room && room.canInvite(cli.getUserId());
}
_getMembersState(members) {
// set the state after determining _showPresence to make sure it's
// taken into account while rerendering
private getMembersState(members: Array<RoomMember>): IState {
// set the state after determining showPresence to make sure it's
// taken into account while rendering
return {
loading: false,
members: members,
filteredJoinedMembers: this._filterMembers(members, 'join'),
filteredInvitedMembers: this._filterMembers(members, 'invite'),
filteredJoinedMembers: this.filterMembers(members, 'join'),
filteredInvitedMembers: this.filterMembers(members, 'invite'),
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
@ -157,72 +187,72 @@ export default class MemberList extends React.Component {
};
}
onUserPresenceChange = (event, user) => {
private onUserPresenceChange = (event: MatrixEvent, user: User): void => {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener.
const tile = this.refs[user.userId];
// console.log(`Got presence update for ${user.userId}. hasTile=${!!tile}`);
if (tile) {
this._updateList(); // reorder the membership list
this.updateList(); // reorder the membership list
}
};
onRoom = room => {
private onRoom = (room: Room): void => {
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this._showMembersAccordingToMembershipWithLL();
this.showMembersAccordingToMembershipWithLL();
};
onMyMembership = (room, membership, oldMembership) => {
private onMyMembership = (room: Room, membership: string, oldMembership: string): void => {
if (room.roomId === this.props.roomId && membership === "join") {
this._showMembersAccordingToMembershipWithLL();
this.showMembersAccordingToMembershipWithLL();
}
};
onRoomStateMember = (ev, state, member) => {
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this._updateList();
this.updateList();
};
onRoomMemberName = (ev, member) => {
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this._updateList();
this.updateList();
};
onRoomStateEvent = (event, state) => {
private onRoomStateEvent = (event: MatrixEvent, state: RoomState): void => {
if (event.getRoomId() === this.props.roomId &&
event.getType() === "m.room.third_party_invite") {
this._updateList();
this.updateList();
}
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
_updateList = rate_limited_func(() => {
this._updateListNow();
private updateList = rateLimitedFunction(() => {
this.updateListNow();
}, 500);
_updateListNow() {
// console.log("Updating memberlist");
const newState = {
private updateListNow(): void {
const members = this.roomMembers()
this.setState({
loading: false,
members: this.roomMembers(),
};
newState.filteredJoinedMembers = this._filterMembers(newState.members, 'join', this.state.searchQuery);
newState.filteredInvitedMembers = this._filterMembers(newState.members, 'invite', this.state.searchQuery);
this.setState(newState);
members: members,
filteredJoinedMembers: this.filterMembers(members, 'join', this.state.searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite', this.state.searchQuery),
});
}
getMembersWithUser() {
private getMembersWithUser(): Array<RoomMember> {
if (!this.props.roomId) return [];
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
@ -230,15 +260,18 @@ export default class MemberList extends React.Component {
const allMembers = Object.values(room.currentState.members);
allMembers.forEach(function(member) {
allMembers.forEach((member) => {
// work around a race where you might have a room member object
// before the user object exists. This may or may not cause
// before the user object exists. This may or may not cause
// https://github.com/vector-im/vector-web/issues/186
if (member.user === null) {
if (!member.user) {
member.user = cli.getUser(member.userId);
}
member.sortName = (member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, "");
this.sortNames.set(
member,
(member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, ""),
);
// XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0
@ -247,7 +280,7 @@ export default class MemberList extends React.Component {
return allMembers;
}
roomMembers() {
private roomMembers(): Array<RoomMember> {
const allMembers = this.getMembersWithUser();
const filteredAndSortedMembers = allMembers.filter((m) => {
return (
@ -255,23 +288,21 @@ export default class MemberList extends React.Component {
);
});
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', usePunctuation: true });
this.collator = new Intl.Collator(language, { sensitivity: 'base', ignorePunctuation: false });
filteredAndSortedMembers.sort(this.memberSort);
return filteredAndSortedMembers;
}
_createOverflowTileJoined = (overflowCount, totalCount) => {
return this._createOverflowTile(overflowCount, totalCount, this._showMoreJoinedMemberList);
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
_createOverflowTileInvited = (overflowCount, totalCount) => {
return this._createOverflowTile(overflowCount, totalCount, this._showMoreInvitedMemberList);
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
};
_createOverflowTile = (overflowCount, totalCount, onClick) => {
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element=> {
// For now we'll pretend this is any entity. It should probably be a separate tile.
const EntityTile = sdk.getComponent("rooms.EntityTile");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const text = _t("and %(count)s others...", { count: overflowCount });
return (
<EntityTile className="mx_EntityTile_ellipsis" avatarJsx={
@ -281,31 +312,48 @@ export default class MemberList extends React.Component {
);
};
_showMoreJoinedMemberList = () => {
private showMoreJoinedMemberList = (): void => {
this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
});
};
_showMoreInvitedMemberList = () => {
private showMoreInvitedMemberList = (): void => {
this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
});
};
memberString(member) {
/**
* SHOULD ONLY BE USED BY TESTS
*/
public memberString(member: RoomMember): string {
if (!member) {
return "(null)";
} else {
const u = member.user;
return "(" + member.name + ", " + member.powerLevel + ", " + (u ? u.lastActiveAgo : "<null>") + ", " + (u ? u.getLastActiveTs() : "<null>") + ", " + (u ? u.currentlyActive : "<null>") + ", " + (u ? u.presence : "<null>") + ")";
return (
"(" +
member.name +
", " +
member.powerLevel +
", " +
(u ? u.lastActiveAgo : "<null>") +
", " +
(u ? u.getLastActiveTs() : "<null>") +
", " +
(u ? u.currentlyActive : "<null>") +
", " +
(u ? u.presence : "<null>") +
")"
);
}
}
// returns negative if a comes before b,
// returns 0 if a and b are equivalent in ordering
// returns positive if a comes after b.
memberSort = (memberA, memberB) => {
private memberSort = (memberA: RoomMember, memberB: RoomMember): number => {
// order by presence, with "active now" first.
// ...and then by power level
// ...and then by last active
@ -325,7 +373,7 @@ export default class MemberList extends React.Component {
if (!userA && userB) return 1;
// First by presence
if (this._showPresence) {
if (this.showPresence) {
const convertPresence = (p) => p === 'unavailable' ? 'online' : p;
const presenceIndex = p => {
const order = ['active', 'online', 'offline'];
@ -349,31 +397,31 @@ export default class MemberList extends React.Component {
}
// Third by last active
if (this._showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
if (this.showPresence && userA.getLastActiveTs() !== userB.getLastActiveTs()) {
// console.log("Comparing on last active timestamp - returning");
return userB.getLastActiveTs() - userA.getLastActiveTs();
}
// Fourth by name (alphabetical)
return this.collator.compare(memberA.sortName, memberB.sortName);
return this.collator.compare(this.sortNames.get(memberA), this.sortNames.get(memberB));
};
onSearchQueryChanged = searchQuery => {
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({
searchQuery,
filteredJoinedMembers: this._filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this._filterMembers(this.state.members, 'invite', searchQuery),
filteredJoinedMembers: this.filterMembers(this.state.members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(this.state.members, 'invite', searchQuery),
});
};
_onPending3pidInviteClick = inviteEvent => {
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({
action: 'view_3pid_invite',
event: inviteEvent,
});
};
_filterMembers(members, membership, query) {
private filterMembers(members: Array<RoomMember>, membership: string, query?: string): Array<RoomMember> {
return members.filter((m) => {
if (query) {
query = query.toLowerCase();
@ -389,7 +437,7 @@ export default class MemberList extends React.Component {
});
}
_getPending3PidInvites() {
private getPending3PidInvites(): Array<MatrixEvent> {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
@ -409,42 +457,40 @@ export default class MemberList extends React.Component {
}
}
_makeMemberTiles(members) {
const MemberTile = sdk.getComponent("rooms.MemberTile");
const EntityTile = sdk.getComponent("rooms.EntityTile");
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>) {
return members.map((m) => {
if (m.userId) {
if (m instanceof RoomMember) {
// Is a Matrix invite
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this._showPresence} />;
return <MemberTile key={m.userId} member={m} ref={m.userId} showPresence={this.showPresence} />;
} else {
// Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this._onPending3pidInviteClick(m)} />;
onClick={() => this.onPending3pidInviteClick(m)} />;
}
});
}
_getChildrenJoined = (start, end) => this._makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
_getChildCountJoined = () => this.state.filteredJoinedMembers.length;
_getChildrenInvited = (start, end) => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this._getPending3PidInvites());
}
return this._makeMemberTiles(targets.slice(start, end));
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end))
};
_getChildCountInvited = () => {
return this.state.filteredInvitedMembers.length + (this._getPending3PidInvites() || []).length;
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
}
render() {
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return <BaseCard
className="mx_MemberList"
onClose={this.props.onClose}
@ -454,9 +500,6 @@ export default class MemberList extends React.Component {
</BaseCard>;
}
const SearchBox = sdk.getComponent('structures.SearchBox');
const TruncatedList = sdk.getComponent("elements.TruncatedList");
const cli = MatrixClientPeg.get();
const room = cli.getRoom(this.props.roomId);
let inviteButton;
@ -470,22 +513,30 @@ export default class MemberList extends React.Component {
inviteButtonText = _t("Invite to this space");
}
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
inviteButton =
<AccessibleButton className="mx_MemberList_invite" onClick={this.onInviteButtonClick} disabled={!this.state.canInvite}>
inviteButton = (
<AccessibleButton
className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<span>{ inviteButtonText }</span>
</AccessibleButton>;
</AccessibleButton>
);
}
let invitedHeader;
let invitedSection;
if (this._getChildCountInvited() > 0) {
if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited}
/>;
invitedSection = (
<TruncatedList
className="mx_MemberList_section mx_MemberList_invited"
truncateAt={this.state.truncateAtInvited}
createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
}
const footer = (
@ -517,17 +568,19 @@ export default class MemberList extends React.Component {
previousPhase={previousPhase}
>
<div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined} />
<TruncatedList
className="mx_MemberList_section mx_MemberList_joined"
truncateAt={this.state.truncateAtJoined}
createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined} />
{ invitedHeader }
{ invitedSection }
</div>
</BaseCard>;
}
onInviteButtonClick = () => {
onInviteButtonClick = (): void => {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;

View file

@ -466,6 +466,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
}
private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] {
if (SettingsStore.getValue("feature_spaces")) return [];
// TODO: Put community invites in a more sensible place (not in the room list)
// See https://github.com/vector-im/element-web/issues/14456
return MatrixClientPeg.get().getGroups().filter(g => {

View file

@ -30,7 +30,7 @@ import RecordingPlayback from "../voice_messages/RecordingPlayback";
import {MsgType} from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import CallMediaHandler from "../../../CallMediaHandler";
import MediaDeviceHandler from "../../../MediaDeviceHandler";
interface IProps {
room: Room;
@ -129,8 +129,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried.
try {
const devices = await CallMediaHandler.getDevices();
if (!devices?.['audioinput']?.length) {
const devices = await MediaDeviceHandler.getDevices();
if (!devices?.['audioInput']?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"),
description: <>

View file

@ -79,8 +79,8 @@ export default class CrossSigningPanel extends React.PureComponent {
async _getUpdatedStatus() {
const cli = MatrixClientPeg.get();
const pkCache = cli.getCrossSigningCacheCallbacks();
const crossSigning = cli.crypto._crossSigningInfo;
const secretStorage = cli.crypto._secretStorage;
const crossSigning = cli.crypto.crossSigningInfo;
const secretStorage = cli.crypto.secretStorage;
const crossSigningPublicKeysOnDevice = crossSigning.getId();
const crossSigningPrivateKeysInStorage = await crossSigning.isStoredInSecretStorage(secretStorage);
const masterPrivateKeyCached = !!(pkCache && await pkCache.getCrossSigningKeyCache("master"));

View file

@ -131,7 +131,7 @@ export default class SecureBackupPanel extends React.PureComponent {
async _getUpdatedDiagnostics() {
const cli = MatrixClientPeg.get();
const secretStorage = cli.crypto._secretStorage;
const secretStorage = cli.crypto.secretStorage;
const backupKeyStored = !!(await cli.isKeyBackupKeyStored());
const backupKeyFromCache = await cli.crypto.getSessionBackupPrivateKey();

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 - 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.
@ -15,68 +15,76 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../../../../languageHandler";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
import * as sdk from "../../../../..";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../../../languageHandler";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import AccessibleButton from "../../../elements/AccessibleButton";
import RoomUpgradeDialog from "../../../dialogs/RoomUpgradeDialog";
import DevtoolsDialog from "../../../dialogs/DevtoolsDialog";
import Modal from "../../../../../Modal";
import dis from "../../../../../dispatcher/dispatcher";
import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
interface IProps {
roomId: string;
closeSettingsFn(): void;
}
interface IRecommendedVersion {
version: string;
needsUpgrade: boolean;
urgent: boolean;
}
interface IState {
upgradeRecommendation?: IRecommendedVersion;
oldRoomId?: string;
oldEventId?: string;
upgraded?: boolean;
}
@replaceableComponent("views.settings.tabs.room.AdvancedRoomSettingsTab")
export default class AdvancedRoomSettingsTab extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
closeSettingsFn: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
export default class AdvancedRoomSettingsTab extends React.Component<IProps, IState> {
constructor(props, context) {
super(props, context);
this.state = {
// This is eventually set to the value of room.getRecommendedVersion()
upgradeRecommendation: null,
};
}
// TODO: [REACT-WARNING] Move this to constructor
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
// we handle lack of this object gracefully later, so don't worry about it failing here.
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
room.getRecommendedVersion().then((v) => {
const tombstone = room.currentState.getStateEvents("m.room.tombstone", "");
const tombstone = room.currentState.getStateEvents(EventType.RoomTombstone, "");
const additionalStateChanges = {};
const createEvent = room.currentState.getStateEvents("m.room.create", "");
const additionalStateChanges: Partial<IState> = {};
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
const predecessor = createEvent ? createEvent.getContent().predecessor : null;
if (predecessor && predecessor.room_id) {
additionalStateChanges['oldRoomId'] = predecessor.room_id;
additionalStateChanges['oldEventId'] = predecessor.event_id;
additionalStateChanges['hasPreviousRoom'] = true;
additionalStateChanges.oldRoomId = predecessor.room_id;
additionalStateChanges.oldEventId = predecessor.event_id;
}
this.setState({
upgraded: tombstone && tombstone.getContent().replacement_room,
upgraded: !!tombstone?.getContent().replacement_room,
upgradeRecommendation: v,
...additionalStateChanges,
});
});
}
_upgradeRoom = (e) => {
const RoomUpgradeDialog = sdk.getComponent('dialogs.RoomUpgradeDialog');
private upgradeRoom = (e) => {
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, {room: room});
Modal.createTrackedDialog('Upgrade Room Version', '', RoomUpgradeDialog, { room });
};
_openDevtools = (e) => {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
private openDevtools = (e) => {
Modal.createDialog(DevtoolsDialog, {roomId: this.props.roomId});
};
_onOldRoomClicked = (e) => {
private onOldRoomClicked = (e) => {
e.preventDefault();
e.stopPropagation();
@ -93,9 +101,9 @@ export default class AdvancedRoomSettingsTab extends React.Component {
const room = client.getRoom(this.props.roomId);
let unfederatableSection;
const createEvent = room.currentState.getStateEvents('m.room.create', '');
const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, '');
if (createEvent && createEvent.getContent()['m.federate'] === false) {
unfederatableSection = <div>{_t('This room is not accessible by remote Matrix servers')}</div>;
unfederatableSection = <div>{ _t('This room is not accessible by remote Matrix servers') }</div>;
}
let roomUpgradeButton;
@ -103,7 +111,7 @@ export default class AdvancedRoomSettingsTab extends React.Component {
roomUpgradeButton = (
<div>
<p className='mx_SettingsTab_warningText'>
{_t(
{ _t(
"<b>Warning</b>: Upgrading a room will <i>not automatically migrate room members " +
"to the new version of the room.</i> We'll post a link to the new room in the old " +
"version of the room - room members will have to click this link to join the new room.",
@ -111,51 +119,53 @@ export default class AdvancedRoomSettingsTab extends React.Component {
"b": (sub) => <b>{sub}</b>,
"i": (sub) => <i>{sub}</i>,
},
)}
) }
</p>
<AccessibleButton onClick={this._upgradeRoom} kind='primary'>
{_t("Upgrade this room to the recommended room version")}
<AccessibleButton onClick={this.upgradeRoom} kind='primary'>
{ _t("Upgrade this room to the recommended room version") }
</AccessibleButton>
</div>
);
}
let oldRoomLink;
if (this.state.hasPreviousRoom) {
if (this.state.oldRoomId) {
let name = _t("this room");
const room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (room && room.name) name = room.name;
oldRoomLink = (
<AccessibleButton element='a' onClick={this._onOldRoomClicked}>
{_t("View older messages in %(roomName)s.", {roomName: name})}
<AccessibleButton element='a' onClick={this.onOldRoomClicked}>
{ _t("View older messages in %(roomName)s.", { roomName: name }) }
</AccessibleButton>
);
}
return (
<div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{_t("Advanced")}</div>
<div className="mx_SettingsTab_heading">{ _t("Advanced") }</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Room information")}</span>
<span className='mx_SettingsTab_subheading'>
{ room?.isSpaceRoom() ? _t("Space information") : _t("Room information") }
</span>
<div>
<span>{_t("Internal room ID:")}</span>&nbsp;
{this.props.roomId}
<span>{ _t("Internal room ID:") }</span>&nbsp;
{ this.props.roomId }
</div>
{unfederatableSection}
{ unfederatableSection }
</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Room version")}</span>
<span className='mx_SettingsTab_subheading'>{ _t("Room version") }</span>
<div>
<span>{_t("Room version:")}</span>&nbsp;
{room.getVersion()}
<span>{ _t("Room version:") }</span>&nbsp;
{ room.getVersion() }
</div>
{oldRoomLink}
{roomUpgradeButton}
{ oldRoomLink }
{ roomUpgradeButton }
</div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t("Developer options")}</span>
<AccessibleButton onClick={this._openDevtools} kind='primary'>
{_t("Open Devtools")}
<span className='mx_SettingsTab_subheading'>{ _t("Developer options") }</span>
<AccessibleButton onClick={this.openDevtools} kind='primary'>
{ _t("Open Devtools") }
</AccessibleButton>
</div>
</div>

View file

@ -60,7 +60,6 @@ export default class GeneralRoomSettingsTab extends React.Component {
const canSetAliases = true; // Previously, we arbitrarily only allowed admins to do this
const canSetCanonical = room.currentState.mayClientSendStateEvent("m.room.canonical_alias", client);
const canonicalAliasEv = room.currentState.getStateEvents("m.room.canonical_alias", '');
const aliasEvents = room.currentState.getStateEvents("m.room.aliases");
const canChangeGroups = room.currentState.mayClientSendStateEvent("m.room.related_groups", client);
const groupsEvent = room.currentState.getStateEvents("m.room.related_groups", "");
@ -100,7 +99,7 @@ export default class GeneralRoomSettingsTab extends React.Component {
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<AliasSettings roomId={this.props.roomId}
canSetCanonicalAlias={canSetCanonical} canSetAliases={canSetAliases}
canonicalAliasEvent={canonicalAliasEv} aliasEvents={aliasEvents} />
canonicalAliasEvent={canonicalAliasEv} />
</div>
<div className="mx_SettingsTab_heading">{_t("Other")}</div>
{ flairSection }

View file

@ -29,19 +29,19 @@ import {UIFeature} from "../../../../../settings/UIFeature";
import { replaceableComponent } from "../../../../../utils/replaceableComponent";
// Knock and private are reserved keywords which are not yet implemented.
enum JoinRule {
export enum JoinRule {
Public = "public",
Knock = "knock",
Invite = "invite",
Private = "private",
}
enum GuestAccess {
export enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
}
enum HistoryVisibility {
export enum HistoryVisibility {
Invited = "invited",
Joined = "joined",
Shared = "shared",
@ -121,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
if (refreshWhenTypes.includes(e.getType())) this.forceUpdate();
};
private onEncryptionChange = (e: React.ChangeEvent) => {
private onEncryptionChange = () => {
Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, {
title: _t('Enable encryption?'),
description: _t(

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import {_t} from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import CallMediaHandler from "../../../../../CallMediaHandler";
import MediaDeviceHandler from "../../../../../MediaDeviceHandler";
import Field from "../../../elements/Field";
import AccessibleButton from "../../../elements/AccessibleButton";
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
@ -41,7 +41,7 @@ export default class VoiceUserSettingsTab extends React.Component {
}
async componentDidMount() {
const canSeeDeviceLabels = await CallMediaHandler.hasAnyLabeledDevices();
const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices();
if (canSeeDeviceLabels) {
this._refreshMediaDevices();
}
@ -49,10 +49,10 @@ export default class VoiceUserSettingsTab extends React.Component {
_refreshMediaDevices = async (stream) => {
this.setState({
mediaDevices: await CallMediaHandler.getDevices(),
activeAudioOutput: CallMediaHandler.getAudioOutput(),
activeAudioInput: CallMediaHandler.getAudioInput(),
activeVideoInput: CallMediaHandler.getVideoInput(),
mediaDevices: await MediaDeviceHandler.getDevices(),
activeAudioOutput: MediaDeviceHandler.getAudioOutput(),
activeAudioInput: MediaDeviceHandler.getAudioInput(),
activeVideoInput: MediaDeviceHandler.getVideoInput(),
});
if (stream) {
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
@ -100,21 +100,21 @@ export default class VoiceUserSettingsTab extends React.Component {
};
_setAudioOutput = (e) => {
CallMediaHandler.setAudioOutput(e.target.value);
MediaDeviceHandler.instance.setAudioOutput(e.target.value);
this.setState({
activeAudioOutput: e.target.value,
});
};
_setAudioInput = (e) => {
CallMediaHandler.setAudioInput(e.target.value);
MediaDeviceHandler.instance.setAudioInput(e.target.value);
this.setState({
activeAudioInput: e.target.value,
});
};
_setVideoInput = (e) => {
CallMediaHandler.setVideoInput(e.target.value);
MediaDeviceHandler.instance.setVideoInput(e.target.value);
this.setState({
activeVideoInput: e.target.value,
});
@ -171,7 +171,7 @@ export default class VoiceUserSettingsTab extends React.Component {
}
};
const audioOutputs = this.state.mediaDevices.audiooutput.slice(0);
const audioOutputs = this.state.mediaDevices.audioOutput.slice(0);
if (audioOutputs.length > 0) {
const defaultDevice = getDefaultDevice(audioOutputs);
speakerDropdown = (
@ -183,7 +183,7 @@ export default class VoiceUserSettingsTab extends React.Component {
);
}
const audioInputs = this.state.mediaDevices.audioinput.slice(0);
const audioInputs = this.state.mediaDevices.audioInput.slice(0);
if (audioInputs.length > 0) {
const defaultDevice = getDefaultDevice(audioInputs);
microphoneDropdown = (
@ -195,7 +195,7 @@ export default class VoiceUserSettingsTab extends React.Component {
);
}
const videoInputs = this.state.mediaDevices.videoinput.slice(0);
const videoInputs = this.state.mediaDevices.videoInput.slice(0);
if (videoInputs.length > 0) {
const defaultDevice = getDefaultDevice(videoInputs);
webcamDropdown = (

View file

@ -35,6 +35,7 @@ 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 RoomAliasField from "../elements/RoomAliasField";
const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return (
@ -60,6 +61,11 @@ const spaceNameValidator = withValidation({
],
});
const nameToAlias = (name: string, domain: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
};
const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null);
@ -67,6 +73,8 @@ const SpaceCreateMenu = ({ onFinished }) => {
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>("");
@ -82,6 +90,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
const initialState: ICreateRoomStateEvent[] = [
{
@ -99,12 +114,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
content: { url },
});
}
if (topic) {
initialState.push({
type: EventType.RoomTopic,
content: { topic },
});
}
try {
await createRoom({
@ -112,7 +121,6 @@ const SpaceCreateMenu = ({ onFinished }) => {
preset: visibility === Visibility.Public ? Preset.PublicChat : Preset.PrivateChat,
name,
creation_content: {
// Based on MSC1840
[RoomCreateTypeField]: RoomType.Space,
},
initial_state: initialState,
@ -121,6 +129,8 @@ const SpaceCreateMenu = ({ onFinished }) => {
events_default: 100,
...Visibility.Public ? { invite: 0 } : {},
},
room_alias_name: alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
topic,
},
spinner: false,
encryption: false,
@ -159,6 +169,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
<SpaceFeedbackPrompt onClick={onFinished} />
</React.Fragment>;
} else {
const domain = cli.getDomain();
body = <React.Fragment>
<AccessibleTooltipButton
className="mx_SpaceCreateMenu_back"
@ -187,12 +198,30 @@ const SpaceCreateMenu = ({ onFinished }) => {
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => setName(ev.target.value)}
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")}
/>
: null
}
<Field
name="spaceTopic"
element="textarea"

View file

@ -14,17 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useState } from "react";
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import {_t} from "../../../languageHandler";
import { _t } from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar";
import {useContextMenu} from "../../structures/ContextMenu";
import { useContextMenu } from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu";
import {SpaceItem} from "./SpaceTreeLevel";
import { SpaceItem } from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import SpaceStore, {
HOME_SPACE,
UPDATE_INVITED_SPACES,
@ -38,9 +39,9 @@ import {
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import {Key} from "../../../Keyboard";
import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore";
import {NotificationState} from "../../../stores/notifications/NotificationState";
import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import SettingsStore from "../../../settings/SettingsStore";
interface IButtonProps {
@ -122,11 +123,65 @@ const useSpaces = (): [Room[], Room[], Room | null] => {
return [invites, spaces, activeSpace];
};
interface IInnerSpacePanelProps {
children?: ReactNode;
isPanelCollapsed: boolean;
setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
}
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : [];
const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms")
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <div className="mx_SpaceTreeLevel">
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
isNarrow={isPanelCollapsed}
/>
{ invites.map(s => (
<SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)) }
{ spaces.map((s, i) => (
<Draggable key={s.roomId} draggableId={s.roomId} index={i}>
{(provided, snapshot) => (
<SpaceItem
{...provided.draggableProps}
{...provided.dragHandleProps}
key={s.roomId}
innerRef={provided.innerRef}
className={snapshot.isDragging
? "mx_SpaceItem_dragging"
: undefined}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>
)}
</Draggable>
)) }
{ children }
</div>;
});
const SpacePanel = () => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
const [invites, spaces, activeSpace] = useSpaces();
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
useEffect(() => {
@ -135,10 +190,6 @@ const SpacePanel = () => {
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
const newClasses = classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
});
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
@ -205,63 +256,61 @@ const SpacePanel = () => {
}
};
const activeSpaces = activeSpace ? [activeSpace] : [];
const expandCollapseButtonTitle = isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel");
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms")
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return (
<DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
}}>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => (
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
>
<Droppable droppableId="top-level-spaces">
{(provided, snapshot) => (
<AutoHideScrollbar
{...provided.droppableProps}
wrappedRef={provided.innerRef}
className="mx_SpacePanel_spaceTreeWrapper"
style={snapshot.isDraggingOver ? {
pointerEvents: "none",
} : undefined}
>
<InnerSpacePanel
isPanelCollapsed={isPanelCollapsed}
setPanelCollapsed={setPanelCollapsed}
>
{ provided.placeholder }
</InnerSpacePanel>
// TODO drag and drop for re-arranging order
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
{({onKeyDownHandler}) => (
<ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler}
>
<AutoHideScrollbar className="mx_SpacePanel_spaceTreeWrapper">
<div className="mx_SpaceTreeLevel">
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
isNarrow={isPanelCollapsed}
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
)}
</Droppable>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", { expanded: !isPanelCollapsed })}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/>
{ invites.map(s => <SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>) }
{ spaces.map(s => <SpaceItem
key={s.roomId}
space={s}
activeSpaces={activeSpaces}
isPanelCollapsed={isPanelCollapsed}
onExpand={() => setPanelCollapsed(false)}
/>) }
</div>
<SpaceButton
className={newClasses}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
}}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar>
<AccessibleTooltipButton
className={classNames("mx_SpacePanel_toggleCollapse", {expanded: !isPanelCollapsed})}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={expandCollapseButtonTitle}
/>
{ contextMenu }
</ul>
)}
</RovingTabIndexProvider>
{ contextMenu }
</ul>
)}
</RovingTabIndexProvider>
</DragDropContext>
);
};
export default SpacePanel;

View file

@ -0,0 +1,143 @@
/*
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 { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView";
import SpaceBasicSettings from "./SpaceBasicSettings";
import { avatarUrlForRoom } from "../../../Avatar";
import { IDialogProps } from "../dialogs/IDialogProps";
import { getTopic } from "../elements/RoomTopic";
import { defaultDispatcher } from "../../../dispatcher/dispatcher";
interface IProps extends IDialogProps {
matrixClient: MatrixClient;
space: Room;
}
const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProps) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const userId = cli.getUserId();
const [newAvatar, setNewAvatar] = useState<File>(null); // undefined means to remove avatar
const canSetAvatar = space.currentState.maySendStateEvent(EventType.RoomAvatar, userId);
const avatarChanged = newAvatar !== null;
const [name, setName] = useState<string>(space.name);
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
const nameChanged = name !== space.name;
const currentTopic = getTopic(space);
const [topic, setTopic] = useState<string>(currentTopic);
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
const topicChanged = topic !== currentTopic;
const onCancel = () => {
setNewAvatar(null);
setName(space.name);
setTopic(currentTopic);
};
const onSave = async () => {
setBusy(true);
const promises = [];
if (avatarChanged) {
if (newAvatar) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
}
if (nameChanged) {
promises.push(cli.setRoomName(space.roomId, name));
}
if (topicChanged) {
promises.push(cli.setRoomTopic(space.roomId, topic));
}
const results = await Promise.allSettled(promises);
setBusy(false);
const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) {
console.error("Failed to save space settings: ", failures);
setError(_t("Failed to save space settings."));
}
};
return <div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{ _t("General") }</div>
<div>{ _t("Edit settings relating to your space.") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<SpaceFeedbackPrompt onClick={() => onFinished(false)} />
<div className="mx_SettingsTab_section">
<SpaceBasicSettings
avatarUrl={avatarUrlForRoom(space, 80, 80, "crop")}
avatarDisabled={busy || !canSetAvatar}
setAvatar={setNewAvatar}
name={name}
nameDisabled={busy || !canSetName}
setName={setName}
topic={topic}
topicDisabled={busy || !canSetTopic}
setTopic={setTopic}
/>
<AccessibleButton
onClick={onCancel}
disabled={busy || !(avatarChanged || nameChanged || topicChanged)}
kind="link"
>
{ _t("Cancel") }
</AccessibleButton>
<AccessibleButton onClick={onSave} disabled={busy} kind="primary">
{ busy ? _t("Saving...") : _t("Save Changes") }
</AccessibleButton>
</div>
<span className="mx_SettingsTab_subheading">{_t("Leave Space")}</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<AccessibleButton
kind="danger"
onClick={() => {
defaultDispatcher.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave Space") }
</AccessibleButton>
</div>
</div>;
};
export default SpaceSettingsGeneralTab;

View file

@ -0,0 +1,187 @@
/*
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 { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import AliasSettings from "../room_settings/AliasSettings";
import { useStateToggle } from "../../../hooks/useStateToggle";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { GuestAccess, HistoryVisibility, JoinRule } from "../settings/tabs/room/SecurityRoomSettingsTab";
import StyledRadioGroup from "../elements/StyledRadioGroup";
interface IProps {
matrixClient: MatrixClient;
space: Room;
}
enum SpaceVisibility {
Unlisted = "unlisted",
Private = "private",
}
const useLocalEcho = <T extends any>(
currentFactory: () => T,
setterFn: (value: T) => Promise<void>,
errorFn: (error: Error) => void,
): [value: T, handler: (value: T) => void] => {
const [value, setValue] = useState(currentFactory);
const handler = async (value: T) => {
setValue(value);
try {
await setterFn(value);
} catch (e) {
setValue(currentFactory());
errorFn(e);
}
};
return [value, handler];
};
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
const [error, setError] = useState("");
const userId = cli.getUserId();
const [visibility, setVisibility] = useLocalEcho<SpaceVisibility>(
() => space.getJoinRule() === JoinRule.Private ? SpaceVisibility.Private : SpaceVisibility.Unlisted,
visibility => cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, {
join_rule: visibility === SpaceVisibility.Unlisted ? JoinRule.Public : JoinRule.Private,
}, ""),
() => setError(_t("Failed to update the visibility of this space")),
);
const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho<boolean>(
() => space.currentState.getStateEvents(EventType.RoomGuestAccess, "")
?.getContent()?.guest_access === GuestAccess.CanJoin,
guestAccessEnabled => cli.sendStateEvent(space.roomId, EventType.RoomGuestAccess, {
guest_access: guestAccessEnabled ? GuestAccess.CanJoin : GuestAccess.Forbidden,
}, ""),
() => setError(_t("Failed to update the guest access of this space")),
);
const [historyVisibility, setHistoryVisibility] = useLocalEcho<HistoryVisibility>(
() => space.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")
?.getContent()?.history_visibility || HistoryVisibility.Shared,
historyVisibility => cli.sendStateEvent(space.roomId, EventType.RoomHistoryVisibility, {
history_visibility: historyVisibility,
}, ""),
() => setError(_t("Failed to update the history visibility of this space")),
);
const [showAdvancedSection, toggleAdvancedSection] = useStateToggle();
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId);
const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId);
const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli);
const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
let advancedSection;
if (showAdvancedSection) {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Hide advanced") }
</AccessibleButton>
<LabelledToggleSwitch
value={guestAccessEnabled}
onChange={setGuestAccessEnabled}
disabled={!canSetGuestAccess}
label={_t("Enable guest access")}
/>
<p>
{ _t("Guests can join a space without having an account.") }
<br />
{ _t("This may be useful for public spaces.") }
</p>
</>;
} else {
advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
{ _t("Show advanced") }
</AccessibleButton>
</>;
}
let addressesSection;
if (visibility !== SpaceVisibility.Private) {
addressesSection = <>
<span className="mx_SettingsTab_subheading">{_t("Address")}</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
<AliasSettings
roomId={space.roomId}
canSetCanonicalAlias={canSetCanonical}
canSetAliases={true}
canonicalAliasEvent={canonicalAliasEv}
hidePublishSetting={true}
/>
</div>
</>;
}
return <div className="mx_SettingsTab">
<div className="mx_SettingsTab_heading">{ _t("Visibility") }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<div className="mx_SettingsTab_section">
<div className="mx_SettingsTab_section_caption">
{ _t("Decide who can view and join %(spaceName)s.", { spaceName: space.name }) }
</div>
<div>
<StyledRadioGroup
name="spaceVisibility"
value={visibility}
onChange={setVisibility}
disabled={!canSetJoinRule}
definitions={[
{
value: SpaceVisibility.Unlisted,
label: _t("Public"),
description: _t("anyone with the link can view and join"),
}, {
value: SpaceVisibility.Private,
label: _t("Invite only"),
description: _t("only invited people can view and join"),
},
]}
/>
</div>
{ advancedSection }
<LabelledToggleSwitch
value={historyVisibility === HistoryVisibility.WorldReadable}
onChange={(checked: boolean) => {
setHistoryVisibility(checked ? HistoryVisibility.WorldReadable : HistoryVisibility.Shared);
}}
disabled={!canSetHistoryVisibility}
label={_t("Preview Space")}
/>
<div>{ _t("Allow people to preview your space before they join.") }</div>
<b>{ _t("Recommended for public spaces.") }</b>
</div>
{ addressesSection }
</div>;
};
export default SpaceSettingsVisibilityTab;

View file

@ -14,23 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { createRef, InputHTMLAttributes, LegacyRef } from "react";
import classNames from "classnames";
import {Room} from "matrix-js-sdk/src/models/room";
import { Room } from "matrix-js-sdk/src/models/room";
import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge";
import {RovingAccessibleButton} from "../../../accessibility/roving/RovingAccessibleButton";
import {RovingAccessibleTooltipButton} from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import {_t} from "../../../languageHandler";
import {ContextMenuTooltipButton} from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import {toRightOf} from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf } from "../../structures/ContextMenu";
import {
shouldShowSpaceSettings,
showAddExistingRooms,
@ -39,33 +38,38 @@ import {
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton, {ButtonEvent} from "../elements/AccessibleButton";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import { Action } from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import {NotificationColor} from "../../../stores/notifications/NotificationColor";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
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";
interface IItemProps {
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
space?: Room;
activeSpaces: Room[];
isNested?: boolean;
isPanelCollapsed?: boolean;
onExpand?: Function;
parents?: Set<string>;
innerRef?: LegacyRef<HTMLLIElement>;
}
interface IItemState {
collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[];
}
export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
static contextType = MatrixClientContext;
private buttonRef = createRef<HTMLDivElement>();
constructor(props) {
super(props);
@ -78,14 +82,36 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = {
collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces,
};
SpaceStore.instance.on(this.props.space.roomId, this.onSpaceUpdate);
}
private toggleCollapse(evt) {
if (this.props.onExpand && this.state.collapsed) {
componentWillUnmount() {
SpaceStore.instance.off(this.props.space.roomId, this.onSpaceUpdate);
}
private onSpaceUpdate = () => {
this.setState({
childSpaces: this.childSpaces,
});
};
private get childSpaces() {
return SpaceStore.instance.getChildSpaces(this.props.space.roomId)
.filter(s => !this.props.parents?.has(s.roomId));
}
private get isCollapsed() {
return this.state.collapsed || this.props.isPanelCollapsed;
}
private toggleCollapse = evt => {
if (this.props.onExpand && this.isCollapsed) {
this.props.onExpand();
}
const newCollapsedState = !this.state.collapsed;
const newCollapsedState = !this.isCollapsed;
SpaceTreeLevelLayoutStore.instance.setSpaceCollapsedState(
this.props.space.roomId,
@ -96,7 +122,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
// don't bubble up so encapsulating button for space
// doesn't get triggered
evt.stopPropagation();
}
};
private onContextMenu = (ev: React.MouseEvent) => {
if (this.props.space.getMyMembership() !== "join") return;
@ -111,6 +137,43 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
});
}
private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true;
const action = getKeyBindingsManager().getRoomListAction(ev);
const hasChildren = this.state.childSpaces?.length;
switch (action) {
case RoomListAction.CollapseSection:
if (hasChildren && !this.isCollapsed) {
this.toggleCollapse(ev);
} else {
const parentItem = this.buttonRef?.current?.parentElement?.parentElement;
const parentButton = parentItem?.previousElementSibling as HTMLElement;
parentButton?.focus();
}
break;
case RoomListAction.ExpandSection:
if (hasChildren) {
if (this.isCollapsed) {
this.toggleCollapse(ev);
} else {
const childLevel = this.buttonRef?.current?.nextElementSibling;
const firstSpaceItemChild = childLevel?.querySelector<HTMLLIElement>(".mx_SpaceItem");
firstSpaceItemChild?.querySelector<HTMLDivElement>(".mx_SpaceButton")?.focus();
}
}
break;
default:
handled = false;
}
if (handled) {
ev.stopPropagation();
ev.preventDefault();
}
};
private onClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -300,27 +363,25 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
}
render() {
const {space, activeSpaces, isNested} = this.props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
...otherProps } = this.props;
const forceCollapsed = this.props.isPanelCollapsed;
const isNarrow = this.props.isPanelCollapsed;
const collapsed = this.state.collapsed || forceCollapsed;
const collapsed = this.isCollapsed;
const childSpaces = SpaceStore.instance.getChildSpaces(space.roomId)
.filter(s => !this.props.parents?.has(s.roomId));
const isActive = activeSpaces.includes(space);
const itemClasses = classNames({
const itemClasses = classNames(this.props.className, {
"mx_SpaceItem": true,
"mx_SpaceItem_narrow": isNarrow,
"mx_SpaceItem_narrow": isPanelCollapsed,
"collapsed": collapsed,
"hasSubSpaces": childSpaces && childSpaces.length,
"hasSubSpaces": this.state.childSpaces?.length,
});
const isInvite = space.getMyMembership() === "invite";
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isNarrow,
mx_SpaceButton_narrow: isPanelCollapsed,
mx_SpaceButton_invite: isInvite,
});
const notificationState = isInvite
@ -328,12 +389,12 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
: SpaceStore.instance.getNotificationState(space.roomId);
let childItems;
if (childSpaces && !collapsed) {
if (this.state.childSpaces?.length && !collapsed) {
childItems = <SpaceTreeLevel
spaces={childSpaces}
spaces={this.state.childSpaces}
activeSpaces={activeSpaces}
isNested={true}
parents={new Set(this.props.parents).add(this.props.space.roomId)}
parents={new Set(parents).add(space.roomId)}
/>;
}
@ -346,53 +407,36 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = childSpaces && childSpaces.length ?
const toggleCollapseButton = this.state.childSpaces?.length ?
<AccessibleButton
className="mx_SpaceButton_toggleCollapse"
onClick={evt => this.toggleCollapse(evt)}
onClick={this.toggleCollapse}
tabIndex={-1}
aria-label={collapsed ? _t("Expand") : _t("Collapse")}
/> : null;
let button;
if (isNarrow) {
button = (
return (
<li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton
className={classes}
title={space.name}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!!this.state.contextMenuPosition}
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
role="treeitem"
aria-expanded={!collapsed}
inputRef={this.buttonRef}
onKeyDown={this.onKeyDown}
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton
className={classes}
onClick={this.onClick}
onContextMenu={this.onContextMenu}
role="treeitem"
>
{ toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper">
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
<span className="mx_SpaceButton_name">{ space.name }</span>
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleButton>
);
}
return (
<li className={itemClasses}>
{ button }
{ childItems }
</li>
);

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, {createRef} from 'react';
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import CallMediaHandler from "../../../CallMediaHandler";
import MediaDeviceHandler, { MediaDeviceHandlerEvent } from "../../../MediaDeviceHandler";
interface IProps {
feed: CallFeed,
@ -27,19 +27,25 @@ export default class AudioFeed extends React.Component<IProps> {
private element = createRef<HTMLAudioElement>();
componentDidMount() {
MediaDeviceHandler.instance.addListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
this.onAudioOutputChanged,
);
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia();
}
componentWillUnmount() {
MediaDeviceHandler.instance.removeListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
this.onAudioOutputChanged,
);
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.stopMedia();
}
private playMedia() {
private onAudioOutputChanged = (audioOutput: string) => {
const element = this.element.current;
const audioOutput = CallMediaHandler.getAudioOutput();
if (audioOutput) {
try {
// This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where
@ -52,7 +58,11 @@ export default class AudioFeed extends React.Component<IProps> {
logger.warn("Couldn't set requested audio output device: using default", e);
}
}
}
private playMedia() {
const element = this.element.current;
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
element.muted = false;
element.srcObject = this.props.feed.stream;
element.autoplay = true;