{ clearButton }
@@ -168,11 +186,7 @@ const TagPanel = createReactClass({
{ this.renderGlobalIcon() }
{ tags }
{ provided.placeholder }
diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx
new file mode 100644
index 0000000000..7a500cd053
--- /dev/null
+++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.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 CommunityPrototypeInviteDialog 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 (
+
+
+
+ );
+ }
+}
diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx
new file mode 100644
index 0000000000..5f8321fd7d
--- /dev/null
+++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx
@@ -0,0 +1,220 @@
+/*
+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 } 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";
+
+interface IProps extends IDialogProps {
+}
+
+interface IState {
+ name: string;
+ localpart: string;
+ error: string;
+ busy: boolean;
+ avatarFile: File;
+ avatarPreview: string;
+}
+
+export default class CreateCommunityPrototypeDialog extends React.PureComponent {
+ private avatarUploadRef: React.RefObject = React.createRef();
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ name: "",
+ localpart: "",
+ error: null,
+ busy: false,
+ avatarFile: null,
+ avatarPreview: null,
+ };
+ }
+
+ private onNameChange = (ev: ChangeEvent) => {
+ const localpart = (ev.target.value || "").toLowerCase().replace(/[^a-z0-9.\-_]/g, '-');
+ this.setState({name: ev.target.value, localpart});
+ };
+
+ private onSubmit = async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.state.busy) return;
+
+ // We'll create the community now to see if it's taken, leaving it active in
+ // the background for the user to look at while they invite people.
+ this.setState({busy: true});
+ try {
+ let avatarUrl = ''; // must be a string for synapse to accept it
+ if (this.state.avatarFile) {
+ avatarUrl = await MatrixClientPeg.get().uploadContent(this.state.avatarFile);
+ }
+
+ const result = await MatrixClientPeg.get().createGroup({
+ localpart: this.state.localpart,
+ profile: {
+ name: this.state.name,
+ avatar_url: avatarUrl,
+ },
+ });
+
+ // Ensure the tag gets selected now that we've created it
+ dis.dispatch({action: 'deselect_tags'}, true);
+ dis.dispatch({
+ action: 'select_tag',
+ tag: result.group_id,
+ });
+
+ // Close our own dialog before moving much further
+ this.props.onFinished(true);
+
+ if (result.room_id) {
+ dis.dispatch({
+ action: 'view_room',
+ room_id: result.room_id,
+ });
+ showCommunityRoomInviteDialog(result.room_id, this.state.name);
+ } else {
+ dis.dispatch({
+ action: 'view_group',
+ group_id: result.group_id,
+ group_is_new: true,
+ });
+ }
+ } catch (e) {
+ console.error(e);
+ this.setState({
+ busy: false,
+ error: _t(
+ "There was an error creating your community. The name may be taken or the " +
+ "server is unable to process your request.",
+ ),
+ });
+ }
+ };
+
+ private onAvatarChanged = (e: ChangeEvent) => {
+ if (!e.target.files || !e.target.files.length) {
+ this.setState({avatarFile: null});
+ } else {
+ this.setState({busy: true});
+ const file = e.target.files[0];
+ const reader = new FileReader();
+ reader.onload = (ev: ProgressEvent) => {
+ this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string});
+ };
+ reader.readAsDataURL(file);
+ }
+ };
+
+ private onChangeAvatar = () => {
+ if (this.avatarUploadRef.current) this.avatarUploadRef.current.click();
+ };
+
+ public render() {
+ let communityId = null;
+ if (this.state.localpart) {
+ communityId = (
+
+ {_t("Community ID: +:%(domain)s", {
+ domain: MatrixClientPeg.getHomeserverName(),
+ }, {
+ localpart: () => {this.state.localpart},
+ })}
+
+
+ );
+ }
+
+ let helpText = (
+
+ {_t("You can change this later if needed.")}
+
+ );
+ if (this.state.error) {
+ helpText = (
+
+ {this.state.error}
+
+ );
+ }
+
+ let preview =
;
+ if (!this.state.avatarPreview) {
+ preview =
+ }
+
+ return (
+
+
+
+ );
+ }
+}
diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js
index d8a8b96961..2b22054947 100644
--- a/src/components/views/dialogs/CreateGroupDialog.js
+++ b/src/components/views/dialogs/CreateGroupDialog.js
@@ -88,6 +88,13 @@ export default createReactClass({
action: 'view_room',
room_id: result.room_id,
});
+
+ // Ensure the tag gets selected now that we've created it
+ dis.dispatch({action: 'deselect_tags'}, true);
+ dis.dispatch({
+ action: 'select_tag',
+ tag: result.group_id,
+ });
} else {
dis.dispatch({
action: 'view_group',
diff --git a/src/components/views/dialogs/IDialogProps.ts b/src/components/views/dialogs/IDialogProps.ts
new file mode 100644
index 0000000000..1027ca7607
--- /dev/null
+++ b/src/components/views/dialogs/IDialogProps.ts
@@ -0,0 +1,19 @@
+/*
+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.
+*/
+
+export interface IDialogProps {
+ onFinished: (bool) => void;
+}
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/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx
index f6767dcb8d..81f628343b 100644
--- a/src/components/views/dialogs/ServerOfflineDialog.tsx
+++ b/src/components/views/dialogs/ServerOfflineDialog.tsx
@@ -27,9 +27,9 @@ import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import { IDialogProps } from "./IDialogProps";
-interface IProps {
- onFinished: (bool) => void;
+interface IProps extends IDialogProps {
}
export default class ServerOfflineDialog extends React.PureComponent {
diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx
index dc2a987f13..22f83d391c 100644
--- a/src/components/views/dialogs/ShareDialog.tsx
+++ b/src/components/views/dialogs/ShareDialog.tsx
@@ -31,6 +31,7 @@ import {toRightOf} from "../../structures/ContextMenu";
import {copyPlaintext, selectText} from "../../../utils/strings";
import StyledCheckbox from '../elements/StyledCheckbox';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
+import { IDialogProps } from "./IDialogProps";
const socials = [
{
@@ -60,8 +61,7 @@ const socials = [
},
];
-interface IProps {
- onFinished: () => void;
+interface IProps extends IDialogProps {
target: Room | User | Group | RoomMember | MatrixEvent;
permalinkCreator: RoomPermalinkCreator;
}
diff --git a/src/components/views/elements/InfoTooltip.tsx b/src/components/views/elements/InfoTooltip.tsx
new file mode 100644
index 0000000000..645951aab9
--- /dev/null
+++ b/src/components/views/elements/InfoTooltip.tsx
@@ -0,0 +1,73 @@
+/*
+Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
+Copyright 2019 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 classNames from 'classnames';
+
+import AccessibleButton from "./AccessibleButton";
+import Tooltip from './Tooltip';
+import { _t } from "../../../languageHandler";
+
+interface ITooltipProps {
+ tooltip?: React.ReactNode;
+ tooltipClassName?: string;
+}
+
+interface IState {
+ hover: boolean;
+}
+
+export default class InfoTooltip extends React.PureComponent {
+ constructor(props: ITooltipProps) {
+ super(props);
+ this.state = {
+ hover: false,
+ };
+ }
+
+ onMouseOver = () => {
+ this.setState({
+ hover: true,
+ });
+ };
+
+ onMouseLeave = () => {
+ this.setState({
+ hover: false,
+ });
+ };
+
+ render() {
+ const {tooltip, children, tooltipClassName} = this.props;
+ const title = _t("Information");
+
+ // Tooltip are forced on the right for a more natural feel to them on info icons
+ const tip = this.state.hover ? : ;
+ return (
+
+
+ {children}
+ {tip}
+
+ );
+ }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index c12b57c033..f3cd1d80b7 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1484,6 +1484,7 @@
"Rotate Right": "Rotate Right",
"Rotate clockwise": "Rotate clockwise",
"Download this file": "Download this file",
+ "Information": "Information",
"Language Dropdown": "Language Dropdown",
"Manage Integrations": "Manage Integrations",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
@@ -1597,6 +1598,15 @@
"Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s",
"Unavailable": "Unavailable",
"Changelog": "Changelog",
+ "Email address": "Email address",
+ "Add another email": "Add another email",
+ "People you know on %(brand)s": "People you know on %(brand)s",
+ "Hide": "Hide",
+ "Show": "Show",
+ "Skip": "Skip",
+ "Send %(count)s invites|other": "Send %(count)s invites",
+ "Send %(count)s invites|one": "Send %(count)s invite",
+ "Invite people to join %(communityName)s": "Invite people to join %(communityName)s",
"You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)",
"Removing…": "Removing…",
"Destroy cross-signing keys?": "Destroy cross-signing keys?",
@@ -1607,6 +1617,15 @@
"Clear all data in this session?": "Clear all data in this session?",
"Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.": "Clearing all data from this session is permanent. Encrypted messages will be lost unless their keys have been backed up.",
"Clear all data": "Clear all data",
+ "There was an error creating your community. The name may be taken or the server is unable to process your request.": "There was an error creating your community. The name may be taken or the server is unable to process your request.",
+ "Community ID: +:%(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.",
+ "You can change this later if needed.": "You can change this later if needed.",
+ "What's the name of your community or team?": "What's the name of your community or team?",
+ "Enter name": "Enter name",
+ "Create": "Create",
+ "Add image (optional)": "Add image (optional)",
+ "An image will help people identify your community.": "An image will help people identify your community.",
"Community IDs cannot be empty.": "Community IDs cannot be empty.",
"Community IDs may only contain characters a-z, 0-9, or '=_-./'": "Community IDs may only contain characters a-z, 0-9, or '=_-./'",
"Something went wrong whilst creating your community": "Something went wrong whilst creating your community",
@@ -1615,7 +1634,6 @@
"Example": "Example",
"Community ID": "Community ID",
"example": "example",
- "Create": "Create",
"Please enter a name for the room": "Please enter a name for the room",
"Set a room address to easily share your room with other people.": "Set a room address to easily share your room with other people.",
"This room is private, and can only be joined by invitation.": "This room is private, and can only be joined by invitation.",
@@ -1774,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",
@@ -1889,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",
@@ -2091,6 +2106,7 @@
"Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio",
+ "Create community": "Create community",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position",