From f690b7b8279551b5cab782e2e4cd5811fc9d3789 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 22 Aug 2014 15:59:15 +0100 Subject: Impl: /rooms/roomid/state/eventtype/state_key - Renamed RoomTopicRestServlet to RoomStateEventRestServlet. Support generic state event sending. --- synapse/rest/room.py | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) (limited to 'synapse/rest/room.py') diff --git a/synapse/rest/room.py b/synapse/rest/room.py index f5b547b963..9752469863 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -95,39 +95,62 @@ class RoomCreateRestServlet(RestServlet): return (200, {}) -class RoomTopicRestServlet(RestServlet): - PATTERN = client_path_pattern("/rooms/(?P[^/]*)/topic$") +class RoomStateEventRestServlet(RestServlet): + def register(self, http_server): + # /room/$roomid/state/$eventtype + no_state_key = "/rooms/(?P[^/]*)/state/(?P[^/]*)$" - def get_event_type(self): - return RoomTopicEvent.TYPE + # /room/$roomid/state/$eventtype/$statekey + state_key = ("/rooms/(?P[^/]*)/state/" + + "(?P[^/]*)/(?P[^/]*)$") + + http_server.register_path("GET", + client_path_pattern(state_key), + self.on_GET) + http_server.register_path("PUT", + client_path_pattern(state_key), + self.on_PUT) + http_server.register_path("GET", + client_path_pattern(no_state_key), + self.on_GET_no_state_key) + http_server.register_path("PUT", + client_path_pattern(no_state_key), + self.on_PUT_no_state_key) + + def on_GET_no_state_key(self, request, room_id, event_type): + return self.on_GET(request, room_id, event_type, "") + + def on_PUT_no_state_key(self, request, room_id, event_type): + return self.on_PUT(request, room_id, event_type, "") @defer.inlineCallbacks - def on_GET(self, request, room_id): + def on_GET(self, request, room_id, event_type, state_key): user = yield self.auth.get_user_by_req(request) msg_handler = self.handlers.message_handler data = yield msg_handler.get_room_data( user_id=user.to_string(), room_id=urllib.unquote(room_id), - event_type=RoomTopicEvent.TYPE, - state_key="", + event_type=event_type, + state_key=state_key, ) if not data: - raise SynapseError(404, "Topic not found.", errcode=Codes.NOT_FOUND) - defer.returnValue((200, data.content)) + raise SynapseError(404, "Event not found.", errcode=Codes.NOT_FOUND) + defer.returnValue((200, data[0].get_dict()["content"])) @defer.inlineCallbacks - def on_PUT(self, request, room_id): + def on_PUT(self, request, room_id, event_type, state_key): user = yield self.auth.get_user_by_req(request) content = _parse_json(request) event = self.event_factory.create_event( - etype=self.get_event_type(), + etype=event_type, content=content, room_id=urllib.unquote(room_id), user_id=user.to_string(), + state_key=state_key ) msg_handler = self.handlers.message_handler @@ -412,7 +435,7 @@ def _parse_json(request): def register_servlets(hs, http_server): - RoomTopicRestServlet(hs).register(http_server) + RoomStateEventRestServlet(hs).register(http_server) RoomMemberRestServlet(hs).register(http_server) MessageRestServlet(hs).register(http_server) FeedbackRestServlet(hs).register(http_server) -- cgit 1.4.1 From cab3095803db0c046f414959d12e3549505f54c4 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 26 Aug 2014 09:26:07 +0100 Subject: Removed member list servlet: now using generic state paths. --- cmdclient/console.py | 6 +-- synapse/api/auth.py | 12 +++-- synapse/api/constants.py | 1 + synapse/api/events/room.py | 10 ++-- synapse/api/notifier.py | 4 +- synapse/handlers/federation.py | 2 +- synapse/handlers/room.py | 16 ++++--- synapse/rest/room.py | 103 +++++++++-------------------------------- synapse/storage/roommember.py | 5 +- 9 files changed, 53 insertions(+), 106 deletions(-) (limited to 'synapse/rest/room.py') diff --git a/cmdclient/console.py b/cmdclient/console.py index 64557a4c40..3c4c69c276 100755 --- a/cmdclient/console.py +++ b/cmdclient/console.py @@ -402,9 +402,7 @@ class SynapseCmd(cmd.Cmd): """Leaves a room: "leave " """ try: args = self._parse(line, ["roomid"], force_keys=True) - path = ("/rooms/%s/members/%s/state" % - (urllib.quote(args["roomid"]), self._usr())) - reactor.callFromThread(self._run_and_pprint, "DELETE", path) + self._do_membership_change(urllib.quote(args["roomid"]), "leave", self._usr()) except Exception as e: print e @@ -567,7 +565,7 @@ class SynapseCmd(cmd.Cmd): alt_text="Sent receipt for %s" % event["msg_id"]) def _do_membership_change(self, roomid, membership, userid): - path = "/rooms/%s/members/%s/state" % (urllib.quote(roomid), userid) + path = "/rooms/%s/state/m.room.member/%s" % (urllib.quote(roomid), urllib.quote(userid)) data = { "membership": membership } diff --git a/synapse/api/auth.py b/synapse/api/auth.py index ae61319a2c..385f93763a 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -77,6 +77,8 @@ class Auth(object): @defer.inlineCallbacks def is_membership_change_allowed(self, event): + target_user_id = event.state_key + # does this room even exist room = yield self.store.get_room(event.room_id) if not room: @@ -94,7 +96,7 @@ class Auth(object): # get info about the target try: target = yield self.store.get_room_member( - user_id=event.target_user_id, + user_id=target_user_id, room_id=event.room_id) except: target = None @@ -108,12 +110,12 @@ class Auth(object): raise AuthError(403, "You are not in room %s." % event.room_id) elif target_in_room: # the target is already in the room. raise AuthError(403, "%s is already in the room." % - event.target_user_id) + target_user_id) elif Membership.JOIN == membership: # Joins are valid iff caller == target and they were: # invited: They are accepting the invitation # joined: It's a NOOP - if event.user_id != event.target_user_id: + if event.user_id != target_user_id: raise AuthError(403, "Cannot force another user to join.") elif room.is_public: pass # anyone can join public rooms. @@ -123,10 +125,10 @@ class Auth(object): elif Membership.LEAVE == membership: if not caller_in_room: # trying to leave a room you aren't joined raise AuthError(403, "You are not in room %s." % event.room_id) - elif event.target_user_id != event.user_id: + elif target_user_id != event.user_id: # trying to force another user to leave raise AuthError(403, "Cannot force %s to leave." % - event.target_user_id) + target_user_id) else: raise AuthError(500, "Unknown membership %s" % membership) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index 1ff1af76ec..2af5424029 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -23,6 +23,7 @@ class Membership(object): JOIN = u"join" KNOCK = u"knock" LEAVE = u"leave" + LIST = (INVITE, JOIN, KNOCK, LEAVE) class Feedback(object): diff --git a/synapse/api/events/room.py b/synapse/api/events/room.py index 42459f3f21..2a7b5e8aba 100644 --- a/synapse/api/events/room.py +++ b/synapse/api/events/room.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from synapse.api.constants import Membership +from synapse.api.errors import SynapseError from . import SynapseEvent @@ -59,15 +61,15 @@ class RoomMemberEvent(SynapseEvent): TYPE = "m.room.member" valid_keys = SynapseEvent.valid_keys + [ - "target_user_id", # target + # target is the state_key "membership", # action ] def __init__(self, **kwargs): - if "target_user_id" in kwargs: - kwargs["state_key"] = kwargs["target_user_id"] if "membership" not in kwargs: kwargs["membership"] = kwargs.get("content", {}).get("membership") + if not kwargs["membership"] in Membership.LIST: + raise SynapseError(400, "Bad membership value.") super(RoomMemberEvent, self).__init__(**kwargs) def get_content_template(self): @@ -108,7 +110,7 @@ class InviteJoinEvent(SynapseEvent): TYPE = "m.room.invite_join" valid_keys = SynapseEvent.valid_keys + [ - "target_user_id", + # target_user_id is the state_key "target_host", ] diff --git a/synapse/api/notifier.py b/synapse/api/notifier.py index 9f622df6bb..ec9c4e513d 100644 --- a/synapse/api/notifier.py +++ b/synapse/api/notifier.py @@ -56,11 +56,11 @@ class Notifier(object): # invites MUST prod the person being invited, who won't be in the room. if (event.type == RoomMemberEvent.TYPE and event.content["membership"] == Membership.INVITE): - member_list.append(event.target_user_id) + member_list.append(event.state_key) # similarly, LEAVEs must be sent to the person leaving if (event.type == RoomMemberEvent.TYPE and event.content["membership"] == Membership.LEAVE): - member_list.append(event.target_user_id) + member_list.append(event.state_key) for user_id in member_list: if user_id in self.stored_event_listeners: diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index aa3bf273f7..351bb3c084 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -56,7 +56,7 @@ class FederationHandler(BaseHandler): content.update({"membership": Membership.JOIN}) new_event = self.event_factory.create_event( etype=RoomMemberEvent.TYPE, - target_user_id=event.user_id, + state_key=event.user_id, room_id=event.room_id, user_id=event.user_id, membership=Membership.JOIN, diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index 6229ee9bfa..049b4884af 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -374,7 +374,7 @@ class RoomCreationHandler(BaseHandler): content = {"membership": Membership.JOIN} join_event = self.event_factory.create_event( etype=RoomMemberEvent.TYPE, - target_user_id=user_id, + state_key=user_id, room_id=room_id, user_id=user_id, membership=Membership.JOIN, @@ -502,9 +502,10 @@ class RoomMemberHandler(BaseHandler): Raises: SynapseError if there was a problem changing the membership. """ + target_user_id = event.state_key prev_state = yield self.store.get_room_member( - event.target_user_id, event.room_id + target_user_id, event.room_id ) if prev_state: @@ -530,7 +531,7 @@ class RoomMemberHandler(BaseHandler): yield self.auth.check(event, raises=True) prev_state = yield self.store.get_room_member( - event.target_user_id, event.room_id + target_user_id, event.room_id ) if prev_state and prev_state.membership == event.membership: # double same action, treat this event as a NOOP. @@ -563,7 +564,7 @@ class RoomMemberHandler(BaseHandler): content.update({"membership": Membership.JOIN}) new_event = self.event_factory.create_event( etype=RoomMemberEvent.TYPE, - target_user_id=joinee.to_string(), + state_key=joinee.to_string(), room_id=room_id, user_id=joinee.to_string(), membership=Membership.JOIN, @@ -576,7 +577,7 @@ class RoomMemberHandler(BaseHandler): @defer.inlineCallbacks def _do_join(self, event, room_host=None, do_auth=True): - joinee = self.hs.parse_userid(event.target_user_id) + joinee = self.hs.parse_userid(event.state_key) # room_id = RoomID.from_string(event.room_id, self.hs) room_id = event.room_id @@ -685,16 +686,17 @@ class RoomMemberHandler(BaseHandler): # If we're inviting someone, then we should also send it to that # HS. + target_user_id = event.state_key if membership == Membership.INVITE: host = UserID.from_string( - event.target_user_id, self.hs + target_user_id, self.hs ).domain destinations.append(host) # If we are joining a remote HS, include that. if membership == Membership.JOIN: host = UserID.from_string( - event.target_user_id, self.hs + target_user_id, self.hs ).domain destinations.append(host) diff --git a/synapse/rest/room.py b/synapse/rest/room.py index 9752469863..a07e031984 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -18,9 +18,10 @@ from twisted.internet import defer from base import RestServlet, client_path_pattern from synapse.api.errors import SynapseError, Codes -from synapse.api.events.room import (RoomTopicEvent, MessageEvent, - RoomMemberEvent, FeedbackEvent) -from synapse.api.constants import Feedback, Membership +from synapse.api.events.room import ( + MessageEvent, RoomMemberEvent, FeedbackEvent +) +from synapse.api.constants import Feedback from synapse.api.streams import PaginationConfig import json @@ -131,8 +132,8 @@ class RoomStateEventRestServlet(RestServlet): data = yield msg_handler.get_room_data( user_id=user.to_string(), room_id=urllib.unquote(room_id), - event_type=event_type, - state_key=state_key, + event_type=urllib.unquote(event_type), + state_key=urllib.unquote(state_key), ) if not data: @@ -142,6 +143,7 @@ class RoomStateEventRestServlet(RestServlet): @defer.inlineCallbacks def on_PUT(self, request, room_id, event_type, state_key): user = yield self.auth.get_user_by_req(request) + event_type = urllib.unquote(event_type) content = _parse_json(request) @@ -150,14 +152,20 @@ class RoomStateEventRestServlet(RestServlet): content=content, room_id=urllib.unquote(room_id), user_id=user.to_string(), - state_key=state_key + state_key=urllib.unquote(state_key) ) - - msg_handler = self.handlers.message_handler - yield msg_handler.store_room_data( - event=event - ) - defer.returnValue((200, "")) + if event_type == RoomMemberEvent.TYPE: + # membership events are special + handler = self.handlers.room_member_handler + yield handler.change_membership(event) + defer.returnValue((200, "")) + else: + # store random bits of state + msg_handler = self.handlers.message_handler + yield msg_handler.store_room_data( + event=event + ) + defer.returnValue((200, "")) class JoinRoomAliasServlet(RestServlet): @@ -180,73 +188,6 @@ class JoinRoomAliasServlet(RestServlet): defer.returnValue((200, ret_dict)) -class RoomMemberRestServlet(RestServlet): - PATTERN = client_path_pattern("/rooms/(?P[^/]*)/members/" - + "(?P[^/]*)/state$") - - def get_event_type(self): - return RoomMemberEvent.TYPE - - @defer.inlineCallbacks - def on_GET(self, request, room_id, target_user_id): - room_id = urllib.unquote(room_id) - user = yield self.auth.get_user_by_req(request) - - handler = self.handlers.room_member_handler - member = yield handler.get_room_member( - room_id, - urllib.unquote(target_user_id), - user.to_string()) - if not member: - raise SynapseError(404, "Member not found.", - errcode=Codes.NOT_FOUND) - defer.returnValue((200, member.content)) - - @defer.inlineCallbacks - def on_DELETE(self, request, roomid, target_user_id): - user = yield self.auth.get_user_by_req(request) - - event = self.event_factory.create_event( - etype=self.get_event_type(), - target_user_id=urllib.unquote(target_user_id), - room_id=urllib.unquote(roomid), - user_id=user.to_string(), - membership=Membership.LEAVE, - content={"membership": Membership.LEAVE} - ) - - handler = self.handlers.room_member_handler - yield handler.change_membership(event) - defer.returnValue((200, "")) - - @defer.inlineCallbacks - def on_PUT(self, request, roomid, target_user_id): - user = yield self.auth.get_user_by_req(request) - - content = _parse_json(request) - if "membership" not in content: - raise SynapseError(400, "No membership key.", - errcode=Codes.BAD_JSON) - - valid_membership_values = [Membership.JOIN, Membership.INVITE] - if (content["membership"] not in valid_membership_values): - raise SynapseError(400, "Membership value must be %s." % ( - valid_membership_values,), errcode=Codes.BAD_JSON) - - event = self.event_factory.create_event( - etype=self.get_event_type(), - target_user_id=urllib.unquote(target_user_id), - room_id=urllib.unquote(roomid), - user_id=user.to_string(), - membership=content["membership"], - content=content - ) - - handler = self.handlers.room_member_handler - yield handler.change_membership(event) - defer.returnValue((200, "")) - - class MessageRestServlet(RestServlet): PATTERN = client_path_pattern("/rooms/(?P[^/]*)/messages/" + "(?P[^/]*)/(?P[^/]*)$") @@ -377,7 +318,7 @@ class RoomMemberListRestServlet(RestServlet): user_id=user.to_string()) for event in members["chunk"]: - target_user = self.hs.parse_userid(event["target_user_id"]) + target_user = self.hs.parse_userid(event["state_key"]) # Presence is an optional cache; don't fail if we can't fetch it try: presence_state = yield self.handlers.presence_handler.get_state( @@ -423,6 +364,7 @@ class RoomTriggerBackfill(RestServlet): res = [event.get_dict() for event in events] defer.returnValue((200, res)) + def _parse_json(request): try: content = json.loads(request.content.read()) @@ -436,7 +378,6 @@ def _parse_json(request): def register_servlets(hs, http_server): RoomStateEventRestServlet(hs).register(http_server) - RoomMemberRestServlet(hs).register(http_server) MessageRestServlet(hs).register(http_server) FeedbackRestServlet(hs).register(http_server) RoomCreateRestServlet(hs).register(http_server) diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py index 89c87290cf..1f8984b6e8 100644 --- a/synapse/storage/roommember.py +++ b/synapse/storage/roommember.py @@ -35,13 +35,14 @@ class RoomMemberStore(SQLBaseStore): def _store_room_member(self, event): """Store a room member in the database. """ - domain = self.hs.parse_userid(event.target_user_id).domain + target_user_id = event.state_key + domain = self.hs.parse_userid(target_user_id).domain yield self._simple_insert( "room_memberships", { "event_id": event.event_id, - "user_id": event.target_user_id, + "user_id": target_user_id, "sender": event.user_id, "room_id": event.room_id, "membership": event.membership, -- cgit 1.4.1 From 5796232cb19927612957db3fcf4b77cd383187a6 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Tue, 26 Aug 2014 10:24:47 +0100 Subject: Adjusted webclient to use new state paths. Updated membership msg template to actually show the person invited. Factored out common membership functions in matrix service. --- synapse/rest/room.py | 3 +- .../components/matrix/event-handler-service.js | 4 -- webclient/components/matrix/matrix-service.js | 47 ++++++---------------- webclient/home/home-controller.js | 2 +- webclient/room/room-controller.js | 13 +++--- webclient/room/room.html | 2 +- 6 files changed, 24 insertions(+), 47 deletions(-) (limited to 'synapse/rest/room.py') diff --git a/synapse/rest/room.py b/synapse/rest/room.py index a07e031984..2d681bd893 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -318,7 +318,8 @@ class RoomMemberListRestServlet(RestServlet): user_id=user.to_string()) for event in members["chunk"]: - target_user = self.hs.parse_userid(event["state_key"]) + # FIXME: should probably be state_key here, not user_id + target_user = self.hs.parse_userid(event["user_id"]) # Presence is an optional cache; don't fail if we can't fetch it try: presence_state = yield self.handlers.presence_handler.get_state( diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index b5eb73d92b..6ea0f58bc5 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -54,10 +54,6 @@ angular.module('eventHandlerService', []) } var handleMessage = function(event, isLiveEvent) { - if ("membership_target" in event.content) { - event.user_id = event.content.membership_target; - } - initRoom(event.room_id); if (isLiveEvent) { diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index d5738e01c8..b5b1815cf9 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -115,19 +115,7 @@ angular.module('matrixService', []) // Joins a room join: function(room_id) { - // The REST path spec - var path = "/rooms/$room_id/members/$user_id/state"; - - // Like the cmd client, escape room ids - room_id = encodeURIComponent(room_id); - - // Customize it - path = path.replace("$room_id", room_id); - path = path.replace("$user_id", config.user_id); - - return doRequest("PUT", path, undefined, { - membership: "join" - }); + return this.membershipChange(room_id, config.user_id, "join"); }, joinAlias: function(room_alias) { @@ -141,34 +129,23 @@ angular.module('matrixService', []) // Invite a user to a room invite: function(room_id, user_id) { - // The REST path spec - var path = "/rooms/$room_id/members/$user_id/state"; - - // Like the cmd client, escape room ids - room_id = encodeURIComponent(room_id); - - // Customize it - path = path.replace("$room_id", room_id); - path = path.replace("$user_id", user_id); - - return doRequest("PUT", path, undefined, { - membership: "invite" - }); + return this.membershipChange(room_id, user_id, "invite"); }, // Leaves a room leave: function(room_id) { - // The REST path spec - var path = "/rooms/$room_id/members/$user_id/state"; - - // Like the cmd client, escape room ids - room_id = encodeURIComponent(room_id); + return this.membershipChange(room_id, config.user_id, "leave"); + }, - // Customize it - path = path.replace("$room_id", room_id); - path = path.replace("$user_id", config.user_id); + membershipChange: function(room_id, user_id, membershipValue) { + // The REST path spec + var path = "/rooms/$room_id/state/m.room.member/$user_id"; + path = path.replace("$room_id", encodeURIComponent(room_id)); + path = path.replace("$user_id", encodeURIComponent(user_id)); - return doRequest("DELETE", path, undefined, undefined); + return doRequest("PUT", path, undefined, { + membership: membershipValue + }); }, // Retrieves the room ID corresponding to a room alias diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js index 35d0ef1654..a5576759fa 100644 --- a/webclient/home/home-controller.js +++ b/webclient/home/home-controller.js @@ -41,7 +41,7 @@ angular.module('HomeController', ['matrixService', 'mFileInput', 'mFileUpload', $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { var config = matrixService.config(); - if (event.target_user_id === config.user_id && event.content.membership === "invite") { + 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; diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 3311618825..f49deaa489 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -154,7 +154,10 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities']) var updateMemberList = function(chunk) { if (chunk.room_id != $scope.room_id) return; - var isNewMember = !(chunk.target_user_id in $scope.members); + // set target_user_id to keep things clear + var target_user_id = chunk.state_key; + + var isNewMember = !(target_user_id in $scope.members); if (isNewMember) { // FIXME: why are we copying these fields around inside chunk? if ("state" in chunk.content) { @@ -172,7 +175,7 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities']) if ("avatar_url" in chunk.content) { chunk.avatar_url = chunk.content.avatar_url; } - $scope.members[chunk.target_user_id] = chunk; + $scope.members[target_user_id] = chunk; /* // Stale code for explicitly hammering the homeserver for every displayname & avatar_url @@ -202,13 +205,13 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities']) }); */ - if (chunk.target_user_id in $rootScope.presence) { - updatePresence($rootScope.presence[chunk.target_user_id]); + if (target_user_id in $rootScope.presence) { + updatePresence($rootScope.presence[target_user_id]); } } else { // selectively update membership else it will nuke the picture and displayname too :/ - var member = $scope.members[chunk.target_user_id]; + var member = $scope.members[target_user_id]; member.content.membership = chunk.content.membership; } } diff --git a/webclient/room/room.html b/webclient/room/room.html index 06ca63d2ea..c167819f15 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -44,7 +44,7 @@ {{ members[msg.user_id].displayname || msg.user_id }} {{ {"join": "joined", "leave": "left", "invite": "invited"}[msg.content.membership] }} - {{ msg.content.target_id || '' }} + {{ msg.content.membership === "invite" ? (msg.state_key || '') : '' }} -- cgit 1.4.1