1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-09-03 08:42:03 +03:00

Merge pull request #23 from matrix-org/read_receipts

Receipts
This commit is contained in:
Kegsay
2015-10-19 15:30:23 +01:00
7 changed files with 560 additions and 1 deletions

View File

@@ -5,7 +5,7 @@
"nonew": true, "nonew": true,
"curly": true, "curly": true,
"forin": true, "forin": true,
"freeze": true, "freeze": false,
"undef": true, "undef": true,
"unused": "vars" "unused": "vars"
} }

View File

@@ -1,3 +1,6 @@
var matrixcs = require("./lib/matrix"); var matrixcs = require("./lib/matrix");
matrixcs.request(require("request")); matrixcs.request(require("request"));
module.exports = matrixcs; module.exports = matrixcs;
var utils = require("./lib/utils");
utils.runPolyfills();

View File

@@ -1124,6 +1124,37 @@ MatrixClient.prototype.sendHtmlNotice = function(roomId, body, htmlBody, callbac
return this.sendMessage(roomId, content, callback); return this.sendMessage(roomId, content, callback);
}; };
/**
* Send a receipt.
* @param {Event} event The event being acknowledged
* @param {string} receiptType The kind of receipt e.g. "m.read"
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixClient.prototype.sendReceipt = function(event, receiptType, callback) {
var path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: event.getRoomId(),
$receiptType: receiptType,
$eventId: event.getId()
});
return this._http.authedRequestWithPrefix(
callback, "POST", path, undefined, {}, httpApi.PREFIX_V2_ALPHA
);
};
/**
* Send a read receipt.
* @param {Event} event The event that has been read.
* @param {module:client.callback} callback Optional.
* @return {module:client.Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
MatrixClient.prototype.sendReadReceipt = function(event, callback) {
return this.sendReceipt(event, "m.read", callback);
};
/** /**
* Upload a file to the media repository on the home server. * Upload a file to the media repository on the home server.
* @param {File} file object * @param {File} file object
@@ -1872,6 +1903,19 @@ function doInitialSync(client, historyLen) {
user.setPresenceEvent(e); user.setPresenceEvent(e);
client.store.storeUser(user); client.store.storeUser(user);
}); });
// group receipts by room ID.
var receiptsByRoom = {};
data.receipts = data.receipts || [];
utils.forEach(data.receipts.map(_PojoToMatrixEventMapper(client)),
function(receiptEvent) {
if (!receiptsByRoom[receiptEvent.getRoomId()]) {
receiptsByRoom[receiptEvent.getRoomId()] = [];
}
receiptsByRoom[receiptEvent.getRoomId()].push(receiptEvent);
}
);
for (i = 0; i < data.rooms.length; i++) { for (i = 0; i < data.rooms.length; i++) {
var room = createNewRoom(client, data.rooms[i].room_id); var room = createNewRoom(client, data.rooms[i].room_id);
if (!data.rooms[i].state) { if (!data.rooms[i].state) {
@@ -1895,6 +1939,11 @@ function doInitialSync(client, historyLen) {
client, room, data.rooms[i].state, data.rooms[i].messages client, room, data.rooms[i].state, data.rooms[i].messages
); );
var receipts = receiptsByRoom[room.roomId] || [];
for (j = 0; j < receipts.length; j++) {
room.addReceipt(receipts[j]);
}
// cache the name/summary/etc prior to storage since we don't // cache the name/summary/etc prior to storage since we don't
// know how the store will serialise the Room. // know how the store will serialise the Room.
room.recalculate(client.credentials.userId); room.recalculate(client.credentials.userId);

View File

@@ -36,6 +36,25 @@ function Room(roomId, storageToken) {
this.summary = null; this.summary = null;
this.storageToken = storageToken; this.storageToken = storageToken;
this._redactions = []; this._redactions = [];
// receipts should clobber based on receipt_type and user_id pairs hence
// the form of this structure. This is sub-optimal for the exposed APIs
// which pass in an event ID and get back some receipts, so we also store
// a pre-cached list for this purpose.
this._receipts = {
// receipt_type: {
// user_id: {
// eventId: <event_id>,
// data: <receipt_data>
// }
// }
};
this._receiptCacheByEventId = {
// $event_id: [{
// type: $type,
// userId: $user_id,
// data: <receipt data>
// }]
};
} }
utils.inherits(Room, EventEmitter); utils.inherits(Room, EventEmitter);
@@ -164,6 +183,9 @@ Room.prototype.addEvents = function(events, duplicateStrategy) {
if (events[i].getType() === "m.typing") { if (events[i].getType() === "m.typing") {
this.currentState.setTypingEvent(events[i]); this.currentState.setTypingEvent(events[i]);
} }
else if (events[i].getType() === "m.receipt") {
this.addReceipt(events[i]);
}
else { else {
if (duplicateStrategy) { if (duplicateStrategy) {
// is there a duplicate? // is there a duplicate?
@@ -220,6 +242,82 @@ Room.prototype.recalculate = function(userId) {
} }
}; };
/**
* Get a list of user IDs who have <b>read up to</b> the given event.
* @param {MatrixEvent} event the event to get read receipts for.
* @return {String[]} A list of user IDs.
*/
Room.prototype.getUsersReadUpTo = function(event) {
return this.getReceiptsForEvent(event).filter(function(receipt) {
return receipt.type === "m.read";
}).map(function(receipt) {
return receipt.userId;
});
};
/**
* Get a list of receipts for the given event.
* @param {MatrixEvent} event the event to get receipts for
* @return {Object[]} A list of receipts with a userId, type and data keys or
* an empty list.
*/
Room.prototype.getReceiptsForEvent = function(event) {
return this._receiptCacheByEventId[event.getId()] || [];
};
/**
* Add a receipt event to the room.
* @param {MatrixEvent} event The m.receipt event.
*/
Room.prototype.addReceipt = function(event) {
// event content looks like:
// content: {
// $event_id: {
// $receipt_type: {
// $user_id: {
// ts: $timestamp
// }
// }
// }
// }
var self = this;
utils.keys(event.getContent()).forEach(function(eventId) {
utils.keys(event.getContent()[eventId]).forEach(function(receiptType) {
utils.keys(event.getContent()[eventId][receiptType]).forEach(
function(userId) {
var receipt = event.getContent()[eventId][receiptType][userId];
if (!self._receipts[receiptType]) {
self._receipts[receiptType] = {};
}
if (!self._receipts[receiptType][userId]) {
self._receipts[receiptType][userId] = {};
}
self._receipts[receiptType][userId] = {
eventId: eventId,
data: receipt
};
});
});
});
// pre-cache receipts by event
self._receiptCacheByEventId = {};
utils.keys(self._receipts).forEach(function(receiptType) {
utils.keys(self._receipts[receiptType]).forEach(function(userId) {
var receipt = self._receipts[receiptType][userId];
if (!self._receiptCacheByEventId[receipt.eventId]) {
self._receiptCacheByEventId[receipt.eventId] = [];
}
self._receiptCacheByEventId[receipt.eventId].push({
userId: userId,
type: receiptType,
data: receipt.data
});
});
});
};
function setEventMetadata(event, stateContext, toStartOfTimeline) { function setEventMetadata(event, stateContext, toStartOfTimeline) {
// set sender and target properties // set sender and target properties
event.sender = stateContext.getSentinelMember( event.sender = stateContext.getSentinelMember(

View File

@@ -228,6 +228,144 @@ module.exports.deepCopy = function(obj) {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
}; };
/**
* Run polyfills to add Array.map and Array.filter if they are missing.
*/
module.exports.runPolyfills = function() {
// Array.prototype.filter
// ========================================================
// SOURCE:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
if (!Array.prototype.filter) {
Array.prototype.filter = function(fun/*, thisArg*/) {
if (this === void 0 || this === null) {
throw new TypeError();
}
var t = Object(this);
var len = t.length >>> 0;
if (typeof fun !== 'function') {
throw new TypeError();
}
var res = [];
var thisArg = arguments.length >= 2 ? arguments[1] : void 0;
for (var i = 0; i < len; i++) {
if (i in t) {
var val = t[i];
// NOTE: Technically this should Object.defineProperty at
// the next index, as push can be affected by
// properties on Object.prototype and Array.prototype.
// But that method's new, and collisions should be
// rare, so use the more-compatible alternative.
if (fun.call(thisArg, val, i, t)) {
res.push(val);
}
}
}
return res;
};
}
// Array.prototype.map
// ========================================================
// SOURCE:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map
// Production steps of ECMA-262, Edition 5, 15.4.4.19
// Reference: http://es5.github.io/#x15.4.4.19
if (!Array.prototype.map) {
Array.prototype.map = function(callback, thisArg) {
var T, A, k;
if (this === null || this === undefined) {
throw new TypeError(' this is null or not defined');
}
// 1. Let O be the result of calling ToObject passing the |this|
// value as the argument.
var O = Object(this);
// 2. Let lenValue be the result of calling the Get internal
// method of O with the argument "length".
// 3. Let len be ToUint32(lenValue).
var len = O.length >>> 0;
// 4. If IsCallable(callback) is false, throw a TypeError exception.
// See: http://es5.github.com/#x9.11
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function');
}
// 5. If thisArg was supplied, let T be thisArg; else let T be undefined.
if (arguments.length > 1) {
T = thisArg;
}
// 6. Let A be a new array created as if by the expression new Array(len)
// where Array is the standard built-in constructor with that name and
// len is the value of len.
A = new Array(len);
// 7. Let k be 0
k = 0;
// 8. Repeat, while k < len
while (k < len) {
var kValue, mappedValue;
// a. Let Pk be ToString(k).
// This is implicit for LHS operands of the in operator
// b. Let kPresent be the result of calling the HasProperty internal
// method of O with argument Pk.
// This step can be combined with c
// c. If kPresent is true, then
if (k in O) {
// i. Let kValue be the result of calling the Get internal
// method of O with argument Pk.
kValue = O[k];
// ii. Let mappedValue be the result of calling the Call internal
// method of callback with T as the this value and argument
// list containing kValue, k, and O.
mappedValue = callback.call(T, kValue, k, O);
// iii. Call the DefineOwnProperty internal method of A with arguments
// Pk, Property Descriptor
// { Value: mappedValue,
// Writable: true,
// Enumerable: true,
// Configurable: true },
// and false.
// In browsers that support Object.defineProperty, use the following:
// Object.defineProperty(A, k, {
// value: mappedValue,
// writable: true,
// enumerable: true,
// configurable: true
// });
// For best browser support, use the following:
A[k] = mappedValue;
}
// d. Increase k by 1.
k++;
}
// 9. return A
return A;
};
}
};
/** /**
* Inherit the prototype methods from one constructor into another. This is a * Inherit the prototype methods from one constructor into another. This is a
* port of the Node.js implementation with an Object.create polyfill. * port of the Node.js implementation with an Object.create polyfill.

View File

@@ -2,6 +2,7 @@
var sdk = require("../.."); var sdk = require("../..");
var HttpBackend = require("../mock-request"); var HttpBackend = require("../mock-request");
var utils = require("../test-utils"); var utils = require("../test-utils");
var MatrixEvent = sdk.MatrixEvent;
describe("MatrixClient syncing", function() { describe("MatrixClient syncing", function() {
var baseUrl = "http://localhost.or.something"; var baseUrl = "http://localhost.or.something";
@@ -270,6 +271,122 @@ describe("MatrixClient syncing", function() {
}); });
}); });
describe("receipts", function() {
var roomOne = "!foo:localhost";
var initialSync = {
end: "s_5_3",
presence: [],
receipts: [],
rooms: [{
membership: "join",
room_id: roomOne,
messages: {
start: "f_1_1",
end: "f_2_2",
chunk: [
utils.mkMessage({
room: roomOne, user: otherUserId, msg: "hello"
})
]
},
state: [
utils.mkEvent({
type: "m.room.name", room: roomOne, user: otherUserId,
content: {
name: "Old room name"
}
}),
utils.mkMembership({
room: roomOne, mship: "join", user: otherUserId
}),
utils.mkMembership({
room: roomOne, mship: "join", user: selfUserId
}),
utils.mkEvent({
type: "m.room.create", room: roomOne, user: selfUserId,
content: {
creator: selfUserId
}
})
]
}]
};
var eventData = {
start: "s_5_3",
end: "e_6_7",
chunk: []
};
beforeEach(function() {
eventData.chunk = [];
initialSync.receipts = [];
});
it("should sync receipts from /initialSync.", function(done) {
var ackEvent = initialSync.rooms[0].messages.chunk[0];
var receipt = {};
receipt[ackEvent.event_id] = {
"m.read": {}
};
receipt[ackEvent.event_id]["m.read"][otherUserId] = {
ts: 176592842636
};
initialSync.receipts = [{
content: receipt,
room_id: roomOne,
type: "m.receipt"
}];
httpBackend.when("GET", "/initialSync").respond(200, initialSync);
httpBackend.when("GET", "/events").respond(200, eventData);
client.startClient();
httpBackend.flush().done(function() {
var room = client.getRoom(roomOne);
expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([{
type: "m.read",
userId: otherUserId,
data: {
ts: 176592842636
}
}]);
done();
});
});
it("should sync receipts from /events.", function(done) {
var ackEvent = initialSync.rooms[0].messages.chunk[0];
var receipt = {};
receipt[ackEvent.event_id] = {
"m.read": {}
};
receipt[ackEvent.event_id]["m.read"][otherUserId] = {
ts: 176592842636
};
eventData.chunk = [{
content: receipt,
room_id: roomOne,
type: "m.receipt"
}];
httpBackend.when("GET", "/initialSync").respond(200, initialSync);
httpBackend.when("GET", "/events").respond(200, eventData);
client.startClient();
httpBackend.flush().done(function() {
var room = client.getRoom(roomOne);
expect(room.getReceiptsForEvent(new MatrixEvent(ackEvent))).toEqual([{
type: "m.read",
userId: otherUserId,
data: {
ts: 176592842636
}
}]);
done();
});
});
});
describe("of a room", function() { describe("of a room", function() {
xit("should sync when a join event (which changes state) for the user" + xit("should sync when a join event (which changes state) for the user" +
" arrives down the event stream (e.g. join from another device)", function() { " arrives down the event stream (e.g. join from another device)", function() {

View File

@@ -2,6 +2,7 @@
var sdk = require("../.."); var sdk = require("../..");
var Room = sdk.Room; var Room = sdk.Room;
var RoomState = sdk.RoomState; var RoomState = sdk.RoomState;
var MatrixEvent = sdk.MatrixEvent;
var utils = require("../test-utils"); var utils = require("../test-utils");
describe("Room", function() { describe("Room", function() {
@@ -549,4 +550,157 @@ describe("Room", function() {
expect(name).toEqual("?"); expect(name).toEqual("?");
}); });
}); });
describe("receipts", function() {
var eventToAck = utils.mkMessage({
room: roomId, user: userA, msg: "PLEASE ACKNOWLEDGE MY EXISTENCE",
event: true
});
function mkReceipt(roomId, records) {
var content = {};
records.forEach(function(r) {
if (!content[r.eventId]) { content[r.eventId] = {}; }
if (!content[r.eventId][r.type]) { content[r.eventId][r.type] = {}; }
content[r.eventId][r.type][r.userId] = {
ts: r.ts
};
});
return new MatrixEvent({
content: content,
room_id: roomId,
type: "m.receipt"
});
}
function mkRecord(eventId, type, userId, ts) {
ts = ts || Date.now();
return {
eventId: eventId,
type: type,
userId: userId,
ts: ts
};
}
describe("addReceipt", function() {
it("should store the receipt so it can be obtained via getReceiptsForEvent",
function() {
var ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts)
]));
expect(room.getReceiptsForEvent(eventToAck)).toEqual([{
type: "m.read",
userId: userB,
data: {
ts: ts
}
}]);
});
it("should clobber receipts based on type and user ID", function() {
var nextEventToAck = utils.mkMessage({
room: roomId, user: userA, msg: "I AM HERE YOU KNOW",
event: true
});
var ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts)
]));
var ts2 = 13787899999;
room.addReceipt(mkReceipt(roomId, [
mkRecord(nextEventToAck.getId(), "m.read", userB, ts2)
]));
expect(room.getReceiptsForEvent(eventToAck)).toEqual([]);
expect(room.getReceiptsForEvent(nextEventToAck)).toEqual([{
type: "m.read",
userId: userB,
data: {
ts: ts2
}
}]);
});
it("should persist multiple receipts for a single event ID", function() {
var ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts),
mkRecord(eventToAck.getId(), "m.read", userC, ts),
mkRecord(eventToAck.getId(), "m.read", userD, ts)
]));
expect(room.getUsersReadUpTo(eventToAck)).toEqual(
[userB, userC, userD]
);
});
it("should persist multiple receipts for a single receipt type", function() {
var eventTwo = utils.mkMessage({
room: roomId, user: userA, msg: "2222",
event: true
});
var eventThree = utils.mkMessage({
room: roomId, user: userA, msg: "3333",
event: true
});
var ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts),
mkRecord(eventTwo.getId(), "m.read", userC, ts),
mkRecord(eventThree.getId(), "m.read", userD, ts)
]));
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]);
expect(room.getUsersReadUpTo(eventTwo)).toEqual([userC]);
expect(room.getUsersReadUpTo(eventThree)).toEqual([userD]);
});
it("should persist multiple receipts for a single user ID", function() {
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.delivered", userB, 13787898424),
mkRecord(eventToAck.getId(), "m.read", userB, 22222222),
mkRecord(eventToAck.getId(), "m.seen", userB, 33333333),
]));
expect(room.getReceiptsForEvent(eventToAck)).toEqual([
{
type: "m.delivered",
userId: userB,
data: {
ts: 13787898424
}
},
{
type: "m.read",
userId: userB,
data: {
ts: 22222222
}
},
{
type: "m.seen",
userId: userB,
data: {
ts: 33333333
}
}
]);
});
});
describe("getUsersReadUpTo", function() {
it("should return user IDs read up to the given event", function() {
var ts = 13787898424;
room.addReceipt(mkReceipt(roomId, [
mkRecord(eventToAck.getId(), "m.read", userB, ts)
]));
expect(room.getUsersReadUpTo(eventToAck)).toEqual([userB]);
});
});
});
}); });