Add progress bar to Community to Space migration tool

and invite-one-by-one to workaround Synapse ratelimits
This commit is contained in:
Michael Telatynski 2021-09-30 13:43:59 +01:00
parent 5eaf0e7e25
commit 8ac77c498f
6 changed files with 78 additions and 15 deletions

View file

@ -75,7 +75,7 @@ limitations under the License.
@mixin ProgressBarBorderRadius 8px; @mixin ProgressBarBorderRadius 8px;
} }
.mx_AddExistingToSpace_progressText { .mx_AddExistingToSpaceDialog_progressText {
margin-top: 8px; margin-top: 8px;
font-size: $font-15px; font-size: $font-15px;
line-height: $font-24px; line-height: $font-24px;

View file

@ -74,6 +74,7 @@ limitations under the License.
font-size: $font-12px; font-size: $font-12px;
line-height: $font-15px; line-height: $font-15px;
color: $secondary-content; color: $secondary-content;
margin-top: -13px; // match height of buttons to prevent height changing
.mx_ProgressBar { .mx_ProgressBar {
height: 8px; height: 8px;

View file

@ -42,10 +42,15 @@ export interface IInviteResult {
* *
* @param {string} roomId The ID of the room to invite to * @param {string} roomId The ID of the room to invite to
* @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids.
* @param {function} progressCallback optional callback, fired after each invite.
* @returns {Promise} Promise * @returns {Promise} Promise
*/ */
export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise<IInviteResult> { export function inviteMultipleToRoom(
const inviter = new MultiInviter(roomId); roomId: string,
addresses: string[],
progressCallback?: () => void,
): Promise<IInviteResult> {
const inviter = new MultiInviter(roomId, progressCallback);
return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter }));
} }
@ -104,8 +109,8 @@ export function isValid3pidInvite(event: MatrixEvent): boolean {
return true; return true;
} }
export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise<void> { export function inviteUsersToRoom(roomId: string, userIds: string[], progressCallback?: () => void): Promise<void> {
return inviteMultipleToRoom(roomId, userIds).then((result) => { return inviteMultipleToRoom(roomId, userIds, progressCallback).then((result) => {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
showAnyInviteErrors(result.states, room, result.inviter); showAnyInviteErrors(result.states, room, result.inviter);
}).catch((err) => { }).catch((err) => {

View file

@ -39,6 +39,8 @@ import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserSettingsDialog"; import { UserTab } from "./UserSettingsDialog";
import TagOrderActions from "../../../actions/TagOrderActions"; import TagOrderActions from "../../../actions/TagOrderActions";
import { inviteUsersToRoom } from "../../../RoomInvite";
import ProgressBar from "../elements/ProgressBar";
interface IProps { interface IProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
@ -90,10 +92,22 @@ export interface IGroupSummary {
} }
/* eslint-enable camelcase */ /* eslint-enable camelcase */
enum Progress {
NotStarted,
ValidatingInputs,
FetchingData,
CreatingSpace,
InvitingUsers,
// anything beyond here is inviting user n - 4
}
const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => { const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, groupId, onFinished }) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(null); const [error, setError] = useState<string>(null);
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState(Progress.NotStarted);
const [numInvites, setNumInvites] = useState(0);
const busy = progress > 0;
const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar const [avatar, setAvatar] = useState<File>(null); // undefined means to remove avatar
const [name, setName] = useState(""); const [name, setName] = useState("");
@ -122,30 +136,34 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
if (busy) return; if (busy) return;
setError(null); setError(null);
setBusy(true); setProgress(Progress.ValidatingInputs);
// require & validate the space name field // require & validate the space name field
if (!(await spaceNameField.current.validate({ allowEmpty: false }))) { if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
setBusy(false); setProgress(0);
spaceNameField.current.focus(); spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true }); spaceNameField.current.validate({ allowEmpty: false, focused: true });
return; return;
} }
// validate the space name alias field but do not require it // validate the space name alias field but do not require it
if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) { if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) {
setBusy(false); setProgress(0);
spaceAliasField.current.focus(); spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true }); spaceAliasField.current.validate({ allowEmpty: true, focused: true });
return; return;
} }
try { try {
setProgress(Progress.FetchingData);
const [rooms, members, invitedMembers] = await Promise.all([ const [rooms, members, invitedMembers] = await Promise.all([
cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>, cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise<IGroupRoom[]>,
cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>, cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>, cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise<GroupMember[]>,
]); ]);
setNumInvites(members.length + invitedMembers.length);
const viaMap = new Map<string, string[]>(); const viaMap = new Map<string, string[]>();
for (const { roomId, canonicalAlias } of rooms) { for (const { roomId, canonicalAlias } of rooms) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
@ -167,6 +185,8 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
} }
} }
setProgress(Progress.CreatingSpace);
const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url; const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url;
const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, { const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, {
creation_content: { creation_content: {
@ -179,11 +199,16 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
via: viaMap.get(roomId) || [], via: viaMap.get(roomId) || [],
}, },
})), })),
invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()), // we do not specify the inviters here because Synapse applies a limit and this may cause it to trip
}, { }, {
andView: false, andView: false,
}); });
setProgress(Progress.InvitingUsers);
const userIds = [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId());
await inviteUsersToRoom(roomId, userIds, () => setProgress(p => p + 1));
// eagerly remove it from the community panel // eagerly remove it from the community panel
dis.dispatch(TagOrderActions.removeTag(cli, groupId)); dis.dispatch(TagOrderActions.removeTag(cli, groupId));
@ -250,7 +275,7 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
setError(e); setError(e);
} }
setBusy(false); setProgress(Progress.NotStarted);
}; };
let footer; let footer;
@ -267,13 +292,41 @@ const CreateSpaceFromCommunityDialog: React.FC<IProps> = ({ matrixClient: cli, g
{ _t("Retry") } { _t("Retry") }
</AccessibleButton> </AccessibleButton>
</>; </>;
} else if (busy) {
let description: string;
switch (progress) {
case Progress.ValidatingInputs:
case Progress.FetchingData:
description = _t("Fetching data...");
break;
case Progress.CreatingSpace:
description = _t("Creating Space...");
break;
case Progress.InvitingUsers:
default:
description = _t("Adding rooms... (%(progress)s out of %(count)s)", {
count: numInvites,
progress,
});
break;
}
footer = <span>
<ProgressBar
value={progress > Progress.FetchingData ? progress : 0}
max={numInvites + Progress.InvitingUsers}
/>
<div className="mx_CreateSpaceFromCommunityDialog_progressText">
{ description }
</div>
</span>;
} else { } else {
footer = <> footer = <>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished()}> <AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{ _t("Cancel") } { _t("Cancel") }
</AccessibleButton> </AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSpaceClick}> <AccessibleButton kind="primary" onClick={onCreateSpaceClick}>
{ busy ? _t("Creating...") : _t("Create Space") } { _t("Create Space") }
</AccessibleButton> </AccessibleButton>
</>; </>;
} }

