Skip to content

Commit

Permalink
Merge pull request #910 from matrix-org/jryans/aggregations
Browse files Browse the repository at this point in the history
Add basic read path for relations
  • Loading branch information
jryans authored May 9, 2019
2 parents ee8a469 + 6078bbb commit d6abd63
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 13 deletions.
6 changes: 6 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
101 changes: 97 additions & 4 deletions src/models/event-timeline-set.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand Down
25 changes: 18 additions & 7 deletions src/models/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ module.exports.EventStatus = {
};

const interns = {};
function intern(str) {
if (!interns[str]) {
interns[str] = str;
}
return interns[str];
}

/**
* Construct a Matrix Event object
Expand Down Expand Up @@ -87,20 +93,25 @@ module.exports.MatrixEvent = function MatrixEvent(
if (!event[prop]) {
return;
}
if (!interns[event[prop]]) {
interns[event[prop]] = event[prop];
}
event[prop] = interns[event[prop]];
event[prop] = intern(event[prop]);
});

["membership", "avatar_url", "displayname"].forEach((prop) => {
if (!event.content || !event.content[prop]) {
return;
}
if (!interns[event.content[prop]]) {
interns[event.content[prop]] = event.content[prop];
event.content[prop] = intern(event.content[prop]);
});

["rel_type"].forEach((prop) => {
if (
!event.content ||
!event.content["m.relates_to"] ||
!event.content["m.relates_to"][prop]
) {
return;
}
event.content[prop] = interns[event.content[prop]];
event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]);
});

this.event = event || {};
Expand Down
149 changes: 149 additions & 0 deletions src/models/relations.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 4 additions & 1 deletion src/models/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,12 @@ function synthesizeReceipt(userId, event, receiptType) {
* "<b>detached</b>", 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.
Expand Down
7 changes: 6 additions & 1 deletion src/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit d6abd63

Please sign in to comment.