Fix outstanding UX issues with replies/mentions/keyword notifs

This commit is contained in:
taffyko 2024-10-22 13:44:30 -07:00
parent d4cf3881bc
commit 3541391747
10 changed files with 121 additions and 12 deletions

View file

@ -26,7 +26,8 @@ Please see LICENSE files in the repository root for full details.
} }
&.mx_UserPill_me, &.mx_UserPill_me,
&.mx_AtRoomPill { &.mx_AtRoomPill,
&.mx_KeywordPill {
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */ background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
} }
@ -45,7 +46,8 @@ Please see LICENSE files in the repository root for full details.
} }
/* We don't want to indicate clickability */ /* We don't want to indicate clickability */
&.mx_AtRoomPill:hover { &.mx_AtRoomPill:hover,
&.mx_KeywordPill:hover {
background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */ background-color: var(--cpd-color-bg-critical-primary) !important; /* To override .markdown-body */
cursor: unset; cursor: unset;
} }

View file

@ -135,12 +135,6 @@ $left-gutter: 64px;
} }
} }
&.mx_EventTile_highlight,
&.mx_EventTile_highlight .markdown-body,
&.mx_EventTile_highlight .mx_EventTile_edited {
color: $alert;
}
&.mx_EventTile_bubbleContainer { &.mx_EventTile_bubbleContainer {
display: grid; display: grid;
grid-template-columns: 1fr 100px; grid-template-columns: 1fr 100px;
@ -584,6 +578,14 @@ $left-gutter: 64px;
padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px); padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px);
} }
} }
&.mx_EventTile_highlight,
&.mx_EventTile_highlight .markdown-body {
> .mx_EventTile_line {
box-shadow: inset var(--EventTile-box-shadow-offset-x) 0 0 var(--EventTile-box-shadow-spread-radius)
$event-highlight-edge-color;
}
}
} }
&[data-layout="bubble"] { &[data-layout="bubble"] {

View file

@ -83,6 +83,7 @@ $spacePanel-bg-color: rgba(38, 39, 43, 0.82);
$panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1); $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1);
$h3-color: $primary-content; $h3-color: $primary-content;
$event-highlight-bg-color: #25271f; $event-highlight-bg-color: #25271f;
$event-highlight-edge-color: #f0b132;
$header-panel-text-primary-color: $text-secondary-color; $header-panel-text-primary-color: $text-secondary-color;
/* ******************** */ /* ******************** */

View file

@ -164,6 +164,7 @@ $widget-menu-bar-bg-color: $header-panel-bg-color;
$widget-body-bg-color: #1a1d23; $widget-body-bg-color: #1a1d23;
$event-highlight-bg-color: #25271f; $event-highlight-bg-color: #25271f;
$event-highlight-edge-color: #f0b132;
/* event timestamp */ /* event timestamp */
$event-timestamp-color: $text-secondary-color; $event-timestamp-color: $text-secondary-color;

View file

@ -235,6 +235,7 @@ $widget-body-bg-color: #fff;
$yellow-background: #fff8e3; $yellow-background: #fff8e3;
$event-highlight-bg-color: $yellow-background; $event-highlight-bg-color: $yellow-background;
$event-highlight-edge-color: var(--cpd-color-yellow-500);
/* event timestamp */ /* event timestamp */
$event-timestamp-color: #acacac; $event-timestamp-color: #acacac;

View file

@ -100,6 +100,7 @@ $button-danger-disabled-bg-color: var(--warning-color-50pct); /* still needs alp
/* --timeline-highlights-color */ /* --timeline-highlights-color */
$event-selected-color: var(--timeline-highlights-color); $event-selected-color: var(--timeline-highlights-color);
$event-highlight-bg-color: var(--timeline-highlights-color); $event-highlight-bg-color: var(--timeline-highlights-color);
$event-highlight-edge-color: var(--timeline-highlights-color);
/* redirect some variables away from their hardcoded values in the light theme */ /* redirect some variables away from their hardcoded values in the light theme */
$settings-grey-fg-color: $primary-content; $settings-grey-fg-color: $primary-content;

View file

@ -112,6 +112,7 @@ $spacePanel-bg-color: rgba(232, 232, 232, 0.77);
$panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1); $panel-gradient: rgba(242, 245, 248, 0), rgba(242, 245, 248, 1);
$h3-color: #3d3b39; $h3-color: #3d3b39;
$event-highlight-bg-color: $yellow-background; $event-highlight-bg-color: $yellow-background;
$event-highlight-edge-color: var(--cpd-color-yellow-500);
$header-panel-text-primary-color: #91a1c0; $header-panel-text-primary-color: #91a1c0;
/* ******************** */ /* ******************** */

View file

