Consolidate RedactionGrouper and HiddenEventGrouper into MELS (#7739)

This commit is contained in:
Michael Telatynski 2022-02-09 10:51:12 +00:00 committed by GitHub
parent 59cdd3edc0
commit 714136d4f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 750 additions and 887 deletions

View file

@ -1,5 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,110 +16,510 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ReactNode, useEffect } from "react";
import { uniqBy } from "lodash";
import React, { ComponentProps } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import { isValid3pidInvite } from "../../../RoomInvite";
import GenericEventListSummary from "./GenericEventListSummary";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases';
import { jsxJoin } from '../../../utils/ReactUtils';
import { Layout } from '../../../settings/enums/Layout';
import RightPanelStore from '../../../stores/right-panel/RightPanelStore';
import AccessibleButton from './AccessibleButton';
interface IProps {
// An array of member events to summarise
events: MatrixEvent[];
// The minimum number of events needed to trigger summarisation
threshold?: number;
// Whether or not to begin with state.expanded=true
startExpanded?: boolean;
// The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[];
// The text to show as the summary of this event list
summaryText?: string | JSX.Element;
// An array of EventTiles to render when expanded
children: ReactNode[];
// Called when the event list expansion is toggled
onToggle?(): void;
// The layout currently used
layout?: Layout;
const onPinnedMessagesClick = (): void => {
RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false);
};
const TARGET_AS_DISPLAY_NAME_EVENTS = [EventType.RoomMember];
interface IProps extends Omit<ComponentProps<typeof GenericEventListSummary>, "summaryText" | "summaryMembers"> {
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength?: number;
// The maximum number of avatars to display in the summary
avatarsMaxLength?: number;
// The currently selected layout
layout: Layout;
}
const EventListSummary: React.FC<IProps> = ({
events,
children,
threshold = 3,
onToggle,
startExpanded,
summaryMembers = [],
summaryText,
layout,
}) => {
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
interface IUserEvents {
// The original event
mxEvent: MatrixEvent;
// The display name of the user (if not, then user ID)
displayName: string;
// The original index of the event in this.props.events
index: number;
}
// Whenever expanded changes call onToggle
useEffect(() => {
if (onToggle) {
onToggle();
}
}, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps
enum TransitionType {
Joined = "joined",
Left = "left",
JoinedAndLeft = "joined_and_left",
LeftAndJoined = "left_and_joined",
InviteReject = "invite_reject",
InviteWithdrawal = "invite_withdrawal",
Invited = "invited",
Banned = "banned",
Unbanned = "unbanned",
Kicked = "kicked",
ChangedName = "changed_name",
ChangedAvatar = "changed_avatar",
NoChange = "no_change",
ServerAcl = "server_acl",
ChangedPins = "pinned_messages",
MessageRemoved = "message_removed",
HiddenEvent = "hidden_event",
}
const eventIds = events.map((e) => e.getId()).join(',');
const SEP = ",";
// If we are only given few events then just pass them through
if (events.length < threshold) {
@replaceableComponent("views.elements.EventListSummary")
export default class EventListSummary extends React.Component<IProps> {
static defaultProps = {
summaryLength: 1,
threshold: 3,
avatarsMaxLength: 5,
layout: Layout.Group,
};
shouldComponentUpdate(nextProps: IProps): boolean {
// Update if
// - The number of summarised events has changed
// - or if the summary is about to toggle to become collapsed
// - or if there are fewEvents, meaning the child eventTiles are shown as-is
return (
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={true} data-layout={layout}>
{ children }
</li>
nextProps.events.length !== this.props.events.length ||
nextProps.events.length < this.props.threshold ||
nextProps.layout !== this.props.layout
);
}
let body;
if (expanded) {
body = <React.Fragment>
<div className="mx_EventListSummary_line">&nbsp;</div>
{ children }
</React.Fragment>;
} else {
const uniqueMembers = uniqBy(summaryMembers.filter(member => {
if (!member?.getMxcAvatarUrl) {
logger.error("EventListSummary given null summaryMember, termites may be afoot eating event senders",
summaryMembers);
return false;
/**
* Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
* the sequences are ordered by `orderedTransitionSequences`.
* @param {object} eventAggregates a map of transition sequence to array of user display names
* or user IDs.
* @param {string[]} orderedTransitionSequences an array which is some ordering of
* `Object.keys(eventAggregates)`.
* @returns {string} the textual summary of the aggregated events that occurred.
*/
private generateSummary(
eventAggregates: Record<string, string[]>,
orderedTransitionSequences: string[],
): string | JSX.Element {
const summaries = orderedTransitionSequences.map((transitions) => {
const userNames = eventAggregates[transitions];
const nameList = this.renderNameList(userNames);
const splitTransitions = transitions.split(SEP) as TransitionType[];
// Some neighbouring transitions are common, so canonicalise some into "pair"
// transitions
const canonicalTransitions = EventListSummary.getCanonicalTransitions(splitTransitions);
// Transform into consecutive repetitions of the same transition (like 5
// consecutive 'joined_and_left's)
const coalescedTransitions = EventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
const descs = coalescedTransitions.map((t) => {
return EventListSummary.getDescriptionForTransition(
t.transitionType, userNames.length, t.repeats,
);
});
const desc = formatCommaSeparatedList(descs);
return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc });
});
if (!summaries) {
return null;
}
return jsxJoin(summaries, ", ");
}
/**
* @param {string[]} users an array of user display names or user IDs.
* @returns {string} a comma-separated list that ends with "and [n] others" if there are
* more items in `users` than `this.props.summaryLength`, which is the number of names
* included before "and [n] others".
*/
private renderNameList(users: string[]) {
return formatCommaSeparatedList(users, this.props.summaryLength);
}
/**
* Canonicalise an array of transitions such that some pairs of transitions become
* single transitions. For example an input ['joined','left'] would result in an output
* ['joined_and_left'].
* @param {string[]} transitions an array of transitions.
* @returns {string[]} an array of transitions.
*/
private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
const modMap = {
[TransitionType.Joined]: {
after: TransitionType.Left,
newTransition: TransitionType.JoinedAndLeft,
},
[TransitionType.Left]: {
after: TransitionType.Joined,
newTransition: TransitionType.LeftAndJoined,
},
// $currentTransition : {
// 'after' : $nextTransition,
// 'newTransition' : 'new_transition_type',
// },
};
const res: TransitionType[] = [];
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const t2 = transitions[i + 1];
let transition = t;
if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) {
transition = modMap[t].newTransition;
i++;
}
return true;
}), member => member.getMxcAvatarUrl());
const avatars = uniqueMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
body = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_EventListSummary_avatars" onClick={toggleExpanded}>
{ avatars }
</span>
<span className="mx_TextualEvent mx_EventListSummary_summary">
{ summaryText }
</span>
</div>
</div>
);
res.push(transition);
}
return res;
}
return (
<li className="mx_EventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""} data-layout={layout}>
<AccessibleButton className="mx_EventListSummary_toggle" onClick={toggleExpanded} aria-expanded={expanded}>
{ expanded ? _t('collapse') : _t('expand') }
</AccessibleButton>
{ body }
</li>
);
};
/**
* Transform an array of transitions into an array of transitions and how many times
* they are repeated consecutively.
*
* An array of 123 "joined_and_left" transitions, would result in:
* ```
* [{
* transitionType: "joined_and_left"
* repeats: 123
* }]
* ```
* @param {string[]} transitions the array of transitions to transform.
* @returns {object[]} an array of coalesced transitions.
*/
private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
const res: {
transitionType: TransitionType;
repeats: number;
}[] = [];
EventListSummary.defaultProps = {
startExpanded: false,
layout: Layout.Group,
};
for (let i = 0; i < transitions.length; i++) {
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
res[res.length - 1].repeats += 1;
} else {
res.push({
transitionType: transitions[i],
repeats: 1,
});
}
}
return res;
}
export default EventListSummary;
/**
* For a certain transition, t, describe what happened to the users that
* underwent the transition.
* @param {string} t the transition type.
* @param {number} userCount number of usernames
* @param {number} repeats the number of times the transition was repeated in a row.
* @returns {string} the written Human Readable equivalent of the transition.
*/
private static getDescriptionForTransition(
t: TransitionType,
userCount: number,
count: number,
): string | JSX.Element {
// The empty interpolations 'severalUsers' and 'oneUser'
// are there only to show translators to non-English languages
// that the verb is conjugated to plural or singular Subject.
let res = null;
switch (t) {
case TransitionType.Joined:
res = (userCount > 1)
? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count });
break;
case TransitionType.Left:
res = (userCount > 1)
? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)sleft %(count)s times", { oneUser: "", count });
break;
case TransitionType.JoinedAndLeft:
res = (userCount > 1)
? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count });
break;
case TransitionType.LeftAndJoined:
res = (userCount > 1)
? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count });
break;
case TransitionType.InviteReject:
res = (userCount > 1)
? _t("%(severalUsers)srejected their invitations %(count)s times", {
severalUsers: "",
count,
})
: _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count });
break;
case TransitionType.InviteWithdrawal:
res = (userCount > 1)
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", {
severalUsers: "",
count,
})
: _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count });
break;
case TransitionType.Invited:
res = (userCount > 1)
? _t("were invited %(count)s times", { count })
: _t("was invited %(count)s times", { count });
break;
case TransitionType.Banned:
res = (userCount > 1)
? _t("were banned %(count)s times", { count })
: _t("was banned %(count)s times", { count });
break;
case TransitionType.Unbanned:
res = (userCount > 1)
? _t("were unbanned %(count)s times", { count })
: _t("was unbanned %(count)s times", { count });
break;
case TransitionType.Kicked:
res = (userCount > 1)
? _t("were removed %(count)s times", { count })
: _t("was removed %(count)s times", { count });
break;
case TransitionType.ChangedName:
res = (userCount > 1)
? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count });
break;
case TransitionType.ChangedAvatar:
res = (userCount > 1)
? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count });
break;
case TransitionType.NoChange:
res = (userCount > 1)
? _t("%(severalUsers)smade no changes %(count)s times", { severalUsers: "", count })
: _t("%(oneUser)smade no changes %(count)s times", { oneUser: "", count });
break;
case TransitionType.ServerAcl:
res = (userCount > 1)
? _t("%(severalUsers)schanged the server ACLs %(count)s times",
{ severalUsers: "", count })
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count });
break;
case TransitionType.ChangedPins:
res = (userCount > 1)
? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
{ severalUsers: "", count },
{
"a": (sub) => <AccessibleButton kind='link_inline' onClick={onPinnedMessagesClick}>
{ sub }
</AccessibleButton>,
})
: _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
{ oneUser: "", count },
{
"a": (sub) => <AccessibleButton kind='link_inline' onClick={onPinnedMessagesClick}>
{ sub }
</AccessibleButton>,
});
break;
case TransitionType.MessageRemoved:
res = (userCount > 1)
? _t("%(severalUsers)sremoved a message %(count)s times",
{ severalUsers: "", count })
: _t("%(oneUser)sremoved a message %(count)s times", { oneUser: "", count });
break;
case TransitionType.HiddenEvent:
res = (userCount > 1)
? _t("%(severalUsers)ssent %(count)s hidden messages",
{ severalUsers: "", count })
: _t("%(oneUser)ssent %(count)s hidden messages", { oneUser: "", count });
break;
}
return res;
}
private static getTransitionSequence(events: IUserEvents[]) {
return events.map(EventListSummary.getTransition);
}
/**
* Label a given membership event, `e`, where `getContent().membership` has
* changed for each transition allowed by the Matrix protocol. This attempts to
* label the membership changes that occur in `../../../TextForEvent.js`.
* @param {MatrixEvent} e the membership change event to label.
* @returns {string?} the transition type given to this event. This defaults to `null`
* if a transition is not recognised.
*/
private static getTransition(e: IUserEvents): TransitionType {
if (e.mxEvent.isRedacted()) {
return TransitionType.MessageRemoved;
}
switch (e.mxEvent.getType()) {
case EventType.RoomThirdPartyInvite:
// Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) {
return TransitionType.InviteWithdrawal;
}
return TransitionType.Invited;
case EventType.RoomServerAcl:
return TransitionType.ServerAcl;
case EventType.RoomPinnedEvents:
return TransitionType.ChangedPins;
case EventType.RoomMember:
switch (e.mxEvent.getContent().membership) {
case 'invite': return TransitionType.Invited;
case 'ban': return TransitionType.Banned;
case 'join':
if (e.mxEvent.getPrevContent().membership === 'join') {
if (e.mxEvent.getContent().displayname !== e.mxEvent.getPrevContent().displayname) {
return TransitionType.ChangedName;
} else if (e.mxEvent.getContent().avatar_url !== e.mxEvent.getPrevContent().avatar_url) {
return TransitionType.ChangedAvatar;
}
// console.log("MELS ignoring duplicate membership join event");
return TransitionType.NoChange;
} else {
return TransitionType.Joined;
}
case 'leave':
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
if (e.mxEvent.getPrevContent().membership === "invite") {
return TransitionType.InviteReject;
}
return TransitionType.Left;
}
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return TransitionType.InviteWithdrawal;
case 'ban': return TransitionType.Unbanned;
// sender is not target and made the target leave, if not from invite/ban then this is a kick
default: return TransitionType.Kicked;
}
default: return null;
}
default:
// otherwise, assume this is a hidden event
return TransitionType.HiddenEvent;
}
}
getAggregate(userEvents: Record<string, IUserEvents[]>) {
// A map of aggregate type to arrays of display names. Each aggregate type
// is a comma-delimited string of transitions, e.g. "joined,left,kicked".
// The array of display names is the array of users who went through that
// sequence during eventsToRender.
const aggregate: Record<string, string[]> = {
// $aggregateType : []:string
};
// A map of aggregate types to the indices that order them (the index of
// the first event for a given transition sequence)
const aggregateIndices: Record<string, number> = {
// $aggregateType : int
};
const users = Object.keys(userEvents);
users.forEach(
(userId) => {
const firstEvent = userEvents[userId][0];
const displayName = firstEvent.displayName;
const seq = EventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
if (!aggregate[seq]) {
aggregate[seq] = [];
aggregateIndices[seq] = -1;
}
aggregate[seq].push(displayName);
if (aggregateIndices[seq] === -1 ||
firstEvent.index < aggregateIndices[seq]
) {
aggregateIndices[seq] = firstEvent.index;
}
},
);
return {
names: aggregate,
indices: aggregateIndices,
};
}
render() {
const eventsToRender = this.props.events;
// Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
// so this works perfectly for us to match event order whilst storing the latest Avatar Member
const latestUserAvatarMember = new Map<string, RoomMember>();
// Object mapping user IDs to an array of IUserEvents
const userEvents: Record<string, IUserEvents[]> = {};
eventsToRender.forEach((e, index) => {
const type = e.getType();
const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey();
// Initialise a user's events
if (!userEvents[userId]) {
userEvents[userId] = [];
}
if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
latestUserAvatarMember.set(userId, e.target);
} else {
latestUserAvatarMember.set(userId, e.sender);
}
let displayName = userId;
if (type === EventType.RoomThirdPartyInvite) {
displayName = e.getContent().display_name;
} else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
displayName = e.target.name;
} else {
displayName = e.sender.name;
}
userEvents[userId].push({
mxEvent: e,
displayName,
index: index,
});
});
const aggregate = this.getAggregate(userEvents);
// Sort types by order of lowest event index within sequence
const orderedTransitionSequences = Object.keys(aggregate.names).sort(
(seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2],
);
return <GenericEventListSummary
events={this.props.events}
threshold={this.props.threshold}
onToggle={this.props.onToggle}
startExpanded={this.props.startExpanded}
children={this.props.children}
summaryMembers={[...latestUserAvatarMember.values()]}
layout={this.props.layout}
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
}
}

