mirror of https://github.com/vector-im/riot-web
Improve remove recent messages dialog (#6114)
* Allow keeping state events when removing recent messages The remove recent messages dialog redacts most state events since they can be abuse vectors as well, however some users that see the option actually want to use it to only remove messages. This adds a checkbox option to do so. Signed-off-by: Robin Townsend <robin@robin.town> * Don't redact encryption events when removing recent messages Signed-off-by: Robin Townsend <robin@robin.town> * Show UserMenu spinner while removing recent messages This also generalizes the UserMenu spinner to work with other types of actions in the future. Signed-off-by: Robin Townsend <robin@robin.town> * Clarify remove recent messages warning Clarify that they are removed for everyone in the conversation, not just yourself. Signed-off-by: Robin Townsend <robin@robin.town> * Adjust copy and preserve state events by default * Redact messages in reverse chronological order Signed-off-by: Robin Townsend <robin@robin.town>pull/21833/head
parent
d8a939df5d
commit
83f2bf4261
|
@ -78,6 +78,7 @@
|
|||
@import "./views/dialogs/_Analytics.scss";
|
||||
@import "./views/dialogs/_AnalyticsLearnMoreDialog.scss";
|
||||
@import "./views/dialogs/_BugReportDialog.scss";
|
||||
@import "./views/dialogs/_BulkRedactDialog.scss";
|
||||
@import "./views/dialogs/_ChangelogDialog.scss";
|
||||
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
|
||||
@import "./views/dialogs/_CommunityPrototypeInviteDialog.scss";
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
Copyright 2021 Robin Townsend <robin@robin.town>
|
||||
|
||||
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_BulkRedactDialog {
|
||||
.mx_Checkbox, .mx_BulkRedactDialog_checkboxMicrocopy {
|
||||
line-height: $font-20px;
|
||||
}
|
||||
|
||||
.mx_BulkRedactDialog_checkboxMicrocopy {
|
||||
margin-left: 26px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
Copyright 2021 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, { useState } from 'react';
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixClient } from 'matrix-js-sdk/src/client';
|
||||
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { IDialogProps } from "../dialogs/IDialogProps";
|
||||
import BaseDialog from "../dialogs/BaseDialog";
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import StyledCheckbox from "../elements/StyledCheckbox";
|
||||
|
||||
interface IBulkRedactDialogProps extends IDialogProps {
|
||||
matrixClient: MatrixClient;
|
||||
room: Room;
|
||||
member: RoomMember;
|
||||
}
|
||||
|
||||
const BulkRedactDialog: React.FC<IBulkRedactDialogProps> = props => {
|
||||
const { matrixClient: cli, room, member, onFinished } = props;
|
||||
const [keepStateEvents, setKeepStateEvents] = useState(true);
|
||||
|
||||
let timeline = room.getLiveTimeline();
|
||||
let eventsToRedact = [];
|
||||
while (timeline) {
|
||||
eventsToRedact = [...eventsToRedact, ...timeline.getEvents().filter(event =>
|
||||
event.getSender() === member.userId &&
|
||||
!event.isRedacted() && !event.isRedaction() &&
|
||||
event.getType() !== EventType.RoomCreate &&
|
||||
// Don't redact ACLs because that'll obliterate the room
|
||||
// See https://github.com/matrix-org/synapse/issues/4042 for details.
|
||||
event.getType() !== EventType.RoomServerAcl &&
|
||||
// Redacting encryption events is equally bad
|
||||
event.getType() !== EventType.RoomEncryption,
|
||||
)];
|
||||
timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
|
||||
}
|
||||
|
||||
if (eventsToRedact.length === 0) {
|
||||
return <InfoDialog
|
||||
onFinished={onFinished}
|
||||
title={_t("No recent messages by %(user)s found", { user: member.name })}
|
||||
description={
|
||||
<div>
|
||||
<p>{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }</p>
|
||||
</div>
|
||||
}
|
||||
/>;
|
||||
} else {
|
||||
eventsToRedact = eventsToRedact.filter(event => !(keepStateEvents && event.isState()));
|
||||
const count = eventsToRedact.length;
|
||||
const user = member.name;
|
||||
|
||||
const redact = async () => {
|
||||
logger.info(`Started redacting recent ${count} messages for ${member.userId} in ${room.roomId}`);
|
||||
dis.dispatch({
|
||||
action: Action.BulkRedactStart,
|
||||
room_id: room.roomId,
|
||||
});
|
||||
|
||||
// Submitting a large number of redactions freezes the UI,
|
||||
// so first yield to allow to rerender after closing the dialog.
|
||||
await Promise.resolve();
|
||||
await Promise.all(eventsToRedact.reverse().map(async event => {
|
||||
try {
|
||||
await cli.redactEvent(room.roomId, event.getId());
|
||||
} catch (err) {
|
||||
// log and swallow errors
|
||||
logger.error("Could not redact", event.getId());
|
||||
logger.error(err);
|
||||
}
|
||||
}));
|
||||
|
||||
logger.info(`Finished redacting recent ${count} messages for ${member.userId} in ${room.roomId}`);
|
||||
dis.dispatch({
|
||||
action: Action.BulkRedactEnd,
|
||||
room_id: room.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
return <BaseDialog
|
||||
className="mx_BulkRedactDialog"
|
||||
onFinished={onFinished}
|
||||
title={_t("Remove recent messages by %(user)s", { user })}
|
||||
contentId="mx_Dialog_content"
|
||||
>
|
||||
<div className="mx_Dialog_content" id="mx_Dialog_content">
|
||||
<p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
|
||||
"This will remove them permanently for everyone in the conversation. " +
|
||||
"Do you wish to continue?", { count, user }) }</p>
|
||||
<p>{ _t("For a large amount of messages, this might take some time. " +
|
||||
"Please don't refresh your client in the meantime.") }</p>
|
||||
<StyledCheckbox
|
||||
checked={keepStateEvents}
|
||||
onChange={e => setKeepStateEvents(e.target.checked)}
|
||||
>
|
||||
{ _t("Preserve system messages") }
|
||||
</StyledCheckbox>
|
||||
<div className="mx_BulkRedactDialog_checkboxMicrocopy">
|
||||
{ _t("Uncheck if you also want to remove system messages on this user " +
|
||||
"(e.g. membership change, profile change…)") }
|
||||
</div>
|
||||
</div>
|
||||
<DialogButtons
|
||||
primaryButton={_t("Remove %(count)s messages", { count })}
|
||||
primaryButtonClass="danger"
|
||||
primaryDisabled={count === 0}
|
||||
onPrimaryButtonClick={() => { setImmediate(redact); onFinished(true); }}
|
||||
onCancel={() => onFinished(false)}
|
||||
/>
|
||||
</BaseDialog>;
|
||||
}
|
||||
};
|
||||
|
||||
export default BulkRedactDialog;
|
|
@ -23,7 +23,6 @@ import { ClientEvent, MatrixClient } from 'matrix-js-sdk/src/client';
|
|||
import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
|
||||
import { User } from 'matrix-js-sdk/src/models/user';
|
||||
import { Room } from 'matrix-js-sdk/src/models/room';
|
||||
import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
|
||||
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
@ -60,11 +59,11 @@ import Spinner from "../elements/Spinner";
|
|||
import PowerSelector from "../elements/PowerSelector";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import PresenceLabel from "../rooms/PresenceLabel";
|
||||
import BulkRedactDialog from "../dialogs/BulkRedactDialog";
|
||||
import ShareDialog from "../dialogs/ShareDialog";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../dialogs/QuestionDialog";
|
||||
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
|
||||
import InfoDialog from "../dialogs/InfoDialog";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import RoomName from "../elements/RoomName";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
|
@ -629,75 +628,14 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBas
|
|||
const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onRedactAllMessages = async () => {
|
||||
const { roomId, userId } = member;
|
||||
const room = cli.getRoom(roomId);
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
let timeline = room.getLiveTimeline();
|
||||
let eventsToRedact = [];
|
||||
while (timeline) {
|
||||
eventsToRedact = timeline.getEvents().reduce((events, event) => {
|
||||
if (event.getSender() === userId && !event.isRedacted() && !event.isRedaction() &&
|
||||
event.getType() !== EventType.RoomCreate &&
|
||||
// Don't redact ACLs because that'll obliterate the room
|
||||
// See https://github.com/matrix-org/synapse/issues/4042 for details.
|
||||
event.getType() !== EventType.RoomServerAcl
|
||||
) {
|
||||
return events.concat(event);
|
||||
} else {
|
||||
return events;
|
||||
}
|
||||
}, eventsToRedact);
|
||||
timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
|
||||
}
|
||||
const onRedactAllMessages = () => {
|
||||
const room = cli.getRoom(member.roomId);
|
||||
if (!room) return;
|
||||
|
||||
const count = eventsToRedact.length;
|
||||
const user = member.name;
|
||||
|
||||
if (count === 0) {
|
||||
Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
|
||||
title: _t("No recent messages by %(user)s found", { user }),
|
||||
description:
|
||||
<div>
|
||||
<p>{ _t("Try scrolling up in the timeline to see if there are any earlier ones.") }</p>
|
||||
</div>,
|
||||
Modal.createTrackedDialog("Bulk Redact Dialog", "", BulkRedactDialog, {
|
||||
matrixClient: cli,
|
||||
room, member,
|
||||
});
|
||||
} else {
|
||||
const { finished } = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
|
||||
title: _t("Remove recent messages by %(user)s", { user }),
|
||||
description:
|
||||
<div>
|
||||
<p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
|
||||
"This cannot be undone. Do you wish to continue?", { count, user }) }</p>
|
||||
<p>{ _t("For a large amount of messages, this might take some time. " +
|
||||
"Please don't refresh your client in the meantime.") }</p>
|
||||
</div>,
|
||||
button: _t("Remove %(count)s messages", { count }),
|
||||
});
|
||||
|
||||
const [confirmed] = await finished;
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Submitting a large number of redactions freezes the UI,
|
||||
// so first yield to allow to rerender after closing the dialog.
|
||||
await Promise.resolve();
|
||||
|
||||
logger.info(`Started redacting recent ${count} messages for ${user} in ${roomId}`);
|
||||
await Promise.all(eventsToRedact.map(async event => {
|
||||
try {
|
||||
await cli.redactEvent(roomId, event.getId());
|
||||
} catch (err) {
|
||||
// log and swallow errors
|
||||
logger.error("Could not redact", event.getId());
|
||||
logger.error(err);
|
||||
}
|
||||
}));
|
||||
logger.info(`Finished redacting recent ${count} messages for ${user} in ${roomId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onRedactAllMessages}>
|
||||
|
|
|
@ -137,29 +137,50 @@ const PrototypeCommunityContextMenu = (props: ComponentProps<typeof SpaceContext
|
|||
</IconizedContextMenu>;
|
||||
};
|
||||
|
||||
const useJoiningRooms = (): Set<string> => {
|
||||
// Long-running actions that should trigger a spinner
|
||||
enum PendingActionType {
|
||||
JoinRoom,
|
||||
BulkRedact,
|
||||
}
|
||||
|
||||
const usePendingActions = (): Map<PendingActionType, Set<string>> => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const [joiningRooms, setJoiningRooms] = useState(new Set<string>());
|
||||
const [actions, setActions] = useState(new Map<PendingActionType, Set<string>>());
|
||||
|
||||
const addAction = (type: PendingActionType, key: string) => {
|
||||
const keys = new Set(actions.get(type));
|
||||
keys.add(key);
|
||||
setActions(new Map(actions).set(type, keys));
|
||||
};
|
||||
const removeAction = (type: PendingActionType, key: string) => {
|
||||
const keys = new Set(actions.get(type));
|
||||
if (keys.delete(key)) {
|
||||
setActions(new Map(actions).set(type, keys));
|
||||
}
|
||||
};
|
||||
|
||||
useDispatcher(defaultDispatcher, payload => {
|
||||
switch (payload.action) {
|
||||
case Action.JoinRoom:
|
||||
setJoiningRooms(new Set(joiningRooms.add(payload.roomId)));
|
||||
addAction(PendingActionType.JoinRoom, payload.roomId);
|
||||
break;
|
||||
case Action.JoinRoomReady:
|
||||
case Action.JoinRoomError:
|
||||
if (joiningRooms.delete(payload.roomId)) {
|
||||
setJoiningRooms(new Set(joiningRooms));
|
||||
}
|
||||
removeAction(PendingActionType.JoinRoom, payload.roomId);
|
||||
break;
|
||||
case Action.BulkRedactStart:
|
||||
addAction(PendingActionType.BulkRedact, payload.roomId);
|
||||
break;
|
||||
case Action.BulkRedactEnd:
|
||||
removeAction(PendingActionType.BulkRedact, payload.roomId);
|
||||
break;
|
||||
}
|
||||
});
|
||||
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => {
|
||||
if (joiningRooms.delete(room.roomId)) {
|
||||
setJoiningRooms(new Set(joiningRooms));
|
||||
}
|
||||
});
|
||||
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) =>
|
||||
removeAction(PendingActionType.JoinRoom, room.roomId),
|
||||
);
|
||||
|
||||
return joiningRooms;
|
||||
return actions;
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
|
@ -179,7 +200,7 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => {
|
|||
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
|
||||
return SpaceStore.instance.allRoomsInHome;
|
||||
});
|
||||
const joiningRooms = useJoiningRooms();
|
||||
const pendingActions = usePendingActions();
|
||||
|
||||
const filterCondition = RoomListStore.instance.getFirstNameFilterCondition();
|
||||
const count = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => {
|
||||
|
@ -398,14 +419,17 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => {
|
|||
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
|
||||
}
|
||||
|
||||
let pendingRoomJoinSpinner: JSX.Element;
|
||||
if (joiningRooms.size) {
|
||||
pendingRoomJoinSpinner = <TooltipTarget
|
||||
label={_t("Currently joining %(count)s rooms", { count: joiningRooms.size })}
|
||||
>
|
||||
<InlineSpinner />
|
||||
</TooltipTarget>;
|
||||
const pendingActionSummary = [...pendingActions.entries()]
|
||||
.filter(([type, keys]) => keys.size > 0)
|
||||
.map(([type, keys]) => {
|
||||
switch (type) {
|
||||
case PendingActionType.JoinRoom:
|
||||
return _t("Currently joining %(count)s rooms", { count: keys.size });
|
||||
case PendingActionType.BulkRedact:
|
||||
return _t("Currently removing messages in %(count)s rooms", { count: keys.size });
|
||||
}
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
let contextMenuButton: JSX.Element = <div className="mx_RoomListHeader_contextLessTitle">{ title }</div>;
|
||||
if (activeSpace || spaceKey === MetaSpace.Home) {
|
||||
|
@ -424,7 +448,9 @@ const RoomListHeader = ({ spacePanelDisabled, onVisibilityChange }: IProps) => {
|
|||
|
||||
return <div className="mx_RoomListHeader">
|
||||
{ contextMenuButton }
|
||||
{ pendingRoomJoinSpinner }
|
||||
{ pendingActionSummary ?
|
||||
<TooltipTarget label={pendingActionSummary}><InlineSpinner /></TooltipTarget> :
|
||||
null }
|
||||
{ canShowPlusMenu && <ContextMenuTooltipButton
|
||||
inputRef={plusMenuHandle}
|
||||
onClick={openPlusMenu}
|
||||
|
|
|
@ -176,6 +176,16 @@ export enum Action {
|
|||
*/
|
||||
JoinRoomError = "join_room_error",
|
||||
|
||||
/**
|
||||
* Fired when starting to bulk redact messages from a user in a room.
|
||||
*/
|
||||
BulkRedactStart = "bulk_redact_start",
|
||||
|
||||
/**
|
||||
* Fired when done bulk redacting messages from a user in a room.
|
||||
*/
|
||||
BulkRedactEnd = "bulk_redact_end",
|
||||
|
||||
/**
|
||||
* Inserts content into the active composer. Should be used with ComposerInsertPayload.
|
||||
*/
|
||||
|
|
|
@ -1802,6 +1802,8 @@
|
|||
"Join public room": "Join public room",
|
||||
"Currently joining %(count)s rooms|other": "Currently joining %(count)s rooms",
|
||||
"Currently joining %(count)s rooms|one": "Currently joining %(count)s room",
|
||||
"Currently removing messages in %(count)s rooms|other": "Currently removing messages in %(count)s rooms",
|
||||
"Currently removing messages in %(count)s rooms|one": "Currently removing messages in %(count)s room",
|
||||
"%(spaceName)s menu": "%(spaceName)s menu",
|
||||
"Home options": "Home options",
|
||||
"This room": "This room",
|
||||
|
@ -1997,14 +1999,6 @@
|
|||
"They'll still be able to access whatever you're not an admin of.": "They'll still be able to access whatever you're not an admin of.",
|
||||
"Failed to remove user": "Failed to remove user",
|
||||
"Remove from room": "Remove from room",
|
||||
"No recent messages by %(user)s found": "No recent messages by %(user)s found",
|
||||
"Try scrolling up in the timeline to see if there are any earlier ones.": "Try scrolling up in the timeline to see if there are any earlier ones.",
|
||||
"Remove recent messages by %(user)s": "Remove recent messages by %(user)s",
|
||||
"You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|other": "You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?",
|
||||
"You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?|one": "You are about to remove 1 message by %(user)s. This cannot be undone. Do you wish to continue?",
|
||||
"For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.",
|
||||
"Remove %(count)s messages|other": "Remove %(count)s messages",
|
||||
"Remove %(count)s messages|one": "Remove 1 message",
|
||||
"Remove recent messages": "Remove recent messages",
|
||||
"Ban": "Ban",
|
||||
"Unban from %(roomName)s": "Unban from %(roomName)s",
|
||||
|
@ -2441,6 +2435,16 @@
|
|||
"Notes": "Notes",
|
||||
"If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.": "If there is additional context that would help in analysing the issue, such as what you were doing at the time, room IDs, user IDs, etc., please include those things here.",
|
||||
"Send logs": "Send logs",
|
||||
"No recent messages by %(user)s found": "No recent messages by %(user)s found",
|
||||
"Try scrolling up in the timeline to see if there are any earlier ones.": "Try scrolling up in the timeline to see if there are any earlier ones.",
|
||||
"Remove recent messages by %(user)s": "Remove recent messages by %(user)s",
|
||||
"You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|other": "You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?",
|
||||
"You are about to remove %(count)s messages by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?|one": "You are about to remove %(count)s message by %(user)s. This will remove them permanently for everyone in the conversation. Do you wish to continue?",
|
||||
"For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.": "For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.",
|
||||
"Preserve system messages": "Preserve system messages",
|
||||
"Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)",
|
||||
"Remove %(count)s messages|other": "Remove %(count)s messages",
|
||||
"Remove %(count)s messages|one": "Remove 1 message",
|
||||
"Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s",
|
||||
"Unavailable": "Unavailable",
|
||||
"Changelog": "Changelog",
|
||||
|
|
Loading…
Reference in New Issue