diff options
author | Mark Haines <mark.haines@matrix.org> | 2014-08-27 16:54:12 +0100 |
---|---|---|
committer | Mark Haines <mark.haines@matrix.org> | 2014-08-27 16:54:12 +0100 |
commit | 1d95e78759cc1307d15d6cc6388f52063e833355 (patch) | |
tree | 0307617989016019bfd1d41a15b22499d02091ef | |
parent | add _get_room_member, fix datastore methods (diff) | |
parent | Added RestServlet for /rooms/$roomid/initialSync (diff) | |
download | synapse-1d95e78759cc1307d15d6cc6388f52063e833355.tar.xz |
Merge branch 'develop' into storage_transactions
34 files changed, 1341 insertions, 792 deletions
diff --git a/.gitignore b/.gitignore index 14bd9078f3..55fa8b819c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ docs/build/ *.egg-info cmdclient_config.json -homeserver.db +homeserver*.db .coverage htmlcov diff --git a/cmdclient/console.py b/cmdclient/console.py index 4cb604e796..a4d8145d72 100755 --- a/cmdclient/console.py +++ b/cmdclient/console.py @@ -471,7 +471,7 @@ class SynapseCmd(cmd.Cmd): room_name = args["vis"] body["room_alias_name"] = room_name - reactor.callFromThread(self._run_and_pprint, "POST", "/rooms", body) + reactor.callFromThread(self._run_and_pprint, "POST", "/createRoom", body) def do_raw(self, line): """Directly send a JSON object: "raw <method> <path> <data> <notoken>" diff --git a/docs/client-server/swagger_matrix/api-docs b/docs/client-server/swagger_matrix/api-docs index d974dbb374..58462a9067 100644 --- a/docs/client-server/swagger_matrix/api-docs +++ b/docs/client-server/swagger_matrix/api-docs @@ -21,6 +21,14 @@ { "path": "/presence", "description": "Presence operations" + }, + { + "path": "/events", + "description": "Event operations" + }, + { + "path": "/directory", + "description": "Directory operations" } ], "authorizations": { diff --git a/docs/client-server/swagger_matrix/directory b/docs/client-server/swagger_matrix/directory new file mode 100644 index 0000000000..3f3bef9c11 --- /dev/null +++ b/docs/client-server/swagger_matrix/directory @@ -0,0 +1,83 @@ +{ + "apiVersion": "1.0.0", + "swaggerVersion": "1.2", + "basePath": "http://localhost:8080/matrix/client/api/v1", + "resourcePath": "/directory", + "produces": [ + "application/json" + ], + "apis": [ + { + "path": "/directory/room/{roomAlias}", + "operations": [ + { + "method": "GET", + "summary": "Get the room ID corresponding to this room alias.", + "type": "DirectoryResponse", + "nickname": "get_room_id_for_alias", + "parameters": [ + { + "name": "roomAlias", + "description": "The room alias.", + "required": true, + "type": "string", + "paramType": "path" + } + ] + }, + { + "method": "PUT", + "summary": "Create a new mapping from room alias to room ID.", + "type": "void", + "nickname": "add_room_alias", + "parameters": [ + { + "name": "roomAlias", + "description": "The room alias to set.", + "required": true, + "type": "string", + "paramType": "path" + }, + { + "name": "body", + "description": "The room ID to set.", + "required": true, + "type": "RoomAliasRequest", + "paramType": "body" + } + ] + } + ] + } + ], + "models": { + "DirectoryResponse": { + "id": "DirectoryResponse", + "properties": { + "room_id": { + "type": "string", + "description": "The fully-qualified room ID.", + "required": true + }, + "servers": { + "type": "array", + "items": { + "$ref": "string" + }, + "description": "A list of servers that know about this room.", + "required": true + } + } + }, + "RoomAliasRequest": { + "id": "RoomAliasRequest", + "properties": { + "room_id": { + "type": "string", + "description": "The room ID to map the alias to.", + "required": true + } + } + } + } +} diff --git a/docs/client-server/swagger_matrix/events b/docs/client-server/swagger_matrix/events index c9eb3f6ff7..ca69d34db5 100644 --- a/docs/client-server/swagger_matrix/events +++ b/docs/client-server/swagger_matrix/events @@ -1,181 +1,59 @@ { "apiVersion": "1.0.0", "swaggerVersion": "1.2", - "basePath": "http://petstore.swagger.wordnik.com/api", - "resourcePath": "/user", + "basePath": "http://localhost:8080/matrix/client/api/v1", + "resourcePath": "/events", "produces": [ "application/json" ], "apis": [ { - "path": "/user", - "operations": [ - { - "method": "POST", - "summary": "Create user", - "notes": "This can only be done by the logged in user.", - "type": "void", - "nickname": "createUser", - "authorizations": { - "oauth2": [ - { - "scope": "test:anything", - "description": "anything" - } - ] - }, - "parameters": [ - { - "name": "body", - "description": "Created user object", - "required": true, - "type": "User", - "paramType": "body" - } - ] - } - ] - }, - { - "path": "/user/logout", + "path": "/events", "operations": [ { "method": "GET", - "summary": "Logs out current logged in user session", - "notes": "", - "type": "void", - "nickname": "logoutUser", - "authorizations": {}, - "parameters": [] + "summary": "Listen on the event stream", + "notes": "This can only be done by the logged in user. This will block until an event is received, or until the timeout is reached.", + "type": "PaginationChunk", + "nickname": "get_event_stream" } - ] - }, - { - "path": "/user/createWithArray", - "operations": [ + ], + "parameters": [ { - "method": "POST", - "summary": "Creates list of users with given input array", - "notes": "", - "type": "void", - "nickname": "createUsersWithArrayInput", - "authorizations": { - "oauth2": [ - { - "scope": "test:anything", - "description": "anything" - } - ] - }, - "parameters": [ - { - "name": "body", - "description": "List of user object", - "required": true, - "type": "array", - "items": { - "$ref": "User" - }, - "paramType": "body" - } - ] + "name": "from", + "description": "The token to stream from.", + "required": false, + "type": "string", + "paramType": "query" + }, + { + "name": "timeout", + "description": "The maximum time in milliseconds to wait for an event.", + "required": false, + "type": "integer", + "paramType": "query" } - ] - }, - { - "path": "/user/createWithList", - "operations": [ + ], + "responseMessages": [ { - "method": "POST", - "summary": "Creates list of users with given list input", - "notes": "", - "type": "void", - "nickname": "createUsersWithListInput", - "authorizations": { - "oauth2": [ - { - "scope": "test:anything", - "description": "anything" - } - ] - }, - "parameters": [ - { - "name": "body", - "description": "List of user object", - "required": true, - "type": "array", - "items": { - "$ref": "User" - }, - "paramType": "body" - } - ] + "code": 400, + "message": "Bad pagination token." } ] }, { - "path": "/user/{username}", + "path": "/events/{eventId}", "operations": [ { - "method": "PUT", - "summary": "Updated user", - "notes": "This can only be done by the logged in user.", - "type": "void", - "nickname": "updateUser", - "authorizations": { - "oauth2": [ - { - "scope": "test:anything", - "description": "anything" - } - ] - }, - "parameters": [ - { - "name": "username", - "description": "name that need to be deleted", - "required": true, - "type": "string", - "paramType": "path" - }, - { - "name": "body", - "description": "Updated user object", - "required": true, - "type": "User", - "paramType": "body" - } - ], - "responseMessages": [ - { - "code": 400, - "message": "Invalid username supplied" - }, - { - "code": 404, - "message": "User not found" - } - ] - }, - { - "method": "DELETE", - "summary": "Delete user", - "notes": "This can only be done by the logged in user.", - "type": "void", - "nickname": "deleteUser", - "authorizations": { - "oauth2": [ - { - "scope": "test:anything", - "description": "anything" - } - ] - }, + "method": "GET", + "summary": "Get information about a single event.", + "notes": "Get information about a single event.", + "type": "Event", + "nickname": "get_event", "parameters": [ { - "name": "username", - "description": "The name that needs to be deleted", + "name": "eventId", + "description": "The event ID to get.", "required": true, "type": "string", "paramType": "path" @@ -183,117 +61,186 @@ ], "responseMessages": [ { - "code": 400, - "message": "Invalid username supplied" - }, - { "code": 404, - "message": "User not found" + "message": "Event not found." } ] - }, + } + ] + }, + { + "path": "/initialSync", + "operations": [ { "method": "GET", - "summary": "Get user by user name", - "notes": "", - "type": "User", - "nickname": "getUserByName", - "authorizations": {}, + "summary": "Get this user's current state.", + "notes": "Get this user's current state.", + "type": "InitialSyncResponse", + "nickname": "initial_sync", "parameters": [ { - "name": "username", - "description": "The name that needs to be fetched. Use user1 for testing.", - "required": true, - "type": "string", - "paramType": "path" - } - ], - "responseMessages": [ - { - "code": 400, - "message": "Invalid username supplied" - }, - { - "code": 404, - "message": "User not found" + "name": "limit", + "description": "The maximum number of messages to return for each room.", + "type": "integer", + "paramType": "query", + "required": false } ] } ] }, { - "path": "/user/login", + "path": "/publicRooms", "operations": [ { "method": "GET", - "summary": "Logs user into the system", - "notes": "", - "type": "string", - "nickname": "loginUser", - "authorizations": {}, - "parameters": [ - { - "name": "username", - "description": "The user name for login", - "required": true, - "type": "string", - "paramType": "query" - }, - { - "name": "password", - "description": "The password for login in clear text", - "required": true, - "type": "string", - "paramType": "query" - } - ], - "responseMessages": [ - { - "code": 400, - "message": "Invalid username and password combination" - } - ] + "summary": "Get a list of publicly visible rooms.", + "type": "PublicRoomsPaginationChunk", + "nickname": "get_public_room_list" } ] } ], "models": { - "User": { - "id": "User", + "PaginationChunk": { + "id": "PaginationChunk", "properties": { - "id": { - "type": "integer", - "format": "int64" + "start": { + "type": "string", + "description": "A token which correlates to the first value in \"chunk\" for paginating.", + "required": true + }, + "end": { + "type": "string", + "description": "A token which correlates to the last value in \"chunk\" for paginating.", + "required": true + }, + "chunk": { + "type": "array", + "description": "An array of events.", + "required": true, + "items": { + "$ref": "Event" + } + } + } + }, + "Event": { + "id": "Event", + "properties": { + "event_id": { + "type": "string", + "description": "An ID which uniquely identifies this event.", + "required": true }, - "firstName": { - "type": "string" + "room_id": { + "type": "string", + "description": "The room in which this event occurred.", + "required": true + } + } + }, + "PublicRoomInfo": { + "id": "PublicRoomInfo", + "properties": { + "aliases": { + "type": "array", + "description": "A list of room aliases for this room.", + "items": { + "$ref": "string" + } + }, + "name": { + "type": "string", + "description": "The name of the room, as given by the m.room.name state event." + }, + "room_id": { + "type": "string", + "description": "The room ID for this public room.", + "required": true + }, + "topic": { + "type": "string", + "description": "The topic of this room, as given by the m.room.topic state event." + } + } + }, + "PublicRoomsPaginationChunk": { + "id": "PublicRoomsPaginationChunk", + "properties": { + "start": { + "type": "string", + "description": "A token which correlates to the first value in \"chunk\" for paginating.", + "required": true }, - "username": { - "type": "string" + "end": { + "type": "string", + "description": "A token which correlates to the last value in \"chunk\" for paginating.", + "required": true + }, + "chunk": { + "type": "array", + "description": "A list of public room data.", + "required": true, + "items": { + "$ref": "PublicRoomInfo" + } + } + } + }, + "InitialSyncResponse": { + "id": "InitialSyncResponse", + "properties": { + "end": { + "type": "string", + "description": "A streaming token which can be used with /events to continue from this snapshot of data.", + "required": true }, - "lastName": { - "type": "string" + "presence": { + "type": "array", + "description": "A list of presence events.", + "items": { + "$ref": "Event" + }, + "required": false }, - "email": { - "type": "string" + "rooms": { + "type": "array", + "description": "A list of initial sync room data.", + "required": false, + "items": { + "$ref": "InitialSyncRoomData" + } + } + } + }, + "InitialSyncRoomData": { + "id": "InitialSyncRoomData", + "properties": { + "membership": { + "type": "string", + "description": "This user's membership state in this room.", + "required": true }, - "password": { - "type": "string" + "room_id": { + "type": "string", + "description": "The ID of this room.", + "required": true }, - "phone": { - "type": "string" + "messages": { + "type": "PaginationChunk", + "description": "The most recent messages for this room, governed by the limit parameter.", + "required": false }, - "userStatus": { - "type": "integer", - "format": "int32", - "description": "User Status", - "enum": [ - "1-registered", - "2-active", - "3-closed" - ] + "state": { + "type": "array", + "description": "A list of state events representing the current state of the room.", + "required": false, + "items": { + "$ref": "Event" + } } } } } -} \ No newline at end of file +} diff --git a/docs/client-server/swagger_matrix/presence b/docs/client-server/swagger_matrix/presence index ee9deb12f0..1b4c7323aa 100644 --- a/docs/client-server/swagger_matrix/presence +++ b/docs/client-server/swagger_matrix/presence @@ -55,7 +55,7 @@ ] }, { - "path": "/presence_list/{userId}", + "path": "/presence/list/{userId}", "operations": [ { "method": "GET", diff --git a/docs/client-server/swagger_matrix/rooms b/docs/client-server/swagger_matrix/rooms index 47a8887240..ce2843d6c9 100644 --- a/docs/client-server/swagger_matrix/rooms +++ b/docs/client-server/swagger_matrix/rooms @@ -14,23 +14,23 @@ }, "apis": [ { - "path": "/rooms/{roomId}/messages/{userId}/{messageId}", + "path": "/rooms/{roomId}/send/{eventType}/{txnId}", "operations": [ { "method": "PUT", - "summary": "Send a message in this room.", - "notes": "Send a message in this room.", - "type": "void", - "nickname": "send_message", + "summary": "Send a generic non-state event to this room.", + "notes": "This operation can also be done as a POST to /rooms/{roomId}/send/{eventType}", + "type": "EventId", + "nickname": "send_non_state_event", "consumes": [ "application/json" ], "parameters": [ { "name": "body", - "description": "The message contents", + "description": "The event contents", "required": true, - "type": "Message", + "type": "EventContent", "paramType": "body" }, { @@ -41,35 +41,44 @@ "paramType": "path" }, { - "name": "userId", - "description": "The fully qualified message sender's user ID.", + "name": "eventType", + "description": "The type of event to send.", "required": true, "type": "string", "paramType": "path" }, { - "name": "messageId", - "description": "A message ID which is unique for each room and user.", + "name": "txnId", + "description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.", "required": true, "type": "string", "paramType": "path" } - ], - "responseMessages": [ - { - "code": 403, - "message": "Must send messages as yourself." - } ] - }, + } + ] + }, + { + "path": "/rooms/{roomId}/state/{eventType}/{stateKey}", + "operations": [ { - "method": "GET", - "summary": "Get a message from this room.", - "notes": "Get a message from this room.", - "type": "Message", - "nickname": "get_message", + "method": "PUT", + "summary": "Send a generic state event to this room.", + "notes": "The state key can be omitted, such that you can PUT to /rooms/{roomId}/state/{eventType}. The state key defaults to a 0 length string in this case.", + "type": "void", + "nickname": "send_state_event", + "consumes": [ + "application/json" + ], "parameters": [ { + "name": "body", + "description": "The event contents", + "required": true, + "type": "EventContent", + "paramType": "body" + }, + { "name": "roomId", "description": "The room to send the message in.", "required": true, @@ -77,31 +86,63 @@ "paramType": "path" }, { - "name": "userId", - "description": "The fully qualified message sender's user ID.", + "name": "eventType", + "description": "The type of event to send.", "required": true, "type": "string", "paramType": "path" }, { - "name": "messageId", - "description": "A message ID which is unique for each room and user.", + "name": "stateKey", + "description": "An identifier used to specify clobbering semantics. State events with the same (roomId, eventType, stateKey) will be replaced.", "required": true, "type": "string", "paramType": "path" } + ] + } + ] + }, + { + "path": "/rooms/{roomId}/send/m.room.message/{txnId}", + "operations": [ + { + "method": "PUT", + "summary": "Send a message in this room.", + "notes": "This operation can also be done as a POST to /rooms/{roomId}/send/m.room.message", + "type": "EventId", + "nickname": "send_message", + "consumes": [ + "application/json" ], - "responseMessages": [ + "parameters": [ { - "code": 404, - "message": "Message not found." + "name": "body", + "description": "The message contents", + "required": true, + "type": "Message", + "paramType": "body" + }, + { + "name": "roomId", + "description": "The room to send the message in.", + "required": true, + "type": "string", + "paramType": "path" + }, + { + "name": "txnId", + "description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.", + "required": true, + "type": "string", + "paramType": "path" } ] } ] }, { - "path": "/rooms/{roomId}/topic", + "path": "/rooms/{roomId}/state/m.room.topic", "operations": [ { "method": "PUT", @@ -127,12 +168,6 @@ "type": "string", "paramType": "path" } - ], - "responseMessages": [ - { - "code": 403, - "message": "Must send messages as yourself." - } ] }, { @@ -160,13 +195,13 @@ ] }, { - "path": "/rooms/{roomId}/messages/{msgSenderId}/{messageId}/feedback/{senderId}/{feedbackType}", + "path": "/rooms/{roomId}/send/m.room.message.feedback/{txnId}", "operations": [ { "method": "PUT", "summary": "Send feedback to a message.", - "notes": "Send feedback to a message.", - "type": "void", + "notes": "This operation can also be done as a POST to /rooms/{roomId}/send/m.room.message.feedback", + "type": "EventId", "nickname": "send_feedback", "consumes": [ "application/json" @@ -187,107 +222,124 @@ "paramType": "path" }, { - "name": "msgSenderId", - "description": "The fully qualified message sender's user ID.", + "name": "txnId", + "description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.", "required": true, "type": "string", "paramType": "path" - }, - { - "name": "messageId", - "description": "A message ID which is unique for each room and user.", - "required": true, - "type": "string", - "paramType": "path" - }, - { - "name": "senderId", - "description": "The fully qualified feedback sender's user ID.", - "required": true, - "type": "string", - "paramType": "path" - }, - { - "name": "feedbackType", - "description": "The type of feedback being sent.", - "required": true, - "type": "string", - "paramType": "path", - "enum": [ - "d", - "r" - ] } ], "responseMessages": [ { - "code": 403, - "message": "Must send feedback as yourself." - }, - { "code": 400, "message": "Bad feedback type." } ] - }, + } + ] + }, + { + "path": "/rooms/{roomId}/invite/{txnId}", + "operations": [ { - "method": "GET", - "summary": "Get feedback for a message.", - "notes": "Get feedback for a message.", - "type": "Feedback", - "nickname": "get_feedback", + "method": "PUT", + "summary": "Invite a user to this room.", + "notes": "This operation can also be done as a POST to /rooms/{roomId}/invite", + "type": "void", + "nickname": "invite", + "consumes": [ + "application/json" + ], "parameters": [ { "name": "roomId", - "description": "The room to send the message in.", + "description": "The room which has this user.", "required": true, "type": "string", "paramType": "path" }, { - "name": "msgSenderId", - "description": "The fully qualified message sender's user ID.", - "required": true, + "name": "txnId", + "description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ", + "required": false, "type": "string", "paramType": "path" }, { - "name": "messageId", - "description": "A message ID which is unique for each room and user.", + "name": "body", + "description": "The user to invite.", "required": true, - "type": "string", - "paramType": "path" - }, + "type": "InviteRequest", + "paramType": "body" + } + ] + } + ] + }, + { + "path": "/rooms/{roomId}/join/{txnId}", + "operations": [ + { + "method": "PUT", + "summary": "Join this room.", + "notes": "This operation can also be done as a POST to /rooms/{roomId}/join", + "type": "void", + "nickname": "join_room", + "consumes": [ + "application/json" + ], + "parameters": [ { - "name": "senderId", - "description": "The fully qualified feedback sender's user ID.", + "name": "roomId", + "description": "The room to join.", "required": true, "type": "string", "paramType": "path" }, { - "name": "feedbackType", - "description": "Enum: The type of feedback being sent.", - "required": true, + "name": "txnId", + "description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ", + "required": false, "type": "string", - "paramType": "path", - "enum": [ - "d", - "r" - ] + "paramType": "path" } + ] + } + ] + }, + { + "path": "/rooms/{roomId}/leave/{txnId}", + "operations": [ + { + "method": "PUT", + "summary": "Leave this room.", + "notes": "This operation can also be done as a POST to /rooms/{roomId}/leave", + "type": "void", + "nickname": "leave", + "consumes": [ + "application/json" ], - "responseMessages": [ + "parameters": [ { - "code": 404, - "message": "Feedback not found." + "name": "roomId", + "description": "The room to leave.", + "required": true, + "type": "string", + "paramType": "path" + }, + { + "name": "txnId", + "description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ", + "required": false, + "type": "string", + "paramType": "path" } ] - } + } ] }, { - "path": "/rooms/{roomId}/members/{userId}/state", + "path": "/rooms/{roomId}/state/m.room.member/{userId}", "operations": [ { "method": "PUT", @@ -376,58 +428,25 @@ "message": "Member not found." } ] - }, - { - "method": "DELETE", - "summary": "Leave a room.", - "notes": "Leave a room.", - "type": "void", - "nickname": "remove_membership", - "parameters": [ - { - "name": "userId", - "description": "The user who is leaving.", - "required": true, - "type": "string", - "paramType": "path" - }, - { - "name": "roomId", - "description": "The room which has this user.", - "required": true, - "type": "string", - "paramType": "path" - } - ], - "responseMessages": [ - { - "code": 403, - "message": "You are not in the room." - }, - { - "code": 403, - "message": "Cannot force another user to leave." - } - ] } ] }, { - "path": "/join/{roomAlias}", + "path": "/join/{roomAliasOrId}", "operations": [ { "method": "PUT", - "summary": "Join a room via a room alias.", - "notes": "Join a room via a room alias.", + "summary": "Join a room via a room alias or room ID.", + "notes": "Join a room via a room alias or room ID.", "type": "RoomInfo", - "nickname": "join_room_via_alias", + "nickname": "join", "consumes": [ "application/json" ], "parameters": [ { - "name": "roomAlias", - "description": "The room alias to join.", + "name": "roomAliasOrId", + "description": "The room alias or room ID to join.", "required": true, "type": "string", "paramType": "path" @@ -443,7 +462,7 @@ ] }, { - "path": "/rooms", + "path": "/createRoom", "operations": [ { "method": "POST", @@ -477,7 +496,7 @@ ] }, { - "path": "/rooms/{roomId}/messages/list", + "path": "/rooms/{roomId}/messages", "operations": [ { "method": "GET", @@ -519,7 +538,7 @@ ] }, { - "path": "/rooms/{roomId}/members/list", + "path": "/rooms/{roomId}/members", "operations": [ { "method": "GET", @@ -704,12 +723,17 @@ "properties": { "event_id": { "type": "string", - "description": "An ID which uniquely identifies this event.", + "description": "An ID which uniquely identifies this event. This is automatically set by the server.", "required": true }, "room_id": { "type": "string", - "description": "The room in which this event occurred.", + "description": "The room in which this event occurred. This is automatically set by the server.", + "required": true + }, + "type": { + "type": "string", + "description": "The event type.", "required": true } }, @@ -717,6 +741,26 @@ "MessageEvent" ] }, + "EventId": { + "id": "EventId", + "properties": { + "event_id": { + "type": "string", + "description": "The allocated event ID for this event.", + "required": true + } + } + }, + "EventContent": { + "id": "EventContent", + "properties": { + "__event_content_keys__": { + "type": "string", + "description": "Event-specific content keys and values.", + "required": false + } + } + }, "MessageEvent": { "id": "MessageEvent", "properties": { @@ -733,73 +777,12 @@ } } }, - "Tag": { - "id": "Tag", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - } - } - }, - "Pet": { - "id": "Pet", - "required": [ - "id", - "name" - ], + "InviteRequest": { + "id": "InviteRequest", "properties": { - "id": { - "type": "integer", - "format": "int64", - "description": "unique identifier for the pet", - "minimum": "0.0", - "maximum": "100.0" - }, - "category": { - "$ref": "Category" - }, - "name": { - "type": "string" - }, - "photoUrls": { - "type": "array", - "items": { - "type": "string" - } - }, - "tags": { - "type": "array", - "items": { - "$ref": "Tag" - } - }, - "status": { + "user_id": { "type": "string", - "description": "pet status in the store", - "enum": [ - "available", - "pending", - "sold" - ] - } - } - }, - "Category": { - "id": "Category", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - }, - "pet": { - "$ref": "Pet" + "description": "The fully-qualified user ID." } } } diff --git a/docs/client-server/urls.rst b/docs/client-server/urls.rst deleted file mode 100644 index c0d2eaa80c..0000000000 --- a/docs/client-server/urls.rst +++ /dev/null @@ -1,92 +0,0 @@ -========================= -Client-Server URL Summary -========================= - -A brief overview of the URL scheme involved in the Synapse Client-Server API. - - -URLs -==== - -Fetch events: - GET /events - -Registering an account - POST /register - -Unregistering an account - POST /unregister - -Rooms ------ - -Creating a room by ID - PUT /rooms/$roomid - -Creating an anonymous room - POST /rooms - -Room topic - GET /rooms/$roomid/topic - PUT /rooms/$roomid/topic - -List rooms - GET /rooms/list - -Invite/Join/Leave - GET /rooms/$roomid/members/$userid/state - PUT /rooms/$roomid/members/$userid/state - DELETE /rooms/$roomid/members/$userid/state - -List members - GET /rooms/$roomid/members/list - -Sending/reading messages - PUT /rooms/$roomid/messages/$sender/$msgid - -Feedback - GET /rooms/$roomid/messages/$sender/$msgid/feedback/$feedbackuser/$feedback - PUT /rooms/$roomid/messages/$sender/$msgid/feedback/$feedbackuser/$feedback - -Paginating messages - GET /rooms/$roomid/messages/list - -Profiles --------- - -Display name - GET /profile/$userid/displayname - PUT /profile/$userid/displayname - -Avatar URL - GET /profile/$userid/avatar_url - PUT /profile/$userid/avatar_url - -Metadata - GET /profile/$userid/metadata - POST /profile/$userid/metadata - -Presence --------- - -My state or status message - GET /presence/$userid/status - PUT /presence/$userid/status - also 'GET' for fetching others - -TODO(paul): per-device idle time, device type; similar to above - -My presence list - GET /presence_list/$myuserid - POST /presence_list/$myuserid - body is JSON-encoded dict of keys: - invite: list of UserID strings to invite - drop: list of UserID strings to remove - TODO(paul): define other ops: accept, group management, ordering? - -Presence polling start/stop - POST /presence_list/$myuserid?op=start - POST /presence_list/$myuserid?op=stop - -Presence invite - POST /presence_list/$myuserid/invite/$targetuserid diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py index 2d0340f0f1..6d292ccf9a 100755 --- a/synapse/app/homeserver.py +++ b/synapse/app/homeserver.py @@ -43,6 +43,22 @@ import re logger = logging.getLogger(__name__) +SCHEMAS = [ + "transactions", + "pdu", + "users", + "profiles", + "presence", + "im", + "room_aliases", +] + + +# Remember to update this number every time an incompatible change is made to +# database schema files, so the users will be informed on server restarts. +SCHEMA_VERSION = 1 + + class SynapseHomeServer(HomeServer): def build_http_client(self): @@ -65,31 +81,39 @@ class SynapseHomeServer(HomeServer): don't have to worry about overwriting existing content. """ logging.info("Preparing database: %s...", self.db_name) - pool = adbapi.ConnectionPool( - 'sqlite3', self.db_name, check_same_thread=False, - cp_min=1, cp_max=1) - schemas = [ - "transactions", - "pdu", - "users", - "profiles", - "presence", - "im", - "room_aliases", - ] + with sqlite3.connect(self.db_name) as db_conn: + c = db_conn.cursor() + c.execute("PRAGMA user_version") + row = c.fetchone() - for sql_loc in schemas: - sql_script = read_schema(sql_loc) + if row and row[0]: + user_version = row[0] - with sqlite3.connect(self.db_name) as db_conn: - c = db_conn.cursor() - c.executescript(sql_script) - c.close() - db_conn.commit() + if user_version < SCHEMA_VERSION: + # TODO(paul): add some kind of intelligent fixup here + raise ValueError("Cannot use this database as the " + + "schema version (%d) does not match (%d)" % + (user_version, SCHEMA_VERSION) + ) + + else: + for sql_loc in SCHEMAS: + sql_script = read_schema(sql_loc) + + c.executescript(sql_script) + db_conn.commit() + + c.execute("PRAGMA user_version = %d" % SCHEMA_VERSION) + + c.close() logging.info("Database prepared in %s.", self.db_name) + pool = adbapi.ConnectionPool( + 'sqlite3', self.db_name, check_same_thread=False, + cp_min=1, cp_max=1) + return pool def create_resource_tree(self, web_client, redirect_root_to_web_client): @@ -184,6 +208,7 @@ class SynapseHomeServer(HomeServer): def start_listening(self, port): reactor.listenTCP(port, Site(self.root_resource)) + logger.info("Synapse now listening on port %d", port) def setup_logging(verbosity=0, filename=None, config_path=None): @@ -278,7 +303,7 @@ def setup(): redirect_root_to_web_client=True) hs.start_listening(args.port) - hs.build_db_pool() + hs.get_db_pool() if args.manhole: f = twisted.manhole.telnet.ShellFactory() diff --git a/synapse/handlers/__init__.py b/synapse/handlers/__init__.py index 7417a02cea..b645977767 100644 --- a/synapse/handlers/__init__.py +++ b/synapse/handlers/__init__.py @@ -23,6 +23,7 @@ from .login import LoginHandler from .profile import ProfileHandler from .presence import PresenceHandler from .directory import DirectoryHandler +from .typing import TypingNotificationHandler class Handlers(object): @@ -46,3 +47,4 @@ class Handlers(object): self.room_list_handler = RoomListHandler(hs) self.login_handler = LoginHandler(hs) self.directory_handler = DirectoryHandler(hs) + self.typing_notification_handler = TypingNotificationHandler(hs) diff --git a/synapse/handlers/typing.py b/synapse/handlers/typing.py new file mode 100644 index 0000000000..9d38a7336e --- /dev/null +++ b/synapse/handlers/typing.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# 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. + +from twisted.internet import defer + +from ._base import BaseHandler + +import logging + +from collections import namedtuple + + +logger = logging.getLogger(__name__) + + +# A tiny object useful for storing a user's membership in a room, as a mapping +# key +RoomMember = namedtuple("RoomMember", ("room_id", "user")) + + +class TypingNotificationHandler(BaseHandler): + def __init__(self, hs): + super(TypingNotificationHandler, self).__init__(hs) + + self.homeserver = hs + + self.clock = hs.get_clock() + + self.federation = hs.get_replication_layer() + + self.federation.register_edu_handler("m.typing", self._recv_edu) + + self._member_typing_until = {} + + @defer.inlineCallbacks + def started_typing(self, target_user, auth_user, room_id, timeout): + if not target_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + if target_user != auth_user: + raise AuthError(400, "Cannot set another user's typing state") + + until = self.clock.time_msec() + timeout + member = RoomMember(room_id=room_id, user=target_user) + + was_present = member in self._member_typing_until + + self._member_typing_until[member] = until + + if was_present: + # No point sending another notification + defer.returnValue(None) + + yield self._push_update( + room_id=room_id, + user=target_user, + typing=True, + ) + + @defer.inlineCallbacks + def stopped_typing(self, target_user, auth_user, room_id): + if not target_user.is_mine: + raise SynapseError(400, "User is not hosted on this Home Server") + + if target_user != auth_user: + raise AuthError(400, "Cannot set another user's typing state") + + member = RoomMember(room_id=room_id, user=target_user) + + if member not in self._member_typing_until: + # No point + defer.returnValue(None) + + yield self._push_update( + room_id=room_id, + user=target_user, + typing=False, + ) + + @defer.inlineCallbacks + def _push_update(self, room_id, user, typing): + localusers = set() + remotedomains = set() + + rm_handler = self.homeserver.get_handlers().room_member_handler + yield rm_handler.fetch_room_distributions_into(room_id, + localusers=localusers, remotedomains=remotedomains, + ignore_user=user) + + for u in localusers: + self.push_update_to_clients( + room_id=room_id, + observer_user=u, + observed_user=user, + typing=typing, + ) + + deferreds = [] + for domain in remotedomains: + deferreds.append(self.federation.send_edu( + destination=domain, + edu_type="m.typing", + content={ + "room_id": room_id, + "user_id": user.to_string(), + "typing": typing, + }, + )) + + yield defer.DeferredList(deferreds, consumeErrors=False) + + @defer.inlineCallbacks + def _recv_edu(self, origin, content): + room_id = content["room_id"] + user = self.homeserver.parse_userid(content["user_id"]) + + localusers = set() + + rm_handler = self.homeserver.get_handlers().room_member_handler + yield rm_handler.fetch_room_distributions_into(room_id, + localusers=localusers) + + for u in localusers: + self.push_update_to_clients( + room_id=room_id, + observer_user=u, + observed_user=user, + typing=content["typing"] + ) + + def push_update_to_clients(self, room_id, observer_user, observed_user, + typing): + # TODO(paul) steal this from presence.py + pass diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py index 47896612ce..f33024e72a 100644 --- a/synapse/rest/__init__.py +++ b/synapse/rest/__init__.py @@ -15,8 +15,7 @@ from . import ( - room, events, register, login, profile, public, presence, initial_sync, - directory + room, events, register, login, profile, presence, initial_sync, directory ) @@ -40,7 +39,6 @@ class RestServletFactory(object): register.register_servlets(hs, client_resource) login.register_servlets(hs, client_resource) profile.register_servlets(hs, client_resource) - public.register_servlets(hs, client_resource) presence.register_servlets(hs, client_resource) initial_sync.register_servlets(hs, client_resource) directory.register_servlets(hs, client_resource) diff --git a/synapse/rest/directory.py b/synapse/rest/directory.py index be9a3f5f9f..dc347652a0 100644 --- a/synapse/rest/directory.py +++ b/synapse/rest/directory.py @@ -31,7 +31,7 @@ def register_servlets(hs, http_server): class ClientDirectoryServer(RestServlet): - PATTERN = client_path_pattern("/ds/room/(?P<room_alias>[^/]*)$") + PATTERN = client_path_pattern("/directory/room/(?P<room_alias>[^/]*)$") @defer.inlineCallbacks def on_GET(self, request, room_alias): diff --git a/synapse/rest/presence.py b/synapse/rest/presence.py index 6043848595..e013b20853 100644 --- a/synapse/rest/presence.py +++ b/synapse/rest/presence.py @@ -68,7 +68,7 @@ class PresenceStatusRestServlet(RestServlet): class PresenceListRestServlet(RestServlet): - PATTERN = client_path_pattern("/presence_list/(?P<user_id>[^/]*)") + PATTERN = client_path_pattern("/presence/list/(?P<user_id>[^/]*)") @defer.inlineCallbacks def on_GET(self, request, user_id): diff --git a/synapse/rest/public.py b/synapse/rest/public.py deleted file mode 100644 index 3430c8049f..0000000000 --- a/synapse/rest/public.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 matrix.org -# -# 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. - -"""This module contains REST servlets to do with public paths: /public""" -from twisted.internet import defer - -from base import RestServlet, client_path_pattern - - -class PublicRoomListRestServlet(RestServlet): - PATTERN = client_path_pattern("/public/rooms$") - - @defer.inlineCallbacks - def on_GET(self, request): - handler = self.handlers.room_list_handler - data = yield handler.get_public_room_list() - defer.returnValue((200, data)) - - -def register_servlets(hs, http_server): - PublicRoomListRestServlet(hs).register(http_server) diff --git a/synapse/rest/room.py b/synapse/rest/room.py index b8d5cb87fd..ebe4e24432 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -34,31 +34,28 @@ class RoomCreateRestServlet(RestServlet): # No PATTERN; we have custom dispatch rules here def register(self, http_server): - # /rooms OR /rooms/<roomid> - http_server.register_path("POST", - client_path_pattern("/rooms$"), - self.on_POST) - http_server.register_path("PUT", - client_path_pattern( - "/rooms/(?P<room_id>[^/]*)$"), - self.on_PUT) + PATTERN = "/createRoom" + register_txn_path(self, PATTERN, http_server) # define CORS for all of /rooms in RoomCreateRestServlet for simplicity http_server.register_path("OPTIONS", client_path_pattern("/rooms(?:/.*)?$"), self.on_OPTIONS) + # define CORS for /createRoom[/txnid] + http_server.register_path("OPTIONS", + client_path_pattern("/createRoom(?:/.*)?$"), + self.on_OPTIONS) @defer.inlineCallbacks - def on_PUT(self, request, room_id): - room_id = urllib.unquote(room_id) - auth_user = yield self.auth.get_user_by_req(request) + def on_PUT(self, request, txn_id): + try: + defer.returnValue(self.txns.get_client_transaction(request, txn_id)) + except KeyError: + pass - if not room_id: - raise SynapseError(400, "PUT must specify a room ID") + response = yield self.on_POST(request) - room_config = self.get_room_config(request) - info = yield self.make_room(room_config, auth_user, room_id) - room_config.update(info) - defer.returnValue((200, info)) + self.txns.store_client_transaction(request, txn_id, response) + defer.returnValue(response) @defer.inlineCallbacks def on_POST(self, request): @@ -268,6 +265,17 @@ class JoinRoomAliasServlet(RestServlet): # TODO: Needs unit testing +class PublicRoomListRestServlet(RestServlet): + PATTERN = client_path_pattern("/publicRooms$") + + @defer.inlineCallbacks + def on_GET(self, request): + handler = self.handlers.room_list_handler + data = yield handler.get_public_room_list() + defer.returnValue((200, data)) + + +# TODO: Needs unit testing class RoomMemberListRestServlet(RestServlet): PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/members$") @@ -314,6 +322,50 @@ class RoomMessageListRestServlet(RestServlet): defer.returnValue((200, msgs)) +# TODO: Needs unit testing +class RoomStateRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/state$") + + @defer.inlineCallbacks + def on_GET(self, request, room_id): + user = yield self.auth.get_user_by_req(request) + # TODO: Get all the current state for this room and return in the same + # format as initial sync, that is: + # [ + # { state event }, { state event } + # ] + defer.returnValue((200, [])) + + +# TODO: Needs unit testing +class RoomInitialSyncRestServlet(RestServlet): + PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/initialSync$") + + @defer.inlineCallbacks + def on_GET(self, request, room_id): + user = yield self.auth.get_user_by_req(request) + # TODO: Get all the initial sync data for this room and return in the + # same format as initial sync, that is: + # { + # membership: join, + # messages: [ + # chunk: [ msg events ], + # start: s_tok, + # end: e_tok + # ], + # room_id: foo, + # state: [ + # { state event } , { state event } + # ] + # } + # Probably worth keeping the keys room_id and membership for parity with + # /initialSync even though they must be joined to sync this and know the + # room ID, so clients can reuse the same code (room_id and membership + # are MANDATORY for /initialSync, so the code will expect it to be + # there) + defer.returnValue((200, {})) + + class RoomTriggerBackfill(RestServlet): PATTERN = client_path_pattern("/rooms/(?P<room_id>[^/]*)/backfill$") @@ -427,3 +479,6 @@ def register_servlets(hs, http_server): RoomTriggerBackfill(hs).register(http_server) RoomMembershipRestServlet(hs).register(http_server) RoomSendEventRestServlet(hs).register(http_server) + PublicRoomListRestServlet(hs).register(http_server) + RoomStateRestServlet(hs).register(http_server) + RoomInitialSyncRestServlet(hs).register(http_server) diff --git a/synapse/server.py b/synapse/server.py index 9de88b6642..94facf9d99 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -124,6 +124,11 @@ class BaseHomeServer(object): object.""" return UserID.from_string(s, hs=self) + def parse_roomid(self, s): + """Parse the string given by 's' as a Room ID and return a RoomID + object.""" + return RoomID.from_string(s, hs=self) + def parse_roomalias(self, s): """Parse the string given by 's' as a Room Alias and return a RoomAlias object.""" diff --git a/tests/handlers/test_typing.py b/tests/handlers/test_typing.py new file mode 100644 index 0000000000..300a6e340a --- /dev/null +++ b/tests/handlers/test_typing.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# 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. + + +from twisted.trial import unittest +from twisted.internet import defer + +from mock import Mock, call, ANY +import json +import logging + +from ..utils import MockHttpResource, MockClock, DeferredMockCallable + +from synapse.server import HomeServer +from synapse.handlers.typing import TypingNotificationHandler + + +logging.getLogger().addHandler(logging.NullHandler()) + + +def _expect_edu(destination, edu_type, content, origin="test"): + return { + "origin": origin, + "ts": 1000000, + "pdus": [], + "edus": [ + { + "origin": origin, + "destination": destination, + "edu_type": edu_type, + "content": content, + } + ], + } + + +def _make_edu_json(origin, edu_type, content): + return json.dumps(_expect_edu("test", edu_type, content, origin=origin)) + + +class JustTypingNotificationHandlers(object): + def __init__(self, hs): + self.typing_notification_handler = TypingNotificationHandler(hs) + + +class TypingNotificationsTestCase(unittest.TestCase): + """Tests typing notifications to rooms.""" + def setUp(self): + self.clock = MockClock() + + self.mock_http_client = Mock(spec=[]) + self.mock_http_client.put_json = DeferredMockCallable() + + self.mock_federation_resource = MockHttpResource() + + hs = HomeServer("test", + clock=self.clock, + db_pool=None, + datastore=Mock(spec=[ + # Bits that Federation needs + "prep_send_transaction", + "delivered_txn", + "get_received_txn_response", + "set_received_txn_response", + ]), + handlers=None, + resource_for_client=Mock(), + resource_for_federation=self.mock_federation_resource, + http_client=self.mock_http_client, + ) + hs.handlers = JustTypingNotificationHandlers(hs) + + self.mock_update_client = Mock() + self.mock_update_client.return_value = defer.succeed(None) + + self.handler = hs.get_handlers().typing_notification_handler + self.handler.push_update_to_clients = self.mock_update_client + + self.datastore = hs.get_datastore() + + def get_received_txn_response(*args): + return defer.succeed(None) + self.datastore.get_received_txn_response = get_received_txn_response + + self.room_id = "a-room" + + # Mock the RoomMemberHandler + hs.handlers.room_member_handler = Mock(spec=[]) + self.room_member_handler = hs.handlers.room_member_handler + + self.room_members = [] + + def get_rooms_for_user(user): + if user in self.room_members: + return defer.succeed([self.room_id]) + else: + return defer.succeed([]) + self.room_member_handler.get_rooms_for_user = get_rooms_for_user + + def get_room_members(room_id): + if room_id == self.room_id: + return defer.succeed(self.room_members) + else: + return defer.succeed([]) + self.room_member_handler.get_room_members = get_room_members + + @defer.inlineCallbacks + def fetch_room_distributions_into(room_id, localusers=None, + remotedomains=None, ignore_user=None): + + members = yield get_room_members(room_id) + for member in members: + if ignore_user is not None and member == ignore_user: + continue + + if member.is_mine: + if localusers is not None: + localusers.add(member) + else: + if remotedomains is not None: + remotedomains.add(member.domain) + self.room_member_handler.fetch_room_distributions_into = ( + fetch_room_distributions_into) + + # Some local users to test with + self.u_apple = hs.parse_userid("@apple:test") + self.u_banana = hs.parse_userid("@banana:test") + + # Remote user + self.u_onion = hs.parse_userid("@onion:farm") + + @defer.inlineCallbacks + def test_started_typing_local(self): + self.room_members = [self.u_apple, self.u_banana] + + yield self.handler.started_typing( + target_user=self.u_apple, + auth_user=self.u_apple, + room_id=self.room_id, + timeout=20000, + ) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_banana, + observed_user=self.u_apple, + room_id=self.room_id, + typing=True), + ]) + + @defer.inlineCallbacks + def test_started_typing_remote_send(self): + self.room_members = [self.u_apple, self.u_onion] + + put_json = self.mock_http_client.put_json + put_json.expect_call_and_return( + call("farm", + path="/matrix/federation/v1/send/1000000/", + data=_expect_edu("farm", "m.typing", + content={ + "room_id": self.room_id, + "user_id": self.u_apple.to_string(), + "typing": True, + } + ) + ), + defer.succeed((200, "OK")) + ) + + yield self.handler.started_typing( + target_user=self.u_apple, + auth_user=self.u_apple, + room_id=self.room_id, + timeout=20000, + ) + + yield put_json.await_calls() + + @defer.inlineCallbacks + def test_started_typing_remote_recv(self): + self.room_members = [self.u_apple, self.u_onion] + + yield self.mock_federation_resource.trigger("PUT", + "/matrix/federation/v1/send/1000000/", + _make_edu_json("farm", "m.typing", + content={ + "room_id": self.room_id, + "user_id": self.u_onion.to_string(), + "typing": True, + } + ) + ) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_apple, + observed_user=self.u_onion, + room_id=self.room_id, + typing=True), + ]) + + @defer.inlineCallbacks + def test_stopped_typing(self): + self.room_members = [self.u_apple, self.u_banana, self.u_onion] + + put_json = self.mock_http_client.put_json + put_json.expect_call_and_return( + call("farm", + path="/matrix/federation/v1/send/1000000/", + data=_expect_edu("farm", "m.typing", + content={ + "room_id": self.room_id, + "user_id": self.u_apple.to_string(), + "typing": False, + } + ) + ), + defer.succeed((200, "OK")) + ) + + # Gut-wrenching + from synapse.handlers.typing import RoomMember + self.handler._member_typing_until[ + RoomMember(self.room_id, self.u_apple) + ] = 1002000 + + yield self.handler.stopped_typing( + target_user=self.u_apple, + auth_user=self.u_apple, + room_id=self.room_id, + ) + + self.mock_update_client.assert_has_calls([ + call(observer_user=self.u_banana, + observed_user=self.u_apple, + room_id=self.room_id, + typing=False), + ]) + + yield put_json.await_calls() diff --git a/tests/rest/test_events.py b/tests/rest/test_events.py index 116acd5600..3099a24e8c 100644 --- a/tests/rest/test_events.py +++ b/tests/rest/test_events.py @@ -179,9 +179,8 @@ class EventStreamPermissionsTestCase(RestTestCase): @defer.inlineCallbacks def test_stream_room_permissions(self): - room_id = "!rid1:test" - yield self.create_room_as(room_id, self.other_user, - tok=self.other_token) + room_id = yield self.create_room_as(self.other_user, + tok=self.other_token) yield self.send(room_id, tok=self.other_token) # invited to room (expect no content for room) diff --git a/tests/rest/test_presence.py b/tests/rest/test_presence.py index 970405d271..e249a0d48a 100644 --- a/tests/rest/test_presence.py +++ b/tests/rest/test_presence.py @@ -171,7 +171,7 @@ class PresenceListTestCase(unittest.TestCase): ) (code, response) = yield self.mock_resource.trigger("GET", - "/presence_list/%s" % (myid), None) + "/presence/list/%s" % (myid), None) self.assertEquals(200, code) self.assertEquals( @@ -192,7 +192,7 @@ class PresenceListTestCase(unittest.TestCase): ) (code, response) = yield self.mock_resource.trigger("POST", - "/presence_list/%s" % (myid), + "/presence/list/%s" % (myid), """{"invite": ["@banana:test"]}""" ) @@ -212,7 +212,7 @@ class PresenceListTestCase(unittest.TestCase): ) (code, response) = yield self.mock_resource.trigger("POST", - "/presence_list/%s" % (myid), + "/presence/list/%s" % (myid), """{"drop": ["@banana:test"]}""" ) diff --git a/tests/rest/test_rooms.py b/tests/rest/test_rooms.py index 465303f166..914dc28f53 100644 --- a/tests/rest/test_rooms.py +++ b/tests/rest/test_rooms.py @@ -74,13 +74,11 @@ class RoomPermissionsTestCase(RestTestCase): # create some rooms under the name rmcreator_id self.uncreated_rmid = "!aa:test" - self.created_rmid = "!abc:test" - yield self.create_room_as(self.created_rmid, self.rmcreator_id, - is_public=False) + self.created_rmid = yield self.create_room_as(self.rmcreator_id, + is_public=False) - self.created_public_rmid = "!def1234ghi:test" - yield self.create_room_as(self.created_public_rmid, self.rmcreator_id, - is_public=True) + self.created_public_rmid = yield self.create_room_as(self.rmcreator_id, + is_public=True) # send a message in one of the rooms self.created_rmid_msg_path = ("/rooms/%s/send/m.room.message/a1" % @@ -423,8 +421,7 @@ class RoomsMemberListTestCase(RestTestCase): @defer.inlineCallbacks def test_get_member_list(self): - room_id = "!aa:test" - yield self.create_room_as(room_id, self.user_id) + room_id = yield self.create_room_as(self.user_id) (code, response) = yield self.mock_resource.trigger_get( "/rooms/%s/members" % room_id) self.assertEquals(200, code, msg=str(response)) @@ -437,18 +434,16 @@ class RoomsMemberListTestCase(RestTestCase): @defer.inlineCallbacks def test_get_member_list_no_permission(self): - room_id = "!bb:test" - yield self.create_room_as(room_id, "@some_other_guy:red") + room_id = yield self.create_room_as("@some_other_guy:red") (code, response) = yield self.mock_resource.trigger_get( "/rooms/%s/members" % room_id) self.assertEquals(403, code, msg=str(response)) @defer.inlineCallbacks def test_get_member_list_mixed_memberships(self): - room_id = "!bb:test" room_creator = "@some_other_guy:blue" + room_id = yield self.create_room_as(room_creator) room_path = "/rooms/%s/members" % room_id - yield self.create_room_as(room_id, room_creator) yield self.invite(room=room_id, src=room_creator, targ=self.user_id) # can't see list if you're just invited. @@ -503,107 +498,57 @@ class RoomsCreateTestCase(RestTestCase): @defer.inlineCallbacks def test_post_room_no_keys(self): # POST with no config keys, expect new room id - (code, response) = yield self.mock_resource.trigger("POST", "/rooms", - "{}") + (code, response) = yield self.mock_resource.trigger("POST", + "/createRoom", + "{}") self.assertEquals(200, code, response) self.assertTrue("room_id" in response) @defer.inlineCallbacks def test_post_room_visibility_key(self): # POST with visibility config key, expect new room id - (code, response) = yield self.mock_resource.trigger("POST", "/rooms", - '{"visibility":"private"}') + (code, response) = yield self.mock_resource.trigger( + "POST", + "/createRoom", + '{"visibility":"private"}') self.assertEquals(200, code) self.assertTrue("room_id" in response) @defer.inlineCallbacks def test_post_room_custom_key(self): # POST with custom config keys, expect new room id - (code, response) = yield self.mock_resource.trigger("POST", "/rooms", - '{"custom":"stuff"}') + (code, response) = yield self.mock_resource.trigger( + "POST", + "/createRoom", + '{"custom":"stuff"}') self.assertEquals(200, code) self.assertTrue("room_id" in response) @defer.inlineCallbacks def test_post_room_known_and_unknown_keys(self): # POST with custom + known config keys, expect new room id - (code, response) = yield self.mock_resource.trigger("POST", "/rooms", - '{"visibility":"private","custom":"things"}') + (code, response) = yield self.mock_resource.trigger( + "POST", + "/createRoom", + '{"visibility":"private","custom":"things"}') self.assertEquals(200, code) self.assertTrue("room_id" in response) @defer.inlineCallbacks def test_post_room_invalid_content(self): # POST with invalid content / paths, expect 400 - (code, response) = yield self.mock_resource.trigger("POST", "/rooms", - '{"visibili') - self.assertEquals(400, code) - - (code, response) = yield self.mock_resource.trigger("POST", "/rooms", - '["hello"]') - self.assertEquals(400, code) - - @defer.inlineCallbacks - def test_put_room_no_keys(self): - # PUT with no config keys, expect new room id - (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/%21aa%3Atest", "{}" - ) - self.assertEquals(200, code) - self.assertTrue("room_id" in response) - - @defer.inlineCallbacks - def test_put_room_visibility_key(self): - # PUT with known config keys, expect new room id - (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/%21bb%3Atest", '{"visibility":"private"}' - ) - self.assertEquals(200, code) - self.assertTrue("room_id" in response) - - @defer.inlineCallbacks - def test_put_room_custom_key(self): - # PUT with custom config keys, expect new room id - (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/%21cc%3Atest", '{"custom":"stuff"}' - ) - self.assertEquals(200, code) - self.assertTrue("room_id" in response) - - @defer.inlineCallbacks - def test_put_room_known_and_unknown_keys(self): - # PUT with custom + known config keys, expect new room id (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/%21dd%3Atest", - '{"visibility":"private","custom":"things"}' - ) - self.assertEquals(200, code) - self.assertTrue("room_id" in response) - - @defer.inlineCallbacks - def test_put_room_invalid_content(self): - # PUT with invalid content / room names, expect 400 - - (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/ee", '{"sdf"' - ) + "POST", + "/createRoom", + '{"visibili') self.assertEquals(400, code) (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/ee", '["hello"]' - ) + "POST", + "/createRoom", + '["hello"]') self.assertEquals(400, code) - @defer.inlineCallbacks - def test_put_room_conflict(self): - yield self.create_room_as("!aa:test", self.user_id) - - # PUT with conflicting room ID, expect 409 - (code, response) = yield self.mock_resource.trigger( - "PUT", "/rooms/%21aa%3Atest", "{}" - ) - self.assertEquals(409, code) - class RoomTopicTestCase(RestTestCase): """ Tests /rooms/$room_id/topic REST events. """ @@ -613,8 +558,6 @@ class RoomTopicTestCase(RestTestCase): def setUp(self): self.mock_resource = MockHttpResource(prefix=PATH_PREFIX) self.auth_user_id = self.user_id - self.room_id = "!rid1:test" - self.path = "/rooms/%s/state/m.room.topic" % self.room_id state_handler = Mock(spec=["handle_new_event"]) state_handler.handle_new_event.return_value = True @@ -640,7 +583,8 @@ class RoomTopicTestCase(RestTestCase): synapse.rest.room.register_servlets(hs, self.mock_resource) # create the room - yield self.create_room_as(self.room_id, self.user_id) + self.room_id = yield self.create_room_as(self.user_id) + self.path = "/rooms/%s/state/m.room.topic" % self.room_id def tearDown(self): pass @@ -717,7 +661,6 @@ class RoomMemberStateTestCase(RestTestCase): def setUp(self): self.mock_resource = MockHttpResource(prefix=PATH_PREFIX) self.auth_user_id = self.user_id - self.room_id = "!rid1:test" state_handler = Mock(spec=["handle_new_event"]) state_handler.handle_new_event.return_value = True @@ -742,7 +685,7 @@ class RoomMemberStateTestCase(RestTestCase): synapse.rest.room.register_servlets(hs, self.mock_resource) - yield self.create_room_as(self.room_id, self.user_id) + self.room_id = yield self.create_room_as(self.user_id) def tearDown(self): pass @@ -843,7 +786,6 @@ class RoomMessagesTestCase(RestTestCase): def setUp(self): self.mock_resource = MockHttpResource(prefix=PATH_PREFIX) self.auth_user_id = self.user_id - self.room_id = "!rid1:test" state_handler = Mock(spec=["handle_new_event"]) state_handler.handle_new_event.return_value = True @@ -868,7 +810,7 @@ class RoomMessagesTestCase(RestTestCase): synapse.rest.room.register_servlets(hs, self.mock_resource) - yield self.create_room_as(self.room_id, self.user_id) + self.room_id = yield self.create_room_as(self.user_id) def tearDown(self): pass diff --git a/tests/rest/utils.py b/tests/rest/utils.py index 590d12f155..ef9a6071e2 100644 --- a/tests/rest/utils.py +++ b/tests/rest/utils.py @@ -24,6 +24,7 @@ from synapse.api.constants import Membership import json import time + class RestTestCase(unittest.TestCase): """Contains extra helper functions to quickly and clearly perform a given REST action, which isn't the focus of the test. @@ -40,18 +41,19 @@ class RestTestCase(unittest.TestCase): return self.auth_user_id @defer.inlineCallbacks - def create_room_as(self, room_id, room_creator, is_public=True, tok=None): + def create_room_as(self, room_creator, is_public=True, tok=None): temp_id = self.auth_user_id self.auth_user_id = room_creator - path = "/rooms/%s" % room_id + path = "/createRoom" content = "{}" if not is_public: content = '{"visibility":"private"}' if tok: path = path + "?access_token=%s" % tok - (code, response) = yield self.mock_resource.trigger("PUT", path, content) + (code, response) = yield self.mock_resource.trigger("POST", path, content) self.assertEquals(200, code, msg=str(response)) self.auth_user_id = temp_id + defer.returnValue(response["room_id"]) @defer.inlineCallbacks def invite(self, room=None, src=None, targ=None, expect_code=200, tok=None): diff --git a/webclient/app-controller.js b/webclient/app-controller.js index f210119e21..5d3fa6ddc8 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -37,7 +37,11 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even mPresence.start(); } - $scope.go = function(url) { + /** + * Open a given page. + * @param {String} url url of the page + */ + $scope.goToPage = function(url) { $location.url(url); }; diff --git a/webclient/app.css b/webclient/app.css index dfa17fae62..8abdd1cb44 100644 --- a/webclient/app.css +++ b/webclient/app.css @@ -342,6 +342,64 @@ h1 { top: 0; } +/*** Recents ***/ +.recentsTable { + max-width: 480px; + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.recentsTable tr { + width: 100%; +} +.recentsTable td { + vertical-align: text-top; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.recentsRoom { + cursor: pointer; +} + +.recentsRoom:hover { + background-color: #f8f8ff; +} + +.recentsRoomSelected { + background-color: #eee; +} + +.recentsRoomName { + font-size: 16px; + padding-top: 7px; + width: auto; +} + +.recentsRoomSummaryTS { + color: #888; + font-size: 12px; + width: 7em; + text-align: right; +} + +.recentsRoomSummary { + color: #888; + font-size: 12px; + padding-bottom: 5px; +} + +/*** Recents in the room page ***/ +#roomRecentsTableWrapper { + float: left; + max-width: 320px; + margin-right: 20px; + height: 100%; + overflow-y: auto; +} + /*** Profile ***/ .profile-avatar { diff --git a/webclient/app.js b/webclient/app.js index 6cd50c5e54..1d5503ebc0 100644 --- a/webclient/app.js +++ b/webclient/app.js @@ -20,6 +20,7 @@ var matrixWebClient = angular.module('matrixWebClient', [ 'LoginController', 'RoomController', 'HomeController', + 'RecentsController', 'SettingsController', 'UserController', 'matrixService', diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 2286485605..2feddac5d8 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -97,7 +97,7 @@ angular.module('matrixService', []) // Create a room create: function(room_id, visibility) { // The REST path spec - var path = "/rooms"; + var path = "/createRoom"; return doRequest("POST", path, undefined, { visibility: visibility, @@ -106,11 +106,20 @@ angular.module('matrixService', []) }, // List all rooms joined or been invited to - rooms: function(from, to, limit) { + rooms: function(limit, feedback) { // The REST path spec + var path = "/initialSync"; - return doRequest("GET", path); + var params = {}; + if (limit) { + params.limit = limit; + } + if (feedback) { + params.feedback = feedback; + } + + return doRequest("GET", path, params); }, // Joins a room @@ -124,7 +133,8 @@ angular.module('matrixService', []) path = path.replace("$room_alias", room_alias); - return doRequest("PUT", path, undefined, {}); + // TODO: PUT with txn ID + return doRequest("POST", path, undefined, {}); }, // Invite a user to a room @@ -154,7 +164,7 @@ angular.module('matrixService', []) // Retrieves the room ID corresponding to a room alias resolveRoomAlias:function(room_alias) { - var path = "/matrix/client/api/v1/ds/room/$room_alias"; + var path = "/matrix/client/api/v1/directory/room/$room_alias"; room_alias = encodeURIComponent(room_alias); path = path.replace("$room_alias", room_alias); @@ -234,7 +244,7 @@ angular.module('matrixService', []) // get a list of public rooms on your home server publicRooms: function() { - var path = "/public/rooms" + var path = "/publicRooms" return doRequest("GET", path); }, @@ -405,6 +415,40 @@ angular.module('matrixService', []) config.version = configVersion; localStorage.setItem("config", JSON.stringify(config)); }, + + + /****** Room aliases management ******/ + + /** + * Enhance data returned by rooms() and publicRooms() by adding room_alias + * & room_display_name which are computed from data already retrieved from the server. + * @param {Array} data the response of rooms() and publicRooms() + * @returns {Array} the same array with enriched objects + */ + assignRoomAliases: function(data) { + for (var i=0; i<data.length; i++) { + var alias = this.getRoomIdToAliasMapping(data[i].room_id); + if (alias) { + // use the existing alias from storage + data[i].room_alias = alias; + data[i].room_display_name = alias; + } + else if (data[i].aliases && data[i].aliases[0]) { + // save the mapping + // TODO: select the smarter alias from the array + this.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]); + data[i].room_display_name = data[i].aliases[0]; + } + else if (data[i].membership == "invite" && "inviter" in data[i]) { + data[i].room_display_name = data[i].inviter + "'s room" + } + else { + // last resort use the room id + data[i].room_display_name = data[i].room_id; + } + } + return data; + }, createRoomIdToAliasMapping: function(roomId, alias) { localStorage.setItem(MAPPING_PREFIX+roomId, alias); diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js index 867ae522a6..e8e91eede7 100644 --- a/webclient/home/home-controller.js +++ b/webclient/home/home-controller.js @@ -16,12 +16,11 @@ limitations under the License. 'use strict'; -angular.module('HomeController', ['matrixService', 'mFileInput', 'mFileUpload', 'eventHandlerService']) -.controller('HomeController', ['$scope', '$location', 'matrixService', 'mFileUpload', 'eventHandlerService', 'eventStreamService', - function($scope, $location, matrixService, mFileUpload, eventHandlerService, eventStreamService) { +angular.module('HomeController', ['matrixService', 'eventHandlerService', 'RecentsController']) +.controller('HomeController', ['$scope', '$location', 'matrixService', 'eventHandlerService', 'eventStreamService', + function($scope, $location, matrixService, eventHandlerService, eventStreamService) { $scope.config = matrixService.config(); - $scope.rooms = {}; $scope.public_rooms = []; $scope.newRoomId = ""; $scope.feedback = ""; @@ -32,72 +31,18 @@ angular.module('HomeController', ['matrixService', 'mFileInput', 'mFileUpload', }; $scope.goToRoom = { - room_id: "", + room_id: "" }; $scope.joinAlias = { - room_alias: "", - }; - - $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { - var config = matrixService.config(); - if (event.state_key === config.user_id && event.content.membership === "invite") { - console.log("Invited to room " + event.room_id); - // FIXME push membership to top level key to match /im/sync - event.membership = event.content.membership; - // FIXME bodge a nicer name than the room ID for this invite. - event.room_display_name = event.user_id + "'s room"; - $scope.rooms[event.room_id] = event; - } - }); - - var assignRoomAliases = function(data) { - for (var i=0; i<data.length; i++) { - var alias = matrixService.getRoomIdToAliasMapping(data[i].room_id); - if (alias) { - // use the existing alias from storage - data[i].room_alias = alias; - data[i].room_display_name = alias; - } - else if (data[i].aliases && data[i].aliases[0]) { - // save the mapping - // TODO: select the smarter alias from the array - matrixService.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]); - data[i].room_display_name = data[i].aliases[0]; - } - else if (data[i].membership == "invite" && "inviter" in data[i]) { - data[i].room_display_name = data[i].inviter + "'s room" - } - else { - // last resort use the room id - data[i].room_display_name = data[i].room_id; - } - } - return data; + room_alias: "" }; var refresh = function() { - // List all rooms joined or been invited to - matrixService.rooms(1,true).then( - function(response) { - var data = assignRoomAliases(response.data.rooms); - $scope.feedback = "Success"; - for (var i=0; i<data.length; i++) { - $scope.rooms[data[i].room_id] = data[i]; - } - - var presence = response.data.presence; - for (var i = 0; i < presence.length; ++i) { - eventHandlerService.handleEvent(presence[i], false); - } - }, - function(error) { - $scope.feedback = "Failure: " + error.data; - }); matrixService.publicRooms().then( function(response) { - $scope.public_rooms = assignRoomAliases(response.data.chunk); + $scope.public_rooms = matrixService.assignRoomAliases(response.data.chunk); } ); diff --git a/webclient/home/home.html b/webclient/home/home.html index 4084f4c388..d38b843d83 100644 --- a/webclient/home/home.html +++ b/webclient/home/home.html @@ -23,13 +23,8 @@ </form> </div> - <h3>My rooms</h3> - - <div class="rooms" ng-repeat="(rm_id, room) in rooms"> - <div> - <a href="#/room/{{ room.room_alias ? room.room_alias : rm_id }}" >{{ room.room_display_name }}</a> {{room.membership === 'invite' ? ' (invited)' : ''}} - </div> - </div> + <h3>Recents</h3> + <div ng-include="'recents/recents.html'"></div> <br/> <h3>Public rooms</h3> diff --git a/webclient/index.html b/webclient/index.html index 6031036e9a..16f0e8ac5f 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -19,6 +19,8 @@ <script src="app-filter.js"></script> <script src="home/home-controller.js"></script> <script src="login/login-controller.js"></script> + <script src="recents/recents-controller.js"></script> + <script src="recents/recents-filter.js"></script> <script src="room/room-controller.js"></script> <script src="room/room-directive.js"></script> <script src="settings/settings-controller.js"></script> @@ -37,7 +39,7 @@ <header id="header"> <!-- Do not show buttons on the login page --> <div id="header-buttons" ng-hide="'/login' == location "> - <button ng-click='go("settings")'>Settings</button> + <button ng-click='goToPage("settings")'>Settings</button> <button ng-click="logout()">Log out</button> </div> </header> diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js new file mode 100644 index 0000000000..8f8b08d5bd --- /dev/null +++ b/webclient/recents/recents-controller.js @@ -0,0 +1,70 @@ +/* + Copyright 2014 matrix.org + + 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. + */ + +'use strict'; + +angular.module('RecentsController', ['matrixService', 'eventHandlerService']) +.controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService', 'eventStreamService', + function($scope, matrixService, eventHandlerService, eventStreamService) { + $scope.rooms = {}; + + // $scope of the parent where the recents component is included can override this value + // in order to highlight a specific room in the list + $scope.recentsSelectedRoomID; + + $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { + var config = matrixService.config(); + if (event.state_key === config.user_id && event.content.membership === "invite") { + console.log("Invited to room " + event.room_id); + // FIXME push membership to top level key to match /im/sync + event.membership = event.content.membership; + // FIXME bodge a nicer name than the room ID for this invite. + event.room_display_name = event.user_id + "'s room"; + $scope.rooms[event.room_id] = event; + } + }); + + var refresh = function() { + // List all rooms joined or been invited to + matrixService.rooms(1, false).then( + function(response) { + var data = matrixService.assignRoomAliases(response.data.rooms); + for (var i=0; i<data.length; i++) { + $scope.rooms[data[i].room_id] = data[i]; + + // Create a shortcut for the last message of this room + if (data[i].messages && data[i].messages.chunk && data[i].messages.chunk[0]) { + $scope.rooms[data[i].room_id].lastMsg = data[i].messages.chunk[0]; + } + } + + var presence = response.data.presence; + for (var i = 0; i < presence.length; ++i) { + eventHandlerService.handleEvent(presence[i], false); + } + }, + function(error) { + $scope.feedback = "Failure: " + error.data; + } + ); + }; + + $scope.onInit = function() { + refresh(); + }; + +}]); + diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js new file mode 100644 index 0000000000..45653fca96 --- /dev/null +++ b/webclient/recents/recents-filter.js @@ -0,0 +1,47 @@ +/* + Copyright 2014 matrix.org + + 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. + */ + +'use strict'; + +angular.module('RecentsController') +.filter('orderRecents', function() { + return function(rooms) { + + // Transform the dict into an array + // The key, room_id, is already in value objects + var filtered = []; + angular.forEach(rooms, function(value, key) { + filtered.push( value ); + }); + + // And time sort them + // The room with the lastest message at first + filtered.sort(function (a, b) { + // Invite message does not have a body message nor ts + // Puth them at the top of the list + if (undefined === a.lastMsg) { + return -1; + } + else if (undefined === b.lastMsg) { + return 1; + } + else { + return b.lastMsg.ts - a.lastMsg.ts; + } + }); + return filtered; + }; +}); \ No newline at end of file diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html new file mode 100644 index 0000000000..6fda6c5c6b --- /dev/null +++ b/webclient/recents/recents.html @@ -0,0 +1,56 @@ +<div ng-controller="RecentsController" data-ng-init="onInit()"> + <table class="recentsTable"> + <tbody ng-repeat="(rm_id, room) in rooms | orderRecents" + ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )" + class ="recentsRoom" + ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}"> + <tr> + <td class="recentsRoomName"> + {{ room.room_display_name }} + </td> + <td class="recentsRoomSummaryTS"> + {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} + </td> + </tr> + + <tr> + <td colspan="2" class="recentsRoomSummary"> + + <div ng-show="room.membership === 'invite'" > + {{ room.inviter }} invited you + </div> + + <div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type" > + <div ng-switch-when="m.room.member"> + {{ room.lastMsg.user_id }} + {{ {"join": "joined", "leave": "left", "invite": "invited"}[room.lastMsg.content.membership] }} + {{ room.lastMsg.content.membership === "invite" ? (room.lastMsg.state_key || '') : '' }} + </div> + + <div ng-switch-when="m.room.message"> + <div ng-switch="room.lastMsg.content.msgtype"> + <div ng-switch-when="m.text"> + {{ room.lastMsg.user_id }} : + <span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'"> + </span> + </div> + + <div ng-switch-when="m.image"> + {{ room.lastMsg.user_id }} sent an image + </div> + + <div ng-switch-default> + {{ room.lastMsg.content }} + </div> + </div> + </div> + + <div ng-switch-default> + {{ room.lastMsg }} + </div> + </div> + </td> + </tr> + </tbody> + </table> +</div> diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index f49deaa489..641ccddce7 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -327,6 +327,9 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities']) var onInit2 = function() { eventHandlerService.reInitRoom($scope.room_id); + // Make recents highlight the current room + $scope.recentsSelectedRoomID = $scope.room_id; + // Join the room matrixService.join($scope.room_id).then( function() { diff --git a/webclient/room/room.html b/webclient/room/room.html index c167819f15..236ca0a89b 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -7,7 +7,11 @@ <div id="roomName"> {{ room_alias || room_id }} </div> - + + <div id="roomRecentsTableWrapper"> + <div ng-include="'recents/recents.html'"></div> + </div> + <div id="usersTableWrapper"> <table id="usersTable"> <tr ng-repeat="member in members | orderMembersList"> |