diff --git a/src/components/views/elements/EventListSummary.js b/src/components/views/elements/EventListSummary.tsx similarity index 81% rename from src/components/views/elements/EventListSummary.js rename to src/components/views/elements/EventListSummary.tsx index 5a4a6e4f5a..1d3b6e8764 100644 --- a/src/components/views/elements/EventListSummary.js +++ b/src/components/views/elements/EventListSummary.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -14,15 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useEffect} from 'react'; -import PropTypes from 'prop-types'; +import React, {ReactChildren, useEffect} from 'react'; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; + import MemberAvatar from '../avatars/MemberAvatar'; import { _t } from '../../../languageHandler'; -import {MatrixEvent, RoomMember} from "matrix-js-sdk"; import {useStateToggle} from "../../../hooks/useStateToggle"; import AccessibleButton from "./AccessibleButton"; -const EventListSummary = ({events, children, threshold=3, onToggle, startExpanded, summaryMembers=[], summaryText}) => { +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, + // An array of EventTiles to render when expanded + children: ReactChildren, + // Called when the event list expansion is toggled + onToggle?(): void; +} + +const EventListSummary: React.FC = ({ + events, + children, + threshold = 3, + onToggle, + startExpanded, + summaryMembers = [], + summaryText, +}) => { const [expanded, toggleExpanded] = useStateToggle(startExpanded); // Whenever expanded changes call onToggle @@ -75,22 +101,4 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande ); }; -EventListSummary.propTypes = { - // An array of member events to summarise - events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, - // An array of EventTiles to render when expanded - children: PropTypes.arrayOf(PropTypes.element).isRequired, - // The minimum number of events needed to trigger summarisation - threshold: PropTypes.number, - // Called when the event list expansion is toggled - onToggle: PropTypes.func, - // Whether or not to begin with state.expanded=true - startExpanded: PropTypes.bool, - - // The list of room members for which to show avatars next to the summary - summaryMembers: PropTypes.arrayOf(PropTypes.instanceOf(RoomMember)), - // The text to show as the summary of this event list - summaryText: PropTypes.string, -}; - export default EventListSummary; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.tsx similarity index 72% rename from src/components/views/elements/MemberEventListSummary.js rename to src/components/views/elements/MemberEventListSummary.tsx index e16b52c8a2..41f468fdd7 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -16,32 +16,60 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { ReactChildren } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; -import * as sdk from "../../../index"; -import {MatrixEvent} from "matrix-js-sdk"; -import {isValid3pidInvite} from "../../../RoomInvite"; +import { isValid3pidInvite } from "../../../RoomInvite"; +import EventListSummary from "./EventListSummary"; -export default class MemberEventListSummary extends React.Component { - static propTypes = { - // An array of member events to summarise - events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, - // An array of EventTiles to render when expanded - children: PropTypes.array.isRequired, - // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" - summaryLength: PropTypes.number, - // The maximum number of avatars to display in the summary - avatarsMaxLength: PropTypes.number, - // The minimum number of events needed to trigger summarisation - threshold: PropTypes.number, - // Called when the MELS expansion is toggled - onToggle: PropTypes.func, - // Whether or not to begin with state.expanded=true - startExpanded: PropTypes.bool, - }; +interface IProps { + // An array of member events to summarise + events: MatrixEvent[]; + // 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 minimum number of events needed to trigger summarisation + threshold?: number, + // Whether or not to begin with state.expanded=true + startExpanded?: boolean, + // An array of EventTiles to render when expanded + children: ReactChildren; + // Called when the MELS expansion is toggled + onToggle?(): void, +} +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", +} + +const SEP = ","; + +export default class MemberEventListSummary extends React.Component { static defaultProps = { summaryLength: 1, threshold: 3, @@ -62,30 +90,28 @@ export default class MemberEventListSummary extends React.Component { /** * 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 + * @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. */ - _generateSummary(eventAggregates, orderedTransitionSequences) { + private generateSummary(eventAggregates: Record, orderedTransitionSequences: string[]) { const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; - const nameList = this._renderNameList(userNames); + const nameList = this.renderNameList(userNames); - const splitTransitions = transitions.split(','); + const splitTransitions = transitions.split(SEP) as TransitionType[]; // Some neighbouring transitions are common, so canonicalise some into "pair" // transitions - const canonicalTransitions = this._getCanonicalTransitions(splitTransitions); + const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions); // Transform into consecutive repetitions of the same transition (like 5 // consecutive 'joined_and_left's) - const coalescedTransitions = this._coalesceRepeatedTransitions( - canonicalTransitions, - ); + const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions); const descs = coalescedTransitions.map((t) => { - return this._getDescriptionForTransition( + return MemberEventListSummary.getDescriptionForTransition( t.transitionType, userNames.length, t.repeats, ); }); @@ -108,7 +134,7 @@ export default class MemberEventListSummary extends React.Component { * more items in `users` than `this.props.summaryLength`, which is the number of names * included before "and [n] others". */ - _renderNameList(users) { + private renderNameList(users: string[]) { return formatCommaSeparatedList(users, this.props.summaryLength); } @@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component { * @param {string[]} transitions an array of transitions. * @returns {string[]} an array of transitions. */ - _getCanonicalTransitions(transitions) { + private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] { const modMap = { - 'joined': { - 'after': 'left', - 'newTransition': 'joined_and_left', + [TransitionType.Joined]: { + after: TransitionType.Left, + newTransition: TransitionType.JoinedAndLeft, }, - 'left': { - 'after': 'joined', - 'newTransition': 'left_and_joined', + [TransitionType.Left]: { + after: TransitionType.Joined, + newTransition: TransitionType.LeftAndJoined, }, // $currentTransition : { // 'after' : $nextTransition, // 'newTransition' : 'new_transition_type', // }, }; - const res = []; + const res: TransitionType[] = []; for (let i = 0; i < transitions.length; i++) { const t = transitions[i]; @@ -166,8 +192,12 @@ export default class MemberEventListSummary extends React.Component { * @param {string[]} transitions the array of transitions to transform. * @returns {object[]} an array of coalesced transitions. */ - _coalesceRepeatedTransitions(transitions) { - const res = []; + 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; @@ -189,7 +219,7 @@ export default class MemberEventListSummary extends React.Component { * @param {number} repeats the number of times the transition was repeated in a row. * @returns {string} the written Human Readable equivalent of the transition. */ - _getDescriptionForTransition(t, userCount, repeats) { + private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) { // 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. @@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component { break; case "invite_reject": res = (userCount > 1) - ? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats }) + ? _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("%(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": @@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component { return res; } - _getTransitionSequence(events) { - return events.map(this._getTransition); + private static getTransitionSequence(events: MatrixEvent[]) { + return events.map(MemberEventListSummary.getTransition); } /** @@ -277,60 +313,60 @@ export default class MemberEventListSummary extends React.Component { * @returns {string?} the transition type given to this event. This defaults to `null` * if a transition is not recognised. */ - _getTransition(e) { + private static getTransition(e: MatrixEvent): TransitionType { if (e.mxEvent.getType() === 'm.room.third_party_invite') { // Handle 3pid invites the same as invites so they get bundled together if (!isValid3pidInvite(e.mxEvent)) { - return 'invite_withdrawal'; + return TransitionType.InviteWithdrawal; } - return 'invited'; + return TransitionType.Invited; } switch (e.mxEvent.getContent().membership) { - case 'invite': return 'invited'; - case 'ban': return 'banned'; + 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 'changed_name'; + return TransitionType.ChangedName; } else if (e.mxEvent.getContent().avatar_url !== e.mxEvent.getPrevContent().avatar_url) { - return 'changed_avatar'; + return TransitionType.ChangedAvatar; } // console.log("MELS ignoring duplicate membership join event"); - return 'no_change'; + return TransitionType.NoChange; } else { - return 'joined'; + return TransitionType.Joined; } case 'leave': if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { switch (e.mxEvent.getPrevContent().membership) { - case 'invite': return 'invite_reject'; - default: return 'left'; + case 'invite': return TransitionType.InviteReject; + default: return TransitionType.Left; } } switch (e.mxEvent.getPrevContent().membership) { - case 'invite': return 'invite_withdrawal'; - case 'ban': return 'unbanned'; + 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 'kicked'; + default: return TransitionType.Kicked; } default: return null; } } - _getAggregate(userEvents) { + getAggregate(userEvents: Record) { // 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 = { + const aggregate: Record = { // $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 = { + const aggregateIndices: Record = { // $aggregateType : int }; @@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component { const firstEvent = userEvents[userId][0]; const displayName = firstEvent.displayName; - const seq = this._getTransitionSequence(userEvents[userId]); + const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP); if (!aggregate[seq]) { aggregate[seq] = []; aggregateIndices[seq] = -1; @@ -349,8 +385,9 @@ export default class MemberEventListSummary extends React.Component { aggregate[seq].push(displayName); if (aggregateIndices[seq] === -1 || - firstEvent.index < aggregateIndices[seq]) { - aggregateIndices[seq] = firstEvent.index; + firstEvent.index < aggregateIndices[seq] + ) { + aggregateIndices[seq] = firstEvent.index; } }, ); @@ -364,19 +401,10 @@ export default class MemberEventListSummary extends React.Component { render() { const eventsToRender = this.props.events; - // Map user IDs to an array of objects: - const userEvents = { - // $userId : [{ - // // The original event - // mxEvent: e, - // // The display name of the user (if not, then user ID) - // displayName: e.target.name || userId, - // // The original index of the event in this.props.events - // index: index, - // }] - }; + // Object mapping user IDs to an array of IUserEvents: + const userEvents: Record = {}; - const avatarMembers = []; + const avatarMembers: RoomMember[] = []; eventsToRender.forEach((e, index) => { const userId = e.getStateKey(); // Initialise a user's events @@ -399,14 +427,13 @@ export default class MemberEventListSummary extends React.Component { }); }); - const aggregate = this._getAggregate(userEvents); + 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], + (seq1, seq2) => aggregate.indices[seq2] - aggregate.indices[seq1], ); - const EventListSummary = sdk.getComponent("views.elements.EventListSummary"); return ; + summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />; } } diff --git a/src/hooks/useStateToggle.ts b/src/hooks/useStateToggle.ts index 85441df328..b50a923234 100644 --- a/src/hooks/useStateToggle.ts +++ b/src/hooks/useStateToggle.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {useState} from "react"; +import {Dispatch, SetStateAction, useState} from "react"; // Hook to simplify toggling of a boolean state value // Returns value, method to toggle boolean value and method to set the boolean value -export const useStateToggle = (initialValue: boolean) => { +export const useStateToggle = (initialValue: boolean): [boolean, () => void, Dispatch>] => { const [value, setValue] = useState(initialValue); const toggleValue = () => { setValue(!value);