diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 698a36127b7..1691d90651d 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; -const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl]; +const groupedEvents = [ + EventType.RoomMember, + EventType.RoomThirdPartyInvite, + EventType.RoomServerAcl, + EventType.RoomPinnedEvents, +]; // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL @@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper { // Wrap consecutive member events in a ListSummary, ignore if redacted class MemberGrouper extends BaseGrouper { static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean { - return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType); + return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType); }; constructor( @@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper { if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { return false; } - return membershipTypes.includes(ev.getType() as EventType); + return groupedEvents.includes(ev.getType() as EventType); } public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index d66319ca732..cbb0e17b427 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -34,7 +34,7 @@ interface IProps { // 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; + summaryText?: string | JSX.Element; // An array of EventTiles to render when expanded children: ReactNode[]; // Called when the event list expansion is toggled diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx index 604e6c63b05..0722cb872af 100644 --- a/src/components/views/elements/MemberEventListSummary.tsx +++ b/src/components/views/elements/MemberEventListSummary.tsx @@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { isValid3pidInvite } from "../../../RoomInvite"; import EventListSummary from "./EventListSummary"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import defaultDispatcher from '../../../dispatcher/dispatcher'; +import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; +import { Action } from '../../../dispatcher/actions'; +import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload'; +import { jsxJoin } from '../../../utils/ReactUtils'; +import { EventType } from 'matrix-js-sdk/src/@types/event'; import { Layout } from '../../../settings/Layout'; +const onPinnedMessagesClick = (): void => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.PinnedMessages, + allowClose: false, + }); +}; + +const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents]; + interface IProps extends Omit, "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; @@ -60,6 +76,7 @@ enum TransitionType { ChangedAvatar = "changed_avatar", NoChange = "no_change", ServerAcl = "server_acl", + ChangedPins = "pinned_messages" } const SEP = ","; @@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component { * `Object.keys(eventAggregates)`. * @returns {string} the textual summary of the aggregated events that occurred. */ - private generateSummary(eventAggregates: Record, orderedTransitionSequences: string[]) { + private generateSummary( + eventAggregates: Record, + orderedTransitionSequences: string[], + ): string | JSX.Element { const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; const nameList = this.renderNameList(userNames); @@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component { return null; } - return summaries.join(", "); + return jsxJoin(summaries, ", "); } /** @@ -216,7 +236,11 @@ 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. */ - private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) { + 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. @@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component { { 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 pinned messages for the room %(count)s times.", + { severalUsers: "", count: repeats }, + { "a": (sub) => { sub } }) + : _t("%(oneUser)schanged the pinned messages for the room %(count)s times.", + { oneUser: "", count: repeats }, + { "a": (sub) => { sub } }); + break; } return res; @@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component { * if a transition is not recognised. */ private static getTransition(e: IUserEvents): TransitionType { - if (e.mxEvent.getType() === 'm.room.third_party_invite') { + 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; - } - - if (e.mxEvent.getType() === 'm.room.server_acl') { + } else if (type === EventType.RoomServerAcl) { return TransitionType.ServerAcl; + } else if (type === EventType.RoomPinnedEvents) { + return TransitionType.ChangedPins; } switch (e.mxEvent.getContent().membership) { @@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component { // Object mapping user IDs to an array of IUserEvents const userEvents: Record = {}; eventsToRender.forEach((e, index) => { - const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey(); + 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.getType() === 'm.room.server_acl') { + 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 (e.getType() === 'm.room.third_party_invite') { + if (type === EventType.RoomThirdPartyInvite) { displayName = e.getContent().display_name; - } else if (e.getType() === 'm.room.server_acl') { + } else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { displayName = e.sender.name; } else if (e.target) { displayName = e.target.name; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 70bb1e436b6..2b29fe443af 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2077,6 +2077,8 @@ "%(severalUsers)schanged the server ACLs %(count)s times|one": "%(severalUsers)schanged the server ACLs", "%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times", "%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs", + "%(severalUsers)schanged the pinned messages for the room %(count)s times.|other": "%(severalUsers)schanged the pinned messages for the room %(count)s times.", + "%(oneUser)schanged the pinned messages for the room %(count)s times.|other": "%(oneUser)schanged the pinned messages for the room %(count)s times.", "Power level": "Power level", "Custom level": "Custom level", "QR Code": "QR Code", diff --git a/src/utils/FormattingUtils.ts b/src/utils/FormattingUtils.ts index 1fe3669f264..b527ee7ea2c 100644 --- a/src/utils/FormattingUtils.ts +++ b/src/utils/FormattingUtils.ts @@ -16,6 +16,7 @@ limitations under the License. */ import { _t } from '../languageHandler'; +import { jsxJoin } from './ReactUtils'; /** * formats numbers to fit into ~3 characters, suitable for badge counts @@ -103,7 +104,7 @@ export function getUserNameColorClass(userId: string): string { * @returns {string} a string constructed by joining `items` with a comma * between each item, but with the last item appended as " and [lastItem]". */ -export function formatCommaSeparatedList(items: string[], itemLimit?: number): string { +export function formatCommaSeparatedList(items: Array, itemLimit?: number): string | JSX.Element { const remaining = itemLimit === undefined ? 0 : Math.max( items.length - itemLimit, 0, ); @@ -113,9 +114,9 @@ export function formatCommaSeparatedList(items: string[], itemLimit?: number): s return items[0]; } else if (remaining > 0) { items = items.slice(0, itemLimit); - return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } ); + return _t("%(items)s and %(count)s others", { items: jsxJoin(items, ', '), count: remaining } ); } else { const lastItem = items.pop(); - return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); + return _t("%(items)s and %(lastItem)s", { items: jsxJoin(items, ', '), lastItem: lastItem }); } } diff --git a/src/utils/ReactUtils.tsx b/src/utils/ReactUtils.tsx new file mode 100644 index 00000000000..4cd2d750f36 --- /dev/null +++ b/src/utils/ReactUtils.tsx @@ -0,0 +1,33 @@ +/* +Copyright 2021 Šimon Brandner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +/** + * Joins an array into one value with a joiner. E.g. join(["hello", "world"], " ") -> hello world + * @param array the array of element to join + * @param joiner the string/JSX.Element to join with + * @returns the joined array + */ +export function jsxJoin(array: Array, joiner?: string | JSX.Element): JSX.Element { + const newArray = []; + array.forEach((element, index) => { + newArray.push(element, (index === array.length - 1) ? null : joiner); + }); + return ( + { newArray } + ); +}