@ -25,6 +25,7 @@ export enum PillType {
AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention AtRoomMention = "TYPE_AT_ROOM_MENTION", // '@room' mention
EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM", EventInSameRoom = "TYPE_EVENT_IN_SAME_ROOM",
EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM", EventInOtherRoom = "TYPE_EVENT_IN_OTHER_ROOM",
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
} }
export const pillRoomNotifPos = (text: string | null): number => { export const pillRoomNotifPos = (text: string | null): number => {
@ -76,14 +77,32 @@ export interface PillProps {
room?: Room; room?: Room;
// Whether to include an avatar in the pill // Whether to include an avatar in the pill
shouldShowPillAvatar?: boolean; shouldShowPillAvatar?: boolean;
// Explicitly-provided text to display in the pill
text?: string;
} }
export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room, shouldShowPillAvatar = true }) => { export const Pill: React.FC<PillProps> = ({
const { event, member, onClick, resourceId, targetRoom, text, type } = usePermalink({ type: propType,
url,
inMessage,
room,
shouldShowPillAvatar = true,
text: customPillText,
}) => {
const {
event,
member,
onClick,
resourceId,
targetRoom,
text: linkText,
type,
} = usePermalink({
room, room,
type: propType, type: propType,
url, url,
}); });
const text = customPillText ?? linkText;
if (!type || !text) { if (!type || !text) {
return null; return null;
@ -96,6 +115,7 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
mx_UserPill: type === PillType.UserMention, mx_UserPill: type === PillType.UserMention,
mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(), mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom, mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
mx_KeywordPill: type === PillType.Keyword,
}); });
let avatar: ReactElement | null = null; let avatar: ReactElement | null = null;
@ -131,6 +151,8 @@ export const Pill: React.FC<PillProps> = ({ type: propType, url, inMessage, room
case PillType.UserMention: case PillType.UserMention:
avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />; avatar = <PillMemberAvatar shouldShowPillAvatar={shouldShowPillAvatar} member={member} />;
break; break;
case PillType.Keyword:
break;
default: default:
return null; return null;
} }

View file

@ -8,8 +8,9 @@ Please see LICENSE files in the repository root for full details.
import React, { createRef, SyntheticEvent, MouseEvent } from "react"; import React, { createRef, SyntheticEvent, MouseEvent } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { MsgType } from "matrix-js-sdk/src/matrix"; import { MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { globToRegexp } from "matrix-js-sdk/src/utils";
import * as HtmlUtils from "../../../HtmlUtils"; import * as HtmlUtils from "../../../HtmlUtils";
import { formatDate } from "../../../DateUtils"; import { formatDate } from "../../../DateUtils";
@ -36,6 +37,7 @@ import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { IEventTileOps } from "../rooms/EventTile"; import { IEventTileOps } from "../rooms/EventTile";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import { Pill, PillType } from "../elements/Pill";
interface IState { interface IState {
// the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody.
@ -102,6 +104,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
} }
} }
} }
// Highlight notification keywords using pills
const pushDetails = this.props.mxEvent.getPushDetails();
if (
pushDetails.rule?.enabled &&
pushDetails.rule.kind === PushRuleKind.ContentSpecific &&
pushDetails.rule.pattern
) {
this.pillifyNotificationKeywords([content], this.regExpForKeywordPattern(pushDetails.rule.pattern));
}
} }
private addCodeElement(pre: HTMLPreElement): void { private addCodeElement(pre: HTMLPreElement): void {
@ -211,6 +223,55 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
} }
} }
/**
* Marks the text that activated a push-notification keyword pattern.
*/
private pillifyNotificationKeywords(nodes: ArrayLike<Element>, exp: RegExp): void {
let node: Node | null = nodes[0];
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue;
if (!text) {
node = node.nextSibling;
continue;
}
const match = text.match(exp);
if (!match || match.length < 3) {
node = node.nextSibling;
continue;
}
const keywordText = match[2];
const idx = match.index! + match[1].length;
const before = text.substring(0, idx);
const after = text.substring(idx + keywordText.length);
const container = document.createElement("span");
const newContent = (
<>
{before}
<TooltipProvider>
<Pill text={keywordText} type={PillType.Keyword} />
</TooltipProvider>
{after}
</>
);
ReactDOM.render(newContent, container);
node.parentNode?.replaceChild(container, node);
} else if (node.childNodes && node.childNodes.length) {
this.pillifyNotificationKeywords(node.childNodes as NodeListOf<Element>, exp);
}
node = node.nextSibling;
}
}
private regExpForKeywordPattern(pattern: string): RegExp {
// Reflects the push notification pattern-matching implementation at
// https://github.com/matrix-org/matrix-js-sdk/blob/dbd7d26968b94700827bac525c39afff2c198e61/src/pushprocessor.ts#L570
return new RegExp("(^|\\W)(" + globToRegexp(pattern) + ")(\\W|$)", "i");
}
private findLinks(nodes: ArrayLike<Element>): string[] { private findLinks(nodes: ArrayLike<Element>): string[] {
let links: string[] = []; let links: string[] = [];

View file

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React from "react";
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixEvent, PushRuleKind } from "matrix-js-sdk/src/matrix";
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { render, waitFor } from "jest-matrix-react"; import { render, waitFor } from "jest-matrix-react";
@ -228,6 +228,23 @@ describe("<TextualBody />", () => {
const content = container.querySelector(".mx_EventTile_body"); const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot(); expect(content.innerHTML.replace(defaultEvent.getId(), "%event_id%")).toMatchSnapshot();
}); });
it("should pillify a keyword responsible for triggering a notification", () => {
const ev = mkRoomTextMessage("foo bar baz");
ev.setPushDetails(undefined, {
actions: [],
pattern: "bar",
rule_id: "bar",
default: false,
enabled: true,
kind: PushRuleKind.ContentSpecific,
});
const { container } = getComponent({ mxEvent: ev });
const content = container.querySelector(".mx_EventTile_body");
expect(content.innerHTML).toMatchInlineSnapshot(
`"<span>foo <bdi><span tabindex="0"><span class="mx_Pill mx_KeywordPill"><span class="mx_Pill_text">bar</span></span></span></bdi> baz</span>"`,
);
});
}); });
describe("renders formatted m.text correctly", () => { describe("renders formatted m.text correctly", () => {