2020-01-03 03:40:18 +03:00
|
|
|
/*
|
|
|
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import React from 'react';
|
|
|
|
import PropTypes from 'prop-types';
|
|
|
|
import {_t} from "../../../languageHandler";
|
|
|
|
import sdk from "../../../index";
|
|
|
|
import MatrixClientPeg from "../../../MatrixClientPeg";
|
|
|
|
import {makeUserPermalink} from "../../../utils/permalinks/Permalinks";
|
|
|
|
import DMRoomMap from "../../../utils/DMRoomMap";
|
|
|
|
import {RoomMember} from "matrix-js-sdk/lib/matrix";
|
|
|
|
import * as humanize from "humanize";
|
2020-01-04 05:41:06 +03:00
|
|
|
import SdkConfig from "../../../SdkConfig";
|
2020-01-04 07:17:48 +03:00
|
|
|
import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo";
|
2020-01-03 03:40:18 +03:00
|
|
|
|
|
|
|
// TODO: [TravisR] Make this generic for all kinds of invites
|
|
|
|
|
|
|
|
const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first
|
|
|
|
const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked
|
|
|
|
|
2020-01-04 07:17:48 +03:00
|
|
|
class DirectoryMember {
|
|
|
|
_userId: string;
|
|
|
|
_displayName: string;
|
|
|
|
_avatarUrl: string;
|
|
|
|
|
|
|
|
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
|
|
|
|
this._userId = userDirResult.user_id;
|
|
|
|
this._displayName = userDirResult.display_name;
|
|
|
|
this._avatarUrl = userDirResult.avatar_url;
|
|
|
|
}
|
|
|
|
|
|
|
|
// These next members are to implement the contract expected by DMRoomTile
|
|
|
|
get name(): string {
|
|
|
|
return this._displayName || this._userId;
|
|
|
|
}
|
|
|
|
|
|
|
|
get userId(): string {
|
|
|
|
return this._userId;
|
|
|
|
}
|
|
|
|
|
|
|
|
getMxcAvatarUrl(): string {
|
|
|
|
return this._avatarUrl;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-03 20:24:07 +03:00
|
|
|
class DMRoomTile extends React.PureComponent {
|
2020-01-03 03:40:18 +03:00
|
|
|
static propTypes = {
|
2020-01-04 07:17:48 +03:00
|
|
|
// Has properties to match RoomMember: userId (str), name (str), getMxcAvatarUrl(): string
|
2020-01-03 03:40:18 +03:00
|
|
|
member: PropTypes.object.isRequired,
|
|
|
|
lastActiveTs: PropTypes.number,
|
|
|
|
onToggle: PropTypes.func.isRequired,
|
2020-01-04 06:35:30 +03:00
|
|
|
highlightWord: PropTypes.string,
|
2020-01-03 03:40:18 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
_onClick = (e) => {
|
|
|
|
// Stop the browser from highlighting text
|
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
|
|
this.props.onToggle(this.props.member.userId);
|
|
|
|
};
|
|
|
|
|
2020-01-04 06:35:30 +03:00
|
|
|
_highlightName(str: string) {
|
|
|
|
if (!this.props.highlightWord) return str;
|
|
|
|
|
|
|
|
// We convert things to lowercase for index searching, but pull substrings from
|
2020-01-06 22:21:59 +03:00
|
|
|
// the submitted text to preserve case. Note: we don't need to htmlEntities the
|
|
|
|
// string because React will safely encode the text for us.
|
2020-01-04 06:35:30 +03:00
|
|
|
const lowerStr = str.toLowerCase();
|
|
|
|
const filterStr = this.props.highlightWord.toLowerCase();
|
|
|
|
|
|
|
|
const result = [];
|
|
|
|
|
|
|
|
let i = 0;
|
|
|
|
let ii;
|
|
|
|
while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) {
|
|
|
|
// Push any text we missed (first bit/middle of text)
|
|
|
|
if (ii > i) {
|
2020-01-06 22:21:59 +03:00
|
|
|
// Push any text we aren't highlighting (middle of text match, or beginning of text)
|
|
|
|
result.push(<span key={i + 'begin'}>{str.substring(i, ii)}</span>);
|
2020-01-04 06:35:30 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching)
|
|
|
|
|
|
|
|
// Highlight the word the user entered
|
|
|
|
const substr = str.substring(i, filterStr.length + i);
|
|
|
|
result.push(<span className='mx_DMInviteDialog_roomTile_highlight' key={i + 'bold'}>{substr}</span>);
|
|
|
|
i += substr.length;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Push any text we missed (end of text)
|
|
|
|
if (i < (str.length - 1)) {
|
|
|
|
result.push(<span key={i + 'end'}>{str.substring(i)}</span>);
|
|
|
|
}
|
|
|
|
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2020-01-03 03:40:18 +03:00
|
|
|
render() {
|
2020-01-04 07:17:48 +03:00
|
|
|
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
|
2020-01-03 03:40:18 +03:00
|
|
|
|
|
|
|
let timestamp = null;
|
|
|
|
if (this.props.lastActiveTs) {
|
|
|
|
// TODO: [TravisR] Figure out how to i18n this
|
|
|
|
// `humanize` wants seconds for a timestamp, so divide by 1000
|
|
|
|
const humanTs = humanize.relativeTime(this.props.lastActiveTs / 1000);
|
|
|
|
timestamp = <span className='mx_DMInviteDialog_roomTile_time'>{humanTs}</span>;
|
|
|
|
}
|
|
|
|
|
2020-01-04 07:17:48 +03:00
|
|
|
const avatarSize = 36;
|
|
|
|
const avatarUrl = getHttpUriForMxc(
|
|
|
|
MatrixClientPeg.get().getHomeserverUrl(), this.props.member.getMxcAvatarUrl(),
|
|
|
|
avatarSize, avatarSize, "crop");
|
|
|
|
|
2020-01-03 03:40:18 +03:00
|
|
|
return (
|
|
|
|
<div className='mx_DMInviteDialog_roomTile' onClick={this._onClick}>
|
2020-01-04 07:17:48 +03:00
|
|
|
<BaseAvatar
|
|
|
|
url={avatarUrl}
|
|
|
|
name={this.props.member.name}
|
|
|
|
idName={this.props.member.userId}
|
|
|
|
width={avatarSize}
|
|
|
|
height={avatarSize}
|
|
|
|
/>
|
2020-01-04 06:35:30 +03:00
|
|
|
<span className='mx_DMInviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</span>
|
|
|
|
<span className='mx_DMInviteDialog_roomTile_userId'>{this._highlightName(this.props.member.userId)}</span>
|
2020-01-03 03:40:18 +03:00
|
|
|
{timestamp}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-03 20:24:07 +03:00
|
|
|
export default class DMInviteDialog extends React.PureComponent {
|
2020-01-03 03:40:18 +03:00
|
|
|
static propTypes = {
|
|
|
|
// Takes an array of user IDs/emails to invite.
|
|
|
|
onFinished: PropTypes.func.isRequired,
|
|
|
|
};
|
|
|
|
|
2020-01-04 07:17:48 +03:00
|
|
|
_debounceTimer: number = null;
|
|
|
|
|
2020-01-03 03:40:18 +03:00
|
|
|
constructor() {
|
|
|
|
super();
|
|
|
|
|
|
|
|
this.state = {
|
|
|
|
targets: [], // string[] of mxids/email addresses
|
|
|
|
filterText: "",
|
|
|
|
recents: this._buildRecents(),
|
|
|
|
numRecentsShown: INITIAL_ROOMS_SHOWN,
|
2020-01-04 05:41:06 +03:00
|
|
|
suggestions: this._buildSuggestions(),
|
|
|
|
numSuggestionsShown: INITIAL_ROOMS_SHOWN,
|
2020-01-04 07:17:48 +03:00
|
|
|
serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions
|
2020-01-03 03:40:18 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
_buildRecents(): {userId: string, user: RoomMember, lastActive: number} {
|
|
|
|
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals();
|
|
|
|
const recents = [];
|
|
|
|
for (const userId in rooms) {
|
|
|
|
const room = rooms[userId];
|
|
|
|
const member = room.getMember(userId);
|
|
|
|
if (!member) continue; // just skip people who don't have memberships for some reason
|
|
|
|
|
2020-01-03 03:44:19 +03:00
|
|
|
const lastEventTs = room.timeline && room.timeline.length
|
|
|
|
? room.timeline[room.timeline.length - 1].getTs()
|
|
|
|
: 0;
|
2020-01-03 03:40:18 +03:00
|
|
|
if (!lastEventTs) continue; // something weird is going on with this room
|
|
|
|
|
|
|
|
recents.push({userId, user: member, lastActive: lastEventTs});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sort the recents by last active to save us time later
|
|
|
|
recents.sort((a, b) => b.lastActive - a.lastActive);
|
|
|
|
|
|
|
|
return recents;
|
|
|
|
}
|
|
|
|
|
2020-01-04 05:41:06 +03:00
|
|
|
_buildSuggestions(): {userId: string, user: RoomMember} {
|
|
|
|
const maxConsideredMembers = 200;
|
|
|
|
const client = MatrixClientPeg.get();
|
|
|
|
const excludedUserIds = [client.getUserId(), SdkConfig.get()['welcomeUserId']];
|
|
|
|
const joinedRooms = client.getRooms()
|
|
|
|
.filter(r => r.getMyMembership() === 'join')
|
|
|
|
.filter(r => r.getJoinedMemberCount() <= maxConsideredMembers);
|
2020-01-06 22:23:35 +03:00
|
|
|
|
|
|
|
// Generates { userId: {member, rooms[]} }
|
2020-01-04 05:41:06 +03:00
|
|
|
const memberRooms = joinedRooms.reduce((members, room) => {
|
|
|
|
const joinedMembers = room.getJoinedMembers().filter(u => !excludedUserIds.includes(u.userId));
|
|
|
|
for (const member of joinedMembers) {
|
|
|
|
if (!members[member.userId]) {
|
|
|
|
members[member.userId] = {
|
|
|
|
member: member,
|
|
|
|
// Track the room size of the 'picked' member so we can use the profile of
|
|
|
|
// the smallest room (likely a DM).
|
|
|
|
pickedMemberRoomSize: room.getJoinedMemberCount(),
|
|
|
|
rooms: [],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
members[member.userId].rooms.push(room);
|
|
|
|
|
|
|
|
if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) {
|
|
|
|
members[member.userId].member = member;
|
|
|
|
members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return members;
|
2020-01-06 22:23:35 +03:00
|
|
|
}, {});
|
|
|
|
|
|
|
|
// Generates { userId: {member, numRooms, score} }
|
2020-01-04 05:41:06 +03:00
|
|
|
const memberScores = Object.values(memberRooms).reduce((scores, entry) => {
|
|
|
|
const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
|
|
|
|
const maxRange = maxConsideredMembers * entry.rooms.length;
|
|
|
|
scores[entry.member.userId] = {
|
|
|
|
member: entry.member,
|
|
|
|
numRooms: entry.rooms.length,
|
|
|
|
score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)),
|
|
|
|
};
|
|
|
|
return scores;
|
2020-01-06 22:23:35 +03:00
|
|
|
}, {});
|
|
|
|
|
2020-01-04 05:41:06 +03:00
|
|
|
const members = Object.values(memberScores);
|
|
|
|
members.sort((a, b) => {
|
|
|
|
if (a.score === b.score) {
|
|
|
|
if (a.numRooms === b.numRooms) {
|
|
|
|
return a.member.userId.localeCompare(b.member.userId);
|
|
|
|
}
|
|
|
|
|
|
|
|
return b.numRooms - a.numRooms;
|
|
|
|
}
|
|
|
|
return b.score - a.score;
|
|
|
|
});
|
2020-01-04 06:35:30 +03:00
|
|
|
return members.map(m => ({userId: m.member.userId, user: m.member}));
|
2020-01-04 05:41:06 +03:00
|
|
|
}
|
|
|
|
|
2020-01-03 03:40:18 +03:00
|
|
|
_startDm = () => {
|
|
|
|
this.props.onFinished(this.state.targets);
|
|
|
|
};
|
|
|
|
|
|
|
|
_cancel = () => {
|
|
|
|
this.props.onFinished([]);
|
|
|
|
};
|
|
|
|
|
|
|
|
_updateFilter = (e) => {
|
2020-01-04 07:17:48 +03:00
|
|
|
const term = e.target.value;
|
|
|
|
this.setState({filterText: term});
|
|
|
|
|
|
|
|
// Debounce server lookups to reduce spam. We don't clear the existing server
|
|
|
|
// results because they might still be vaguely accurate, likewise for races which
|
|
|
|
// could happen here.
|
|
|
|
if (this._debounceTimer) {
|
|
|
|
clearTimeout(this._debounceTimer);
|
|
|
|
}
|
|
|
|
this._debounceTimer = setTimeout(() => {
|
|
|
|
MatrixClientPeg.get().searchUserDirectory({term}).then(r => {
|
|
|
|
if (term !== this.state.filterText) {
|
|
|
|
// Discard the results - we were probably too slow on the server-side to make
|
|
|
|
// these results useful. This is a race we want to avoid because we could overwrite
|
|
|
|
// more accurate results.
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.setState({
|
|
|
|
serverResultsMixin: r.results.map(u => ({
|
|
|
|
userId: u.user_id,
|
|
|
|
user: new DirectoryMember(u),
|
|
|
|
})),
|
|
|
|
});
|
|
|
|
}).catch(e => {
|
|
|
|
console.error("Error searching user directory:");
|
|
|
|
console.error(e);
|
|
|
|
this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal
|
|
|
|
});
|
|
|
|
}, 150); // 150ms debounce (human reaction time + some)
|
2020-01-03 03:40:18 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
_showMoreRecents = () => {
|
|
|
|
this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN});
|
|
|
|
};
|
|
|
|
|
2020-01-04 05:41:06 +03:00
|
|
|
_showMoreSuggestions = () => {
|
|
|
|
this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN});
|
|
|
|
};
|
|
|
|
|
2020-01-03 03:40:18 +03:00
|
|
|
_toggleMember = (userId) => {
|
|
|
|
const targets = this.state.targets.map(t => t); // cheap clone for mutation
|
|
|
|
const idx = targets.indexOf(userId);
|
|
|
|
if (idx >= 0) targets.splice(idx, 1);
|
|
|
|
else targets.push(userId);
|
|
|
|
this.setState({targets});
|
|
|
|
};
|
|
|
|
|
2020-01-04 05:41:06 +03:00
|
|
|
_renderSection(kind: "recents"|"suggestions") {
|
2020-01-04 06:35:30 +03:00
|
|
|
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
|
2020-01-04 05:41:06 +03:00
|
|
|
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
|
|
|
|
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this);
|
|
|
|
const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
|
|
|
|
const sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
|
|
|
|
|
2020-01-04 07:17:48 +03:00
|
|
|
// Mix in the server results if we have any, but only if we're searching
|
|
|
|
if (this.state.filterText && this.state.serverResultsMixin && kind === 'suggestions') {
|
|
|
|
// only pick out the server results that aren't already covered though
|
|
|
|
const uniqueServerResults = this.state.serverResultsMixin
|
|
|
|
.filter(u => !sourceMembers.some(m => m.userId === u.userId));
|
|
|
|
|
|
|
|
sourceMembers = sourceMembers.concat(uniqueServerResults);
|
|
|
|
}
|
|
|
|
|
2020-01-04 06:35:30 +03:00
|
|
|
// Hide the section if there's nothing to filter by
|
2020-01-04 05:41:06 +03:00
|
|
|
if (!sourceMembers || sourceMembers.length === 0) return null;
|
|
|
|
|
2020-01-04 06:35:30 +03:00
|
|
|
// Do some simple filtering on the input before going much further. If we get no results, say so.
|
|
|
|
if (this.state.filterText) {
|
|
|
|
const filterBy = this.state.filterText.toLowerCase();
|
|
|
|
sourceMembers = sourceMembers
|
|
|
|
.filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy));
|
|
|
|
|
|
|
|
if (sourceMembers.length === 0) {
|
|
|
|
return (
|
|
|
|
<div className='mx_DMInviteDialog_section'>
|
|
|
|
<h3>{sectionName}</h3>
|
|
|
|
<p>{_t("No results")}</p>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-04 05:41:06 +03:00
|
|
|
// If we're going to hide one member behind 'show more', just use up the space of the button
|
|
|
|
// with the member's tile instead.
|
|
|
|
if (showNum === sourceMembers.length - 1) showNum++;
|
2020-01-03 03:40:18 +03:00
|
|
|
|
|
|
|
// .slice() will return an incomplete array but won't error on us if we go too far
|
2020-01-04 05:41:06 +03:00
|
|
|
const toRender = sourceMembers.slice(0, showNum);
|
|
|
|
const hasMore = toRender.length < sourceMembers.length;
|
2020-01-03 03:40:18 +03:00
|
|
|
|
|
|
|
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
|
|
|
let showMore = null;
|
|
|
|
if (hasMore) {
|
|
|
|
showMore = (
|
2020-01-04 05:41:06 +03:00
|
|
|
<AccessibleButton onClick={showMoreFn} kind="link">
|
2020-01-03 03:40:18 +03:00
|
|
|
{_t("Show more")}
|
|
|
|
</AccessibleButton>
|
2020-01-03 03:44:19 +03:00
|
|
|
);
|
2020-01-03 03:40:18 +03:00
|
|
|
}
|
|
|
|
|
2020-01-03 03:44:19 +03:00
|
|
|
const tiles = toRender.map(r => (
|
2020-01-04 06:35:30 +03:00
|
|
|
<DMRoomTile
|
|
|
|
member={r.user}
|
|
|
|
lastActiveTs={lastActive(r)}
|
|
|
|
key={r.userId}
|
|
|
|
onToggle={this._toggleMember}
|
|
|
|
highlightWord={this.state.filterText}
|
|
|
|
/>
|
2020-01-03 03:44:19 +03:00
|
|
|
));
|
2020-01-03 03:40:18 +03:00
|
|
|
return (
|
|
|
|
<div className='mx_DMInviteDialog_section'>
|
2020-01-04 05:41:06 +03:00
|
|
|
<h3>{sectionName}</h3>
|
2020-01-03 03:44:19 +03:00
|
|
|
{tiles}
|
2020-01-03 03:40:18 +03:00
|
|
|
{showMore}
|
|
|
|
</div>
|
2020-01-03 03:44:19 +03:00
|
|
|
);
|
2020-01-03 03:40:18 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
render() {
|
|
|
|
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
|
|
|
|
const Field = sdk.getComponent("elements.Field");
|
|
|
|
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
|
|
|
|
|
|
|
|
// Dev note: The use of Field is temporary/incomplete pending https://github.com/vector-im/riot-web/issues/11197
|
|
|
|
// For now, we just list who the targets are.
|
|
|
|
const editor = (
|
|
|
|
<div className='mx_DMInviteDialog_editor'>
|
|
|
|
<Field
|
|
|
|
id="inviteTargets"
|
|
|
|
value={this.state.filterText}
|
|
|
|
onChange={this._updateFilter}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
const targets = this.state.targets.map(t => <div key={t}>{t}</div>);
|
|
|
|
|
|
|
|
const userId = MatrixClientPeg.get().getUserId();
|
|
|
|
return (
|
|
|
|
<BaseDialog
|
|
|
|
className='mx_DMInviteDialog'
|
|
|
|
hasCancel={true}
|
|
|
|
onFinished={this._cancel}
|
|
|
|
title={_t("Direct Messages")}
|
|
|
|
>
|
|
|
|
<div className='mx_DMInviteDialog_content'>
|
|
|
|
<p>
|
|
|
|
{_t(
|
|
|
|
"If you can't find someone, ask them for their username, or share your " +
|
|
|
|
"username (%(userId)s) or <a>profile link</a>.",
|
|
|
|
{userId},
|
|
|
|
{a: (sub) => <a href={makeUserPermalink(userId)} rel="noopener" target="_blank">{sub}</a>},
|
|
|
|
)}
|
|
|
|
</p>
|
|
|
|
{targets}
|
|
|
|
<div className='mx_DMInviteDialog_addressBar'>
|
|
|
|
{editor}
|
|
|
|
<AccessibleButton
|
|
|
|
kind="primary"
|
|
|
|
onClick={this._startDm}
|
|
|
|
className='mx_DMInviteDialog_goButton'
|
|
|
|
>
|
|
|
|
{_t("Go")}
|
|
|
|
</AccessibleButton>
|
|
|
|
</div>
|
2020-01-04 05:41:06 +03:00
|
|
|
{this._renderSection('recents')}
|
|
|
|
{this._renderSection('suggestions')}
|
2020-01-03 03:40:18 +03:00
|
|
|
</div>
|
|
|
|
</BaseDialog>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|