View file

@ -2280,6 +2280,8 @@
"<SpaceName/> has been made and everyone who was a part of the community has been invited to it.": "<SpaceName/> has been made and everyone who was a part of the community has been invited to it.", "<SpaceName/> has been made and everyone who was a part of the community has been invited to it.": "<SpaceName/> has been made and everyone who was a part of the community has been invited to it.",
"To create a Space from another community, just pick the community in Preferences.": "To create a Space from another community, just pick the community in Preferences.", "To create a Space from another community, just pick the community in Preferences.": "To create a Space from another community, just pick the community in Preferences.",
"Failed to migrate community": "Failed to migrate community", "Failed to migrate community": "Failed to migrate community",
"Fetching data...": "Fetching data...",
"Creating Space...": "Creating Space...",
"Create Space from community": "Create Space from community", "Create Space from community": "Create Space from community",
"A link to the Space will be put in your community description.": "A link to the Space will be put in your community description.", "A link to the Space will be put in your community description.": "A link to the Space will be put in your community description.",
"All rooms will be added and all community members will be invited.": "All rooms will be added and all community members will be invited.", "All rooms will be added and all community members will be invited.": "All rooms will be added and all community members will be invited.",

View file

@ -62,8 +62,9 @@ export default class MultiInviter {
/** /**
* @param {string} targetId The ID of the room or group to invite to * @param {string} targetId The ID of the room or group to invite to
* @param {function} progressCallback optional callback, fired after each invite.
*/ */
constructor(targetId: string) { constructor(targetId: string, private readonly progressCallback?: () => void) {
if (targetId[0] === '+') { if (targetId[0] === '+') {
this.roomId = null; this.roomId = null;
this.groupId = targetId; this.groupId = targetId;
@ -181,6 +182,7 @@ export default class MultiInviter {
delete this.errors[address]; delete this.errors[address];
resolve(); resolve();
this.progressCallback?.();
}).catch((err) => { }).catch((err) => {
if (this.canceled) { if (this.canceled) {
return; return;