diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index ede8694545..c265ed4bb9 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -601,6 +601,8 @@
"See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room",
"with an empty state key": "with an empty state key",
"with state key %(stateKey)s": "with state key %(stateKey)s",
+ "The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well",
+ "The above, but in as well": "The above, but in as well",
"Send %(eventType)s events as you in this room": "Send %(eventType)s events as you in this room",
"See %(eventType)s events posted to this room": "See %(eventType)s events posted to this room",
"Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room",
@@ -806,6 +808,7 @@
"Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
"Message Pinning": "Message Pinning",
+ "Threaded messaging": "Threaded messaging",
"Custom user status messages": "Custom user status messages",
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
"Render simple counters in room header": "Render simple counters in room header",
@@ -1803,6 +1806,7 @@
"%(count)s people|other": "%(count)s people",
"%(count)s people|one": "%(count)s person",
"Show files": "Show files",
+ "Show threads": "Show threads",
"Share room": "Share room",
"Room settings": "Room settings",
"Trusted": "Trusted",
@@ -1922,6 +1926,7 @@
"React": "React",
"Edit": "Edit",
"Reply": "Reply",
+ "Thread": "Thread",
"Message Actions": "Message Actions",
"Download %(text)s": "Download %(text)s",
"Error decrypting attachment": "Error decrypting attachment",
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index 28c5b1353f..a0261e9994 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -211,6 +211,15 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
+ "feature_thread": {
+ isFeature: true,
+ // Requires a reload as we change an option flag on the `js-sdk`
+ // And the entire sync history needs to be parsed again
+ controller: new ReloadOnChangeController(),
+ displayName: _td("Threaded messaging"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
"feature_custom_status": {
isFeature: true,
displayName: _td("Custom user status messages"),
diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts
index d62f6c6110..96a585b676 100644
--- a/src/stores/RightPanelStorePhases.ts
+++ b/src/stores/RightPanelStorePhases.ts
@@ -37,6 +37,10 @@ export enum RightPanelPhases {
SpaceMemberList = "SpaceMemberList",
SpaceMemberInfo = "SpaceMemberInfo",
Space3pidMemberInfo = "Space3pidMemberInfo",
+
+ // Thread stuff
+ ThreadView = "ThreadView",
+ ThreadPanel = "ThreadPanel",
}
// These are the phases that are safe to persist (the ones that don't require additional
diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx
index c08c66714b..8a7d51b60a 100644
--- a/src/stores/SpaceStore.tsx
+++ b/src/stores/SpaceStore.tsx
@@ -145,9 +145,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
return this._allRoomsInHome;
}
- public async setActiveRoomInSpace(space: Room | null): Promise {
+ public setActiveRoomInSpace(space: Room | null): void {
if (space && !space.isSpaceRoom()) return;
- if (space !== this.activeSpace) await this.setActiveSpace(space);
+ if (space !== this.activeSpace) this.setActiveSpace(space);
if (space) {
const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications();
@@ -190,7 +190,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
* @param contextSwitch whether to switch the user's context,
* should not be done when the space switch is done implicitly due to another event like switching room.
*/
- public async setActiveSpace(space: Room | null, contextSwitch = true) {
+ public setActiveSpace(space: Room | null, contextSwitch = true) {
if (space === this.activeSpace || (space && !space.isSpaceRoom())) return;
this._activeSpace = space;
@@ -293,11 +293,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
}
if (space) {
- const suggestedRooms = await this.fetchSuggestedRooms(space);
- if (this._activeSpace === space) {
- this._suggestedRooms = suggestedRooms;
- this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
- }
+ this.loadSuggestedRooms(space);
+ }
+ }
+
+ private async loadSuggestedRooms(space) {
+ const suggestedRooms = await this.fetchSuggestedRooms(space);
+ if (this._activeSpace === space) {
+ this._suggestedRooms = suggestedRooms;
+ this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
}
}
@@ -666,6 +670,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
this.onSpaceUpdate();
this.emit(room.roomId);
}
+
+ if (room === this.activeSpace && // current space
+ this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined
+ ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed
+ ) {
+ this.loadSuggestedRooms(room);
+ }
+
break;
case EventType.SpaceParent:
@@ -678,12 +690,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
}
this.emit(room.roomId);
break;
+ }
+ };
- case EventType.RoomMember:
- if (room.isSpaceRoom()) {
- this.onSpaceMembersChange(ev);
- }
- break;
+ // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then
+ private onRoomStateMembers = (ev: MatrixEvent) => {
+ const room = this.matrixClient.getRoom(ev.getRoomId());
+ if (room?.isSpaceRoom()) {
+ this.onSpaceMembersChange(ev);
}
};
@@ -743,6 +757,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
this.matrixClient.removeListener("Room.myMembership", this.onRoom);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("RoomState.events", this.onRoomState);
+ this.matrixClient.removeListener("RoomState.members", this.onRoomStateMembers);
this.matrixClient.removeListener("accountData", this.onAccountData);
}
await this.reset();
@@ -754,6 +769,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient {
this.matrixClient.on("Room.myMembership", this.onRoom);
this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("RoomState.events", this.onRoomState);
+ this.matrixClient.on("RoomState.members", this.onRoomStateMembers);
this.matrixClient.on("accountData", this.onAccountData);
this.matrixClient.getCapabilities().then(capabilities => {
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index daa1e0e787..d3c886d4b8 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -1,5 +1,5 @@
/*
- * Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
+ * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -55,6 +55,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ELEMENT_CLIENT_ID } from "../../identifiers";
import { getUserLanguage } from "../../languageHandler";
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
+import { arrayFastClone } from "../../utils/arrays";
// TODO: Destroy all of this code
@@ -146,6 +147,7 @@ export class StopGapWidget extends EventEmitter {
private scalarToken: string;
private roomId?: string;
private kind: WidgetKind;
+ private readUpToMap: {[roomId: string]: string} = {}; // room ID to event ID
constructor(private appTileProps: IAppTileProps) {
super();
@@ -294,6 +296,14 @@ export class StopGapWidget extends EventEmitter {
this.messaging.transport.reply(ev.detail, {});
});
+ // Populate the map of "read up to" events for this widget with the current event in every room.
+ // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
+ // requests timeline capabilities in other rooms down the road. It's just easier to manage here.
+ for (const room of MatrixClientPeg.get().getRooms()) {
+ // Timelines are most recent last
+ this.readUpToMap[room.roomId] = arrayFastClone(room.getLiveTimeline().getEvents()).reverse()[0].getId();
+ }
+
// Attach listeners for feeding events - the underlying widget classes handle permissions for us
MatrixClientPeg.get().on('event', this.onEvent);
MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted);
@@ -408,21 +418,56 @@ export class StopGapWidget extends EventEmitter {
private onEvent = (ev: MatrixEvent) => {
MatrixClientPeg.get().decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
- if (ev.getRoomId() !== this.eventListenerRoomId) return;
this.feedEvent(ev);
};
private onEventDecrypted = (ev: MatrixEvent) => {
if (ev.isDecryptionFailure()) return;
- if (ev.getRoomId() !== this.eventListenerRoomId) return;
this.feedEvent(ev);
};
private feedEvent(ev: MatrixEvent) {
if (!this.messaging) return;
+ // Check to see if this event would be before or after our "read up to" marker. If it's
+ // before, or we can't decide, then we assume the widget will have already seen the event.
+ // If the event is after, or we don't have a marker for the room, then we'll send it through.
+ //
+ // This approach of "read up to" prevents widgets receiving decryption spam from startup or
+ // receiving out-of-order events from backfill and such.
+ const upToEventId = this.readUpToMap[ev.getRoomId()];
+ if (upToEventId) {
+ // Small optimization for exact match (prevent search)
+ if (upToEventId === ev.getId()) {
+ return;
+ }
+
+ let isBeforeMark = true;
+
+ // Timelines are most recent last, so reverse the order and limit ourselves to 100 events
+ // to avoid overusing the CPU.
+ const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline();
+ const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);
+
+ for (const timelineEvent of events) {
+ if (timelineEvent.getId() === upToEventId) {
+ break;
+ } else if (timelineEvent.getId() === ev.getId()) {
+ isBeforeMark = false;
+ break;
+ }
+ }
+
+ if (isBeforeMark) {
+ // Ignore the event: it is before our interest.
+ return;
+ }
+ }
+
+ this.readUpToMap[ev.getRoomId()] = ev.getId();
+
const raw = ev.getEffectiveEvent();
- this.messaging.feedEvent(raw).catch(e => {
+ this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => {
console.error("Error sending event to widget: ", e);
});
}
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index d473ecf3b1..91a4cf6642 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Matrix.org Foundation C.I.C.
+ * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import {
MatrixCapabilities,
OpenIDRequestState,
SimpleObservable,
+ Symbols,
Widget,
WidgetDriver,
WidgetEventCapability,
@@ -42,7 +43,8 @@ import { CHAT_EFFECTS } from "../../effects";
import { containsEmoji } from "../../effects/utils";
import dis from "../../dispatcher/dispatcher";
import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks";
-import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { Room } from "matrix-js-sdk";
// TODO: Purge this from the universe
@@ -133,9 +135,14 @@ export class StopGapWidgetDriver extends WidgetDriver {
return allAllowed;
}
- public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise {
+ public async sendEvent(
+ eventType: string,
+ content: any,
+ stateKey: string = null,
+ targetRoomId: string = null,
+ ): Promise {
const client = MatrixClientPeg.get();
- const roomId = ActiveRoomObserver.activeRoomId;
+ const roomId = targetRoomId || ActiveRoomObserver.activeRoomId;
if (!client || !roomId) throw new Error("Not in a room or not attached to a client");
@@ -162,48 +169,68 @@ export class StopGapWidgetDriver extends WidgetDriver {
return { roomId, eventId: r.event_id };
}
- public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise