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
13 changes: 12 additions & 1 deletion src/@types/PushRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<N extends ConditionKind | string> {
Expand Down Expand Up @@ -114,6 +118,12 @@ export interface ICallStartedPrefixCondition extends IPushRuleCondition<Conditio
// no additional fields
}

/** MSC4306: matches when the event is in a thread the user is (or isn't) subscribed to. */
export interface IThreadSubscriptionCondition
extends IPushRuleCondition<ConditionKind.ThreadSubscription | ConditionKind.ThreadSubscriptionUnstable> {
subscribed: boolean;
}

// XXX: custom conditions are possible but always fail, and break the typescript discriminated union so ignore them here
// IPushRuleCondition<Exclude<string, ConditionKind>> unfortunately does not resolve this at the time of writing.
export type PushRuleCondition =
Expand All @@ -124,7 +134,8 @@ export type PushRuleCondition =
| IRoomMemberCountCondition
| ISenderNotificationPermissionCondition
| ICallStartedCondition
| ICallStartedPrefixCondition;
| ICallStartedPrefixCondition
| IThreadSubscriptionCondition;

export enum PushRuleKind {
Override = "override",
Expand Down
87 changes: 87 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1271,6 +1273,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected syncApi?: SlidingSyncSdk | SyncApi;
public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
public pushRules?: IPushRules;
/** MSC4306: in-memory thread subscription state, keyed by `${roomId}|${rootEventId}`. */
private threadSubscriptionCache = new Map<string, boolean>();
protected syncLeftRoomsPromise?: Promise<Room[]>;
protected syncedLeftRooms = false;
protected clientOpts?: IStoredClientOpts;
Expand Down Expand Up @@ -7850,6 +7854,89 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Put, path, undefined, { actions: actions });
}

/**
* MSC4306: Get the user's subscription state for a thread.
* @param roomId - The room the thread is in.
* @param eventId - The thread root event ID.
* @returns Subscription details, or `null` if the user is not subscribed.
*/
public async getThreadSubscription(
roomId: string,
eventId: string,
): Promise<{ automatic: boolean } | null> {
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<EmptyObject> {
const path = `/rooms/${encodeURIComponent(roomId)}/thread/${encodeURIComponent(eventId)}/subscription`;
const body = automaticCauseEventId ? { automatic: automaticCauseEventId } : {};
const result = await this.http.authedRequest<EmptyObject>(
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<EmptyObject> {
const path = `/rooms/${encodeURIComponent(roomId)}/thread/${encodeURIComponent(eventId)}/subscription`;
const result = await this.http.authedRequest<EmptyObject>(
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
Expand Down
24 changes: 24 additions & 0 deletions src/pushprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type IPushRules,
type IRoomMemberCountCondition,
type ISenderNotificationPermissionCondition,
type IThreadSubscriptionCondition,
type PushRuleAction,
PushRuleActionName,
type PushRuleCondition,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading