From d40198ca9848cdf865c4dcfd95abe0ffd496365a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathana=C3=ABl=20HANNEBERT?= Date: Mon, 4 May 2026 17:26:28 +0200 Subject: [PATCH] feat: add MSC4306 thread subscription API and push condition Add client-side support for MSC4306 (Thread Subscriptions): - `subscribeToThread()`, `unsubscribeFromThread()`, `getThreadSubscription()` methods on `MatrixClient` using the unstable prefix `/_matrix/client/unstable/io.element.msc4306`. - In-memory subscription cache (`getCachedThreadSubscription()`) for synchronous lookups from `PushProcessor`. - New `thread_subscription` / `io.element.msc4306.thread_subscription` condition kind in `PushProcessor.eventFulfillsCondition()`, backed by the cache (fails closed when state is unknown). Ref: https://github.com/matrix-org/matrix-spec-proposals/pull/4306 --- src/@types/PushRules.ts | 13 +++++- src/client.ts | 87 +++++++++++++++++++++++++++++++++++++++++ src/pushprocessor.ts | 24 ++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 4ca1355c689..97be561960d 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -69,6 +69,10 @@ export enum ConditionKind { SenderNotificationPermission = "sender_notification_permission", CallStarted = "call_started", CallStartedPrefix = "org.matrix.msc3914.call_started", + /** MSC4306 stable name. */ + ThreadSubscription = "thread_subscription", + /** MSC4306 unstable name; servers using the unstable prefix send this kind. */ + ThreadSubscriptionUnstable = "io.element.msc4306.thread_subscription", } export interface IPushRuleCondition { @@ -114,6 +118,12 @@ export interface ICallStartedPrefixCondition extends IPushRuleCondition { + subscribed: boolean; +} + // XXX: custom conditions are possible but always fail, and break the typescript discriminated union so ignore them here // IPushRuleCondition> unfortunately does not resolve this at the time of writing. export type PushRuleCondition = @@ -124,7 +134,8 @@ export type PushRuleCondition = | IRoomMemberCountCondition | ISenderNotificationPermissionCondition | ICallStartedCondition - | ICallStartedPrefixCondition; + | ICallStartedPrefixCondition + | IThreadSubscriptionCondition; export enum PushRuleKind { Override = "override", diff --git a/src/client.ts b/src/client.ts index 27afcf9e709..9bb98546eec 100644 --- a/src/client.ts +++ b/src/client.ts @@ -554,6 +554,8 @@ export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_m export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140"; export const UNSTABLE_MSC4354_STICKY_EVENTS = "org.matrix.msc4354"; +export const UNSTABLE_MSC4306_THREAD_SUBSCRIPTIONS = "org.matrix.msc4306"; +const MSC4306_PREFIX = "/_matrix/client/unstable/io.element.msc4306"; export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133"; export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable"; @@ -1271,6 +1273,8 @@ export class MatrixClient extends TypedEventEmitter(); protected syncLeftRoomsPromise?: Promise; protected syncedLeftRooms = false; protected clientOpts?: IStoredClientOpts; @@ -7850,6 +7854,89 @@ export class MatrixClient extends TypedEventEmitter { + const path = `/rooms/${encodeURIComponent(roomId)}/thread/${encodeURIComponent(eventId)}/subscription`; + try { + const result = await this.http.authedRequest<{ automatic: boolean }>( + Method.Get, + path, + undefined, + undefined, + { prefix: MSC4306_PREFIX }, + ); + this.threadSubscriptionCache.set(`${roomId}|${eventId}`, true); + return result; + } catch (err) { + if ((err as MatrixError).httpStatus === 404) { + this.threadSubscriptionCache.set(`${roomId}|${eventId}`, false); + return null; + } + throw err; + } + } + + /** + * MSC4306: Subscribe to a thread. + * @param roomId - The room the thread is in. + * @param eventId - The thread root event ID. + * @param automaticCauseEventId - When set, marks the subscription as automatic + * and supplies the cause event ID (e.g. the event that mentioned the user). + * Omit for manual / user-initiated subscriptions. + */ + public async subscribeToThread( + roomId: string, + eventId: string, + automaticCauseEventId?: string, + ): Promise { + const path = `/rooms/${encodeURIComponent(roomId)}/thread/${encodeURIComponent(eventId)}/subscription`; + const body = automaticCauseEventId ? { automatic: automaticCauseEventId } : {}; + const result = await this.http.authedRequest( + Method.Put, + path, + undefined, + body, + { prefix: MSC4306_PREFIX }, + ); + this.threadSubscriptionCache.set(`${roomId}|${eventId}`, true); + return result; + } + + /** + * MSC4306: Remove the user's subscription to a thread. Idempotent — succeeds + * even if no subscription exists. + */ + public async unsubscribeFromThread(roomId: string, eventId: string): Promise { + const path = `/rooms/${encodeURIComponent(roomId)}/thread/${encodeURIComponent(eventId)}/subscription`; + const result = await this.http.authedRequest( + Method.Delete, + path, + undefined, + undefined, + { prefix: MSC4306_PREFIX }, + ); + this.threadSubscriptionCache.set(`${roomId}|${eventId}`, false); + return result; + } + + /** + * MSC4306: Synchronously read the cached thread subscription state. Returns + * `undefined` if no GET/PUT/DELETE has populated the cache for this thread + * yet. Used by `PushProcessor` to evaluate the `thread_subscription` + * condition without an async hop. + */ + public getCachedThreadSubscription(roomId: string, eventId: string): boolean | undefined { + return this.threadSubscriptionCache.get(`${roomId}|${eventId}`); + } + /** * Perform a server-side search. * @param params diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 20449774028..86e75c4eaca 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -31,6 +31,7 @@ import { type IPushRules, type IRoomMemberCountCondition, type ISenderNotificationPermissionCondition, + type IThreadSubscriptionCondition, type PushRuleAction, PushRuleActionName, type PushRuleCondition, @@ -482,6 +483,9 @@ export class PushProcessor { case ConditionKind.CallStarted: case ConditionKind.CallStartedPrefix: return this.eventFulfillsCallStartedCondition(cond, ev); + case ConditionKind.ThreadSubscription: + case ConditionKind.ThreadSubscriptionUnstable: + return this.eventFulfillsThreadSubscriptionCondition(cond, ev); } // unknown conditions: we previously matched all unknown conditions, @@ -637,6 +641,26 @@ export class PushProcessor { return val.includes(cond.value); } + private eventFulfillsThreadSubscriptionCondition( + cond: IThreadSubscriptionCondition, + ev: MatrixEvent, + ): boolean { + const roomId = ev.getRoomId(); + const rootId = ev.threadRootId; + if (!roomId || !rootId) { + // The condition only applies to thread events. + return false; + } + const cached = this.client.getCachedThreadSubscription(roomId, rootId); + if (cached === undefined) { + // Unknown subscription state — fail the condition. The server is + // authoritative for delivered notifications; missing the local + // suppression is preferable to spurious matches. + return false; + } + return cached === cond.subscribed; + } + private eventFulfillsCallStartedCondition( _cond: ICallStartedCondition | ICallStartedPrefixCondition, ev: MatrixEvent,