Pillify event permalinks (#10392)

This commit is contained in:
Michael Weimann 2023-03-21 10:23:20 +01:00 committed by GitHub
parent d8acdd1750
commit 96d1b74ffc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 742 additions and 144 deletions

View file

@ -67,7 +67,7 @@ describe("Pills", () => {
// go back to the message room and try to click on the pill text, as a user would // go back to the message room and try to click on the pill text, as a user would
cy.viewRoomByName(messageRoom); cy.viewRoomByName(messageRoom);
cy.get(".mx_EventTile_body .mx_Pill .mx_Pill_linkText") cy.get(".mx_EventTile_body .mx_Pill .mx_Pill_text")
.should("have.css", "pointer-events", "none") .should("have.css", "pointer-events", "none")
.click({ force: true }); // force is to ensure we bypass pointer-events .click({ force: true }); // force is to ensure we bypass pointer-events
cy.url().should("contain", localUrl); cy.url().should("contain", localUrl);

View file

@ -29,6 +29,10 @@ limitations under the License.
color: $accent-fg-color !important; /* To override .markdown-body */ color: $accent-fg-color !important; /* To override .markdown-body */
background-color: $pill-bg-color !important; /* To override .markdown-body */ background-color: $pill-bg-color !important; /* To override .markdown-body */
> * {
pointer-events: none;
}
&.mx_UserPill_me, &.mx_UserPill_me,
&.mx_AtRoomPill { &.mx_AtRoomPill {
background-color: $alert !important; /* To override .markdown-body */ background-color: $alert !important; /* To override .markdown-body */
@ -55,12 +59,17 @@ limitations under the License.
min-width: $font-16px; /* ensure the avatar is not compressed */ min-width: $font-16px; /* ensure the avatar is not compressed */
} }
.mx_Pill_linkText { &.mx_EventPill .mx_BaseAvatar {
white-space: nowrap; /* enforce the pill text to be a single line */ /* Event pill avatars are inside the text. */
margin-inline-start: 0.2em;
margin-inline-end: 0.2em;
}
.mx_Pill_text {
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none; /* ensure clicks on the pills go through the anchor */
} }
a& { a& {
@ -69,4 +78,20 @@ limitations under the License.
overflow: hidden; overflow: hidden;
text-decoration: none !important; /* To override .markdown-body */ text-decoration: none !important; /* To override .markdown-body */
} }
.mx_Pill_LinkIcon {
background-color: $link-external;
box-sizing: border-box;
color: $background;
height: 16px;
padding: 1px;
width: 16px;
}
.mx_Pill_UserIcon {
box-sizing: border-box;
color: $secondary-content;
height: 16px;
width: 16px;
}
} }

View file

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_1123_14017" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.7093 21.9506C16.7941 23.2445 14.4853 24 12 24C9.29855 24 6.80558 23.1073 4.8 21.6009C1.88532 19.4116 0 15.926 0 12C0 5.37258 5.37258 0 12 0C18.6274 0 24 5.37258 24 12C24 16.1421 21.9013 19.7941 18.7093 21.9506ZM12 12.6C13.9882 12.6 15.6 10.8539 15.6 8.7C15.6 6.54609 13.9882 4.8 12 4.8C10.0118 4.8 8.4 6.54609 8.4 8.7C8.4 10.8539 10.0118 12.6 12 12.6ZM12 21.6C14.5944 21.6 16.9484 20.5709 18.6761 18.8986C17.6076 16.2607 15.0211 14.4 12 14.4C8.9789 14.4 6.39239 16.2607 5.32394 18.8986C7.05162 20.5709 9.40563 21.6 12 21.6Z"/>
</mask>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.7093 21.9506C16.7941 23.2445 14.4853 24 12 24C9.29855 24 6.80558 23.1073 4.8 21.6009C1.88532 19.4116 0 15.926 0 12C0 5.37258 5.37258 0 12 0C18.6274 0 24 5.37258 24 12C24 16.1421 21.9013 19.7941 18.7093 21.9506ZM12 12.6C13.9882 12.6 15.6 10.8539 15.6 8.7C15.6 6.54609 13.9882 4.8 12 4.8C10.0118 4.8 8.4 6.54609 8.4 8.7C8.4 10.8539 10.0118 12.6 12 12.6ZM12 21.6C14.5944 21.6 16.9484 20.5709 18.6761 18.8986C17.6076 16.2607 15.0211 14.4 12 14.4C8.9789 14.4 6.39239 16.2607 5.32394 18.8986C7.05162 20.5709 9.40563 21.6 12 21.6Z" fill="currentColor"/>
<path d="M18.7093 21.9506L19.5643 23.2161L18.7093 21.9506ZM4.8 21.6009L3.88276 22.822H3.88276L4.8 21.6009ZM18.6761 18.8986L19.7383 19.996L20.4782 19.2797L20.0916 18.3252L18.6761 18.8986ZM5.32394 18.8986L3.90838 18.3252L3.52177 19.2797L4.26173 19.996L5.32394 18.8986ZM12 25.5273C14.7995 25.5273 17.4045 24.6752 19.5643 23.2161L17.8543 20.6851C16.1837 21.8137 14.1711 22.4727 12 22.4727V25.5273ZM3.88276 22.822C6.14374 24.5203 8.95648 25.5273 12 25.5273V22.4727C9.64061 22.4727 7.46743 21.6943 5.71724 20.3797L3.88276 22.822ZM-1.52727 12C-1.52727 16.4266 0.600567 20.3567 3.88276 22.822L5.71724 20.3797C3.17008 18.4665 1.52727 15.4253 1.52727 12H-1.52727ZM12 -1.52727C4.52909 -1.52727 -1.52727 4.52909 -1.52727 12H1.52727C1.52727 6.21607 6.21607 1.52727 12 1.52727V-1.52727ZM25.5273 12C25.5273 4.52909 19.4709 -1.52727 12 -1.52727V1.52727C17.7839 1.52727 22.4727 6.21607 22.4727 12H25.5273ZM19.5643 23.2161C23.1587 20.7878 25.5273 16.6707 25.5273 12H22.4727C22.4727 15.6135 20.6439 18.8004 17.8543 20.6851L19.5643 23.2161ZM14.0727 8.7C14.0727 10.1278 13.0319 11.0727 12 11.0727V14.1273C14.9445 14.1273 17.1273 11.58 17.1273 8.7H14.0727ZM12 6.32727C13.0319 6.32727 14.0727 7.27217 14.0727 8.7H17.1273C17.1273 5.82 14.9445 3.27273 12 3.27273V6.32727ZM9.92727 8.7C9.92727 7.27217 10.9681 6.32727 12 6.32727V3.27273C9.05549 3.27273 6.87273 5.82 6.87273 8.7H9.92727ZM12 11.0727C10.9681 11.0727 9.92727 10.1278 9.92727 8.7H6.87273C6.87273 11.58 9.05549 14.1273 12 14.1273V11.0727ZM17.6138 17.8012C16.1595 19.2089 14.1822 20.0727 12 20.0727V23.1273C15.0065 23.1273 17.7372 21.9329 19.7383 19.996L17.6138 17.8012ZM12 15.9273C14.3779 15.9273 16.4175 17.3908 17.2605 19.4719L20.0916 18.3252C18.7977 15.1306 15.6643 12.8727 12 12.8727V15.9273ZM6.7395 19.4719C7.58245 17.3908 9.62215 15.9273 12 15.9273V12.8727C8.33566 12.8727 5.20233 15.1306 3.90838 18.3252L6.7395 19.4719ZM12 20.0727C9.81778 20.0727 7.84046 19.2089 6.38615 17.8012L4.26173 19.996C6.26278 21.9329 8.99347 23.1273 12 23.1273V20.0727Z" fill="currentColor" mask="url(#path-1-inside-1_1123_14017)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -90,6 +90,7 @@ $room-icon-unread-color: #fff;
$accent: #0dbd8b; $accent: #0dbd8b;
$alert: #ff5b55; $alert: #ff5b55;
$links: #0086e6; $links: #0086e6;
$link-external: #0467dd;
$primary-content: $primary-fg-color; $primary-content: $primary-fg-color;
$secondary-content: $secondary-fg-color; $secondary-content: $secondary-fg-color;
$tertiary-content: $tertiary-fg-color; $tertiary-content: $tertiary-fg-color;

View file

@ -149,6 +149,7 @@ $presence-busy: #ff5b55;
$accent: #0dbd8b; $accent: #0dbd8b;
$alert: #ff5b55; $alert: #ff5b55;
$links: #0086e6; $links: #0086e6;
$link-external: #0467dd;
$primary-content: $primary-fg-color; $primary-content: $primary-fg-color;
$secondary-content: $secondary-fg-color; $secondary-content: $secondary-fg-color;
$tertiary-content: $tertiary-fg-color; $tertiary-content: $tertiary-fg-color;

View file

@ -41,6 +41,7 @@ $space-nav: rgba($tertiary-content, 0.15);
$accent: #0dbd8b; $accent: #0dbd8b;
$alert: #ff5b55; $alert: #ff5b55;
$links: #0086e6; $links: #0086e6;
$link-external: #0467dd;
$username-variant1-color: #368bd6; $username-variant1-color: #368bd6;
$username-variant2-color: #ac3ba8; $username-variant2-color: #ac3ba8;

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ReactElement, useState } from "react"; import React, { ReactElement, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
@ -24,11 +25,16 @@ import Tooltip, { Alignment } from "../elements/Tooltip";
import { usePermalink } from "../../../hooks/usePermalink"; import { usePermalink } from "../../../hooks/usePermalink";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from "../../../languageHandler";
import { Icon as LinkIcon } from "../../../../res/img/element-icons/room/composer/link.svg";
import { Icon as UserIcon } from "../../../../res/img/compound/user.svg";
export enum PillType { export enum PillType {
UserMention = "TYPE_USER_MENTION", UserMention = "TYPE_USER_MENTION",
RoomMention = "TYPE_ROOM_MENTION", RoomMention = "TYPE_ROOM_MENTION",
AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention
EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM",
EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM",
} }
export const pillRoomNotifPos = (text: string): number => { export const pillRoomNotifPos = (text: string): number => {
@ -39,6 +45,34 @@ export const pillRoomNotifLen = (): number => {
return "@room".length; return "@room".length;
}; };
const PillRoomAvatar: React.FC<{
shouldShowPillAvatar: boolean;
room: Room | null;
}> = ({ shouldShowPillAvatar, room }) => {
if (!shouldShowPillAvatar) {
return null;
}
if (room) {
return <RoomAvatar room={room} width={16} height={16} aria-hidden="true" />;
}
return <LinkIcon className="mx_Pill_LinkIcon mx_BaseAvatar mx_BaseAvatar_image" />;
};
const PillMemberAvatar: React.FC<{
shouldShowPillAvatar: boolean;
member: RoomMember | null;
}> = ({ shouldShowPillAvatar, member }) => {
if (!shouldShowPillAvatar) {
return null;
}
if (member) {
return <MemberAvatar member={member} width={16} height={16} aria-hidden="true" hideTitle />;
}
return <UserIcon className="mx_Pill_UserIcon mx_BaseAvatar mx_BaseAvatar_image" />;
};
export interface PillProps { export interface PillProps {
// The Type of this Pill. If url is given, this is auto-detected. // The Type of this Pill. If url is given, this is auto-detected.
type?: PillType; type?: PillType;
@ -52,7 +86,7 @@ export interface PillProps {
shouldShowPillAvatar?: boolean; shouldShowPillAvatar?: boolean;
} }
export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room, shouldShowPillAvatar }) => { export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => {
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
const { member, onClick, resourceId, targetRoom, text, type } = usePermalink({ const { member, onClick, resourceId, targetRoom, text, type } = usePermalink({
room, room,
@ -70,6 +104,7 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
mx_SpacePill: type === "space", mx_SpacePill: type === "space",
mx_UserPill: type === PillType.UserMention, mx_UserPill: type === PillType.UserMention,
mx_UserPill_me: resourceId === MatrixClientPeg.get().getUserId(), mx_UserPill_me: resourceId === MatrixClientPeg.get().getUserId(),
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
}); });
const onMouseOver = (): void => { const onMouseOver = (): void => {
@ -81,28 +116,40 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
}; };
const tip = hover && resourceId ? <Tooltip label={resourceId} alignment={Alignment.Right} /> : null; const tip = hover && resourceId ? <Tooltip label={resourceId} alignment={Alignment.Right} /> : null;
let avatar: ReactElement | null = null; let content: (ReactElement | string)[] = [];
const textElement = <span className="mx_Pill_text">{text}</span>;
switch (type) { switch (type) {
case PillType.EventInOtherRoom:
{
const avatar = <PillRoomAvatar shouldShowPillAvatar={shouldShowPillAvatar} room={targetRoom} />;
content = [_t("Message in"), avatar || " ", textElement];
}
break;
case PillType.EventInSameRoom:
{
const avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />;
content = [_t("Message from"), avatar || " ", textElement];
}
break;
case PillType.AtRoomMention: case PillType.AtRoomMention:
case PillType.RoomMention: case PillType.RoomMention:
case "space": case "space":
avatar = targetRoom ? <RoomAvatar room={targetRoom} width={16} height={16} aria-hidden="true" /> : null; {
const avatar = <PillRoomAvatar shouldShowPillAvatar={shouldShowPillAvatar} room={targetRoom} />;
content = [avatar, textElement];
}
break; break;
case PillType.UserMention: case PillType.UserMention:
avatar = <MemberAvatar member={member} width={16} height={16} aria-hidden="true" hideTitle />; {
const avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />;
content = [avatar, textElement];
}
break; break;
default: default:
return null; return null;
} }
const content = (
<>
{shouldShowPillAvatar && avatar}
<span className="mx_Pill_linkText">{text}</span>
</>
);
return ( return (
<bdi> <bdi>
<MatrixClientContext.Provider value={MatrixClientPeg.get()}> <MatrixClientContext.Provider value={MatrixClientPeg.get()}>

View file

@ -14,16 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { logger } from "matrix-js-sdk/src/logger"; import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { useCallback, useMemo, useState } from "react";
import { ButtonEvent } from "../components/views/elements/AccessibleButton"; import { ButtonEvent } from "../components/views/elements/AccessibleButton";
import { PillType } from "../components/views/elements/Pill"; import { PillType } from "../components/views/elements/Pill";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { parsePermalink } from "../utils/permalinks/Permalinks"; import { parsePermalink } from "../utils/permalinks/Permalinks";
import dis from "../dispatcher/dispatcher"; import dis from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
import { _t } from "../languageHandler";
import { usePermalinkTargetRoom } from "./usePermalinkTargetRoom";
import { usePermalinkEvent } from "./usePermalinkEvent";
import { usePermalinkMember } from "./usePermalinkMember";
interface Args { interface Args {
/** Room in which the permalink should be displayed. */ /** Room in which the permalink should be displayed. */
@ -71,97 +73,77 @@ interface HookResult {
} }
/** /**
* Can be used to retrieve all information to display a permalink. * Tries to determine the pill type.
*
* If forcedType is present it will be returned.
* If the parse result contains a room Id or alias and an event Id:
* - Type is EventInSameRoom if the permalink room Id or alias equals the parsed room Id or alias
* - Type is EventInOtherRoom if the permalink room Id or alias not equals the parsed room Id or alias
* If the parse result contains a primary entity Id it will try to detect the type from it.
* Otherwise returns null.
*
* @param forcedType - Forced pill type. Will be used if present and short-circuits all othe conditions.
* @param parseResult - Permalink parser result
* @param permalinkRoom - Room in which the permalink is displayed.
* @returns Pill type or null if unable to determine.
*/ */
export const usePermalink: (args: Args) => HookResult = ({ room, type: argType, url }): HookResult => { const determineType = (
const [member, setMember] = useState<RoomMember | null>(null); forcedType: PillType | undefined,
// room of the entity this pill points to parseResult: PermalinkParts | null,
const [targetRoom, setTargetRoom] = useState<Room | null>(room ?? null); permalinkRoom: Room | undefined,
): PillType | null => {
if (forcedType) return forcedType;
if (parseResult?.roomIdOrAlias && parseResult?.eventId) {
if (parseResult.roomIdOrAlias === permalinkRoom?.roomId) {
return PillType.EventInSameRoom;
}
return PillType.EventInOtherRoom;
}
if (parseResult?.primaryEntityId) {
const prefix = parseResult.primaryEntityId[0] || "";
return (
{
"@": PillType.UserMention,
"#": PillType.RoomMention,
"!": PillType.RoomMention,
}[prefix] || null
);
}
return null;
};
/**
* Can be used to retrieve all information needed to display a permalink.
*/
export const usePermalink: (args: Args) => HookResult = ({
room: permalinkRoom,
type: forcedType,
url,
}): HookResult => {
let resourceId: string | null = null; let resourceId: string | null = null;
let parseResult: PermalinkParts | null = null;
if (url) { if (url) {
const parseResult = parsePermalink(url); parseResult = parsePermalink(url);
if (parseResult?.primaryEntityId) { if (parseResult?.primaryEntityId) {
resourceId = parseResult.primaryEntityId; resourceId = parseResult.primaryEntityId;
} }
} }
const prefix = resourceId ? resourceId[0] : "";
const type =
argType ||
// try to detect the permalink type from the URL prefix
{
"@": PillType.UserMention,
"#": PillType.RoomMention,
"!": PillType.RoomMention,
}[prefix] ||
null;
const doProfileLookup = useCallback((userId: string, member: RoomMember): void => { const type = determineType(forcedType, parseResult, permalinkRoom);
MatrixClientPeg.get() const targetRoom = usePermalinkTargetRoom(type, parseResult, permalinkRoom);
.getProfileInfo(userId) const event = usePermalinkEvent(type, parseResult, targetRoom);
.then((resp) => { const member = usePermalinkMember(type, parseResult, targetRoom, event);
const newMember = new RoomMember(member.roomId, userId);
newMember.name = resp.displayname || userId;
newMember.rawDisplayName = resp.displayname || userId;
newMember.getMxcAvatarUrl();
newMember.events.member = {
getContent: () => {
return { avatar_url: resp.avatar_url };
},
getDirectionalContent: function () {
// eslint-disable-next-line
return this.getContent();
},
} as MatrixEvent;
setMember(newMember);
})
.catch((err) => {
logger.error("Could not retrieve profile data for " + userId + ":", err);
});
}, []);
useMemo(() => {
switch (type) {
case PillType.UserMention:
{
if (resourceId) {
let member = room?.getMember(resourceId) || null;
setMember(member);
if (!member) {
member = new RoomMember("", resourceId);
doProfileLookup(resourceId, member);
}
}
}
break;
case PillType.RoomMention:
{
if (resourceId) {
const newRoom =
resourceId[0] === "#"
? MatrixClientPeg.get()
.getRooms()
.find((r) => {
return (
r.getCanonicalAlias() === resourceId ||
(resourceId && r.getAltAliases().includes(resourceId))
);
})
: MatrixClientPeg.get().getRoom(resourceId);
setTargetRoom(newRoom || null);
}
}
break;
}
}, [doProfileLookup, type, resourceId, room]);
let onClick: (e: ButtonEvent) => void = () => {}; let onClick: (e: ButtonEvent) => void = () => {};
let text = resourceId; let text = resourceId;
if (type === PillType.AtRoomMention && room) { if (type === PillType.AtRoomMention && permalinkRoom) {
text = "@room"; text = "@room";
} else if (type === PillType.UserMention && member) { } else if (type === PillType.UserMention && member) {
text = member.name || resourceId; text = member.name || resourceId;
@ -177,6 +159,10 @@ export const usePermalink: (args: Args) => HookResult = ({ room, type: argType,
if (targetRoom) { if (targetRoom) {
text = targetRoom.name || resourceId; text = targetRoom.name || resourceId;
} }
} else if (type === PillType.EventInSameRoom) {
text = member?.name || _t("User");
} else if (type === PillType.EventInOtherRoom) {
text = targetRoom?.name || _t("Room");
} }
return { return {

View file

@ -0,0 +1,89 @@
/*
Copyright 2023 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 { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { useEffect, useState } from "react";
import { PillType } from "../components/views/elements/Pill";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
/**
* Tries to find the initial event.
* If the event should not be looked up or there is no target room it returns null.
* Otherwise it tries to get the event from the target room.
*
* @param shouldLookUpEvent - whether the parmalink event should be looked up
* @param Room | null targetRoom - target room of the permalink
* @param parseResult - permalink parse result
* @returns The event if found or null if it should not be looked up or was not found.
*/
const determineInitialEvent = (
shouldLookUpEvent: boolean,
targetRoom: Room | null,
parseResult: PermalinkParts | null,
): MatrixEvent | null => {
if (!shouldLookUpEvent || !targetRoom || !parseResult?.eventId) return null;
return targetRoom.findEventById(parseResult.eventId) || null;
};
/**
* Hook to get a permalink target event
*
* @param type - Permalink type
* @param parseResult - Permalink parse result
* @param targetRoom - Target room of the permalink {@link ./usePermalinkTargetRoom.ts}
* @returns The permalink event if it targets an event and it can be loaded.
* Else null.
*/
export const usePermalinkEvent = (
type: PillType | null,
parseResult: PermalinkParts | null,
targetRoom: Room | null,
): MatrixEvent | null => {
// Event permalinks require to know the event.
// If it cannot be initially determined, it will be looked up later by a memo hook.
const shouldLookUpEvent =
!!type &&
!!parseResult?.roomIdOrAlias &&
!!parseResult?.eventId &&
[PillType.EventInSameRoom, PillType.EventInOtherRoom].includes(type);
const eventId = parseResult?.eventId;
const eventInRoom = determineInitialEvent(shouldLookUpEvent, targetRoom, parseResult);
const [event, setEvent] = useState<MatrixEvent | null>(eventInRoom);
useEffect(() => {
if (!shouldLookUpEvent || !eventId || event || !parseResult?.roomIdOrAlias || !parseResult.eventId) {
// nothing to do here
return;
}
const fetchRoomEvent = async (): Promise<void> => {
try {
const eventData = await MatrixClientPeg.get().fetchRoomEvent(
parseResult.roomIdOrAlias,
parseResult.eventId,
);
setEvent(new MatrixEvent(eventData));
} catch {}
};
fetchRoomEvent();
}, [event, eventId, parseResult?.eventId, parseResult?.roomIdOrAlias, shouldLookUpEvent]);
return event;
};

View file

@ -0,0 +1,111 @@
/*
Copyright 2023 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 { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { useEffect, useState } from "react";
import { PillType } from "../components/views/elements/Pill";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
/**
* Tries to determine the user Id of a permalink.
* In case of a user permalink it is the user id.
* In case of an event permalink it is the sender user Id of the event if that event is available.
* Otherwise returns null.
*
* @param type - pill type
* @param parseResult - permalink parse result
* @param event - permalink event, if available
* @returns permalink user Id. null if the Id cannot be determined.
*/
const determineUserId = (
type: PillType | null,
parseResult: PermalinkParts | null,
event: MatrixEvent | null,
): string | null => {
if (type === null) return null;
if (parseResult?.userId) return parseResult.userId;
if (event && [PillType.EventInSameRoom, PillType.EventInOtherRoom].includes(type)) {
return event.getSender() ?? null;
}
return null;
};
/**
* Hook to get the permalink member
*
* @param type - Permalink type
* @param parseResult - Permalink parse result
* @param targetRoom - Permalink target room {@link ./usePermalinkTargetRoom.ts}
* @param event - Permalink event
* @returns The permalink member:
* - The room member for a user mention
* - The sender for a permalink to an event in the same room
* - Null in other cases or the user cannot be loaded.
*/
export const usePermalinkMember = (
type: PillType | null,
parseResult: PermalinkParts | null,
targetRoom: Room | null,
event: MatrixEvent | null,
): RoomMember | null => {
// User mentions and permalinks to events in the same room require to know the user.
// If it cannot be initially determined, it will be looked up later by a memo hook.
const shouldLookUpUser = type && [PillType.UserMention, PillType.EventInSameRoom].includes(type);
const userId = determineUserId(type, parseResult, event);
const userInRoom = shouldLookUpUser && userId && targetRoom ? targetRoom.getMember(userId) : null;
const [member, setMember] = useState<RoomMember | null>(userInRoom);
useEffect(() => {
if (!shouldLookUpUser || !userId || member) {
// nothing to do here
return;
}
const doProfileLookup = (userId: string): void => {
MatrixClientPeg.get()
.getProfileInfo(userId)
.then((resp) => {
const newMember = new RoomMember("", userId);
newMember.name = resp.displayname || userId;
newMember.rawDisplayName = resp.displayname || userId;
newMember.getMxcAvatarUrl();
newMember.events.member = {
getContent: () => {
return { avatar_url: resp.avatar_url };
},
getDirectionalContent: function () {
// eslint-disable-next-line
return this.getContent();
},
} as MatrixEvent;
setMember(newMember);
})
.catch((err) => {
logger.error("Could not retrieve profile data for " + userId + ":", err);
});
};
doProfileLookup(userId);
}, [member, shouldLookUpUser, targetRoom, userId]);
return member;
};

View file

@ -0,0 +1,103 @@
/*
Copyright 2023 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 { Room } from "matrix-js-sdk/src/matrix";
import { useEffect, useState } from "react";
import { PillType } from "../components/views/elements/Pill";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
/**
* Tries to determine the initial room.
* Initial here means it should be possible to load the room without sending API requests.
* For an @room or a user mention it is the permalinkRoom.
* If the parse result contains a room Id or alias try to find it with {@link findRoom}.
* Otherwise returns null.
*
* @param type - Pill type
* @param permalinkRoom - Room in which the permalink is displayed.
* @param parseResult - Permalink parser result
* @returns Initial room or null if it cannot be determined.
*/
const determineInitialRoom = (
type: PillType | null,
parseResult: PermalinkParts | null,
permalinkRoom: Room | undefined,
): Room | null => {
if (type === PillType.AtRoomMention && permalinkRoom) return permalinkRoom;
if (type === PillType.UserMention && permalinkRoom) {
return permalinkRoom;
}
if (parseResult?.roomIdOrAlias) {
const room = findRoom(parseResult.roomIdOrAlias);
if (room) return room;
}
return null;
};
/**
* Tries to find a room by room Id or searching all rooms for an alias.
*
* @param roomIdOrAlias - Id or alias of the room to find.
* @returns Room if found, else null.
*/
const findRoom = (roomIdOrAlias: string): Room | null => {
const client = MatrixClientPeg.get();
return roomIdOrAlias[0] === "#"
? client.getRooms().find((r) => {
return r.getCanonicalAlias() === roomIdOrAlias || r.getAltAliases().includes(roomIdOrAlias);
}) ?? null
: client.getRoom(roomIdOrAlias);
};
/**
* Hook to get the permalink target room:
*
* @param type - Permalink type
* @param parseResult - Permalink parse result
* @param permalinkRoom - Room in which the permalink is rendered
* @returns Returns the target room:
* - The permalinkRoom for an @room or user mention
* - The room of the parse result for a room mention
* - The room of the event for an event permalink
* - Null in other cases or if the room cannot be found
*/
export const usePermalinkTargetRoom = (
type: PillType | null,
parseResult: PermalinkParts | null,
permalinkRoom: Room | null,
): Room | null => {
// The listed permalink types require a room.
// If it cannot be initially determined, it will be looked up later by a memo hook.
const shouldLookUpRoom =
type && [PillType.RoomMention, PillType.EventInSameRoom, PillType.EventInOtherRoom, "space"].includes(type);
const initialRoom = determineInitialRoom(type, parseResult, permalinkRoom);
const [targetRoom, setTargetRoom] = useState<Room | null>(initialRoom);
useEffect(() => {
if (shouldLookUpRoom && !targetRoom && parseResult?.roomIdOrAlias) {
const newRoom = findRoom(parseResult.roomIdOrAlias);
setTargetRoom(newRoom);
}
}, [parseResult?.roomIdOrAlias, shouldLookUpRoom, targetRoom]);
return targetRoom;
};

View file

@ -1001,7 +1001,7 @@
"Expand code blocks by default": "Expand code blocks by default", "Expand code blocks by default": "Expand code blocks by default",
"Show line numbers in code blocks": "Show line numbers in code blocks", "Show line numbers in code blocks": "Show line numbers in code blocks",
"Jump to the bottom of the timeline when you send a message": "Jump to the bottom of the timeline when you send a message", "Jump to the bottom of the timeline when you send a message": "Jump to the bottom of the timeline when you send a message",
"Show avatars in user and room mentions": "Show avatars in user and room mentions", "Show avatars in user, room and event mentions": "Show avatars in user, room and event mentions",
"Enable big emoji in chat": "Enable big emoji in chat", "Enable big emoji in chat": "Enable big emoji in chat",
"Send typing notifications": "Send typing notifications", "Send typing notifications": "Send typing notifications",
"Show typing notifications": "Show typing notifications", "Show typing notifications": "Show typing notifications",
@ -1078,6 +1078,8 @@
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Connecting": "Connecting", "Connecting": "Connecting",
"Sorry — this call is currently full": "Sorry — this call is currently full", "Sorry — this call is currently full": "Sorry — this call is currently full",
"User": "User",
"Room": "Room",
"Create account": "Create account", "Create account": "Create account",
"You made it!": "You made it!", "You made it!": "You made it!",
"Find and invite your friends": "Find and invite your friends", "Find and invite your friends": "Find and invite your friends",
@ -2590,6 +2592,8 @@
"Rotate Right": "Rotate Right", "Rotate Right": "Rotate Right",
"Information": "Information", "Information": "Information",
"Language Dropdown": "Language Dropdown", "Language Dropdown": "Language Dropdown",
"Message in": "Message in",
"Message from": "Message from",
"Create poll": "Create poll", "Create poll": "Create poll",
"Create Poll": "Create Poll", "Create Poll": "Create Poll",
"Edit poll": "Edit poll", "Edit poll": "Edit poll",
@ -2789,7 +2793,6 @@
"You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number", "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number": "You will be removed from the identity server: your friends will no longer be able to find you with your email or phone number",
"Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?", "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?": "Your old messages will still be visible to people who received them, just like emails you sent in the past. Would you like to hide your sent messages from people who join rooms in the future?",
"Hide my messages from new joiners": "Hide my messages from new joiners", "Hide my messages from new joiners": "Hide my messages from new joiners",
"Room": "Room",
"Send custom timeline event": "Send custom timeline event", "Send custom timeline event": "Send custom timeline event",
"Explore room state": "Explore room state", "Explore room state": "Explore room state",
"Explore room account data": "Explore room account data", "Explore room account data": "Explore room account data",

View file

@ -618,7 +618,7 @@ export const SETTINGS: { [setting: string]: ISetting } = {
}, },
"Pill.shouldShowPillAvatar": { "Pill.shouldShowPillAvatar": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Show avatars in user and room mentions"), displayName: _td("Show avatars in user, room and event mentions"),
default: true, default: true,
invertedSettingName: "Pill.shouldHidePillAvatar", invertedSettingName: "Pill.shouldHidePillAvatar",
}, },

View file

@ -28,8 +28,6 @@ import { PermalinkParts } from "./permalinks/PermalinkConstructor";
/** /**
* A node here is an A element with a href attribute tag. * A node here is an A element with a href attribute tag.
* *
* It should not be pillified if the permalink parser result contains an event Id.
*
* It should be pillified if the permalink parser returns a result and one of the following conditions match: * It should be pillified if the permalink parser returns a result and one of the following conditions match:
* - Text content equals href. This is the case when sending a plain permalink inside a message. * - Text content equals href. This is the case when sending a plain permalink inside a message.
* - The link does not have the "linkified" class. * - The link does not have the "linkified" class.
@ -37,9 +35,14 @@ import { PermalinkParts } from "./permalinks/PermalinkConstructor";
* Linkify will not linkify things again. There won't be a "linkified" class. * Linkify will not linkify things again. There won't be a "linkified" class.
*/ */
const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null): boolean => { const shouldBePillified = (node: Element, href: string, parts: PermalinkParts | null): boolean => {
if (!parts || parts.eventId) return false; // permalink parser didn't return any parts
if (!parts) return false;
const textContent = node.textContent; const textContent = node.textContent;
// event permalink with custom label
if (parts.eventId && href !== textContent) return false;
return href === textContent || !node.classList.contains("linkified"); return href === textContent || !node.classList.contains("linkified");
}; };

View file

@ -18,13 +18,14 @@ import React from "react";
import { act, render, RenderResult, screen } from "@testing-library/react"; import { act, render, RenderResult, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { mocked, Mocked } from "jest-mock"; import { mocked, Mocked } from "jest-mock";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import dis from "../../../../src/dispatcher/dispatcher"; import dis from "../../../../src/dispatcher/dispatcher";
import { Pill, PillProps, PillType } from "../../../../src/components/views/elements/Pill"; import { Pill, PillProps, PillType } from "../../../../src/components/views/elements/Pill";
import { import {
filterConsole, filterConsole,
flushPromises, flushPromises,
mkMessage,
mkRoomCanonicalAliasEvent, mkRoomCanonicalAliasEvent,
mkRoomMemberJoinEvent, mkRoomMemberJoinEvent,
stubClient, stubClient,
@ -39,6 +40,9 @@ describe("<Pill>", () => {
const room1Alias = "#room1:example.com"; const room1Alias = "#room1:example.com";
const room1Id = "!room1:example.com"; const room1Id = "!room1:example.com";
let room1: Room; let room1: Room;
let room1Message: MatrixEvent;
const room2Id = "!room2:example.com";
let room2: Room;
const space1Id = "!space1:example.com"; const space1Id = "!space1:example.com";
let space1: Room; let space1: Room;
const user1Id = "@user1:example.com"; const user1Id = "@user1:example.com";
@ -63,21 +67,33 @@ describe("<Pill>", () => {
filterConsole( filterConsole(
"Failed to parse permalink Error: Unknown entity type in permalink", "Failed to parse permalink Error: Unknown entity type in permalink",
"Room !room1:example.com does not have an m.room.create event", "Room !room1:example.com does not have an m.room.create event",
"Room !space1:example.com does not have an m.room.create event",
); );
beforeEach(() => { beforeEach(() => {
client = mocked(stubClient()); client = mocked(stubClient());
DMRoomMap.makeShared(); DMRoomMap.makeShared();
room1 = new Room(room1Id, client, client.getSafeUserId()); room1 = new Room(room1Id, client, user1Id);
room1.name = "Room 1"; room1.name = "Room 1";
const user1JoinRoom1Event = mkRoomMemberJoinEvent(user1Id, room1Id, { const user1JoinRoom1Event = mkRoomMemberJoinEvent(user1Id, room1Id, {
displayname: "User 1", displayname: "User 1",
}); });
room1.currentState.setStateEvents([ room1.currentState.setStateEvents([
mkRoomCanonicalAliasEvent(client.getSafeUserId(), room1Id, room1Alias), mkRoomCanonicalAliasEvent(user1Id, room1Id, room1Alias),
user1JoinRoom1Event, user1JoinRoom1Event,
]); ]);
room1.getMember(user1Id)!.setMembershipEvent(user1JoinRoom1Event); room1.getMember(user1Id)!.setMembershipEvent(user1JoinRoom1Event);
room1Message = mkMessage({
id: "$123-456",
event: true,
user: user1Id,
room: room1Id,
msg: "Room 1 Message",
});
room1.addLiveEvents([room1Message]);
room2 = new Room(room2Id, client, user1Id);
room2.name = "Room 2";
space1 = new Room(space1Id, client, client.getSafeUserId()); space1 = new Room(space1Id, client, client.getSafeUserId());
space1.name = "Space 1"; space1.name = "Space 1";
@ -85,6 +101,7 @@ describe("<Pill>", () => {
client.getRooms.mockReturnValue([room1, space1]); client.getRooms.mockReturnValue([room1, space1]);
client.getRoom.mockImplementation((roomId: string) => { client.getRoom.mockImplementation((roomId: string) => {
if (roomId === room1.roomId) return room1; if (roomId === room1.roomId) return room1;
if (roomId === room2.roomId) return room2;
if (roomId === space1.roomId) return space1; if (roomId === space1.roomId) return space1;
return null; return null;
}); });
@ -220,4 +237,29 @@ describe("<Pill>", () => {
}); });
expect(renderResult.asFragment()).toMatchSnapshot(); expect(renderResult.asFragment()).toMatchSnapshot();
}); });
it("should render the expected pill for a message in the same room", () => {
renderPill({
room: room1,
url: `${permalinkPrefix}${room1Id}/${room1Message.getId()}`,
});
expect(renderResult.asFragment()).toMatchSnapshot();
});
it("should render the expected pill for a message in another room", () => {
renderPill({
room: room2,
url: `${permalinkPrefix}${room1Id}/${room1Message.getId()}`,
});
expect(renderResult.asFragment()).toMatchSnapshot();
});
it("should not render a pill with an unknown type", () => {
// @ts-ignore
renderPill({ type: "unknown" });
expect(renderResult.asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div />
</DocumentFragment>
`);
});
}); });

View file

@ -14,7 +14,7 @@ exports[`<Pill> should not render an avatar or link when called with inMessage =
class="mx_Pill mx_RoomPill" class="mx_Pill mx_RoomPill"
> >
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
Room 1 Room 1
</span> </span>
@ -53,7 +53,7 @@ exports[`<Pill> should render the expected pill for @room 1`] = `
/> />
</span> </span>
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
@room @room
</span> </span>
@ -63,6 +63,88 @@ exports[`<Pill> should render the expected pill for @room 1`] = `
</DocumentFragment> </DocumentFragment>
`; `;
exports[`<Pill> should render the expected pill for a message in another room 1`] = `
<DocumentFragment>
<div>
<bdi>
<a
class="mx_Pill mx_EventPill"
href="https://matrix.to/#/!room1:example.com/$123-456"
>
Message in
<span
aria-hidden="true"
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 10.4px; width: 16px; line-height: 16px;"
>
R
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 16px; height: 16px;"
/>
</span>
<span
class="mx_Pill_text"
>
Room 1
</span>
</a>
</bdi>
</div>
</DocumentFragment>
`;
exports[`<Pill> should render the expected pill for a message in the same room 1`] = `
<DocumentFragment>
<div>
<bdi>
<a
class="mx_Pill mx_EventPill"
href="https://matrix.to/#/!room1:example.com/$123-456"
>
Message from
<span
aria-hidden="true"
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 10.4px; width: 16px; line-height: 16px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 16px; height: 16px;"
/>
</span>
<span
class="mx_Pill_text"
>
User 1
</span>
</a>
</bdi>
</div>
</DocumentFragment>
`;
exports[`<Pill> should render the expected pill for a room alias 1`] = ` exports[`<Pill> should render the expected pill for a room alias 1`] = `
<DocumentFragment> <DocumentFragment>
<div> <div>
@ -93,7 +175,7 @@ exports[`<Pill> should render the expected pill for a room alias 1`] = `
/> />
</span> </span>
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
Room 1 Room 1
</span> </span>
@ -133,7 +215,7 @@ exports[`<Pill> should render the expected pill for a space 1`] = `
/> />
</span> </span>
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
Space 1 Space 1
</span> </span>
@ -173,7 +255,7 @@ exports[`<Pill> should render the expected pill for a user not in the room 1`] =
/> />
</span> </span>
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
User 2 User 2
</span> </span>
@ -213,7 +295,7 @@ exports[`<Pill> when rendering a pill for a room should render the expected pill
/> />
</span> </span>
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
Room 1 Room 1
</span> </span>
@ -253,7 +335,7 @@ exports[`<Pill> when rendering a pill for a user in the room should render as ex
/> />
</span> </span>
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
User 1 User 1
</span> </span>

View file

@ -63,6 +63,9 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s: string) => s, mxcUrlToHttp: (s: string) => s,
getUserId: () => "@user:example.com", getUserId: () => "@user:example.com",
fetchRoomEvent: () => {
throw new Error("MockClient event not found");
},
}); });
}); });
@ -172,7 +175,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Chat with <span><bdi><a class="mx_Pill mx_UserPill mx_UserPill_me" href="https://matrix.to/#/@user:example.com"><img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" style="width: 16px; height: 16px;" alt="" data-testid="avatar-img" aria-hidden="true"><span class="mx_Pill_linkText">Member</span></a></bdi></span>"`, `"Chat with <span><bdi><a class="mx_Pill mx_UserPill mx_UserPill_me" href="https://matrix.to/#/@user:example.com"><img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" style="width: 16px; height: 16px;" alt="" data-testid="avatar-img" aria-hidden="true"><span class="mx_Pill_text">Member</span></a></bdi></span>"`,
); );
}); });
@ -190,7 +193,7 @@ describe("<TextualBody />", () => {
const { container } = getComponent({ mxEvent: ev }); const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot( expect(content.innerHTML).toMatchInlineSnapshot(
`"Visit <span><bdi><a class="mx_Pill mx_RoomPill" href="https://matrix.to/#/#room:example.com"><span class="mx_Pill_linkText">#room:example.com</span></a></bdi></span>"`, `"Visit <span><bdi><a class="mx_Pill mx_RoomPill" href="https://matrix.to/#/#room:example.com"><div class="mx_Pill_LinkIcon mx_BaseAvatar mx_BaseAvatar_image"></div><span class="mx_Pill_text">#room:example.com</span></a></bdi></span>"`,
); );
}); });
}); });
@ -275,23 +278,26 @@ describe("<TextualBody />", () => {
expect(content).toMatchSnapshot(); expect(content).toMatchSnapshot();
}); });
it("pills do not appear for event permalinks", () => { it("pills do not appear for event permalinks with a custom label", () => {
const ev = mkFormattedMessage( const ev = mkFormattedMessage(
"An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" + "An [event link](https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/" +
"$16085560162aNpaH:example.com?via=example.com) with text", "$16085560162aNpaH:example.com?via=example.com) with text",
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' + 'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' +
'$16085560162aNpaH:example.com?via=example.com">event link</a> with text', '$16085560162aNpaH:example.com?via=example.com">event link</a> with text',
); );
const { container } = getComponent({ mxEvent: ev }, matrixClient); const { asFragment, container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("An event link with text"); expect(container).toHaveTextContent("An event link with text");
const content = container.querySelector(".mx_EventTile_body"); expect(asFragment()).toMatchSnapshot();
expect(content).toContainHTML( });
'<span class="mx_EventTile_body markdown-body" dir="auto">' +
'An <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/' + it("pills appear for event permalinks without a custom label", () => {
'$16085560162aNpaH:example.com?via=example.com" ' + const ev = mkFormattedMessage(
'rel="noreferrer noopener">event link</a> with text</span>', "See this message https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com",
'See this message <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com">' +
"https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com</a>",
); );
const { asFragment } = getComponent({ mxEvent: ev }, matrixClient);
expect(asFragment()).toMatchSnapshot();
}); });
it("pills appear for room links with vias", () => { it("pills appear for room links with vias", () => {
@ -301,19 +307,9 @@ describe("<TextualBody />", () => {
'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' + 'A <a href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
'?via=example.com&amp;via=bob.com">room link</a> with vias', '?via=example.com&amp;via=bob.com">room link</a> with vias',
); );
const { container } = getComponent({ mxEvent: ev }, matrixClient); const { asFragment, container } = getComponent({ mxEvent: ev }, matrixClient);
expect(container).toHaveTextContent("A room name with vias"); expect(container).toHaveTextContent("A room name with vias");
const content = container.querySelector(".mx_EventTile_body"); expect(asFragment()).toMatchSnapshot();
expect(content).toContainHTML(
'<span class="mx_EventTile_body markdown-body" dir="auto">' +
'A <span><bdi><a class="mx_Pill mx_RoomPill" ' +
'href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com' +
'?via=example.com&amp;via=bob.com"' +
'><img class="mx_BaseAvatar mx_BaseAvatar_image" ' +
'src="mxc://avatar.url/room.png" ' +
'style="width: 16px; height: 16px;" alt="" data-testid="avatar-img" aria-hidden="true">' +
'<span class="mx_Pill_linkText">room name</span></a></bdi></span> with vias</span>',
);
}); });
it("pills appear for an MXID permalink", () => { it("pills appear for an MXID permalink", () => {

View file

@ -62,7 +62,7 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for an
style="width: 16px; height: 16px;" style="width: 16px; height: 16px;"
/> />
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
Member Member
</span> </span>
@ -72,6 +72,104 @@ exports[`<TextualBody /> renders formatted m.text correctly pills appear for an
</span> </span>
`; `;
exports[`<TextualBody /> renders formatted m.text correctly pills appear for event permalinks without a custom label 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
See this message
<span>
<bdi>
<a
class="mx_Pill mx_EventPill"
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com"
>
Message in
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar mx_BaseAvatar_image"
data-testid="avatar-img"
src="mxc://avatar.url/room.png"
style="width: 16px; height: 16px;"
/>
<span
class="mx_Pill_text"
>
room name
</span>
</a>
</bdi>
</span>
</span>
</div>
</DocumentFragment>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills appear for room links with vias 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
A
<span>
<bdi>
<a
class="mx_Pill mx_RoomPill"
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com?via=example.com&via=bob.com"
>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar mx_BaseAvatar_image"
data-testid="avatar-img"
src="mxc://avatar.url/room.png"
style="width: 16px; height: 16px;"
/>
<span
class="mx_Pill_text"
>
room name
</span>
</a>
</bdi>
</span>
with vias
</span>
</div>
</DocumentFragment>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear for event permalinks with a custom label 1`] = `
<DocumentFragment>
<div
class="mx_MTextBody mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
An
<a
href="https://matrix.to/#/!ZxbRYPQXDXKGmDnJNg:example.com/$16085560162aNpaH:example.com?via=example.com"
rel="noreferrer noopener"
>
event link
</a>
with text
</span>
</div>
</DocumentFragment>
`;
exports[`<TextualBody /> renders formatted m.text correctly pills do not appear in code blocks 1`] = ` exports[`<TextualBody /> renders formatted m.text correctly pills do not appear in code blocks 1`] = `
<span <span
class="mx_EventTile_body markdown-body" class="mx_EventTile_body markdown-body"
@ -133,7 +231,7 @@ exports[`<TextualBody /> renders formatted m.text correctly pills get injected c
style="width: 16px; height: 16px;" style="width: 16px; height: 16px;"
/> />
<span <span
class="mx_Pill_linkText" class="mx_Pill_text"
> >
Member Member
</span> </span>

View file

@ -255,6 +255,8 @@ type MakeEventPassThruProps = {
skey?: string; skey?: string;
}; };
type MakeEventProps = MakeEventPassThruProps & { type MakeEventProps = MakeEventPassThruProps & {
/** If provided will be used as event Id. Else an Id is generated. */
id?: string;
type: string; type: string;
redacts?: string; redacts?: string;
content: IContent; content: IContent;
@ -301,7 +303,7 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent {
sender: opts.user, sender: opts.user,
content: opts.content, content: opts.content,
prev_content: opts.prev_content, prev_content: opts.prev_content,
event_id: "$" + Math.random() + "-" + Math.random(), event_id: opts.id ?? "$" + Math.random() + "-" + Math.random(),
origin_server_ts: opts.ts ?? 0, origin_server_ts: opts.ts ?? 0,
unsigned: opts.unsigned, unsigned: opts.unsigned,
redacts: opts.redacts, redacts: opts.redacts,
@ -483,12 +485,13 @@ export function mkMessage({
formattedMsg, formattedMsg,
relatesTo, relatesTo,
...opts ...opts
}: MakeEventPassThruProps & { }: MakeEventPassThruProps &
room: Room["roomId"]; Pick<MakeEventProps, "id"> & {
msg?: string; room: Room["roomId"];
format?: string; msg?: string;
formattedMsg?: string; format?: string;
}): MatrixEvent { formattedMsg?: string;
}): MatrixEvent {
if (!opts.room || !opts.user) { if (!opts.room || !opts.user) {
throw new Error("Missing .room or .user from options"); throw new Error("Missing .room or .user from options");
} }