diff --git a/src/client.js b/src/client.js index 83912c45a..8e9b7d175 100644 --- a/src/client.js +++ b/src/client.js @@ -148,6 +148,11 @@ function keyFromRecoverySession(session, decryptionKey) { * maintain support for back-paginating the live timeline after a '/sync' * result with a gap. * + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `EventTimelineSet#getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. + * * @param {Array} [opts.verificationMethods] Optional. The verification method * that the application can handle. Each element should be an item from {@link * module:crypto~verificationMethods verificationMethods}, or a class that @@ -213,6 +218,7 @@ function MatrixClient(opts) { this.timelineSupport = Boolean(opts.timelineSupport); this.urlPreviewCache = {}; this._notifTimelineSet = null; + this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; this._crypto = null; this._cryptoStore = opts.cryptoStore; diff --git a/src/models/event-timeline-set.js b/src/models/event-timeline-set.js index 6025b7d77..a64201da8 100644 --- a/src/models/event-timeline-set.js +++ b/src/models/event-timeline-set.js @@ -20,6 +20,7 @@ limitations under the License. const EventEmitter = require("events").EventEmitter; const utils = require("../utils"); const EventTimeline = require("./event-timeline"); +import Relations from './relations'; // var DEBUG = false; const DEBUG = true; @@ -54,22 +55,38 @@ if (DEBUG) { * map from event_id to timeline and index. * * @constructor - * @param {?Room} room the optional room for this timelineSet - * @param {Object} opts hash of options inherited from Room. - * opts.timelineSupport gives whether timeline support is enabled - * opts.filter is the filter object, if any, for this timelineSet. + * @param {?Room} room + * Room for this timelineSet. May be null for non-room cases, such as the + * notification timeline. + * @param {Object} opts Options inherited from Room. + * + * @param {boolean} [opts.timelineSupport = false] + * Set to true to enable improved timeline support. + * @param {Object} [opts.filter = null] + * The filter object, if any, for this timelineSet. + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. */ function EventTimelineSet(room, opts) { this.room = room; this._timelineSupport = Boolean(opts.timelineSupport); this._liveTimeline = new EventTimeline(this); + this._unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; // just a list - *not* ordered. this._timelines = [this._liveTimeline]; this._eventIdToTimeline = {}; this._filter = opts.filter || null; + + if (this._unstableClientRelationAggregation) { + // A tree of objects to access a set of relations for an event, as in: + // this._relations[relatesToEventId][relationType][relationEventType] + this._relations = {}; + } } utils.inherits(EventTimelineSet, EventEmitter); @@ -523,6 +540,8 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, timeline.addEvent(event, toStartOfTimeline); this._eventIdToTimeline[eventId] = timeline; + this._aggregateRelations(event); + const data = { timeline: timeline, liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, @@ -657,6 +676,80 @@ EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) { return null; }; +/** + * Get a collection of relations to a given event in this timeline set. + * + * @param {String} eventId + * The ID of the event that you'd like to access relation events for. + * For example, with annotations, this would be the ID of the event being annotated. + * @param {String} relationType + * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. + * @param {String} eventType + * The relation event's type, such as "m.reaction", etc. + * + * @returns {Relations} + * A container for relation events. + */ +EventTimelineSet.prototype.getRelationsForEvent = function( + eventId, relationType, eventType, +) { + if (!this._unstableClientRelationAggregation) { + throw new Error("Client-side relation aggregation is disabled"); + } + + if (!eventId || !relationType || !eventType) { + throw new Error("Invalid arguments for `getRelationsForEvent`"); + } + + // debuglog("Getting relations for: ", eventId, relationType, eventType); + + const relationsForEvent = this._relations[eventId] || {}; + const relationsWithRelType = relationsForEvent[relationType] || {}; + return relationsWithRelType[eventType]; +}; + +/** + * Add relation events to the relevant relation collection. + * + * @param {MatrixEvent} event + * The new relation event to be aggregated. + */ +EventTimelineSet.prototype._aggregateRelations = function(event) { + if (!this._unstableClientRelationAggregation) { + return; + } + + const content = event.getContent(); + const relation = content && content["m.relates_to"]; + if (!relation || !relation.rel_type || !relation.event_id) { + return; + } + + const relatesToEventId = relation.event_id; + const relationType = relation.rel_type; + const eventType = event.getType(); + + // debuglog("Aggregating relation: ", event.getId(), eventType, relation); + + let relationsForEvent = this._relations[relatesToEventId]; + if (!relationsForEvent) { + relationsForEvent = this._relations[relatesToEventId] = {}; + } + let relationsWithRelType = relationsForEvent[relationType]; + if (!relationsWithRelType) { + relationsWithRelType = relationsForEvent[relationType] = {}; + } + let relationsWithEventType = relationsWithRelType[eventType]; + if (!relationsWithEventType) { + relationsWithEventType = relationsWithRelType[eventType] = new Relations( + relationType, + eventType, + ); + } + + relationsWithEventType.addEvent(event); +}; + /** * The EventTimelineSet class. */ diff --git a/src/models/relations.js b/src/models/relations.js new file mode 100644 index 000000000..16f02cb26 --- /dev/null +++ b/src/models/relations.js @@ -0,0 +1,149 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * A container for relation events that supports easy access to common ways of + * aggregating such events. Each instance holds events that of a single relation + * type and event type. All of the events also relate to the same original event. + * + * The typical way to get one of these containers is via + * EventTimelineSet#getRelationsForEvent. + */ +export default class Relations { + /** + * @param {String} relationType + * The type of relation involved, such as "m.annotation", "m.reference", + * "m.replace", etc. + * @param {String} eventType + * The relation event's type, such as "m.reaction", etc. + */ + constructor(relationType, eventType) { + this.relationType = relationType; + this.eventType = eventType; + this._events = []; + this._annotationsByKey = {}; + this._annotationsBySender = {}; + this._sortedAnnotationsByKey = []; + } + + /** + * Add relation events to this collection. + * + * @param {MatrixEvent} event + * The new relation event to be aggregated. + */ + addEvent(event) { + const content = event.getContent(); + const relation = content && content["m.relates_to"]; + if (!relation || !relation.rel_type || !relation.event_id) { + console.error("Event must have relation info"); + return; + } + + const relationType = relation.rel_type; + const eventType = event.getType(); + + if (this.relationType !== relationType || this.eventType !== eventType) { + console.error("Event relation info doesn't match this container"); + return; + } + + if (this.relationType === "m.annotation") { + const key = relation.key; + this._aggregateAnnotation(key, event); + } + + this._events.push(event); + } + + /** + * Get all events in this collection. + * + * These are currently in the order of insertion to this collection, which + * won't match timeline order in the case of scrollback. + * TODO: Tweak `addEvent` to insert correctly for scrollback. + * + * @return {Array} + * Relation events in insertion order. + */ + getEvents() { + return this._events; + } + + _aggregateAnnotation(key, event) { + if (!key) { + return; + } + + let eventsForKey = this._annotationsByKey[key]; + if (!eventsForKey) { + eventsForKey = this._annotationsByKey[key] = []; + this._sortedAnnotationsByKey.push([key, eventsForKey]); + } + // Add the new event to the list for this key + eventsForKey.push(event); + // Re-sort the [key, events] pairs in descending order of event count + this._sortedAnnotationsByKey.sort((a, b) => { + const aEvents = a[1]; + const bEvents = b[1]; + return bEvents.length - aEvents.length; + }); + + const sender = event.getSender(); + let eventsFromSender = this._annotationsBySender[sender]; + if (!eventsFromSender) { + eventsFromSender = this._annotationsBySender[sender] = []; + } + // Add the new event to the list for this sender + eventsFromSender.push(event); + } + + /** + * Get all events in this collection grouped by key and sorted by descending + * event count in each group. + * + * This is currently only supported for the annotation relation type. + * + * @return {Array} + * An array of [key, events] pairs sorted by descending event count. + */ + getSortedAnnotationsByKey() { + if (this.relationType !== "m.annotation") { + // Other relation types are not grouped currently. + return null; + } + + return this._sortedAnnotationsByKey; + } + + /** + * Get all events in this collection grouped by sender. + * + * This is currently only supported for the annotation relation type. + * + * @return {Object} + * An object with each relation sender as a key and the matching list of + * events for that sender as a value. + */ + getAnnotationsBySender() { + if (this.relationType !== "m.annotation") { + // Other relation types are not grouped currently. + return null; + } + + return this._annotationsBySender; + } +} diff --git a/src/models/room.js b/src/models/room.js index 84bcfd0ff..d026c673d 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -92,9 +92,12 @@ function synthesizeReceipt(userId, event, receiptType) { * "detached", pending messages will appear in a separate list, * accessbile via {@link module:models/room#getPendingEvents}. Default: * "chronological". - * * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved * timeline support. + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `EventTimelineSet#getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. * * @prop {string} roomId The ID of this room. * @prop {string} name The human-readable display name for this room. diff --git a/src/sync.js b/src/sync.js index c863af955..ea909214b 100644 --- a/src/sync.js +++ b/src/sync.js @@ -116,10 +116,15 @@ function SyncApi(client, opts) { */ SyncApi.prototype.createRoom = function(roomId) { const client = this.client; + const { + timelineSupport, + unstableClientRelationAggregation, + } = client; const room = new Room(roomId, client, client.getUserId(), { lazyLoadMembers: this.opts.lazyLoadMembers, pendingEventOrdering: this.opts.pendingEventOrdering, - timelineSupport: client.timelineSupport, + timelineSupport, + unstableClientRelationAggregation, }); client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", "Room.redaction", "Room.receipt", "Room.tags",