Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Threads notification proof of concept (MSC3773) #9204

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 0 additions & 28 deletions src/Unread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import { M_BEACON } from "matrix-js-sdk/src/@types/beacon";
import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore";
import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore";

/**
* Returns true if this event arriving in a room should affect the room's
Expand Down Expand Up @@ -62,32 +60,6 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
// despite the name of the method :((
const readUpToId = room.getEventReadUpTo(myUserId);

if (!SettingsStore.getValue("feature_thread")) {
// as we don't send RRs for our own messages, make sure we special case that
// if *we* sent the last message into the room, we consider it not unread!
// Should fix: https://github.com/vector-im/element-web/issues/3263
// https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
}
} else {
const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room);
if (threadState.color > 0) {
return true;
}
}

// if the read receipt relates to an event is that part of a thread
// we consider that there are no unread messages
// This might be a false negative, but probably the best we can do until
// the read receipts have evolved to cater for threads
const event = room.findEventById(readUpToId);
if (event?.getThread()) {
return false;
}

// this just looks at whatever history we have, which if we've only just started
// up probably won't be very much, so if the last couple of events are ones that
// don't count, we don't know if there are any events that do count between where
Expand Down
37 changes: 15 additions & 22 deletions src/components/views/right_panel/RoomHeaderButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ limitations under the License.

import React from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";

import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton';
Expand Down Expand Up @@ -129,30 +129,11 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
RightPanelPhases.ThreadPanel,
RightPanelPhases.ThreadView,
];
private threadNotificationState: ThreadsRoomNotificationState;

constructor(props: IProps) {
super(props, HeaderKind.Room);

this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room);
}

public componentDidMount(): void {
super.componentDidMount();
this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification);
}

public componentWillUnmount(): void {
super.componentWillUnmount();
this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification);
}

private onThreadNotification = (): void => {
this.setState({
threadNotificationColor: this.threadNotificationState.color,
});
};

protected onAction(payload: ActionPayload) {
if (payload.action === Action.ViewUser) {
if (payload.member) {
Expand Down Expand Up @@ -233,6 +214,18 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
isHighlighted={this.isPhase(RightPanelPhases.Timeline)}
onClick={this.onTimelineCardClicked} />,
);

const unreadCount = this.props.room?.getTotalUnreadNotificationCount(NotificationCountType.Total)
?? this.props.room?.getTotalUnreadNotificationCount(NotificationCountType.Highlight)
?? 0;

// Nested ternary, niiice 😏
const color = this.props.room?.getTotalUnreadNotificationCount(NotificationCountType.Highlight) > 0
? NotificationColor.Red
: this.props.room?.getTotalUnreadNotificationCount(NotificationCountType.Total) > 0
? NotificationColor.Grey
: NotificationColor.None;

rightPanelPhaseButtons.set(RightPanelPhases.ThreadPanel,
SettingsStore.getValue("feature_thread")
? <HeaderButton
Expand All @@ -241,9 +234,9 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
title={_t("Threads")}
onClick={this.onThreadsPanelClicked}
isHighlighted={this.isPhase(RoomHeaderButtons.THREAD_PHASES)}
isUnread={this.threadNotificationState.color > 0}
isUnread={unreadCount > 0}
>
<UnreadIndicator color={this.threadNotificationState.color} />
<UnreadIndicator color={color} />
</HeaderButton>
: null,
);
Expand Down
58 changes: 12 additions & 46 deletions src/components/views/rooms/EventTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContex
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import Toolbar from '../../../accessibility/Toolbar';
import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton';
import { ThreadNotificationState } from '../../../stores/notifications/ThreadNotificationState';
import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore';
import { NotificationStateEvents } from '../../../stores/notifications/NotificationState';
import { NotificationColor } from '../../../stores/notifications/NotificationColor';
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import { copyPlaintext, getSelectedText } from '../../../utils/strings';
import { DecryptionFailureTracker } from '../../../DecryptionFailureTracker';
Expand Down Expand Up @@ -252,7 +248,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
private isListeningForReceipts: boolean;
private tile = React.createRef<IEventTileType>();
private replyChain = React.createRef<ReplyChain>();
private threadState: ThreadNotificationState;

public readonly ref = createRef<HTMLElement>();

Expand Down Expand Up @@ -392,10 +387,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {

if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);

if (this.thread) {
this.setupNotificationListener(this.thread);
}
}

client.decryptEventIfNeeded(this.props.mxEvent);
Expand All @@ -404,40 +395,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
room?.on(ThreadEvent.New, this.onNewThread);
}

private setupNotificationListener(thread: Thread): void {
const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);

this.threadState = notifications.getThreadRoomState(thread);

this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
this.onThreadStateUpdate();
}

private onThreadStateUpdate = (): void => {
let threadNotification = null;
switch (this.threadState?.color) {
case NotificationColor.Grey:
threadNotification = NotificationCountType.Total;
break;
case NotificationColor.Red:
threadNotification = NotificationCountType.Highlight;
break;
}

this.setState({
threadNotification,
});
};

private updateThread = (thread: Thread) => {
if (thread !== this.state.thread) {
if (this.threadState) {
this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}

this.setupNotificationListener(thread);
}

this.setState({ thread });
};

Expand Down Expand Up @@ -475,9 +433,6 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {

const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
room?.off(ThreadEvent.New, this.onNewThread);
if (this.threadState) {
this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}
}

componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) {
Expand Down Expand Up @@ -1339,6 +1294,17 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
]);
}
case TimelineRenderingType.ThreadsList: {
const evt = this.props.mxEvent;
const room = MatrixClientPeg.get().getRoom(evt.getRoomId());

const color = room.getThreadUnreadNotificationCount(
evt.threadRootId, NotificationCountType.Highlight,
) > 0
? NotificationCountType.Highlight
: room.getThreadUnreadNotificationCount(evt.threadRootId, NotificationCountType.Total) > 0
? NotificationCountType.Total
: undefined;

// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
React.createElement(this.props.as || "li", {
Expand All @@ -1352,7 +1318,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
"data-shape": this.context.timelineRenderingType,
"data-self": isOwnEvent,
"data-has-reply": !!replyChain,
"data-notification": this.state.threadNotification,
"data-notification": color,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onClick": (ev: MouseEvent) => {
Expand Down
2 changes: 1 addition & 1 deletion src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
controller: new ThreadBetaController(),
displayName: _td("Threaded messaging"),
supportedLevels: LEVELS_FEATURE,
default: false,
default: true,
betaInfo: {
title: _td("Threads"),
caption: () => <>
Expand Down
4 changes: 2 additions & 2 deletions src/stores/notifications/RoomNotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this._symbol = "!";
this._count = 1; // not used, technically
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, NotificationCountType.Highlight);
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, NotificationCountType.Total);
const redNotifs = this.room.getTotalUnreadNotificationCount(NotificationCountType.Highlight);
const greyNotifs = this.room.getTotalUnreadNotificationCount(NotificationCountType.Total);

// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
Expand Down
113 changes: 80 additions & 33 deletions src/stores/notifications/ThreadNotificationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,64 +14,111 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientEvent } from "matrix-js-sdk/src/matrix";

import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { NotificationState } from "./NotificationState";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import * as RoomNotifs from '../../RoomNotifs';

export class ThreadNotificationState extends NotificationState implements IDestroyable {
protected _symbol = null;
protected _count = 0;
protected _color = NotificationColor.None;

constructor(public readonly thread: Thread) {
constructor(public readonly room: Room, public readonly threadId: string) {
super();
this.thread.on(ThreadEvent.NewReply, this.handleNewThreadReply);
this.thread.on(ThreadEvent.ViewThread, this.resetThreadNotification);
if (this.thread.replyToEvent) {
// Process the current tip event
this.handleNewThreadReply(this.thread, this.thread.replyToEvent);
}
this.room.on(RoomEvent.Receipt, this.handleReadReceipt);
this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate);
this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate);
this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate);
this.updateNotificationState();
}

public destroy(): void {
super.destroy();
this.thread.off(ThreadEvent.NewReply, this.handleNewThreadReply);
this.thread.off(ThreadEvent.ViewThread, this.resetThreadNotification);
this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt);
this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate);
this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate);
this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate);
}
}
private handleLocalEchoUpdated = () => {
this.updateNotificationState();
};

private handleNewThreadReply = (thread: Thread, event: MatrixEvent) => {
const client = MatrixClientPeg.get();
private handleReadReceipt = (event: MatrixEvent, room: Room) => {
if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
if (room.roomId !== this.room.roomId) return; // not for us - ignore
this.updateNotificationState();
};

const myUserId = client.getUserId();
private handleMembershipUpdate = () => {
this.updateNotificationState();
};

const isOwn = myUserId === event.getSender();
const readReceipt = this.thread.room.getReadReceiptForUserId(myUserId);
private onEventDecrypted = (event: MatrixEvent) => {
if (event.getRoomId() !== this.room.roomId) return; // ignore - not for us or notifications timeline

if (!isOwn && !readReceipt || (readReceipt && event.getTs() >= readReceipt.data.ts)) {
const actions = client.getPushActionsForEvent(event, true);
this.updateNotificationState();
};

if (actions?.tweaks) {
const color = !!actions.tweaks.highlight
? NotificationColor.Red
: NotificationColor.Grey;
private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => {
if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline

this.updateNotificationState(color);
}
}
this.updateNotificationState();
};

private resetThreadNotification = (): void => {
this.updateNotificationState(NotificationColor.None);
private handleAccountDataUpdate = (ev: MatrixEvent) => {
if (ev.getType() === "m.push_rules") {
this.updateNotificationState();
}
};

private updateNotificationState(color: NotificationColor) {
private updateNotificationState() {
const snapshot = this.snapshot();

this._color = color;
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.RoomNotifState.Mute) {
// When muted we suppress all notification states, even if we have context on them.
this._color = NotificationColor.None;
this._symbol = null;
this._count = 0;
} else {
const redNotifs = this.room.getThreadUnreadNotificationCount(
this.threadId,
NotificationCountType.Highlight,
);
const greyNotifs = this.room.getThreadUnreadNotificationCount(
this.threadId,
NotificationCountType.Total,
);

// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);

// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.

if (redNotifs > 0) {
this._color = NotificationColor.Red;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else if (greyNotifs > 0) {
this._color = NotificationColor.Grey;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
}
}

// finally, publish an update if needed
this.emitIfUpdated(snapshot);
Expand Down
Loading