Skip to content
Draft
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
39 changes: 39 additions & 0 deletions apps/web/src/Notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import {
M_LOCATION,
EventType,
TypedEventEmitter,
MatrixError,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { type PermissionChanged as PermissionChangedEvent } from "@matrix-org/analytics-events/types/typescript/PermissionChanged";
import { type SessionMembershipData, type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
Expand Down Expand Up @@ -155,6 +157,11 @@ export type NotificationSound = {
class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents> {
private notifsByRoom: Record<string, Notification[]> = {};

/** MSC4306: threads we've already issued an automatic subscription PUT for in this session,
* keyed by `${roomId}|${rootEventId}`. Avoids spamming PUTs when a subscribed thread
* receives multiple mention events. */
private autoSubscribedThreads = new Set<string>();

// A list of event IDs that we've received but need to wait until
// they're decrypted until we decide whether to notify for them
// or not
Expand Down Expand Up @@ -499,6 +506,36 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
}
};

/**
* MSC4306 subscribe-on-mention: when push-rule evaluation produces a `notify`
* action for a thread event, automatically subscribe the user to that thread,
* unless the sender is ignored/banned or we already auto-subscribed this session.
*/
private maybeAutoSubscribeToThread(ev: MatrixEvent, room: Room, threadId: string | undefined): void {
if (!threadId) return;

const senderId = ev.getSender();
if (!senderId) return;

const cli = MatrixClientPeg.safeGet();
if (cli.isUserIgnored(senderId)) return;
if (room.getMember(senderId)?.membership === KnownMembership.Ban) return;

const cacheKey = `${room.roomId}|${threadId}`;
if (this.autoSubscribedThreads.has(cacheKey)) return;
if (cli.getCachedThreadSubscription(room.roomId, threadId) === true) return;

this.autoSubscribedThreads.add(cacheKey);
cli.subscribeToThread(room.roomId, threadId, ev.getId()!).catch((e) => {
// 409 M_CONFLICTING_UNSUBSCRIPTION: the user explicitly unsubscribed
// earlier — that's expected, don't retry.
if ((e as MatrixError)?.httpStatus !== 409) {
this.autoSubscribedThreads.delete(cacheKey);
}
logger.warn("MSC4306 subscribe-on-mention failed", e);
});
}

// XXX: exported for tests
public evaluateEvent(ev: MatrixEvent): void {
const roomId = ev.getRoomId()!;
Expand All @@ -519,6 +556,8 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
const threadId: string | undefined = ev.getId() !== ev.threadRootId ? ev.threadRootId : undefined;
const isViewingThread = store.getThreadId() === threadId;

this.maybeAutoSubscribeToThread(ev, room, threadId);

const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread);

if (isViewingEventTimeline && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs()) {
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/structures/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/paylo
import Heading from "../views/typography/Heading";
import { type ThreadPayload } from "../../dispatcher/payloads/ThreadPayload";
import { ScopedRoomContextProvider } from "../../contexts/ScopedRoomContext.tsx";
import ThreadSubscriptionButton from "../views/elements/ThreadSubscriptionButton";

interface IProps {
room: Room;
Expand Down Expand Up @@ -367,6 +368,10 @@ export default class ThreadView extends React.Component<IProps, IState> {
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("common|thread")}
</Heading>
<ThreadSubscriptionButton
roomId={this.props.room.roomId}
threadId={this.eventId}
/>
<ThreadListContextMenu mxEvent={this.props.mxEvent} permalinkCreator={this.props.permalinkCreator} />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
Copyright 2024 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@vector-im/compound-web";
import {
NotificationsIcon,
NotificationsOffIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";

import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";

interface Props {
roomId: string;
threadId: string;
}

type SubscriptionState = "subscribed" | "unsubscribed" | "loading";

/**
* Button to subscribe/unsubscribe to a thread using MSC4306.
*/
const ThreadSubscriptionButton: React.FC<Props> = ({ roomId, threadId }) => {
const [state, setState] = useState<SubscriptionState>("loading");

const fetchSubscription = useCallback(async (): Promise<void> => {
const client = MatrixClientPeg.safeGet();
try {
const sub = await client.getThreadSubscription(roomId, threadId);
setState(sub ? "subscribed" : "unsubscribed");
} catch {
setState("unsubscribed");
}
}, [roomId, threadId]);

useEffect(() => {
setState("loading");
fetchSubscription();
}, [fetchSubscription]);

const toggleSubscription = useCallback(async (): Promise<void> => {
const client = MatrixClientPeg.safeGet();
const wasSubscribed = state === "subscribed";
setState(wasSubscribed ? "unsubscribed" : "subscribed");
try {
if (wasSubscribed) {
await client.unsubscribeFromThread(roomId, threadId);
} else {
await client.subscribeToThread(roomId, threadId);
}
} catch {
setState(wasSubscribed ? "subscribed" : "unsubscribed");
}
}, [roomId, threadId, state]);

if (state === "loading") {
return null;
}

const isSubscribed = state === "subscribed";
const label = isSubscribed ? _t("threads|subscribed") : _t("threads|subscribe");

return (
<Button
kind="tertiary"
size="sm"
Icon={isSubscribed ? NotificationsIcon : NotificationsOffIcon}
onClick={toggleSubscription}
data-testid="thread-subscription-button"
>
{label}
</Button>
);
};

export default ThreadSubscriptionButton;
7 changes: 7 additions & 0 deletions apps/web/src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,13 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
(actualRoomId: string) => this.props.mxClient.sendMessage(actualRoomId, threadId ?? null, content!),
this.props.mxClient,
);
if (threadId) {
// MSC4306: subscribe-on-send. Manual subscription (no `automatic` field).
// Fire-and-forget: the subscription must not block the message send.
this.props.mxClient
.subscribeToThread(roomId, threadId)
.catch((e) => logger.warn("MSC4306 subscribe-on-send failed", e));
}
if (replyToEvent) {
// Clear reply_to_event as we put the message into the queue
// if the send fails, retry will handle resending.
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3340,7 +3340,9 @@
"my_threads": "My threads",
"my_threads_description": "Shows all threads you've participated in",
"open_thread": "Open thread",
"show_thread_filter": "Show:"
"show_thread_filter": "Show:",
"subscribe": "Follow",
"subscribed": "Following"
},
"threads_activity_centre": {
"header": "Threads activity",
Expand Down
Loading