From 56c7f8698330569367de839f45f36c944eff6f16 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 25 Aug 2020 21:02:32 -0600 Subject: [PATCH] Add an invite users to community step to dialog flow --- res/css/_components.scss | 1 + .../_PrototypeCommunityInviteDialog.scss | 88 ++++++ src/RoomInvite.js | 16 +- src/components/views/dialogs/InviteDialog.js | 4 +- .../PrototypeCommunityInviteDialog.tsx | 252 ++++++++++++++++++ .../dialogs/PrototypeCreateGroupDialog.tsx | 9 +- src/i18n/strings/en_EN.json | 12 +- 7 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 res/css/views/dialogs/_PrototypeCommunityInviteDialog.scss create mode 100644 src/components/views/dialogs/PrototypeCommunityInviteDialog.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index 60725ff5d4..88fbbb5c3e 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -72,6 +72,7 @@ @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; +@import "./views/dialogs/_PrototypeCommunityInviteDialog.scss"; @import "./views/dialogs/_PrototypeCreateGroupDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; diff --git a/res/css/views/dialogs/_PrototypeCommunityInviteDialog.scss b/res/css/views/dialogs/_PrototypeCommunityInviteDialog.scss new file mode 100644 index 0000000000..8d2ff598d8 --- /dev/null +++ b/res/css/views/dialogs/_PrototypeCommunityInviteDialog.scss @@ -0,0 +1,88 @@ +/* +Copyright 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. +*/ + +.mx_PrototypeCommunityInviteDialog { + &.mx_Dialog_fixedWidth { + width: 360px; + } + + .mx_Dialog_content { + margin-bottom: 0; + + .mx_PrototypeCommunityInviteDialog_people { + position: relative; + margin-bottom: 4px; + + .mx_AccessibleButton { + display: inline-block; + background-color: $focus-bg-color; // XXX: Abuse of variables + border-radius: 4px; + padding: 3px 5px; + font-size: $font-12px; + float: right; + } + } + + .mx_PrototypeCommunityInviteDialog_morePeople { + margin-top: 8px; + } + + .mx_PrototypeCommunityInviteDialog_person { + position: relative; + margin-top: 4px; + + & > * { + vertical-align: middle; + } + + .mx_Checkbox { + position: absolute; + right: 0; + top: calc(50% - 8px); // checkbox is 16px high + width: 16px; // to force a square + } + + .mx_PrototypeCommunityInviteDialog_personIdentifiers { + display: inline-block; + + & > * { + display: block; + } + + .mx_PrototypeCommunityInviteDialog_personName { + font-weight: 600; + font-size: $font-14px; + color: $primary-fg-color; + margin-left: 7px; + } + + .mx_PrototypeCommunityInviteDialog_personId { + font-size: $font-12px; + color: $muted-fg-color; + margin-left: 7px; + } + } + } + + .mx_PrototypeCommunityInviteDialog_primaryButton { + display: block; + font-size: $font-13px; + line-height: 20px; + height: 20px; + margin-top: 24px; + } + } +} diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 839d677069..3347a8288d 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -23,6 +23,7 @@ import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import PrototypeCommunityInviteDialog from "./components/views/dialogs/PrototypeCommunityInviteDialog"; /** * Invites multiple addresses to a room @@ -56,6 +57,13 @@ export function showRoomInviteDialog(roomId) { ); } +export function showCommunityRoomInviteDialog(roomId, communityName) { + Modal.createTrackedDialog( + 'Invite Users to Community', '', PrototypeCommunityInviteDialog, {communityName, roomId}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); +} + /** * Checks if the given MatrixEvent is a valid 3rd party user invite. * @param {MatrixEvent} event The event to check @@ -77,7 +85,7 @@ export function isValid3pidInvite(event) { export function inviteUsersToRoom(roomId, userIds) { return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); - return _showAnyInviteErrors(result.states, room, result.inviter); + showAnyInviteErrors(result.states, room, result.inviter); }).catch((err) => { console.error(err.stack); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -88,7 +96,7 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -function _showAnyInviteErrors(addrs, room, inviter) { +export function showAnyInviteErrors(addrs, room, inviter) { // Show user any errors const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); if (failedUsers.length === 1 && inviter.fatal) { @@ -100,6 +108,7 @@ function _showAnyInviteErrors(addrs, room, inviter) { title: _t("Failed to invite users to the room:", {roomName: room.name}), description: inviter.getErrorText(failedUsers[0]), }); + return false; } else { const errorList = []; for (const addr of failedUsers) { @@ -118,8 +127,9 @@ function _showAnyInviteErrors(addrs, room, inviter) { title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), description, }); + return false; } } - return addrs; + return true; } diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index c90811ed5a..6cd0b22505 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -327,7 +327,7 @@ export default class InviteDialog extends React.PureComponent { this.state = { targets: [], // array of Member objects (see interface above) filterText: "", - recents: this._buildRecents(alreadyInvited), + recents: InviteDialog.buildRecents(alreadyInvited), numRecentsShown: INITIAL_ROOMS_SHOWN, suggestions: this._buildSuggestions(alreadyInvited), numSuggestionsShown: INITIAL_ROOMS_SHOWN, @@ -344,7 +344,7 @@ export default class InviteDialog extends React.PureComponent { this._editorRef = createRef(); } - _buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the diff --git a/src/components/views/dialogs/PrototypeCommunityInviteDialog.tsx b/src/components/views/dialogs/PrototypeCommunityInviteDialog.tsx new file mode 100644 index 0000000000..08d3f0208a --- /dev/null +++ b/src/components/views/dialogs/PrototypeCommunityInviteDialog.tsx @@ -0,0 +1,252 @@ +/* +Copyright 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, { ChangeEvent, FormEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import InfoTooltip from "../elements/InfoTooltip"; +import dis from "../../../dispatcher/dispatcher"; +import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; +import { arrayFastClone } from "../../../utils/arrays"; +import SdkConfig from "../../../SdkConfig"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import InviteDialog from "./InviteDialog"; +import BaseAvatar from "../avatars/BaseAvatar"; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; +import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; +import {humanizeTime} from "../../../utils/humanize"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import ErrorDialog from "./ErrorDialog"; + +interface IProps extends IDialogProps { + roomId: string; + communityName: string; +} + +interface IPerson { + userId: string; + user: RoomMember; + lastActive: number; +} + +interface IState { + emailTargets: string[]; + userTargets: string[]; + showPeople: boolean; + people: IPerson[]; + numPeople: number; + busy: boolean; +} + +export default class PrototypeCommunityInviteDialog extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + emailTargets: [], + userTargets: [], + showPeople: false, + people: this.buildSuggestions(), + numPeople: 5, // arbitrary default + busy: false, + }; + } + + private buildSuggestions(): IPerson[] { + const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]); + if (this.props.roomId) { + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); + room.getMembersWithMembership('invite').forEach(m => alreadyInvited.add(m.userId)); + room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId)); + // add banned users, so we don't try to invite them + room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId)); + } + + return InviteDialog.buildRecents(alreadyInvited); + } + + private onSubmit = async (ev: FormEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + this.setState({busy: true}); + try { + const targets = [...this.state.emailTargets, ...this.state.userTargets]; + const result = await inviteMultipleToRoom(this.props.roomId, targets); + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + const success = showAnyInviteErrors(result.states, room, result.inviter); + if (success) { + this.props.onFinished(true); + } else { + this.setState({busy: false}); + } + } catch (e) { + this.setState({busy: false}); + console.error(e); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((e && e.message) ? e.message : _t("Operation failed")), + }); + } + }; + + private onAddressChange = (ev: ChangeEvent, index: number) => { + const targets = arrayFastClone(this.state.emailTargets); + if (index >= targets.length) { + targets.push(ev.target.value); + } else { + targets[index] = ev.target.value; + } + this.setState({emailTargets: targets}); + }; + + private onAddressBlur = (index: number) => { + const targets = arrayFastClone(this.state.emailTargets); + if (index >= targets.length) return; // not important + if (targets[index].trim() === "") { + targets.splice(index, 1); + this.setState({emailTargets: targets}); + } + }; + + private onShowPeopleClick = () => { + this.setState({showPeople: !this.state.showPeople}); + }; + + private setPersonToggle = (person: IPerson, selected: boolean) => { + const targets = arrayFastClone(this.state.userTargets); + if (selected && !targets.includes(person.userId)) { + targets.push(person.userId); + } else if (!selected && targets.includes(person.userId)) { + targets.splice(targets.indexOf(person.userId), 1); + } + this.setState({userTargets: targets}); + }; + + private renderPerson(person: IPerson, key: any) { + const avatarSize = 36; + return ( +
+ +
+ {person.user.name} + {person.userId} +
+ this.setPersonToggle(person, e.target.checked)} /> +
+ ); + } + + private onShowMorePeople = () => { + this.setState({numPeople: this.state.numPeople + 5}); // arbitrary increase + }; + + public render() { + const emailAddresses = []; + this.state.emailTargets.forEach((address, i) => { + emailAddresses.push( + this.onAddressChange(e, i)} + label={_t("Email address")} + placeholder={_t("Email address")} + onBlur={() => this.onAddressBlur(i)} + /> + ); + }); + + // Push a clean input + emailAddresses.push( + this.onAddressChange(e, emailAddresses.length)} + label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} + placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} + /> + ); + + let peopleIntro = null; + let people = []; + if (this.state.showPeople) { + const humansToPresent = this.state.people.slice(0, this.state.numPeople); + humansToPresent.forEach((person, i) => { + people.push(this.renderPerson(person, i)); + }); + if (humansToPresent.length < this.state.people.length) { + people.push( + {_t("Show more")} + ); + } + } + if (this.state.people.length > 0) { + peopleIntro = ( +
+ {_t("People you know on %(brand)s", {brand: SdkConfig.get().brand})} + + {this.state.showPeople ? _t("Hide") : _t("Show")} + +
+ ); + } + + let buttonText = _t("Skip"); + const targetCount = this.state.userTargets.length + this.state.emailTargets.length; + if (targetCount > 0) { + buttonText = _t("Send %(count)s invites", {count: targetCount}); + } + + return ( + +
+
+ {emailAddresses} + {peopleIntro} + {people} + {buttonText} +
+
+
+ ); + } +} diff --git a/src/components/views/dialogs/PrototypeCreateGroupDialog.tsx b/src/components/views/dialogs/PrototypeCreateGroupDialog.tsx index 7427b2737c..8978b9cf0d 100644 --- a/src/components/views/dialogs/PrototypeCreateGroupDialog.tsx +++ b/src/components/views/dialogs/PrototypeCreateGroupDialog.tsx @@ -23,6 +23,7 @@ import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import InfoTooltip from "../elements/InfoTooltip"; import dis from "../../../dispatcher/dispatcher"; +import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; interface IProps extends IDialogProps { } @@ -67,7 +68,7 @@ export default class PrototypeCreateGroupDialog extends React.PureComponent:%(domain)s": "Community ID: +:%(domain)s", "Use this when referencing your community to others. The community ID cannot be changed.": "Use this when referencing your community to others. The community ID cannot be changed.", @@ -1783,9 +1792,7 @@ "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.": "Clearing your browser's storage may fix the problem, but will sign you out and cause any encrypted chat history to become unreadable.", "Verification Pending": "Verification Pending", "Please check your email and click on the link it contains. Once this is done, click continue.": "Please check your email and click on the link it contains. Once this is done, click continue.", - "Email address": "Email address", "This will allow you to reset your password and receive notifications.": "This will allow you to reset your password and receive notifications.", - "Skip": "Skip", "A username can only contain lower case letters, numbers and '=_-./'": "A username can only contain lower case letters, numbers and '=_-./'", "Username not available": "Username not available", "Username invalid: %(errMessage)s": "Username invalid: %(errMessage)s", @@ -1898,7 +1905,6 @@ "Set status": "Set status", "Set a new status...": "Set a new status...", "View Community": "View Community", - "Hide": "Hide", "Reload": "Reload", "Take picture": "Take picture", "Remove for everyone": "Remove for everyone",