Convert RoomDirectory and NetworkDropdown to Typescript

This commit is contained in:
Michael Telatynski 2021-05-19 18:40:03 +01:00
parent aa4984019c
commit d10a45c6a3
2 changed files with 253 additions and 189 deletions

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 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,18 +15,17 @@ 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 { MatrixClientPeg } from "../../MatrixClientPeg";
import * as sdk from "../../index";
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";
@ -35,20 +33,72 @@ 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);
@ -57,34 +107,12 @@ export default class RoomDirectory extends React.Component {
this.startTime = CountlyAnalytics.getTimestamp(); this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0];
this.state = {
publicRooms: [],
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 (!this.state.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});
@ -109,13 +137,27 @@ export default class RoomDirectory extends React.Component {
}); });
} 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: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
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,22 +442,22 @@ 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',
@ -426,13 +465,13 @@ export default class RoomDirectory extends React.Component {
}); });
}; };
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 +488,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 +510,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 +532,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,8 +574,12 @@ 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}
height={32}
resizeMethod='crop'
name={name}
idName={name}
url={avatarUrl} url={avatarUrl}
/> />
</div>, </div>,
@ -547,7 +594,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 +623,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 +648,52 @@ 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; let scrollPanelContent;
if (cells.length === 0 && !this.state.loading) { if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>; scrollPanelContent = <i>{ _t('No rooms to show') }</i>;
} else { } else {
scrollpanel_content = <div className="mx_RoomDirectory_table"> scrollPanelContent = <div className="mx_RoomDirectory_table">
{ cells } { cells }
</div>; </div>;
} }
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 }
</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 +701,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 +753,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 +785,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,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;