View file

@ -0,0 +1,118 @@
/*
Copyright 2019 - 2022 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, { ReactNode, useEffect } from "react";
import { uniqBy } from "lodash";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { logger } from "matrix-js-sdk/src/logger";
import MemberAvatar from '../avatars/MemberAvatar';
import { _t } from '../../../languageHandler';
import { useStateToggle } from "../../../hooks/useStateToggle";
import AccessibleButton from "./AccessibleButton";
import { Layout } from '../../../settings/enums/Layout';
interface IProps {
// An array of member events to summarise
events: MatrixEvent[];
// The minimum number of events needed to trigger summarisation
threshold?: number;
// Whether or not to begin with state.expanded=true
startExpanded?: boolean;
// The list of room members for which to show avatars next to the summary
summaryMembers?: RoomMember[];
// The text to show as the summary of this event list
summaryText?: string | JSX.Element;
// An array of EventTiles to render when expanded
children: ReactNode[];
// Called when the event list expansion is toggled
onToggle?(): void;
// The layout currently used
layout?: Layout;
}
const GenericEventListSummary: React.FC<IProps> = ({
events,
children,
threshold = 3,
onToggle,
startExpanded = false,
summaryMembers = [],
summaryText,
layout = Layout.Group,
}) => {
const [expanded, toggleExpanded] = useStateToggle(startExpanded);
// Whenever expanded changes call onToggle
useEffect(() => {
if (onToggle) {
onToggle();
}
}, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps
const eventIds = events.map((e) => e.getId()).join(',');
// If we are only given few events then just pass them through
if (events.length < threshold) {
return (
<li className="mx_GenericEventListSummary" data-scroll-tokens={eventIds} data-expanded={true} data-layout={layout}>
{ children }
</li>
);
}
let body;
if (expanded) {
body = <React.Fragment>
<div className="mx_GenericEventListSummary_line">&nbsp;</div>
{ children }
</React.Fragment>;
} else {
const uniqueMembers = uniqBy(summaryMembers.filter(member => {
if (!member?.getMxcAvatarUrl) {
logger.error("EventListSummary given null summaryMember, termites may be afoot eating event senders",
summaryMembers);
return false;
}
return true;
}), member => member.getMxcAvatarUrl());
const avatars = uniqueMembers.map((m) => <MemberAvatar key={m.userId} member={m} width={14} height={14} />);
body = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_GenericEventListSummary_avatars" onClick={toggleExpanded}>
{ avatars }
</span>
<span className="mx_TextualEvent mx_GenericEventListSummary_summary">
{ summaryText }
</span>
</div>
</div>
);
}
return (
<li className="mx_GenericEventListSummary" data-scroll-tokens={eventIds} data-expanded={expanded + ""} data-layout={layout}>
<AccessibleButton className="mx_GenericEventListSummary_toggle" onClick={toggleExpanded} aria-expanded={expanded}>
{ expanded ? _t('collapse') : _t('expand') }
</AccessibleButton>
{ body }
</li>
);
};
export default GenericEventListSummary;

View file

@ -1,503 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
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, { ComponentProps } from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import { isValid3pidInvite } from "../../../RoomInvite";
import EventListSummary from "./EventListSummary";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases';
import { jsxJoin } from '../../../utils/ReactUtils';
import { Layout } from '../../../settings/enums/Layout';
import RightPanelStore from '../../../stores/right-panel/RightPanelStore';
import AccessibleButton from './AccessibleButton';
const onPinnedMessagesClick = (): void => {
RightPanelStore.instance.setCard({ phase: RightPanelPhases.PinnedMessages }, false);
};
const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents];
interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
// The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength?: number;
// The maximum number of avatars to display in the summary
avatarsMaxLength?: number;
// The currently selected layout
layout: Layout;
}
interface IUserEvents {
// The original event
mxEvent: MatrixEvent;
// The display name of the user (if not, then user ID)
displayName: string;
// The original index of the event in this.props.events
index: number;
}
enum TransitionType {
Joined = "joined",
Left = "left",
JoinedAndLeft = "joined_and_left",
LeftAndJoined = "left_and_joined",
InviteReject = "invite_reject",
InviteWithdrawal = "invite_withdrawal",
Invited = "invited",
Banned = "banned",
Unbanned = "unbanned",
Kicked = "kicked",
ChangedName = "changed_name",
ChangedAvatar = "changed_avatar",
NoChange = "no_change",
ServerAcl = "server_acl",
ChangedPins = "pinned_messages"
}
const SEP = ",";
@replaceableComponent("views.elements.MemberEventListSummary")
export default class MemberEventListSummary extends React.Component<IProps> {
static defaultProps = {
summaryLength: 1,
threshold: 3,
avatarsMaxLength: 5,
layout: Layout.Group,
};
shouldComponentUpdate(nextProps: IProps): boolean {
// Update if
// - The number of summarised events has changed
// - or if the summary is about to toggle to become collapsed
// - or if there are fewEvents, meaning the child eventTiles are shown as-is
return (
nextProps.events.length !== this.props.events.length ||
nextProps.events.length < this.props.threshold ||
nextProps.layout !== this.props.layout
);
}
/**
* Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
* the sequences are ordered by `orderedTransitionSequences`.
* @param {object} eventAggregates a map of transition sequence to array of user display names
* or user IDs.
* @param {string[]} orderedTransitionSequences an array which is some ordering of
* `Object.keys(eventAggregates)`.
* @returns {string} the textual summary of the aggregated events that occurred.
*/
private generateSummary(
eventAggregates: Record<string, string[]>,
orderedTransitionSequences: string[],
): string | JSX.Element {
const summaries = orderedTransitionSequences.map((transitions) => {
const userNames = eventAggregates[transitions];
const nameList = this.renderNameList(userNames);
const splitTransitions = transitions.split(SEP) as TransitionType[];
// Some neighbouring transitions are common, so canonicalise some into "pair"
// transitions
const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
// Transform into consecutive repetitions of the same transition (like 5
// consecutive 'joined_and_left's)
const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
const descs = coalescedTransitions.map((t) => {
return MemberEventListSummary.getDescriptionForTransition(
t.transitionType, userNames.length, t.repeats,
);
});
const desc = formatCommaSeparatedList(descs);
return _t('%(nameList)s %(transitionList)s', { nameList, transitionList: desc });
});
if (!summaries) {
return null;
}
return jsxJoin(summaries, ", ");
}
/**
* @param {string[]} users an array of user display names or user IDs.
* @returns {string} a comma-separated list that ends with "and [n] others" if there are
* more items in `users` than `this.props.summaryLength`, which is the number of names
* included before "and [n] others".
*/
private renderNameList(users: string[]) {
return formatCommaSeparatedList(users, this.props.summaryLength);
}
/**
* Canonicalise an array of transitions such that some pairs of transitions become
* single transitions. For example an input ['joined','left'] would result in an output
* ['joined_and_left'].
* @param {string[]} transitions an array of transitions.
* @returns {string[]} an array of transitions.
*/
private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
const modMap = {
[TransitionType.Joined]: {
after: TransitionType.Left,
newTransition: TransitionType.JoinedAndLeft,
},
[TransitionType.Left]: {
after: TransitionType.Joined,
newTransition: TransitionType.LeftAndJoined,
},
// $currentTransition : {
// 'after' : $nextTransition,
// 'newTransition' : 'new_transition_type',
// },
};
const res: TransitionType[] = [];
for (let i = 0; i < transitions.length; i++) {
const t = transitions[i];
const t2 = transitions[i + 1];
let transition = t;
if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) {
transition = modMap[t].newTransition;
i++;
}
res.push(transition);
}
return res;
}
/**
* Transform an array of transitions into an array of transitions and how many times
* they are repeated consecutively.
*
* An array of 123 "joined_and_left" transitions, would result in:
* ```
* [{
* transitionType: "joined_and_left"
* repeats: 123
* }]
* ```
* @param {string[]} transitions the array of transitions to transform.
* @returns {object[]} an array of coalesced transitions.
*/
private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
const res: {
transitionType: TransitionType;
repeats: number;
}[] = [];
for (let i = 0; i < transitions.length; i++) {
if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
res[res.length - 1].repeats += 1;
} else {
res.push({
transitionType: transitions[i],
repeats: 1,
});
}
}
return res;
}
/**
* For a certain transition, t, describe what happened to the users that
* underwent the transition.
* @param {string} t the transition type.
* @param {number} userCount number of usernames
* @param {number} repeats the number of times the transition was repeated in a row.
* @returns {string} the written Human Readable equivalent of the transition.
*/
private static getDescriptionForTransition(
t: TransitionType,
userCount: number,
repeats: number,
): string | JSX.Element {
// The empty interpolations 'severalUsers' and 'oneUser'
// are there only to show translators to non-English languages
// that the verb is conjugated to plural or singular Subject.
let res = null;
switch (t) {
case "joined":
res = (userCount > 1)
? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count: repeats });
break;
case "left":
res = (userCount > 1)
? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)sleft %(count)s times", { oneUser: "", count: repeats });
break;
case "joined_and_left":
res = (userCount > 1)
? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count: repeats });
break;
case "left_and_joined":
res = (userCount > 1)
? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count: repeats });
break;
case "invite_reject":
res = (userCount > 1)
? _t("%(severalUsers)srejected their invitations %(count)s times", {
severalUsers: "",
count: repeats,
})
: _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
break;
case "invite_withdrawal":
res = (userCount > 1)
? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", {
severalUsers: "",
count: repeats,
})
: _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
break;
case "invited":
res = (userCount > 1)
? _t("were invited %(count)s times", { count: repeats })
: _t("was invited %(count)s times", { count: repeats });
break;
case "banned":
res = (userCount > 1)
? _t("were banned %(count)s times", { count: repeats })
: _t("was banned %(count)s times", { count: repeats });
break;
case "unbanned":
res = (userCount > 1)
? _t("were unbanned %(count)s times", { count: repeats })
: _t("was unbanned %(count)s times", { count: repeats });
break;
case "kicked":
res = (userCount > 1)
? _t("were removed %(count)s times", { count: repeats })
: _t("was removed %(count)s times", { count: repeats });
break;
case "changed_name":
res = (userCount > 1)
? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count: repeats });
break;
case "changed_avatar":
res = (userCount > 1)
? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count: repeats });
break;
case "no_change":
res = (userCount > 1)
? _t("%(severalUsers)smade no changes %(count)s times", { severalUsers: "", count: repeats })
: _t("%(oneUser)smade no changes %(count)s times", { oneUser: "", count: repeats });
break;
case "server_acl":
res = (userCount > 1)
? _t("%(severalUsers)schanged the server ACLs %(count)s times",
{ severalUsers: "", count: repeats })
: _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
break;
case "pinned_messages":
res = (userCount > 1)
? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
{ severalUsers: "", count: repeats },
{
"a": (sub) => <AccessibleButton kind='link_inline' onClick={onPinnedMessagesClick}>
{ sub }
</AccessibleButton>,
})
: _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
{ oneUser: "", count: repeats },
{
"a": (sub) => <AccessibleButton kind='link_inline' onClick={onPinnedMessagesClick}>
{ sub }
</AccessibleButton>,
});
break;
}
return res;
}
private static getTransitionSequence(events: IUserEvents[]) {
return events.map(MemberEventListSummary.getTransition);
}
/**
* Label a given membership event, `e`, where `getContent().membership` has
* changed for each transition allowed by the Matrix protocol. This attempts to
* label the membership changes that occur in `../../../TextForEvent.js`.
* @param {MatrixEvent} e the membership change event to label.
* @returns {string?} the transition type given to this event. This defaults to `null`
* if a transition is not recognised.
*/
private static getTransition(e: IUserEvents): TransitionType {
const type = e.mxEvent.getType();
if (type === EventType.RoomThirdPartyInvite) {
// Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) {
return TransitionType.InviteWithdrawal;
}
return TransitionType.Invited;
} else if (type === EventType.RoomServerAcl) {
return TransitionType.ServerAcl;
} else if (type === EventType.RoomPinnedEvents) {
return TransitionType.ChangedPins;
}
switch (e.mxEvent.getContent().membership) {
case 'invite': return TransitionType.Invited;
case 'ban': return TransitionType.Banned;
case 'join':
if (e.mxEvent.getPrevContent().membership === 'join') {
if (e.mxEvent.getContent().displayname !==
e.mxEvent.getPrevContent().displayname) {
return TransitionType.ChangedName;
} else if (e.mxEvent.getContent().avatar_url !==
e.mxEvent.getPrevContent().avatar_url) {
return TransitionType.ChangedAvatar;
}
// console.log("MELS ignoring duplicate membership join event");
return TransitionType.NoChange;
} else {
return TransitionType.Joined;
}
case 'leave':
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return TransitionType.InviteReject;
default: return TransitionType.Left;
}
}
switch (e.mxEvent.getPrevContent().membership) {
case 'invite': return TransitionType.InviteWithdrawal;
case 'ban': return TransitionType.Unbanned;
// sender is not target and made the target leave, if not from invite/ban then this is a kick
default: return TransitionType.Kicked;
}
default: return null;
}
}
getAggregate(userEvents: Record<string, IUserEvents[]>) {
// A map of aggregate type to arrays of display names. Each aggregate type
// is a comma-delimited string of transitions, e.g. "joined,left,kicked".
// The array of display names is the array of users who went through that
// sequence during eventsToRender.
const aggregate: Record<string, string[]> = {
// $aggregateType : []:string
};
// A map of aggregate types to the indices that order them (the index of
// the first event for a given transition sequence)
const aggregateIndices: Record<string, number> = {
// $aggregateType : int
};
const users = Object.keys(userEvents);
users.forEach(
(userId) => {
const firstEvent = userEvents[userId][0];
const displayName = firstEvent.displayName;
const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
if (!aggregate[seq]) {
aggregate[seq] = [];
aggregateIndices[seq] = -1;
}
aggregate[seq].push(displayName);
if (aggregateIndices[seq] === -1 ||
firstEvent.index < aggregateIndices[seq]
) {
aggregateIndices[seq] = firstEvent.index;
}
},
);
return {
names: aggregate,
indices: aggregateIndices,
};
}
render() {
const eventsToRender = this.props.events;
// Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
// so this works perfectly for us to match event order whilst storing the latest Avatar Member
const latestUserAvatarMember = new Map<string, RoomMember>();
// Object mapping user IDs to an array of IUserEvents
const userEvents: Record<string, IUserEvents[]> = {};
eventsToRender.forEach((e, index) => {
const type = e.getType();
const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey();
// Initialise a user's events
if (!userEvents[userId]) {
userEvents[userId] = [];
}
if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
latestUserAvatarMember.set(userId, e.sender);
} else if (e.target) {
latestUserAvatarMember.set(userId, e.target);
}
let displayName = userId;
if (type === EventType.RoomThirdPartyInvite) {
displayName = e.getContent().display_name;
} else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
displayName = e.sender.name;
} else if (e.target) {
displayName = e.target.name;
}
userEvents[userId].push({
mxEvent: e,
displayName,
index: index,
});
});
const aggregate = this.getAggregate(userEvents);
// Sort types by order of lowest event index within sequence
const orderedTransitionSequences = Object.keys(aggregate.names).sort(
(seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2],
);
return <EventListSummary
events={this.props.events}
threshold={this.props.threshold}
onToggle={this.props.onToggle}
startExpanded={this.props.startExpanded}
children={this.props.children}
summaryMembers={[...latestUserAvatarMember.values()]}
layout={this.props.layout}
summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
}
}