Merge branch 'develop' into improved-forwarding-ui

This commit is contained in:
Robin Townsend 2021-05-24 08:23:07 -04:00
commit 88e0e9b9fb
7 changed files with 426 additions and 278 deletions

View file

@ -61,6 +61,39 @@ limitations under the License.
.mx_RoomDirectory_tableWrapper { .mx_RoomDirectory_tableWrapper {
overflow-y: auto; overflow-y: auto;
flex: 1 1 0; flex: 1 1 0;
.mx_RoomDirectory_footer {
margin-top: 24px;
text-align: center;
> h5 {
margin: 0;
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-18px;
color: $primary-fg-color;
}
> p {
margin: 40px auto 60px;
font-size: $font-14px;
line-height: $font-20px;
color: $secondary-fg-color;
max-width: 464px; // easier reading
}
> hr {
margin: 0;
border: none;
height: 1px;
background-color: $header-panel-bg-color;
}
.mx_RoomDirectory_newRoom {
margin: 24px auto 0;
width: max-content;
}
}
} }
.mx_RoomDirectory_table { .mx_RoomDirectory_table {
@ -138,11 +171,6 @@ limitations under the License.
color: $settings-grey-fg-color; color: $settings-grey-fg-color;
} }
.mx_RoomDirectory_table tr {
padding-bottom: 10px;
cursor: pointer;
}
.mx_RoomDirectory .mx_RoomView_MessageList { .mx_RoomDirectory .mx_RoomView_MessageList {
padding: 0; padding: 0;
} }

View file

@ -665,7 +665,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break; break;
} }
case 'view_create_room': case 'view_create_room':
this.createRoom(payload.public); this.createRoom(payload.public, payload.defaultName);
break; break;
case 'view_create_group': { case 'view_create_group': {
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog") let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog")
@ -1011,7 +1011,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
private async createRoom(defaultPublic = false) { private async createRoom(defaultPublic = false, defaultName?: string) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
if (communityId) { if (communityId) {
// double check the user will have permission to associate this room with the community // double check the user will have permission to associate this room with the community
@ -1025,7 +1025,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
defaultPublic,
defaultName,
});
const [shouldCreate, opts] = await modal.finished; const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) { if (shouldCreate) {

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,39 +15,90 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal"; import Modal from "../../Modal";
import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig'; import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics'; import Analytics from '../../Analytics';
import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore"; import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore"; import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics"; import CountlyAnalytics from "../../CountlyAnalytics";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media"; import { mediaFromMxc } from "../../customisations/Media";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseAvatar from "../views/avatars/BaseAvatar";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import QuestionDialog from "../views/dialogs/QuestionDialog";
import BaseDialog from "../views/dialogs/BaseDialog";
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
import NetworkDropdown from "../views/directory/NetworkDropdown";
import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800; const MAX_TOPIC_LENGTH = 800;
function track(action) { function track(action: string) {
Analytics.trackEvent('RoomDirectory', action); Analytics.trackEvent('RoomDirectory', action);
} }
interface IProps extends IDialogProps {
initialText?: string;
}
interface IState {
publicRooms: IRoom[];
loading: boolean;
protocolsLoading: boolean;
error?: string;
instanceId: string | symbol;
roomServer: string;
filterString: string;
selectedCommunityId?: string;
communityName?: string;
}
/* eslint-disable camelcase */
interface IRoom {
room_id: string;
name?: string;
avatar_url?: string;
topic?: string;
canonical_alias?: string;
aliases?: string[];
world_readable: boolean;
guest_can_join: boolean;
num_joined_members: number;
}
interface IPublicRoomsRequest {
limit?: number;
since?: string;
server?: string;
filter?: object;
include_all_networks?: boolean;
third_party_instance_id?: string;
}
/* eslint-enable camelcase */
@replaceableComponent("structures.RoomDirectory") @replaceableComponent("structures.RoomDirectory")
export default class RoomDirectory extends React.Component { export default class RoomDirectory extends React.Component<IProps, IState> {
static propTypes = { private readonly startTime: number;
initialText: PropTypes.string, private unmounted = false
onFinished: PropTypes.func.isRequired, private nextBatch: string = null;
}; private filterTimeout: NodeJS.Timeout;
private protocols: Protocols;
constructor(props) { constructor(props) {
super(props); super(props);
@ -56,41 +106,21 @@ export default class RoomDirectory extends React.Component {
CountlyAnalytics.instance.trackRoomDirectoryBegin(); CountlyAnalytics.instance.trackRoomDirectoryBegin();
this.startTime = CountlyAnalytics.getTimestamp(); this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes")
this.state = { ? GroupFilterOrderStore.getSelectedTags()[0]
publicRooms: [], : null;
loading: true,
protocolsLoading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
communityName: null,
};
this._unmounted = false; let protocolsLoading = true;
this.nextBatch = null;
this.filterTimeout = null;
this.scrollPanel = null;
this.protocols = null;
this.state.protocolsLoading = true;
if (!MatrixClientPeg.get()) { if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page // We may not have a client yet when invoked from welcome page
this.state.protocolsLoading = false; protocolsLoading = false;
return; } else if (!selectedCommunityId) {
}
if (!this.state.selectedCommunityId) {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response; this.protocols = response;
this.setState({protocolsLoading: false}); this.setState({ protocolsLoading: false });
}, (err) => { }, (err) => {
console.warn(`error loading third party protocols: ${err}`); console.warn(`error loading third party protocols: ${err}`);
this.setState({protocolsLoading: false}); this.setState({ protocolsLoading: false });
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// Guests currently aren't allowed to use this API, so // Guests currently aren't allowed to use this API, so
// ignore this as otherwise this error is literally the // ignore this as otherwise this error is literally the
@ -103,19 +133,31 @@ export default class RoomDirectory extends React.Component {
error: _t( error: _t(
'%(brand)s failed to get the protocol list from the homeserver. ' + '%(brand)s failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.', 'The homeserver may be too old to support third party networks.',
{brand}, { brand },
), ),
}); });
}); });
} else { } else {
// We don't use the protocols in the communities v2 prototype experience // We don't use the protocols in the communities v2 prototype experience
this.state.protocolsLoading = false; protocolsLoading = false;
// Grab the profile info async // Grab the profile info async
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => { FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
this.setState({communityName: profile.name}); this.setState({ communityName: profile.name });
}); });
} }
this.state = {
publicRooms: [],
loading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId,
communityName: null,
protocolsLoading,
};
} }
componentDidMount() { componentDidMount() {
@ -126,10 +168,10 @@ export default class RoomDirectory extends React.Component {
if (this.filterTimeout) { if (this.filterTimeout) {
clearTimeout(this.filterTimeout); clearTimeout(this.filterTimeout);
} }
this._unmounted = true; this.unmounted = true;
} }
refreshRoomList = () => { private refreshRoomList = () => {
if (this.state.selectedCommunityId) { if (this.state.selectedCommunityId) {
this.setState({ this.setState({
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => { publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
@ -165,7 +207,7 @@ export default class RoomDirectory extends React.Component {
this.getMoreRooms(); this.getMoreRooms();
}; };
getMoreRooms() { private getMoreRooms() {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve(); if (!MatrixClientPeg.get()) return Promise.resolve();
@ -173,34 +215,34 @@ export default class RoomDirectory extends React.Component {
loading: true, loading: true,
}); });
const my_filter_string = this.state.filterString; const filterString = this.state.filterString;
const my_server = this.state.roomServer; const roomServer = this.state.roomServer;
// remember the next batch token when we sent the request // remember the next batch token when we sent the request
// too. If it's changed, appending to the list will corrupt it. // too. If it's changed, appending to the list will corrupt it.
const my_next_batch = this.nextBatch; const nextBatch = this.nextBatch;
const opts = {limit: 20}; const opts: IPublicRoomsRequest = { limit: 20 };
if (my_server != MatrixClientPeg.getHomeserverName()) { if (roomServer != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server; opts.server = roomServer;
} }
if (this.state.instanceId === ALL_ROOMS) { if (this.state.instanceId === ALL_ROOMS) {
opts.include_all_networks = true; opts.include_all_networks = true;
} else if (this.state.instanceId) { } else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId; opts.third_party_instance_id = this.state.instanceId as string;
} }
if (this.nextBatch) opts.since = this.nextBatch; if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; if (filterString) opts.filter = { generic_search_term: filterString };
return MatrixClientPeg.get().publicRooms(opts).then((data) => { return MatrixClientPeg.get().publicRooms(opts).then((data) => {
if ( if (
my_filter_string != this.state.filterString || filterString != this.state.filterString ||
my_server != this.state.roomServer || roomServer != this.state.roomServer ||
my_next_batch != this.nextBatch) { nextBatch != this.nextBatch) {
// if the filter or server has changed since this request was sent, // if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag // throw away the result (don't even clear the busy flag
// since we must still have a request in flight) // since we must still have a request in flight)
return; return;
} }
if (this._unmounted) { if (this.unmounted) {
// if we've been unmounted, we don't care either. // if we've been unmounted, we don't care either.
return; return;
} }
@ -211,23 +253,23 @@ export default class RoomDirectory extends React.Component {
} }
this.nextBatch = data.next_batch; this.nextBatch = data.next_batch;
this.setState((s) => { this.setState((s) => ({
s.publicRooms.push(...(data.chunk || [])); ...s,
s.loading = false; publicRooms: [...s.publicRooms, ...(data.chunk || [])],
return s; loading: false,
}); }));
return Boolean(data.next_batch); return Boolean(data.next_batch);
}, (err) => { }, (err) => {
if ( if (
my_filter_string != this.state.filterString || filterString != this.state.filterString ||
my_server != this.state.roomServer || roomServer != this.state.roomServer ||
my_next_batch != this.nextBatch) { nextBatch != this.nextBatch) {
// as above: we don't care about errors for old // as above: we don't care about errors for old
// requests either // requests either
return; return;
} }
if (this._unmounted) { if (this.unmounted) {
// if we've been unmounted, we don't care either. // if we've been unmounted, we don't care either.
return; return;
} }
@ -252,13 +294,10 @@ export default class RoomDirectory extends React.Component {
* HS admins to do this through the RoomSettings interface, but * HS admins to do this through the RoomSettings interface, but
* this needs SPEC-417. * this needs SPEC-417.
*/ */
removeFromDirectory(room) { private removeFromDirectory(room: IRoom) {
const alias = get_display_alias_for_room(room); const alias = getDisplayAliasForRoom(room);
const name = room.name || alias || _t('Unnamed room'); const name = room.name || alias || _t('Unnamed room');
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let desc; let desc;
if (alias) { if (alias) {
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name}); desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name});
@ -269,11 +308,10 @@ export default class RoomDirectory extends React.Component {
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, { Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
title: _t('Remove from Directory'), title: _t('Remove from Directory'),
description: desc, description: desc,
onFinished: (should_delete) => { onFinished: (shouldDelete: boolean) => {
if (!should_delete) return; if (!shouldDelete) return;
const Loader = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog(Spinner);
const modal = Modal.createDialog(Loader);
let step = _t('remove %(name)s from the directory.', {name: name}); let step = _t('remove %(name)s from the directory.', {name: name});
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
@ -289,14 +327,16 @@ export default class RoomDirectory extends React.Component {
console.error("Failed to " + step + ": " + err); console.error("Failed to " + step + ": " + err);
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, { Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
title: _t('Error'), title: _t('Error'),
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')), description: (err && err.message)
? err.message
: _t('The server may be unavailable or overloaded'),
}); });
}); });
}, },
}); });
} }
onRoomClicked = (room, ev) => { private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
if (ev.shiftKey && !this.state.selectedCommunityId) { if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault(); ev.preventDefault();
this.removeFromDirectory(room); this.removeFromDirectory(room);
@ -305,7 +345,7 @@ export default class RoomDirectory extends React.Component {
} }
}; };
onOptionChange = (server, instanceId) => { private onOptionChange = (server: string, instanceId?: string | symbol) => {
// clear next batch so we don't try to load more rooms // clear next batch so we don't try to load more rooms
this.nextBatch = null; this.nextBatch = null;
this.setState({ this.setState({
@ -325,13 +365,13 @@ export default class RoomDirectory extends React.Component {
// Easiest to just blow away the state & re-fetch. // Easiest to just blow away the state & re-fetch.
}; };
onFillRequest = (backwards) => { private onFillRequest = (backwards: boolean) => {
if (backwards || !this.nextBatch) return Promise.resolve(false); if (backwards || !this.nextBatch) return Promise.resolve(false);
return this.getMoreRooms(); return this.getMoreRooms();
}; };
onFilterChange = (alias) => { private onFilterChange = (alias: string) => {
this.setState({ this.setState({
filterString: alias || null, filterString: alias || null,
}); });
@ -349,7 +389,7 @@ export default class RoomDirectory extends React.Component {
}, 700); }, 700);
}; };
onFilterClear = () => { private onFilterClear = () => {
// update immediately // update immediately
this.setState({ this.setState({
filterString: null, filterString: null,
@ -360,7 +400,7 @@ export default class RoomDirectory extends React.Component {
} }
}; };
onJoinFromSearchClick = (alias) => { private onJoinFromSearchClick = (alias: string) => {
// If we don't have a particular instance id selected, just show that rooms alias // If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected // If the user specified an alias without a domain, add on whichever server is selected
@ -373,9 +413,10 @@ export default class RoomDirectory extends React.Component {
// This is a 3rd party protocol. Let's see if we can join it // This is a 3rd party protocol. Let's see if we can join it
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
const instance = instanceForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null; const fields = protocolName
? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance)
: null;
if (!fields) { if (!fields) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, { Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
title: _t('Unable to join network'), title: _t('Unable to join network'),
@ -387,14 +428,12 @@ export default class RoomDirectory extends React.Component {
if (resp.length > 0 && resp[0].alias) { if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias, true); this.showRoomAlias(resp[0].alias, true);
} else { } else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Room not found', '', ErrorDialog, { Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
title: _t('Room not found'), title: _t('Room not found'),
description: _t('Couldn\'t find a matching Matrix room'), description: _t('Couldn\'t find a matching Matrix room'),
}); });
} }
}, (e) => { }, (e) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, { Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
title: _t('Fetching third party location failed'), title: _t('Fetching third party location failed'),
description: _t('Unable to look up room ID from server'), description: _t('Unable to look up room ID from server'),
@ -403,36 +442,37 @@ export default class RoomDirectory extends React.Component {
} }
}; };
onPreviewClick = (ev, room) => { private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, false, true); this.showRoom(room, null, false, true);
ev.stopPropagation(); ev.stopPropagation();
}; };
onViewClick = (ev, room) => { private onViewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room); this.showRoom(room);
ev.stopPropagation(); ev.stopPropagation();
}; };
onJoinClick = (ev, room) => { private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, true); this.showRoom(room, null, true);
ev.stopPropagation(); ev.stopPropagation();
}; };
onCreateRoomClick = room => { private onCreateRoomClick = () => {
this.onFinished(); this.onFinished();
dis.dispatch({ dis.dispatch({
action: 'view_create_room', action: 'view_create_room',
public: true, public: true,
defaultName: this.state.filterString.trim(),
}); });
}; };
showRoomAlias(alias, autoJoin=false) { private showRoomAlias(alias: string, autoJoin = false) {
this.showRoom(null, alias, autoJoin); this.showRoom(null, alias, autoJoin);
} }
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished(); this.onFinished();
const payload = { const payload: ActionPayload = {
action: 'view_room', action: 'view_room',
auto_join: autoJoin, auto_join: autoJoin,
should_peek: shouldPeek, should_peek: shouldPeek,
@ -449,15 +489,15 @@ export default class RoomDirectory extends React.Component {
} }
} }
if (!room_alias) { if (!roomAlias) {
room_alias = get_display_alias_for_room(room); roomAlias = getDisplayAliasForRoom(room);
} }
payload.oob_data = { payload.oob_data = {
avatarUrl: room.avatar_url, avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which // XXX: This logic is duplicated from the JS SDK which
// would normally decide what the name is. // would normally decide what the name is.
name: room.name || room_alias || _t('Unnamed room'), name: room.name || roomAlias || _t('Unnamed room'),
}; };
if (this.state.roomServer) { if (this.state.roomServer) {
@ -471,21 +511,19 @@ export default class RoomDirectory extends React.Component {
// which servers to start querying. However, there's no other way to join rooms in // which servers to start querying. However, there's no other way to join rooms in
// this list without aliases at present, so if roomAlias isn't set here we have no // this list without aliases at present, so if roomAlias isn't set here we have no
// choice but to supply the ID. // choice but to supply the ID.
if (room_alias) { if (roomAlias) {
payload.room_alias = room_alias; payload.room_alias = roomAlias;
} else { } else {
payload.room_id = room.room_id; payload.room_id = room.room_id;
} }
dis.dispatch(payload); dis.dispatch(payload);
} }
createRoomCells(room) { private createRoomCells(room: IRoom) {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id); const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
const isGuest = client.isGuest(); const isGuest = client.isGuest();
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let previewButton; let previewButton;
let joinOrViewButton; let joinOrViewButton;
@ -495,20 +533,26 @@ export default class RoomDirectory extends React.Component {
// it is readable, the preview appears as normal. // it is readable, the preview appears as normal.
if (!hasJoinedRoom && (room.world_readable || isGuest)) { if (!hasJoinedRoom && (room.world_readable || isGuest)) {
previewButton = ( previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton> <AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>
{ _t("Preview") }
</AccessibleButton>
); );
} }
if (hasJoinedRoom) { if (hasJoinedRoom) {
joinOrViewButton = ( joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton> <AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>
{ _t("View") }
</AccessibleButton>
); );
} else if (!isGuest) { } else if (!isGuest) {
joinOrViewButton = ( joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton> <AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>
{ _t("Join") }
</AccessibleButton>
); );
} }
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room'); let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) { if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`; name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
} }
@ -531,9 +575,13 @@ export default class RoomDirectory extends React.Component {
onMouseDown={(ev) => {ev.preventDefault();}} onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomAvatar" className="mx_RoomDirectory_roomAvatar"
> >
<BaseAvatar width={32} height={32} resizeMethod='crop' <BaseAvatar
name={ name } idName={ name } width={32}
url={ avatarUrl } height={32}
resizeMethod='crop'
name={name}
idName={name}
url={avatarUrl}
/> />
</div>, </div>,
<div key={ `${room.room_id}_description` } <div key={ `${room.room_id}_description` }
@ -547,7 +595,7 @@ export default class RoomDirectory extends React.Component {
onClick={ (ev) => { ev.stopPropagation(); } } onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }} dangerouslySetInnerHTML={{ __html: topic }}
/> />
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div> <div className="mx_RoomDirectory_alias">{ getDisplayAliasForRoom(room) }</div>
</div>, </div>,
<div key={ `${room.room_id}_memberCount` } <div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)} onClick={(ev) => this.onRoomClicked(room, ev)}
@ -576,20 +624,16 @@ export default class RoomDirectory extends React.Component {
]; ];
} }
collectScrollPanel = (element) => { private stringLooksLikeId(s: string, fieldType: IFieldType) {
this.scrollPanel = element;
};
_stringLooksLikeId(s, field_type) {
let pat = /^#[^\s]+:[^\s]/; let pat = /^#[^\s]+:[^\s]/;
if (field_type && field_type.regexp) { if (fieldType && fieldType.regexp) {
pat = new RegExp(field_type.regexp); pat = new RegExp(fieldType.regexp);
} }
return pat.test(s); return pat.test(s);
} }
_getFieldsForThirdPartyLocation(userInput, protocol, instance) { private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) {
// make an object with the fields specified by that protocol. We // make an object with the fields specified by that protocol. We
// require that the values of all but the last field come from the // require that the values of all but the last field come from the
// instance. The last is the user input. // instance. The last is the user input.
@ -605,71 +649,73 @@ export default class RoomDirectory extends React.Component {
return fields; return fields;
} }
/** private onFinished = () => {
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
handleScrollKey = ev => {
if (this.scrollPanel) {
this.scrollPanel.handleScrollKey(ev);
}
};
onFinished = () => {
CountlyAnalytics.instance.trackRoomDirectory(this.startTime); CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
this.props.onFinished(); this.props.onFinished(false);
}; };
render() { render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let content; let content;
if (this.state.error) { if (this.state.error) {
content = this.state.error; content = this.state.error;
} else if (this.state.protocolsLoading) { } else if (this.state.protocolsLoading) {
content = <Loader />; content = <Spinner />;
} else { } else {
const cells = (this.state.publicRooms || []) const cells = (this.state.publicRooms || [])
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],); .reduce((cells, room) => cells.concat(this.createRoomCells(room)), []);
// we still show the scrollpanel, at least for now, because // we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill // otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one // request from the scrollpanel because there isn't one
let spinner; let spinner;
if (this.state.loading) { if (this.state.loading) {
spinner = <Loader />; spinner = <Spinner />;
} }
let scrollpanel_content; const createNewButton = <>
<hr />
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
{ _t("Create new room") }
</AccessibleButton>
</>;
let scrollPanelContent;
let footer;
if (cells.length === 0 && !this.state.loading) { if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>; footer = <>
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
<p>
{ _t("Try different words or check for typos. " +
"Some results may not be visible as they're private and you need an invite to join them.") }
</p>
{ createNewButton }
</>;
} else { } else {
scrollpanel_content = <div className="mx_RoomDirectory_table"> scrollPanelContent = <div className="mx_RoomDirectory_table">
{ cells } { cells }
</div>; </div>;
if (!this.state.loading && !this.nextBatch) {
footer = createNewButton;
}
} }
const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); content = <ScrollPanel
content = <ScrollPanel ref={this.collectScrollPanel}
className="mx_RoomDirectory_tableWrapper" className="mx_RoomDirectory_tableWrapper"
onFillRequest={ this.onFillRequest } onFillRequest={this.onFillRequest}
stickyBottom={false} stickyBottom={false}
startAtBottom={false} startAtBottom={false}
> >
{ scrollpanel_content } { scrollPanelContent }
{ spinner } { spinner }
{ footer && <div className="mx_RoomDirectory_footer">
{ footer }
</div> }
</ScrollPanel>; </ScrollPanel>;
} }
let listHeader; let listHeader;
if (!this.state.protocolsLoading) { if (!this.state.protocolsLoading) {
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type; let instanceExpectedFieldType;
if ( if (
protocolName && protocolName &&
this.protocols && this.protocols &&
@ -677,21 +723,27 @@ export default class RoomDirectory extends React.Component {
this.protocols[protocolName].location_fields.length > 0 && this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types this.protocols[protocolName].field_types
) { ) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
} }
let placeholder = _t('Find a room…'); let placeholder = _t('Find a room…');
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
} else if (instance_expected_field_type) { exampleRoom: "#example:" + this.state.roomServer,
placeholder = instance_expected_field_type.placeholder; });
} else if (instanceExpectedFieldType) {
placeholder = instanceExpectedFieldType.placeholder;
} }
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
if (protocolName) { if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { if (this.getFieldsForThirdPartyLocation(
this.state.filterString,
this.protocols[protocolName],
instance,
) === null) {
showJoinButton = false; showJoinButton = false;
} }
} }
@ -723,12 +775,11 @@ export default class RoomDirectory extends React.Component {
} }
const explanation = const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null, _t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => { {a: sub => (
return (<AccessibleButton <AccessibleButton kind="secondary" onClick={this.onCreateRoomClick}>
kind="secondary" { sub }
onClick={this.onCreateRoomClick} </AccessibleButton>
>{sub}</AccessibleButton>); )},
}},
); );
const title = this.state.selectedCommunityId const title = this.state.selectedCommunityId
@ -756,6 +807,6 @@ export default class RoomDirectory extends React.Component {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list // but works with the objects we get from the public room list
function get_display_alias_for_room(room) { function getDisplayAliasForRoom(room: IRoom) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); return room.canonical_alias || room.aliases?.[0] || "";
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,27 +15,46 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, {ChangeEvent, createRef, KeyboardEvent, SyntheticEvent} from "react";
import PropTypes from 'prop-types';
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation'; import withValidation, {IFieldState} from '../elements/Validation';
import { _t } from '../../../languageHandler'; import {_t} from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import {privateShouldBeEncrypted} from "../../../createRoom"; import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
isPublic: boolean;
isEncrypted: boolean;
name: string;
topic: string;
alias: string;
detailsOpen: boolean;
noFederate: boolean;
nameIsValid: boolean;
canChangeEncryption: boolean;
}
@replaceableComponent("views.dialogs.CreateRoomDialog") @replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component { export default class CreateRoomDialog extends React.Component<IProps, IState> {
static propTypes = { private nameField = createRef<Field>();
onFinished: PropTypes.func.isRequired, private aliasField = createRef<RoomAliasField>();
defaultPublic: PropTypes.bool,
parentSpace: PropTypes.instanceOf(Room),
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -44,7 +63,7 @@ export default class CreateRoomDialog extends React.Component {
this.state = { this.state = {
isPublic: this.props.defaultPublic || false, isPublic: this.props.defaultPublic || false,
isEncrypted: privateShouldBeEncrypted(), isEncrypted: privateShouldBeEncrypted(),
name: "", name: this.props.defaultName || "",
topic: "", topic: "",
alias: "", alias: "",
detailsOpen: false, detailsOpen: false,
@ -54,26 +73,25 @@ export default class CreateRoomDialog extends React.Component {
}; };
MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") MatrixClientPeg.get().doesServerForceEncryptionForPreset("private")
.then(isForced => this.setState({canChangeEncryption: !isForced})); .then(isForced => this.setState({ canChangeEncryption: !isForced }));
} }
_roomCreateOptions() { private roomCreateOptions() {
const opts = {}; const opts: IOpts = {};
const createOpts = opts.createOpts = {}; const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name; createOpts.name = this.state.name;
if (this.state.isPublic) { if (this.state.isPublic) {
createOpts.visibility = "public"; createOpts.visibility = Visibility.Public;
createOpts.preset = "public_chat"; createOpts.preset = Preset.PublicChat;
opts.guestAccess = false; opts.guestAccess = false;
const {alias} = this.state; const { alias } = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1); createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
} }
if (this.state.topic) { if (this.state.topic) {
createOpts.topic = this.state.topic; createOpts.topic = this.state.topic;
} }
if (this.state.noFederate) { if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false}; createOpts.creation_content = { 'm.federate': false };
} }
if (!this.state.isPublic) { if (!this.state.isPublic) {
@ -98,16 +116,14 @@ export default class CreateRoomDialog extends React.Component {
} }
componentDidMount() { componentDidMount() {
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
// move focus to first field when showing dialog // move focus to first field when showing dialog
this._nameFieldRef.focus(); this.nameField.current.focus();
} }
componentWillUnmount() { componentWillUnmount() {
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
} }
_onKeyDown = event => { private onKeyDown = (event: KeyboardEvent) => {
if (event.key === Key.ENTER) { if (event.key === Key.ENTER) {
this.onOk(); this.onOk();
event.preventDefault(); event.preventDefault();
@ -115,26 +131,26 @@ export default class CreateRoomDialog extends React.Component {
} }
}; };
onOk = async () => { private onOk = async () => {
const activeElement = document.activeElement; const activeElement = document.activeElement as HTMLElement;
if (activeElement) { if (activeElement) {
activeElement.blur(); activeElement.blur();
} }
await this._nameFieldRef.validate({allowEmpty: false}); await this.nameField.current.validate({allowEmpty: false});
if (this._aliasFieldRef) { if (this.aliasField.current) {
await this._aliasFieldRef.validate({allowEmpty: false}); await this.aliasField.current.validate({allowEmpty: false});
} }
// Validation and state updates are async, so we need to wait for them to complete // Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve. // first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve)); await new Promise<void>(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) { if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) {
this.props.onFinished(true, this._roomCreateOptions()); this.props.onFinished(true, this.roomCreateOptions());
} else { } else {
let field; let field;
if (!this.state.nameIsValid) { if (!this.state.nameIsValid) {
field = this._nameFieldRef; field = this.nameField.current;
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) { } else if (this.aliasField.current && !this.aliasField.current.isValid) {
field = this._aliasFieldRef; field = this.aliasField.current;
} }
if (field) { if (field) {
field.focus(); field.focus();
@ -143,49 +159,45 @@ export default class CreateRoomDialog extends React.Component {
} }
}; };
onCancel = () => { private onCancel = () => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
onNameChange = ev => { private onNameChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({name: ev.target.value}); this.setState({ name: ev.target.value });
}; };
onTopicChange = ev => { private onTopicChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({topic: ev.target.value}); this.setState({ topic: ev.target.value });
}; };
onPublicChange = isPublic => { private onPublicChange = (isPublic: boolean) => {
this.setState({isPublic}); this.setState({ isPublic });
}; };
onEncryptedChange = isEncrypted => { private onEncryptedChange = (isEncrypted: boolean) => {
this.setState({isEncrypted}); this.setState({ isEncrypted });
}; };
onAliasChange = alias => { private onAliasChange = (alias: string) => {
this.setState({alias}); this.setState({ alias });
}; };
onDetailsToggled = ev => { private onDetailsToggled = (ev: SyntheticEvent<HTMLDetailsElement>) => {
this.setState({detailsOpen: ev.target.open}); this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
}; };
onNoFederateChange = noFederate => { private onNoFederateChange = (noFederate: boolean) => {
this.setState({noFederate}); this.setState({ noFederate });
}; };
collectDetailsRef = ref => { private onNameValidate = async (fieldState: IFieldState) => {
this._detailsRef = ref; const result = await CreateRoomDialog.validateRoomName(fieldState);
};
onNameValidate = async fieldState => {
const result = await CreateRoomDialog._validateRoomName(fieldState);
this.setState({nameIsValid: result.valid}); this.setState({nameIsValid: result.valid});
return result; return result;
}; };
static _validateRoomName = withValidation({ private static validateRoomName = withValidation({
rules: [ rules: [
{ {
key: "required", key: "required",
@ -196,18 +208,17 @@ export default class CreateRoomDialog extends React.Component {
}); });
render() { render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
let aliasField; let aliasField;
if (this.state.isPublic) { if (this.state.isPublic) {
const domain = MatrixClientPeg.get().getDomain(); const domain = MatrixClientPeg.get().getDomain();
aliasField = ( aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer"> <div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} /> <RoomAliasField
ref={this.aliasField}
onChange={this.onAliasChange}
domain={domain}
value={this.state.alias}
/>
</div> </div>
); );
} }
@ -270,16 +281,34 @@ export default class CreateRoomDialog extends React.Component {
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={title} title={title}
> >
<form onSubmit={this.onOk} onKeyDown={this._onKeyDown}> <form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<Field ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" /> <Field
<Field label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} className="mx_CreateRoomDialog_topic" /> ref={this.nameField}
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} /> label={_t('Name')}
onChange={this.onNameChange}
onValidate={this.onNameValidate}
value={this.state.name}
className="mx_CreateRoomDialog_name"
/>
<Field
label={_t('Topic (optional)')}
onChange={this.onTopicChange}
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<LabelledToggleSwitch
label={_t("Make this room public")}
onChange={this.onPublicChange}
value={this.state.isPublic}
/>
{ publicPrivateLabel } { publicPrivateLabel }
{ e2eeSection } { e2eeSection }
{ aliasField } { aliasField }
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details"> <details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary> <summary className="mx_CreateRoomDialog_details_summary">
{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }
</summary>
<LabelledToggleSwitch <LabelledToggleSwitch
label={_t( label={_t(
"Block anyone not part of %(serverName)s from ever joining this room.", "Block anyone not part of %(serverName)s from ever joining this room.",

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2016, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,39 +15,42 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from "react";
import PropTypes from 'prop-types'; import { MatrixError } from "matrix-js-sdk/src/http-api";
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
import { import {
ChevronFace,
ContextMenu, ContextMenu,
useContextMenu,
ContextMenuButton, ContextMenuButton,
MenuItemRadio,
MenuItem,
MenuGroup, MenuGroup,
MenuItem,
MenuItemRadio,
useContextMenu,
} from "../../structures/ContextMenu"; } from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import {useSettingValue} from "../../../hooks/useSettings"; import { useSettingValue } from "../../../hooks/useSettings";
import * as sdk from "../../../index";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import withValidation from "../elements/Validation"; import withValidation from "../elements/Validation";
import { SettingLevel } from "../../../settings/SettingLevel";
import TextInputDialog from "../dialogs/TextInputDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
export const ALL_ROOMS = Symbol("ALL_ROOMS"); export const ALL_ROOMS = Symbol("ALL_ROOMS");
const SETTING_NAME = "room_directory_servers"; const SETTING_NAME = "room_directory_servers";
const inPlaceOf = (elementRect) => ({ const inPlaceOf = (elementRect: Pick<DOMRect, "right" | "top">) => ({
right: window.innerWidth - elementRect.right, right: window.innerWidth - elementRect.right,
top: elementRect.top, top: elementRect.top,
chevronOffset: 0, chevronOffset: 0,
chevronFace: "none", chevronFace: ChevronFace.None,
}); });
const validServer = withValidation({ const validServer = withValidation<undefined, { error?: MatrixError }>({
deriveData: async ({ value }) => { deriveData: async ({ value }) => {
try { try {
// check if we can successfully load this server's room directory // check if we can successfully load this server's room directory
@ -78,15 +80,49 @@ const validServer = withValidation({
], ],
}); });
/* eslint-disable camelcase */
export interface IFieldType {
regexp: string;
placeholder: string;
}
export interface IInstance {
desc: string;
icon?: string;
fields: object;
network_id: string;
// XXX: this is undocumented but we rely on it.
// we inject a fake entry with a symbolic instance_id.
instance_id: string | symbol;
}
export interface IProtocol {
user_fields: string[];
location_fields: string[];
icon: string;
field_types: Record<string, IFieldType>;
instances: IInstance[];
}
/* eslint-enable camelcase */
export type Protocols = Record<string, IProtocol>;
interface IProps {
protocols: Protocols;
selectedServerName: string;
selectedInstanceId: string | symbol;
onOptionChange(server: string, instanceId?: string | symbol): void;
}
// This dropdown sources homeservers from three places: // This dropdown sources homeservers from three places:
// + your currently connected homeserver // + your currently connected homeserver
// + homeservers in config.json["roomDirectory"] // + homeservers in config.json["roomDirectory"]
// + homeservers in SettingsStore["room_directory_servers"] // + homeservers in SettingsStore["room_directory_servers"]
// if a server exists in multiple, only keep the top-most entry. // if a server exists in multiple, only keep the top-most entry.
const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => { const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const _userDefinedServers = useSettingValue(SETTING_NAME); const _userDefinedServers: string[] = useSettingValue(SETTING_NAME);
const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers); const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
const handlerFactory = (server, instanceId) => { const handlerFactory = (server, instanceId) => {
@ -98,7 +134,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const setUserDefinedServers = servers => { const setUserDefinedServers = servers => {
_setUserDefinedServers(servers); _setUserDefinedServers(servers);
SettingsStore.setValue(SETTING_NAME, null, "account", servers); SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers);
}; };
// keep local echo up to date with external changes // keep local echo up to date with external changes
useEffect(() => { useEffect(() => {
@ -112,7 +148,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const roomDirectory = config.roomDirectory || {}; const roomDirectory = config.roomDirectory || {};
const hsName = MatrixClientPeg.getHomeserverName(); const hsName = MatrixClientPeg.getHomeserverName();
const configServers = new Set(roomDirectory.servers); const configServers = new Set<string>(roomDirectory.servers);
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName)); const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
@ -136,9 +172,15 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
// add a fake protocol with the ALL_ROOMS symbol // add a fake protocol with the ALL_ROOMS symbol
protocolsList.push({ protocolsList.push({
instances: [{ instances: [{
fields: [],
network_id: "",
instance_id: ALL_ROOMS, instance_id: ALL_ROOMS,
desc: _t("All rooms"), desc: _t("All rooms"),
}], }],
location_fields: [],
user_fields: [],
field_types: {},
icon: "",
}); });
} }
@ -172,7 +214,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
if (removableServers.has(server)) { if (removableServers.has(server)) {
const onClick = async () => { const onClick = async () => {
closeMenu(); closeMenu();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, { const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
title: _t("Are you sure?"), title: _t("Are you sure?"),
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", { description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
@ -191,7 +232,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
setUserDefinedServers(servers.filter(s => s !== server)); setUserDefinedServers(servers.filter(s => s !== server));
// the selected server is being removed, reset to our HS // the selected server is being removed, reset to our HS
if (serverSelected === server) { if (serverSelected) {
onOptionChange(hsName, undefined); onOptionChange(hsName, undefined);
} }
}; };
@ -223,7 +264,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const onClick = async () => { const onClick = async () => {
closeMenu(); closeMenu();
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, { const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
title: _t("Add a new server"), title: _t("Add a new server"),
description: _t("Enter the name of a new server you want to explore."), description: _t("Enter the name of a new server you want to explore."),
@ -284,9 +324,4 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
</div>; </div>;
}; };
NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object,
};
export default NetworkDropdown; export default NetworkDropdown;

View file

@ -39,7 +39,7 @@ import { makeSpaceParentEvent } from "./utils/space";
/* eslint-disable camelcase */ /* eslint-disable camelcase */
// TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them // TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them
enum Visibility { export enum Visibility {
Public = "public", Public = "public",
Private = "private", Private = "private",
} }

View file

@ -2651,6 +2651,8 @@
"Unable to look up room ID from server": "Unable to look up room ID from server", "Unable to look up room ID from server": "Unable to look up room ID from server",
"Preview": "Preview", "Preview": "Preview",
"View": "View", "View": "View",
"No results for \"%(query)s\"": "No results for \"%(query)s\"",
"Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.": "Try different words or check for typos. Some results may not be visible as they're private and you need an invite to join them.",
"Find a room…": "Find a room…", "Find a room…": "Find a room…",
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",