diff --git a/CHANGES.rst b/CHANGES.rst
index 400ded0f15..1690490f66 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,28 @@
+Changes in synapse 0.3.4 (2014-09-25)
+=====================================
+This version adds support for using a TURN server. See docs/turn-howto.rst on
+how to set one up.
+
+Homeserver:
+ * Add support for redaction of messages.
+ * Fix bug where inviting a user on a remote home server could take up to
+ 20-30s.
+ * Implement a get current room state API.
+ * Add support specifying and retrieving turn server configuration.
+
+Webclient:
+ * Add button to send messages to users from the home page.
+ * Add support for using TURN for VoIP calls.
+ * Show display name change messages.
+ * Fix bug where the client didn't get the state of a newly joined room
+ until after it has been refreshed.
+ * Fix bugs with tab complete.
+ * Fix bug where holding down the down arrow caused chrome to chew 100% CPU.
+ * Fix bug where desktop notifications occasionally used "Undefined" as the
+ display name.
+ * Fix more places where we sometimes saw room IDs incorrectly.
+ * Fix bug which caused lag when entering text in the text box.
+
Changes in synapse 0.3.3 (2014-09-22)
=====================================
diff --git a/VERSION b/VERSION
index 1c09c74e22..42045acae2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.3
+0.3.4
diff --git a/docs/client-server/swagger_matrix/api-docs-rooms b/docs/client-server/swagger_matrix/api-docs-rooms
index 0e1fa452a2..b941e58139 100644
--- a/docs/client-server/swagger_matrix/api-docs-rooms
+++ b/docs/client-server/swagger_matrix/api-docs-rooms
@@ -639,7 +639,7 @@
{
"method": "GET",
"summary": "Get a list of all the current state events for this room.",
- "notes": "NOT YET IMPLEMENTED.",
+ "notes": "This is equivalent to the events returned under the 'state' key for this room in /initialSync.",
"type": "array",
"items": {
"$ref": "Event"
diff --git a/docs/turn-howto.rst b/docs/turn-howto.rst
new file mode 100644
index 0000000000..82b59538c8
--- /dev/null
+++ b/docs/turn-howto.rst
@@ -0,0 +1,93 @@
+How to enable VoIP relaying on your Home Server with TURN
+
+Overview
+--------
+The synapse Matrix Home Server supports integration with TURN server via the
+TURN server REST API
+(http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00). This allows
+the Home Server to generate credentials that are valid for use on the TURN
+server through the use of a secret shared between the Home Server and the
+TURN server.
+
+This document described how to install coturn
+(https://code.google.com/p/coturn/) which also supports the TURN REST API,
+and integrate it with synapse.
+
+coturn Setup
+============
+
+ 1. Check out coturn::
+ svn checkout http://coturn.googlecode.com/svn/trunk/ coturn
+ cd coturn
+
+ 2. Configure it::
+ ./configure
+
+ You may need to install libevent2: if so, you should do so
+ in the way recommended by your operating system.
+ You can ignore warnings about lack of database support: a
+ database is unnecessary for this purpose.
+
+ 3. Build and install it::
+ make
+ make install
+
+ 4. Make a config file in /etc/turnserver.conf. You can customise
+ a config file from turnserver.conf.default. The relevant
+ lines, with example values, are::
+
+ lt-cred-mech
+ use-auth-secret
+ static-auth-secret=[your secret key here]
+ realm=turn.myserver.org
+
+ See turnserver.conf.default for explanations of the options.
+ One way to generate the static-auth-secret is with pwgen::
+
+ pwgen -s 64 1
+
+ 5. Ensure youe firewall allows traffic into the TURN server on
+ the ports you've configured it to listen on (remember to allow
+ both TCP and UDP if you've enabled both).
+
+ 6. If you've configured coturn to support TLS/DTLS, generate or
+ import your private key and certificate.
+
+ 7. Start the turn server::
+ bin/turnserver -o
+
+
+synapse Setup
+=============
+
+Your home server configuration file needs the following extra keys:
+
+ 1. "turn_uris": This needs to be a yaml list
+ of public-facing URIs for your TURN server to be given out
+ to your clients. Add separate entries for each transport your
+ TURN server supports.
+
+ 2. "turn_shared_secret": This is the secret shared between your Home
+ server and your TURN server, so you should set it to the same
+ string you used in turnserver.conf.
+
+ 3. "turn_user_lifetime": This is the amount of time credentials
+ generated by your Home Server are valid for (in milliseconds).
+ Shorter times offer less potential for abuse at the expense
+ of increased traffic between web clients and your home server
+ to refresh credentials. The TURN REST API specification recommends
+ one day (86400000).
+
+As an example, here is the relevant section of the config file for
+matrix.org::
+
+ turn_uris: turn:turn.matrix.org:3478?transport=udp,turn:turn.matrix.org:3478?transport=tcp
+ turn_shared_secret: n0t4ctuAllymatr1Xd0TorgSshar3d5ecret4obvIousreAsons
+ turn_user_lifetime: 86400000
+
+Now, restart synapse::
+
+ cd /where/you/run/synapse
+ ./synctl restart
+
+...and your Home Server now supports VoIP relaying!
diff --git a/synapse/__init__.py b/synapse/__init__.py
index bba551b2c4..a340a5db66 100644
--- a/synapse/__init__.py
+++ b/synapse/__init__.py
@@ -16,4 +16,4 @@
""" This is a reference implementation of a synapse home server.
"""
-__version__ = "0.3.3"
+__version__ = "0.3.4"
diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index 8f32191b57..9bfd25c86e 100644
--- a/synapse/api/auth.py
+++ b/synapse/api/auth.py
@@ -19,7 +19,9 @@ from twisted.internet import defer
from synapse.api.constants import Membership, JoinRules
from synapse.api.errors import AuthError, StoreError, Codes, SynapseError
-from synapse.api.events.room import RoomMemberEvent, RoomPowerLevelsEvent
+from synapse.api.events.room import (
+ RoomMemberEvent, RoomPowerLevelsEvent, RoomRedactionEvent,
+)
from synapse.util.logutils import log_function
import logging
@@ -70,6 +72,9 @@ class Auth(object):
if event.type == RoomPowerLevelsEvent.TYPE:
yield self._check_power_levels(event)
+ if event.type == RoomRedactionEvent.TYPE:
+ yield self._check_redaction(event)
+
defer.returnValue(True)
else:
raise AuthError(500, "Unknown event: %s" % event)
@@ -170,7 +175,7 @@ class Auth(object):
event.room_id,
event.user_id,
)
- _, kick_level = yield self.store.get_ops_levels(event.room_id)
+ _, kick_level, _ = yield self.store.get_ops_levels(event.room_id)
if kick_level:
kick_level = int(kick_level)
@@ -187,7 +192,7 @@ class Auth(object):
event.user_id,
)
- ban_level, _ = yield self.store.get_ops_levels(event.room_id)
+ ban_level, _, _ = yield self.store.get_ops_levels(event.room_id)
if ban_level:
ban_level = int(ban_level)
@@ -322,6 +327,29 @@ class Auth(object):
)
@defer.inlineCallbacks
+ def _check_redaction(self, event):
+ user_level = yield self.store.get_power_level(
+ event.room_id,
+ event.user_id,
+ )
+
+ if user_level:
+ user_level = int(user_level)
+ else:
+ user_level = 0
+
+ _, _, redact_level = yield self.store.get_ops_levels(event.room_id)
+
+ if not redact_level:
+ redact_level = 50
+
+ if user_level < redact_level:
+ raise AuthError(
+ 403,
+ "You don't have permission to redact events"
+ )
+
+ @defer.inlineCallbacks
def _check_power_levels(self, event):
for k, v in event.content.items():
if k == "default":
@@ -372,11 +400,11 @@ class Auth(object):
}
removed = set(old_people.keys()) - set(new_people.keys())
- added = set(old_people.keys()) - set(new_people.keys())
+ added = set(new_people.keys()) - set(old_people.keys())
same = set(old_people.keys()) & set(new_people.keys())
for r in removed:
- if int(old_list.content[r]) > user_level:
+ if int(old_list[r]) > user_level:
raise AuthError(
403,
"You don't have permission to remove user: %s" % (r, )
diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py
index 0cee196851..f66fea2904 100644
--- a/synapse/api/events/__init__.py
+++ b/synapse/api/events/__init__.py
@@ -22,7 +22,8 @@ def serialize_event(hs, e):
if not isinstance(e, SynapseEvent):
return e
- d = e.get_dict()
+ # Should this strip out None's?
+ d = {k: v for k, v in e.get_dict().items()}
if "age_ts" in d:
d["age"] = int(hs.get_clock().time_msec()) - d["age_ts"]
del d["age_ts"]
@@ -58,17 +59,19 @@ class SynapseEvent(JsonEncodedObject):
"required_power_level",
"age_ts",
"prev_content",
+ "prev_state",
+ "redacted_because",
]
internal_keys = [
"is_state",
"prev_events",
- "prev_state",
"depth",
"destinations",
"origin",
"outlier",
"power_level",
+ "redacted",
]
required_keys = [
diff --git a/synapse/api/events/factory.py b/synapse/api/events/factory.py
index d3d96d73eb..0d94850cec 100644
--- a/synapse/api/events/factory.py
+++ b/synapse/api/events/factory.py
@@ -17,7 +17,8 @@ from synapse.api.events.room import (
RoomTopicEvent, MessageEvent, RoomMemberEvent, FeedbackEvent,
InviteJoinEvent, RoomConfigEvent, RoomNameEvent, GenericEvent,
RoomPowerLevelsEvent, RoomJoinRulesEvent, RoomOpsPowerLevelsEvent,
- RoomCreateEvent, RoomAddStateLevelEvent, RoomSendEventLevelEvent
+ RoomCreateEvent, RoomAddStateLevelEvent, RoomSendEventLevelEvent,
+ RoomRedactionEvent,
)
from synapse.util.stringutils import random_string
@@ -39,6 +40,7 @@ class EventFactory(object):
RoomAddStateLevelEvent,
RoomSendEventLevelEvent,
RoomOpsPowerLevelsEvent,
+ RoomRedactionEvent,
]
def __init__(self, hs):
diff --git a/synapse/api/events/room.py b/synapse/api/events/room.py
index 3a4dbc58ce..cd936074fc 100644
--- a/synapse/api/events/room.py
+++ b/synapse/api/events/room.py
@@ -180,3 +180,12 @@ class RoomAliasesEvent(SynapseStateEvent):
def get_content_template(self):
return {}
+
+
+class RoomRedactionEvent(SynapseEvent):
+ TYPE = "m.room.redaction"
+
+ valid_keys = SynapseEvent.valid_keys + ["redacts"]
+
+ def get_content_template(self):
+ return {}
diff --git a/synapse/api/events/utils.py b/synapse/api/events/utils.py
new file mode 100644
index 0000000000..c3a32be8c1
--- /dev/null
+++ b/synapse/api/events/utils.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .room import (
+ RoomMemberEvent, RoomJoinRulesEvent, RoomPowerLevelsEvent,
+ RoomAddStateLevelEvent, RoomSendEventLevelEvent, RoomOpsPowerLevelsEvent,
+ RoomAliasesEvent, RoomCreateEvent,
+)
+
+def prune_event(event):
+ """ Prunes the given event of all keys we don't know about or think could
+ potentially be dodgy.
+
+ This is used when we "redact" an event. We want to remove all fields that
+ the user has specified, but we do want to keep necessary information like
+ type, state_key etc.
+ """
+
+ # Remove all extraneous fields.
+ event.unrecognized_keys = {}
+
+ new_content = {}
+
+ def add_fields(*fields):
+ for field in fields:
+ if field in event.content:
+ new_content[field] = event.content[field]
+
+ if event.type == RoomMemberEvent.TYPE:
+ add_fields("membership")
+ elif event.type == RoomCreateEvent.TYPE:
+ add_fields("creator")
+ elif event.type == RoomJoinRulesEvent.TYPE:
+ add_fields("join_rule")
+ elif event.type == RoomPowerLevelsEvent.TYPE:
+ # TODO: Actually check these are valid user_ids etc.
+ add_fields("default")
+ for k, v in event.content.items():
+ if k.startswith("@") and isinstance(v, (int, long)):
+ new_content[k] = v
+ elif event.type == RoomAddStateLevelEvent.TYPE:
+ add_fields("level")
+ elif event.type == RoomSendEventLevelEvent.TYPE:
+ add_fields("level")
+ elif event.type == RoomOpsPowerLevelsEvent.TYPE:
+ add_fields("kick_level", "ban_level", "redact_level")
+ elif event.type == RoomAliasesEvent.TYPE:
+ add_fields("aliases")
+
+ event.content = new_content
+
+ return event
diff --git a/synapse/config/captcha.py b/synapse/config/captcha.py
index 8ebcfc3623..4ed9070b9e 100644
--- a/synapse/config/captcha.py
+++ b/synapse/config/captcha.py
@@ -24,6 +24,7 @@ class CaptchaConfig(Config):
self.captcha_ip_origin_is_x_forwarded = (
args.captcha_ip_origin_is_x_forwarded
)
+ self.captcha_bypass_secret = args.captcha_bypass_secret
@classmethod
def add_arguments(cls, parser):
@@ -43,4 +44,8 @@ class CaptchaConfig(Config):
"--captcha_ip_origin_is_x_forwarded", type=bool, default=False,
help="When checking captchas, use the X-Forwarded-For (XFF) header"
+ " as the client IP and not the actual client IP."
- )
\ No newline at end of file
+ )
+ group.add_argument(
+ "--captcha_bypass_secret", type=str,
+ help="A secret key used to bypass the captcha test entirely."
+ )
diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py
index 4b810a2302..5a11fd6c76 100644
--- a/synapse/config/homeserver.py
+++ b/synapse/config/homeserver.py
@@ -21,11 +21,12 @@ from .ratelimiting import RatelimitConfig
from .repository import ContentRepositoryConfig
from .captcha import CaptchaConfig
from .email import EmailConfig
+from .voip import VoipConfig
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
- EmailConfig):
+ EmailConfig, VoipConfig):
pass
diff --git a/synapse/config/voip.py b/synapse/config/voip.py
new file mode 100644
index 0000000000..3a51664f46
--- /dev/null
+++ b/synapse/config/voip.py
@@ -0,0 +1,41 @@
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from ._base import Config
+
+
+class VoipConfig(Config):
+
+ def __init__(self, args):
+ super(VoipConfig, self).__init__(args)
+ self.turn_uris = args.turn_uris
+ self.turn_shared_secret = args.turn_shared_secret
+ self.turn_user_lifetime = args.turn_user_lifetime
+
+ @classmethod
+ def add_arguments(cls, parser):
+ super(VoipConfig, cls).add_arguments(parser)
+ group = parser.add_argument_group("voip")
+ group.add_argument(
+ "--turn-uris", type=str, default=None,
+ help="The public URIs of the TURN server to give to clients"
+ )
+ group.add_argument(
+ "--turn-shared-secret", type=str, default=None,
+ help="The shared secret used to compute passwords for the TURN server"
+ )
+ group.add_argument(
+ "--turn-user-lifetime", type=int, default=(1000 * 60 * 60),
+ help="How long generated TURN credentials last, in ms"
+ )
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index 001c6c110c..f52591d2a3 100644
--- a/synapse/handlers/federation.py
+++ b/synapse/handlers/federation.py
@@ -169,7 +169,15 @@ class FederationHandler(BaseHandler):
)
if not backfilled:
- yield self.notifier.on_new_room_event(event)
+ extra_users = []
+ if event.type == RoomMemberEvent.TYPE:
+ target_user_id = event.state_key
+ target_user = self.hs.parse_userid(target_user_id)
+ extra_users.append(target_user)
+
+ yield self.notifier.on_new_room_event(
+ event, extra_users=extra_users
+ )
if event.type == RoomMemberEvent.TYPE:
if event.membership == Membership.JOIN:
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 14fae689f2..317ef2c80c 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -233,6 +233,22 @@ class MessageHandler(BaseHandler):
yield self._on_new_room_event(event, snapshot)
@defer.inlineCallbacks
+ def get_state_events(self, user_id, room_id):
+ """Retrieve all state events for a given room.
+
+ Args:
+ user_id(str): The user requesting state events.
+ room_id(str): The room ID to get all state events from.
+ Returns:
+ A list of dicts representing state events. [{}, {}, {}]
+ """
+ yield self.auth.check_joined_room(room_id, user_id)
+
+ # TODO: This is duplicating logic from snapshot_all_rooms
+ current_state = yield self.store.get_current_state(room_id)
+ defer.returnValue([self.hs.serialize_event(c) for c in current_state])
+
+ @defer.inlineCallbacks
def snapshot_all_rooms(self, user_id=None, pagin_config=None,
feedback=False):
"""Retrieve a snapshot of all rooms the user is invited or has joined.
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 5bc1280432..c0f9a7c807 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -146,17 +146,6 @@ class RoomCreationHandler(BaseHandler):
)
yield handle_event(name_event)
- elif room_alias:
- name = room_alias.to_string()
- name_event = self.event_factory.create_event(
- etype=RoomNameEvent.TYPE,
- room_id=room_id,
- user_id=user_id,
- required_power_level=50,
- content={"name": name},
- )
-
- yield handle_event(name_event)
if "topic" in config:
topic = config["topic"]
@@ -255,6 +244,7 @@ class RoomCreationHandler(BaseHandler):
etype=RoomOpsPowerLevelsEvent.TYPE,
ban_level=50,
kick_level=50,
+ redact_level=50,
)
return [
diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py
index ed785cfbd5..3b9aa59733 100644
--- a/synapse/rest/__init__.py
+++ b/synapse/rest/__init__.py
@@ -15,7 +15,7 @@
from . import (
- room, events, register, login, profile, presence, initial_sync, directory
+ room, events, register, login, profile, presence, initial_sync, directory, voip
)
@@ -42,3 +42,4 @@ class RestServletFactory(object):
presence.register_servlets(hs, client_resource)
initial_sync.register_servlets(hs, client_resource)
directory.register_servlets(hs, client_resource)
+ voip.register_servlets(hs, client_resource)
diff --git a/synapse/rest/register.py b/synapse/rest/register.py
index af528a44f6..4935e323d9 100644
--- a/synapse/rest/register.py
+++ b/synapse/rest/register.py
@@ -21,6 +21,8 @@ from synapse.api.constants import LoginType
from base import RestServlet, client_path_pattern
import synapse.util.stringutils as stringutils
+from hashlib import sha1
+import hmac
import json
import logging
import urllib
@@ -28,6 +30,16 @@ import urllib
logger = logging.getLogger(__name__)
+# We ought to be using hmac.compare_digest() but on older pythons it doesn't
+# exist. It's a _really minor_ security flaw to use plain string comparison
+# because the timing attack is so obscured by all the other code here it's
+# unlikely to make much difference
+if hasattr(hmac, "compare_digest"):
+ compare_digest = hmac.compare_digest
+else:
+ compare_digest = lambda a, b: a == b
+
+
class RegisterRestServlet(RestServlet):
"""Handles registration with the home server.
@@ -142,6 +154,38 @@ class RegisterRestServlet(RestServlet):
if not self.hs.config.enable_registration_captcha:
raise SynapseError(400, "Captcha not required.")
+ yield self._check_recaptcha(request, register_json, session)
+
+ session[LoginType.RECAPTCHA] = True # mark captcha as done
+ self._save_session(session)
+ defer.returnValue({
+ "next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
+ })
+
+ @defer.inlineCallbacks
+ def _check_recaptcha(self, request, register_json, session):
+ if ("captcha_bypass_hmac" in register_json and
+ self.hs.config.captcha_bypass_secret):
+ if "user" not in register_json:
+ raise SynapseError(400, "Captcha bypass needs 'user'")
+
+ want = hmac.new(
+ key=self.hs.config.captcha_bypass_secret,
+ msg=register_json["user"],
+ digestmod=sha1,
+ ).hexdigest()
+
+ # str() because otherwise hmac complains that 'unicode' does not
+ # have the buffer interface
+ got = str(register_json["captcha_bypass_hmac"])
+
+ if compare_digest(want, got):
+ session["user"] = register_json["user"]
+ defer.returnValue(None)
+ else:
+ raise SynapseError(400, "Captcha bypass HMAC incorrect",
+ errcode=Codes.CAPTCHA_NEEDED)
+
challenge = None
user_response = None
try:
@@ -166,11 +210,6 @@ class RegisterRestServlet(RestServlet):
challenge,
user_response
)
- session[LoginType.RECAPTCHA] = True # mark captcha as done
- self._save_session(session)
- defer.returnValue({
- "next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
- })
@defer.inlineCallbacks
def _do_email_identity(self, request, register_json, session):
@@ -195,6 +234,10 @@ class RegisterRestServlet(RestServlet):
# captcha should've been done by this stage!
raise SynapseError(400, "Captcha is required.")
+ if ("user" in session and "user" in register_json and
+ session["user"] != register_json["user"]):
+ raise SynapseError(400, "Cannot change user ID during registration")
+
password = register_json["password"].encode("utf-8")
desired_user_id = (register_json["user"].encode("utf-8") if "user"
in register_json else None)
diff --git a/synapse/rest/room.py b/synapse/rest/room.py
index ecb1e346d9..a01dab1b8e 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/room.py
@@ -19,7 +19,7 @@ from twisted.internet import defer
from base import RestServlet, client_path_pattern
from synapse.api.errors import SynapseError, Codes
from synapse.streams.config import PaginationConfig
-from synapse.api.events.room import RoomMemberEvent
+from synapse.api.events.room import RoomMemberEvent, RoomRedactionEvent
from synapse.api.constants import Membership
import json
@@ -329,12 +329,13 @@ class RoomStateRestServlet(RestServlet):
@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, []))
+ handler = self.handlers.message_handler
+ # Get all the current state for this room
+ events = yield handler.get_state_events(
+ room_id=urllib.unquote(room_id),
+ user_id=user.to_string(),
+ )
+ defer.returnValue((200, events))
# TODO: Needs unit testing
@@ -430,6 +431,41 @@ class RoomMembershipRestServlet(RestServlet):
self.txns.store_client_transaction(request, txn_id, response)
defer.returnValue(response)
+class RoomRedactEventRestServlet(RestServlet):
+ def register(self, http_server):
+ PATTERN = ("/rooms/(?P<room_id>[^/]*)/redact/(?P<event_id>[^/]*)")
+ register_txn_path(self, PATTERN, http_server)
+
+ @defer.inlineCallbacks
+ def on_POST(self, request, room_id, event_id):
+ user = yield self.auth.get_user_by_req(request)
+ content = _parse_json(request)
+
+ event = self.event_factory.create_event(
+ etype=RoomRedactionEvent.TYPE,
+ room_id=urllib.unquote(room_id),
+ user_id=user.to_string(),
+ content=content,
+ redacts=event_id,
+ )
+
+ msg_handler = self.handlers.message_handler
+ yield msg_handler.send_message(event)
+
+ defer.returnValue((200, {"event_id": event.event_id}))
+
+ @defer.inlineCallbacks
+ def on_PUT(self, request, room_id, event_id, txn_id):
+ try:
+ defer.returnValue(self.txns.get_client_transaction(request, txn_id))
+ except KeyError:
+ pass
+
+ response = yield self.on_POST(request, room_id, event_id)
+
+ self.txns.store_client_transaction(request, txn_id, response)
+ defer.returnValue(response)
+
def _parse_json(request):
try:
@@ -485,3 +521,4 @@ def register_servlets(hs, http_server):
PublicRoomListRestServlet(hs).register(http_server)
RoomStateRestServlet(hs).register(http_server)
RoomInitialSyncRestServlet(hs).register(http_server)
+ RoomRedactEventRestServlet(hs).register(http_server)
diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py
new file mode 100644
index 0000000000..2e4627606f
--- /dev/null
+++ b/synapse/rest/voip.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from twisted.internet import defer
+
+from base import RestServlet, client_path_pattern
+
+
+import hmac
+import hashlib
+import base64
+
+
+class VoipRestServlet(RestServlet):
+ PATTERN = client_path_pattern("/voip/turnServer$")
+
+ @defer.inlineCallbacks
+ def on_GET(self, request):
+ auth_user = yield self.auth.get_user_by_req(request)
+
+ turnUris = self.hs.config.turn_uris
+ turnSecret = self.hs.config.turn_shared_secret
+ userLifetime = self.hs.config.turn_user_lifetime
+ if not turnUris or not turnSecret or not userLifetime:
+ defer.returnValue( (200, {}) )
+
+ expiry = self.hs.get_clock().time_msec() + userLifetime
+ username = "%d:%s" % (expiry, auth_user.to_string())
+
+ mac = hmac.new(turnSecret, msg=username, digestmod=hashlib.sha1)
+ # We need to use standard base64 encoding here, *not* syutil's encode_base64
+ # because we need to add the standard padding to get the same result as the
+ # TURN server.
+ password = base64.b64encode(mac.digest())
+
+ defer.returnValue( (200, {
+ 'username': username,
+ 'password': password,
+ 'ttl': userLifetime / 1000,
+ 'uris': turnUris,
+ }) )
+
+ def on_OPTIONS(self, request):
+ return (200, {})
+
+
+def register_servlets(hs, http_server):
+ VoipRestServlet(hs).register(http_server)
diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py
index 66658f6721..15919eb580 100644
--- a/synapse/storage/__init__.py
+++ b/synapse/storage/__init__.py
@@ -24,6 +24,7 @@ from synapse.api.events.room import (
RoomAddStateLevelEvent,
RoomSendEventLevelEvent,
RoomOpsPowerLevelsEvent,
+ RoomRedactionEvent,
)
from synapse.util.logutils import log_function
@@ -56,12 +57,13 @@ SCHEMAS = [
"presence",
"im",
"room_aliases",
+ "redactions",
]
# 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 = 3
+SCHEMA_VERSION = 4
class _RollbackButIsFineException(Exception):
@@ -182,6 +184,8 @@ class DataStore(RoomMemberStore, RoomStore,
self._store_send_event_level(txn, event)
elif event.type == RoomOpsPowerLevelsEvent.TYPE:
self._store_ops_level(txn, event)
+ elif event.type == RoomRedactionEvent.TYPE:
+ self._store_redaction(txn, event)
vals = {
"topological_ordering": event.depth,
@@ -203,7 +207,7 @@ class DataStore(RoomMemberStore, RoomStore,
unrec = {
k: v
for k, v in event.get_full_dict().items()
- if k not in vals.keys()
+ if k not in vals.keys() and k not in ["redacted", "redacted_because"]
}
vals["unrecognized_keys"] = json.dumps(unrec)
@@ -217,7 +221,8 @@ class DataStore(RoomMemberStore, RoomStore,
)
raise _RollbackButIsFineException("_persist_event")
- if is_new_state and hasattr(event, "state_key"):
+ is_state = hasattr(event, "state_key") and event.state_key is not None
+ if is_new_state and is_state:
vals = {
"event_id": event.event_id,
"room_id": event.room_id,
@@ -241,14 +246,28 @@ class DataStore(RoomMemberStore, RoomStore,
}
)
+ def _store_redaction(self, txn, event):
+ txn.execute(
+ "INSERT OR IGNORE INTO redactions "
+ "(event_id, redacts) VALUES (?,?)",
+ (event.event_id, event.redacts)
+ )
+
@defer.inlineCallbacks
def get_current_state(self, room_id, event_type=None, state_key=""):
+ del_sql = (
+ "SELECT event_id FROM redactions WHERE redacts = e.event_id "
+ "LIMIT 1"
+ )
+
sql = (
- "SELECT e.* FROM events as e "
+ "SELECT e.*, (%(redacted)s) AS redacted FROM events as e "
"INNER JOIN current_state_events as c ON e.event_id = c.event_id "
"INNER JOIN state_events as s ON e.event_id = s.event_id "
"WHERE c.room_id = ? "
- )
+ ) % {
+ "redacted": del_sql,
+ }
if event_type:
sql += " AND s.type = ? AND s.state_key = ? "
diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py
index 76ed7d06fb..889de2bedc 100644
--- a/synapse/storage/_base.py
+++ b/synapse/storage/_base.py
@@ -17,6 +17,7 @@ import logging
from twisted.internet import defer
from synapse.api.errors import StoreError
+from synapse.api.events.utils import prune_event
from synapse.util.logutils import log_function
import collections
@@ -345,7 +346,7 @@ class SQLBaseStore(object):
return self.runInteraction(func)
def _parse_event_from_row(self, row_dict):
- d = copy.deepcopy({k: v for k, v in row_dict.items() if v})
+ d = copy.deepcopy({k: v for k, v in row_dict.items()})
d.pop("stream_ordering", None)
d.pop("topological_ordering", None)
@@ -373,8 +374,8 @@ class SQLBaseStore(object):
sql = "SELECT * FROM events WHERE event_id = ?"
for ev in events:
- if hasattr(ev, "prev_state"):
- # Load previous state_content.
+ if hasattr(ev, "prev_state"):
+ # Load previous state_content.
# TODO: Should we be pulling this out above?
cursor = txn.execute(sql, (ev.prev_state,))
prevs = self.cursor_to_dict(cursor)
@@ -382,8 +383,32 @@ class SQLBaseStore(object):
prev = self._parse_event_from_row(prevs[0])
ev.prev_content = prev.content
+ if not hasattr(ev, "redacted"):
+ logger.debug("Doesn't have redacted key: %s", ev)
+ ev.redacted = self._has_been_redacted_txn(txn, ev)
+
+ if ev.redacted:
+ # Get the redaction event.
+ sql = "SELECT * FROM events WHERE event_id = ?"
+ txn.execute(sql, (ev.redacted,))
+
+ del_evs = self._parse_events_txn(
+ txn, self.cursor_to_dict(txn)
+ )
+
+ if del_evs:
+ prune_event(ev)
+ ev.redacted_because = del_evs[0]
+
return events
+ def _has_been_redacted_txn(self, txn, event):
+ sql = "SELECT event_id FROM redactions WHERE redacts = ?"
+ txn.execute(sql, (event.event_id,))
+ result = txn.fetchone()
+ return result[0] if result else None
+
+
class Table(object):
""" A base class used to store information about a particular table.
"""
diff --git a/synapse/storage/room.py b/synapse/storage/room.py
index 5adf8cdf1b..8cd46334cf 100644
--- a/synapse/storage/room.py
+++ b/synapse/storage/room.py
@@ -27,7 +27,7 @@ import logging
logger = logging.getLogger(__name__)
-OpsLevel = collections.namedtuple("OpsLevel", ("ban_level", "kick_level"))
+OpsLevel = collections.namedtuple("OpsLevel", ("ban_level", "kick_level", "redact_level"))
class RoomStore(SQLBaseStore):
@@ -189,7 +189,8 @@ class RoomStore(SQLBaseStore):
def _get_ops_levels(self, txn, room_id):
sql = (
- "SELECT ban_level, kick_level FROM room_ops_levels as r "
+ "SELECT ban_level, kick_level, redact_level "
+ "FROM room_ops_levels as r "
"INNER JOIN current_state_events as c "
"ON r.event_id = c.event_id "
"WHERE c.room_id = ? "
@@ -198,7 +199,7 @@ class RoomStore(SQLBaseStore):
rows = txn.execute(sql, (room_id,)).fetchall()
if len(rows) == 1:
- return OpsLevel(rows[0][0], rows[0][1])
+ return OpsLevel(rows[0][0], rows[0][1], rows[0][2])
else:
return OpsLevel(None, None)
@@ -326,6 +327,9 @@ class RoomStore(SQLBaseStore):
if "ban_level" in event.content:
content["ban_level"] = event.content["ban_level"]
+ if "redact_level" in event.content:
+ content["redact_level"] = event.content["redact_level"]
+
self._simple_insert_txn(
txn,
"room_ops_levels",
diff --git a/synapse/storage/roommember.py b/synapse/storage/roommember.py
index 04b4067d03..958e730591 100644
--- a/synapse/storage/roommember.py
+++ b/synapse/storage/roommember.py
@@ -182,14 +182,22 @@ class RoomMemberStore(SQLBaseStore):
)
def _get_members_query_txn(self, txn, where_clause, where_values):
+ del_sql = (
+ "SELECT event_id FROM redactions WHERE redacts = e.event_id "
+ "LIMIT 1"
+ )
+
sql = (
- "SELECT e.* FROM events as e "
+ "SELECT e.*, (%(redacted)s) AS redacted FROM events as e "
"INNER JOIN room_memberships as m "
"ON e.event_id = m.event_id "
"INNER JOIN current_state_events as c "
"ON m.event_id = c.event_id "
- "WHERE %s "
- ) % (where_clause,)
+ "WHERE %(where)s "
+ ) % {
+ "redacted": del_sql,
+ "where": where_clause,
+ }
txn.execute(sql, where_values)
rows = self.cursor_to_dict(txn)
diff --git a/synapse/storage/schema/delta/v4.sql b/synapse/storage/schema/delta/v4.sql
new file mode 100644
index 0000000000..25d2ead450
--- /dev/null
+++ b/synapse/storage/schema/delta/v4.sql
@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS redactions (
+ event_id TEXT NOT NULL,
+ redacts TEXT NOT NULL,
+ CONSTRAINT ev_uniq UNIQUE (event_id)
+);
+
+CREATE INDEX IF NOT EXISTS redactions_event_id ON redactions (event_id);
+CREATE INDEX IF NOT EXISTS redactions_redacts ON redactions (redacts);
+
+ALTER TABLE room_ops_levels ADD COLUMN redact_level INTEGER;
+
+PRAGMA user_version = 4;
diff --git a/synapse/storage/schema/im.sql b/synapse/storage/schema/im.sql
index 6ffea51310..3aa83f5c8c 100644
--- a/synapse/storage/schema/im.sql
+++ b/synapse/storage/schema/im.sql
@@ -150,7 +150,8 @@ CREATE TABLE IF NOT EXISTS room_ops_levels(
event_id TEXT NOT NULL,
room_id TEXT NOT NULL,
ban_level INTEGER,
- kick_level INTEGER
+ kick_level INTEGER,
+ redact_level INTEGER
);
CREATE INDEX IF NOT EXISTS room_ops_levels_event_id ON room_ops_levels(event_id);
diff --git a/synapse/storage/schema/redactions.sql b/synapse/storage/schema/redactions.sql
new file mode 100644
index 0000000000..4c2829d05d
--- /dev/null
+++ b/synapse/storage/schema/redactions.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS redactions (
+ event_id TEXT NOT NULL,
+ redacts TEXT NOT NULL,
+ CONSTRAINT ev_uniq UNIQUE (event_id)
+);
+
+CREATE INDEX IF NOT EXISTS redactions_event_id ON redactions (event_id);
+CREATE INDEX IF NOT EXISTS redactions_redacts ON redactions (redacts);
diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py
index a76fecf24f..d61f909939 100644
--- a/synapse/storage/stream.py
+++ b/synapse/storage/stream.py
@@ -157,6 +157,11 @@ class StreamStore(SQLBaseStore):
"WHERE m.user_id = ? "
)
+ del_sql = (
+ "SELECT event_id FROM redactions WHERE redacts = e.event_id "
+ "LIMIT 1"
+ )
+
if limit:
limit = max(limit, MAX_STREAM_SIZE)
else:
@@ -171,13 +176,14 @@ class StreamStore(SQLBaseStore):
return
sql = (
- "SELECT * FROM events as e WHERE "
+ "SELECT *, (%(redacted)s) AS redacted FROM events AS e WHERE "
"((room_id IN (%(current)s)) OR "
"(event_id IN (%(invites)s))) "
"AND e.stream_ordering > ? AND e.stream_ordering <= ? "
"AND e.outlier = 0 "
"ORDER BY stream_ordering ASC LIMIT %(limit)d "
) % {
+ "redacted": del_sql,
"current": current_room_membership_sql,
"invites": membership_sql,
"limit": limit
@@ -224,11 +230,21 @@ class StreamStore(SQLBaseStore):
else:
limit_str = ""
+ del_sql = (
+ "SELECT event_id FROM redactions WHERE redacts = events.event_id "
+ "LIMIT 1"
+ )
+
sql = (
- "SELECT * FROM events "
+ "SELECT *, (%(redacted)s) AS redacted FROM events "
"WHERE outlier = 0 AND room_id = ? AND %(bounds)s "
"ORDER BY topological_ordering %(order)s, stream_ordering %(order)s %(limit)s "
- ) % {"bounds": bounds, "order": order, "limit": limit_str}
+ ) % {
+ "redacted": del_sql,
+ "bounds": bounds,
+ "order": order,
+ "limit": limit_str
+ }
rows = yield self._execute_and_decode(
sql,
@@ -257,11 +273,18 @@ class StreamStore(SQLBaseStore):
with_feedback=False):
# TODO (erikj): Handle compressed feedback
+ del_sql = (
+ "SELECT event_id FROM redactions WHERE redacts = events.event_id "
+ "LIMIT 1"
+ )
+
sql = (
- "SELECT * FROM events "
+ "SELECT *, (%(redacted)s) AS redacted FROM events "
"WHERE room_id = ? AND stream_ordering <= ? "
"ORDER BY topological_ordering DESC, stream_ordering DESC LIMIT ? "
- )
+ ) % {
+ "redacted": del_sql,
+ }
rows = yield self._execute_and_decode(
sql,
diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py
index eb6b7c22ef..7208afdb3b 100644
--- a/tests/handlers/test_federation.py
+++ b/tests/handlers/test_federation.py
@@ -77,7 +77,7 @@ class FederationTestCase(unittest.TestCase):
self.datastore.persist_event.assert_called_once_with(
ANY, False, is_new_state=False
)
- self.notifier.on_new_room_event.assert_called_once_with(ANY)
+ self.notifier.on_new_room_event.assert_called_once_with(ANY, extra_users=[])
@defer.inlineCallbacks
def test_invite_join_target_this(self):
diff --git a/tests/storage/test_redaction.py b/tests/storage/test_redaction.py
new file mode 100644
index 0000000000..dae1641ea1
--- /dev/null
+++ b/tests/storage/test_redaction.py
@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from tests import unittest
+from twisted.internet import defer
+
+from synapse.server import HomeServer
+from synapse.api.constants import Membership
+from synapse.api.events.room import (
+ RoomMemberEvent, MessageEvent, RoomRedactionEvent,
+)
+
+from tests.utils import SQLiteMemoryDbPool
+
+
+class RedactionTestCase(unittest.TestCase):
+
+ @defer.inlineCallbacks
+ def setUp(self):
+ db_pool = SQLiteMemoryDbPool()
+ yield db_pool.prepare()
+
+ hs = HomeServer(
+ "test",
+ db_pool=db_pool,
+ )
+
+ self.store = hs.get_datastore()
+ self.event_factory = hs.get_event_factory()
+
+ self.u_alice = hs.parse_userid("@alice:test")
+ self.u_bob = hs.parse_userid("@bob:test")
+
+ self.room1 = hs.parse_roomid("!abc123:test")
+
+ self.depth = 1
+
+ @defer.inlineCallbacks
+ def inject_room_member(self, room, user, membership, prev_state=None,
+ extra_content={}):
+ self.depth += 1
+
+ event = self.event_factory.create_event(
+ etype=RoomMemberEvent.TYPE,
+ user_id=user.to_string(),
+ state_key=user.to_string(),
+ room_id=room.to_string(),
+ membership=membership,
+ content={"membership": membership},
+ depth=self.depth,
+ )
+
+ event.content.update(extra_content)
+
+ if prev_state:
+ event.prev_state = prev_state
+
+ # Have to create a join event using the eventfactory
+ yield self.store.persist_event(
+ event
+ )
+
+ defer.returnValue(event)
+
+ @defer.inlineCallbacks
+ def inject_message(self, room, user, body):
+ self.depth += 1
+
+ event = self.event_factory.create_event(
+ etype=MessageEvent.TYPE,
+ user_id=user.to_string(),
+ room_id=room.to_string(),
+ content={"body": body, "msgtype": u"message"},
+ depth=self.depth,
+ )
+
+ yield self.store.persist_event(
+ event
+ )
+
+ defer.returnValue(event)
+
+ @defer.inlineCallbacks
+ def inject_redaction(self, room, event_id, user, reason):
+ event = self.event_factory.create_event(
+ etype=RoomRedactionEvent.TYPE,
+ user_id=user.to_string(),
+ room_id=room.to_string(),
+ content={"reason": reason},
+ depth=self.depth,
+ redacts=event_id,
+ )
+
+ yield self.store.persist_event(
+ event
+ )
+
+ defer.returnValue(event)
+
+ @defer.inlineCallbacks
+ def test_redact(self):
+ yield self.inject_room_member(
+ self.room1, self.u_alice, Membership.JOIN
+ )
+
+ start = yield self.store.get_room_events_max_id()
+
+ msg_event = yield self.inject_message(self.room1, self.u_alice, u"t")
+
+ end = yield self.store.get_room_events_max_id()
+
+ results, _ = yield self.store.get_room_events_stream(
+ self.u_alice.to_string(),
+ start,
+ end,
+ None, # Is currently ignored
+ )
+
+ self.assertEqual(1, len(results))
+
+ # Check event has not been redacted:
+ event = results[0]
+
+ self.assertObjectHasAttributes(
+ {
+ "type": MessageEvent.TYPE,
+ "user_id": self.u_alice.to_string(),
+ "content": {"body": "t", "msgtype": "message"},
+ },
+ event,
+ )
+
+ self.assertFalse(hasattr(event, "redacted_because"))
+
+ # Redact event
+ reason = "Because I said so"
+ yield self.inject_redaction(
+ self.room1, msg_event.event_id, self.u_alice, reason
+ )
+
+ results, _ = yield self.store.get_room_events_stream(
+ self.u_alice.to_string(),
+ start,
+ end,
+ None, # Is currently ignored
+ )
+
+ self.assertEqual(1, len(results))
+
+ # Check redaction
+
+ event = results[0]
+
+ self.assertObjectHasAttributes(
+ {
+ "type": MessageEvent.TYPE,
+ "user_id": self.u_alice.to_string(),
+ "content": {},
+ },
+ event,
+ )
+
+ self.assertTrue(hasattr(event, "redacted_because"))
+
+ self.assertObjectHasAttributes(
+ {
+ "type": RoomRedactionEvent.TYPE,
+ "user_id": self.u_alice.to_string(),
+ "content": {"reason": reason},
+ },
+ event.redacted_because,
+ )
+
+ @defer.inlineCallbacks
+ def test_redact_join(self):
+ yield self.inject_room_member(
+ self.room1, self.u_alice, Membership.JOIN
+ )
+
+ start = yield self.store.get_room_events_max_id()
+
+ msg_event = yield self.inject_room_member(
+ self.room1, self.u_bob, Membership.JOIN,
+ extra_content={"blue": "red"},
+ )
+
+ end = yield self.store.get_room_events_max_id()
+
+ results, _ = yield self.store.get_room_events_stream(
+ self.u_alice.to_string(),
+ start,
+ end,
+ None, # Is currently ignored
+ )
+
+ self.assertEqual(1, len(results))
+
+ # Check event has not been redacted:
+ event = results[0]
+
+ self.assertObjectHasAttributes(
+ {
+ "type": RoomMemberEvent.TYPE,
+ "user_id": self.u_bob.to_string(),
+ "content": {"membership": Membership.JOIN, "blue": "red"},
+ },
+ event,
+ )
+
+ self.assertFalse(hasattr(event, "redacted_because"))
+
+ # Redact event
+ reason = "Because I said so"
+ yield self.inject_redaction(
+ self.room1, msg_event.event_id, self.u_alice, reason
+ )
+
+ results, _ = yield self.store.get_room_events_stream(
+ self.u_alice.to_string(),
+ start,
+ end,
+ None, # Is currently ignored
+ )
+
+ self.assertEqual(1, len(results))
+
+ # Check redaction
+
+ event = results[0]
+
+ self.assertObjectHasAttributes(
+ {
+ "type": RoomMemberEvent.TYPE,
+ "user_id": self.u_bob.to_string(),
+ "content": {"membership": Membership.JOIN},
+ },
+ event,
+ )
+
+ self.assertTrue(hasattr(event, "redacted_because"))
+
+ self.assertObjectHasAttributes(
+ {
+ "type": RoomRedactionEvent.TYPE,
+ "user_id": self.u_alice.to_string(),
+ "content": {"reason": reason},
+ },
+ event.redacted_because,
+ )
diff --git a/tests/utils.py b/tests/utils.py
index bc5d35e56b..bb8e9964dd 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -262,7 +262,7 @@ class MemoryDataStore(object):
return defer.succeed("invite")
def get_ops_levels(self, room_id):
- return defer.succeed((5, 5))
+ return defer.succeed((5, 5, 5))
def _format_call(args, kwargs):
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 0e823b43e7..7d61207554 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -67,6 +67,16 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
}
};
+ $scope.leave = function(room_id) {
+ matrixService.leave(room_id).then(
+ function(response) {
+ console.log("Left room " + room_id);
+ },
+ function(error) {
+ console.log("Failed to leave room " + room_id + ": " + error.data.error);
+ });
+ };
+
// Logs the user out
$scope.logout = function() {
diff --git a/webclient/app-filter.js b/webclient/app-filter.js
index ee9374668b..fc16492ef3 100644
--- a/webclient/app-filter.js
+++ b/webclient/app-filter.js
@@ -45,33 +45,34 @@ angular.module('matrixWebClient')
angular.forEach(members, function(value, key) {
value["id"] = key;
filtered.push( value );
- if (value["displayname"]) {
- if (!displayNames[value["displayname"]]) {
- displayNames[value["displayname"]] = [];
- }
- displayNames[value["displayname"]].push(key);
- }
});
- // FIXME: we shouldn't disambiguate displayNames on every orderMembersList
- // invocation but keep track of duplicates incrementally somewhere
- angular.forEach(displayNames, function(value, key) {
- if (value.length > 1) {
- // console.log(key + ": " + value);
- for (var i=0; i < value.length; i++) {
- var v = value[i];
- // FIXME: this permenantly rewrites the displayname for a given
- // room member. which means we can't reset their name if it is
- // no longer ambiguous!
- members[v].displayname += " (" + v + ")";
- // console.log(v + " " + members[v]);
+ filtered.sort(function (a, b) {
+ // Sort members on their last_active absolute time
+ var aLastActiveTS = 0, bLastActiveTS = 0;
+ if (undefined !== a.last_active_ago) {
+ aLastActiveTS = a.last_updated - a.last_active_ago;
+ }
+ if (undefined !== b.last_active_ago) {
+ bLastActiveTS = b.last_updated - b.last_active_ago;
+ }
+ if (aLastActiveTS || bLastActiveTS) {
+ return bLastActiveTS - aLastActiveTS;
+ }
+ else {
+ // If they do not have last_active_ago, sort them according to their presence state
+ // Online users go first amongs members who do not have last_active_ago
+ var presenceLevels = {
+ offline: 1,
+ unavailable: 2,
+ online: 4,
+ free_for_chat: 3
};
+ var aPresence = (a.presence in presenceLevels) ? presenceLevels[a.presence] : 0;
+ var bPresence = (b.presence in presenceLevels) ? presenceLevels[b.presence] : 0;
+ return bPresence - aPresence;
}
});
-
- filtered.sort(function (a, b) {
- return ((a["last_active_ago"] || 10e10) > (b["last_active_ago"] || 10e10) ? 1 : -1);
- });
return filtered;
};
})
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 98003e97bf..e990d42d05 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -101,7 +101,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
var initRoom = function(room_id, room) {
if (!(room_id in $rootScope.events.rooms)) {
- console.log("Creating new handler entry for " + room_id);
+ console.log("Creating new rooms entry for " + room_id);
$rootScope.events.rooms[room_id] = {
room_id: room_id,
messages: [],
@@ -113,10 +113,12 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
};
}
- if (room) {
+ if (room) { // we got an existing room object from initialsync, seemingly.
// Report all other metadata of the room object (membership, inviter, visibility, ...)
for (var field in room) {
- if (-1 === ["room_id", "messages", "state"].indexOf(field)) {
+ if (!room.hasOwnProperty(field)) continue;
+
+ if (-1 === ["room_id", "messages", "state"].indexOf(field)) { // why indexOf - why not ===? --Matthew
$rootScope.events.rooms[room_id][field] = room[field];
}
}
@@ -211,11 +213,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
if (shouldBing && isIdle) {
console.log("Displaying notification for "+JSON.stringify(event));
- var member = $rootScope.events.rooms[event.room_id].members[event.user_id];
- var displayname = undefined;
- if (member) {
- displayname = member.displayname;
- }
+ var member = getMember(event.room_id, event.user_id);
+ var displayname = getUserDisplayName(event.room_id, event.user_id);
var message = event.content.body;
if (event.content.msgtype === "m.emote") {
@@ -223,7 +222,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
}
var notification = new window.Notification(
- (displayname || event.user_id) +
+ displayname +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": message,
@@ -253,12 +252,29 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
// Exception: Do not do this if the event is a room state event because such events already come
// as room messages events. Moreover, when they come as room messages events, they are relatively ordered
// with other other room messages
- if (event.content.prev !== event.content.membership && !isStateEvent) {
- if (isLiveEvent) {
- $rootScope.events.rooms[event.room_id].messages.push(event);
+ if (!isStateEvent) {
+ // could be a membership change, display name change, etc.
+ // Find out which one.
+ var memberChanges = undefined;
+ if (event.content.prev !== event.content.membership) {
+ memberChanges = "membership";
}
- else {
- $rootScope.events.rooms[event.room_id].messages.unshift(event);
+ else if (event.prev_content && (event.prev_content.displayname !== event.content.displayname)) {
+ memberChanges = "displayname";
+ }
+
+ // mark the key which changed
+ event.changedKey = memberChanges;
+
+ // If there was a change we want to display, dump it in the message
+ // list.
+ if (memberChanges) {
+ if (isLiveEvent) {
+ $rootScope.events.rooms[event.room_id].messages.push(event);
+ }
+ else {
+ $rootScope.events.rooms[event.room_id].messages.unshift(event);
+ }
}
}
@@ -328,6 +344,65 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
return index;
};
+ /**
+ * Get the member object of a room member
+ * @param {String} room_id the room id
+ * @param {String} user_id the id of the user
+ * @returns {undefined | Object} the member object of this user in this room if he is part of the room
+ */
+ var getMember = function(room_id, user_id) {
+ var member;
+
+ var room = $rootScope.events.rooms[room_id];
+ if (room) {
+ member = room.members[user_id];
+ }
+ return member;
+ };
+
+ /**
+ * Return the display name of an user acccording to data already downloaded
+ * @param {String} room_id the room id
+ * @param {String} user_id the id of the user
+ * @returns {String} the user displayname or user_id if not available
+ */
+ var getUserDisplayName = function(room_id, user_id) {
+ var displayName;
+
+ // Get the user display name from the member list of the room
+ var member = getMember(room_id, user_id);
+ if (member && member.content.displayname) { // Do not consider null displayname
+ displayName = member.content.displayname;
+
+ // Disambiguate users who have the same displayname in the room
+ if (user_id !== matrixService.config().user_id) {
+ var room = $rootScope.events.rooms[room_id];
+
+ for (var member_id in room.members) {
+ if (room.members.hasOwnProperty(member_id) && member_id !== user_id) {
+ var member2 = room.members[member_id];
+ if (member2.content.displayname && member2.content.displayname === displayName) {
+ displayName = displayName + " (" + user_id + ")";
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // The user may not have joined the room yet. So try to resolve display name from presence data
+ // Note: This data may not be available
+ if (undefined === displayName && user_id in $rootScope.presence) {
+ displayName = $rootScope.presence[user_id].content.displayname;
+ }
+
+ if (undefined === displayName) {
+ // By default, use the user ID
+ displayName = user_id;
+ }
+ return displayName;
+ };
+
return {
ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
MSG_EVENT: MSG_EVENT,
@@ -499,6 +574,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
memberCount = 0;
for (var i in room.members) {
+ if (!room.members.hasOwnProperty(i)) continue;
+
var member = room.members[i];
if ("join" === member.membership) {
@@ -517,15 +594,19 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
* @returns {undefined | Object} the member object of this user in this room if he is part of the room
*/
getMember: function(room_id, user_id) {
- var member;
-
- var room = $rootScope.events.rooms[room_id];
- if (room) {
- member = room.members[user_id];
- }
- return member;
+ return getMember(room_id, user_id);
},
+ /**
+ * Return the display name of an user acccording to data already downloaded
+ * @param {String} room_id the room id
+ * @param {String} user_id the id of the user
+ * @returns {String} the user displayname or user_id if not available
+ */
+ getUserDisplayName: function(room_id, user_id) {
+ return getUserDisplayName(room_id, user_id);
+ },
+
setRoomVisibility: function(room_id, visible) {
if (!visible) {
return;
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index 7b5d9cffef..3e8811e5fc 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -66,15 +66,67 @@ angular.module('MatrixCall', [])
}
+ MatrixCall.getTurnServer = function() {
+ matrixService.getTurnServer().then(function(response) {
+ if (response.data.uris) {
+ console.log("Got TURN URIs: "+response.data.uris);
+ MatrixCall.turnServer = response.data;
+ $rootScope.haveTurn = true;
+ // re-fetch when we're about to reach the TTL
+ $timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9);
+ } else {
+ console.log("Got no TURN URIs from HS");
+ $rootScope.haveTurn = false;
+ }
+ }, function(error) {
+ console.log("Failed to get TURN URIs");
+ MatrixCall.turnServer = {};
+ $timeout(MatrixCall.getTurnServer, 60000);
+ });
+ }
+
+ // FIXME: we should prevent any class from being placed or accepted before this has finished
+ MatrixCall.getTurnServer();
+
MatrixCall.CALL_TIMEOUT = 60000;
+ MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302';
MatrixCall.prototype.createPeerConnection = function() {
- var stunServer = 'stun:stun.l.google.com:19302';
var pc;
if (window.mozRTCPeerConnection) {
- pc = new window.mozRTCPeerConnection({'url': stunServer});
+ var iceServers = [];
+ if (MatrixCall.turnServer) {
+ if (MatrixCall.turnServer.uris) {
+ for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) {
+ iceServers.push({
+ 'url': MatrixCall.turnServer.uris[i],
+ 'username': MatrixCall.turnServer.username,
+ 'credential': MatrixCall.turnServer.password,
+ });
+ }
+ } else {
+ console.log("No TURN server: using fallback STUN server");
+ iceServers.push({ 'url' : MatrixCall.FALLBACK_STUN_SERVER });
+ }
+ }
+
+ pc = new window.mozRTCPeerConnection({"iceServers":iceServers});
} else {
- pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
+ var iceServers = [];
+ if (MatrixCall.turnServer) {
+ if (MatrixCall.turnServer.uris) {
+ iceServers.push({
+ 'urls': MatrixCall.turnServer.uris,
+ 'username': MatrixCall.turnServer.username,
+ 'credential': MatrixCall.turnServer.password,
+ });
+ } else {
+ console.log("No TURN server: using fallback STUN server");
+ iceServers.push({ 'urls' : MatrixCall.FALLBACK_STUN_SERVER });
+ }
+ }
+
+ pc = new window.RTCPeerConnection({"iceServers":iceServers});
}
var self = this;
pc.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index 328e3a7086..e6f2acc5fd 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -19,7 +19,7 @@
angular.module('matrixFilter', [])
// Compute the room name according to information we have
-.filter('mRoomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) {
+.filter('mRoomName', ['$rootScope', 'matrixService', 'eventHandlerService', function($rootScope, matrixService, eventHandlerService) {
return function(room_id) {
var roomName;
@@ -31,49 +31,57 @@ angular.module('matrixFilter', [])
if (room) {
// Get name from room state date
var room_name_event = room["m.room.name"];
+
+ // Determine if it is a public room
+ var isPublicRoom = false;
+ if (room["m.room.join_rules"] && room["m.room.join_rules"].content) {
+ isPublicRoom = ("public" === room["m.room.join_rules"].content.join_rule);
+ }
+
if (room_name_event) {
roomName = room_name_event.content.name;
}
else if (alias) {
roomName = alias;
}
- else if (room.members) {
-
+ else if (room.members && !isPublicRoom) { // Do not rename public room
+
var user_id = matrixService.config().user_id;
// Else, build the name from its users
// Limit the room renaming to 1:1 room
if (2 === Object.keys(room.members).length) {
for (var i in room.members) {
+ if (!room.members.hasOwnProperty(i)) continue;
+
var member = room.members[i];
if (member.state_key !== user_id) {
-
- if (member.state_key in $rootScope.presence) {
- // If the user is available in presence, use the displayname there
- // as it is the most uptodate
- roomName = $rootScope.presence[member.state_key].content.displayname;
- }
- else if (member.content.displayname) {
- roomName = member.content.displayname;
- }
- else {
- roomName = member.state_key;
- }
+ roomName = eventHandlerService.getUserDisplayName(room_id, member.state_key);
+ break;
}
}
}
- else if (1 === Object.keys(room.members).length) {
+ else if (Object.keys(room.members).length <= 1) {
+
var otherUserId;
- if (Object.keys(room.members)[0] !== user_id) {
+ if (Object.keys(room.members)[0] && Object.keys(room.members)[0] !== user_id) {
otherUserId = Object.keys(room.members)[0];
}
else {
+ // it's got to be an invite, or failing that a self-chat;
+ otherUserId = room.inviter || user_id;
+/*
+ // XXX: This should all be unnecessary now thanks to using the /rooms/<room>/roomid API
+
// The other member may be in the invite list, get all invited users
var invitedUserIDs = [];
+
+ // XXX: *SURELY* we shouldn't have to trawl through the whole messages list to
+ // find invite - surely the other user should be in room.members with state invited? :/ --Matthew
for (var i in room.messages) {
var message = room.messages[i];
- if ("m.room.member" === message.type && "invite" === message.membership) {
+ if ("m.room.member" === message.type && "invite" === message.content.membership) {
// Filter out the current user
var member_id = message.state_key;
if (member_id === user_id) {
@@ -92,15 +100,11 @@ angular.module('matrixFilter', [])
if (1 === invitedUserIDs.length) {
otherUserId = invitedUserIDs[0];
}
+*/
}
-
- // Try to resolve his displayname in presence global data
- if (otherUserId in $rootScope.presence) {
- roomName = $rootScope.presence[otherUserId].content.displayname;
- }
- else {
- roomName = otherUserId;
- }
+
+ // Get the user display name
+ roomName = eventHandlerService.getUserDisplayName(room_id, otherUserId);
}
}
}
@@ -127,37 +131,9 @@ angular.module('matrixFilter', [])
};
}])
-// Compute the user display name in a room according to the data already downloaded
-.filter('mUserDisplayName', ['$rootScope', function($rootScope) {
+// Return the user display name
+.filter('mUserDisplayName', ['eventHandlerService', function(eventHandlerService) {
return function(user_id, room_id) {
- var displayName;
-
- // Try to find the user name among presence data
- // Warning: that means we have received before a presence event for this
- // user which cannot be guaranted.
- // However, if we get the info by this way, we are sure this is the latest user display name
- // See FIXME comment below
- if (user_id in $rootScope.presence) {
- displayName = $rootScope.presence[user_id].content.displayname;
- }
-
- // FIXME: Would like to use the display name as defined in room members of the room.
- // But this information is the display name of the user when he has joined the room.
- // It does not take into account user display name update
- if (room_id) {
- var room = $rootScope.events.rooms[room_id];
- if (room && (user_id in room.members)) {
- var member = room.members[user_id];
- if (member.content.displayname) {
- displayName = member.content.displayname;
- }
- }
- }
-
- if (undefined === displayName) {
- // By default, use the user ID
- displayName = user_id;
- }
- return displayName;
+ return eventHandlerService.getUserDisplayName(room_id, user_id);
};
}]);
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 069e02e939..a4f0568bce 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -264,7 +264,13 @@ angular.module('matrixService', [])
return doRequest("GET", path, params);
},
-
+
+ // get room state for a specific room
+ roomState: function(room_id) {
+ var path = "/rooms/" + room_id + "/state";
+ return doRequest("GET", path);
+ },
+
// Joins a room
join: function(room_id) {
return this.membershipChange(room_id, undefined, "join");
@@ -697,11 +703,10 @@ angular.module('matrixService', [])
createRoomIdToAliasMapping: function(roomId, alias) {
roomIdToAlias[roomId] = alias;
aliasToRoomId[alias] = roomId;
- // localStorage.setItem(MAPPING_PREFIX+roomId, alias);
},
getRoomIdToAliasMapping: function(roomId) {
- var alias = roomIdToAlias[roomId]; // was localStorage.getItem(MAPPING_PREFIX+roomId)
+ var alias = roomIdToAlias[roomId];
//console.log("looking for alias for " + roomId + "; found: " + alias);
return alias;
},
@@ -762,6 +767,10 @@ angular.module('matrixService', [])
var deferred = $q.defer();
deferred.reject({data:{error: "Invalid room: " + room_id}});
return deferred.promise;
+ },
+
+ getTurnServer: function() {
+ return doRequest("GET", "/voip/turnServer");
}
};
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index e35219bebb..f1295560ef 100644
--- a/webclient/home/home-controller.js
+++ b/webclient/home/home-controller.js
@@ -42,6 +42,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
displayName: "",
avatarUrl: ""
};
+
+ $scope.newChat = {
+ user: ""
+ };
var refresh = function() {
@@ -82,18 +86,24 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
// Go to a room
$scope.goToRoom = function(room_id) {
- // Simply open the room page on this room id
- //$location.url("room/" + room_id);
matrixService.join(room_id).then(
function(response) {
+ var final_room_id = room_id;
if (response.data.hasOwnProperty("room_id")) {
- if (response.data.room_id != room_id) {
- $location.url("room/" + response.data.room_id);
- return;
- }
+ final_room_id = response.data.room_id;
}
- $location.url("room/" + room_id);
+ // TODO: factor out the common housekeeping whenever we try to join a room or alias
+ matrixService.roomState(final_room_id).then(
+ function(response) {
+ eventHandlerService.handleEvents(response.data, false, true);
+ },
+ function(error) {
+ $scope.feedback = "Failed to get room state for: " + final_room_id;
+ }
+ );
+
+ $location.url("room/" + final_room_id);
},
function(error) {
$scope.feedback = "Can't join room: " + JSON.stringify(error.data);
@@ -104,6 +114,15 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
$scope.joinAlias = function(room_alias) {
matrixService.joinAlias(room_alias).then(
function(response) {
+ // TODO: factor out the common housekeeping whenever we try to join a room or alias
+ matrixService.roomState(response.room_id).then(
+ function(response) {
+ eventHandlerService.handleEvents(response.data, false, true);
+ },
+ function(error) {
+ $scope.feedback = "Failed to get room state for: " + response.room_id;
+ }
+ );
// Go to this room
$location.url("room/" + room_alias);
},
@@ -112,6 +131,32 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
}
);
};
+
+ // FIXME: factor this out between user-controller and home-controller etc.
+ $scope.messageUser = function() {
+
+ // FIXME: create a new room every time, for now
+
+ matrixService.create(null, 'private').then(
+ function(response) {
+ // This room has been created. Refresh the rooms list
+ var room_id = response.data.room_id;
+ console.log("Created room with id: "+ room_id);
+
+ matrixService.invite(room_id, $scope.newChat.user).then(
+ function() {
+ $scope.feedback = "Invite sent successfully";
+ $scope.$parent.goToPage("/room/" + room_id);
+ },
+ function(reason) {
+ $scope.feedback = "Failure: " + JSON.stringify(reason);
+ });
+ },
+ function(error) {
+ $scope.feedback = "Failure: " + JSON.stringify(error.data);
+ });
+ };
+
$scope.onInit = function() {
// Load profile data
diff --git a/webclient/home/home.html b/webclient/home/home.html
index 5a1e18e1de..0af382916e 100644
--- a/webclient/home/home.html
+++ b/webclient/home/home.html
@@ -17,7 +17,7 @@
<div>{{ config.user_id }}</div>
</div>
</div>
-
+
<h3>Recent conversations</h3>
<div ng-include="'recents/recents.html'"></div>
<br/>
@@ -52,17 +52,24 @@
<div>
<form>
- <input size="40" ng-model="newRoom.room_alias" ng-enter="createNewRoom(newRoom.room_alias, newRoom.private)" placeholder="(e.g. foo_channel)"/>
+ <input size="40" ng-model="newRoom.room_alias" ng-enter="createNewRoom(newRoom.room_alias, newRoom.private)" placeholder="(e.g. foo)"/>
<input type="checkbox" ng-model="newRoom.private">private
<button ng-disabled="!newRoom.room_alias" ng-click="createNewRoom(newRoom.room_alias, newRoom.private)">Create room</button>
</form>
</div>
<div>
<form>
- <input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo_channel:example.org)"/>
+ <input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo:example.org)"/>
<button ng-disabled="!joinAlias.room_alias" ng-click="joinAlias(joinAlias.room_alias)">Join room</button>
</form>
</div>
+ <div>
+ <form>
+ <input size="40" ng-model="newChat.user" ng-enter="messageUser()" placeholder="e.g. @user:domain.com"/>
+ <button ng-disabled="!newChat.user" ng-click="messageUser()">Message user</button>
+ </form>
+ </div>
+
<br/>
{{ feedback }}
diff --git a/webclient/img/close.png b/webclient/img/close.png
new file mode 100644
index 0000000000..fbcdb51e6b
--- /dev/null
+++ b/webclient/img/close.png
Binary files differdiff --git a/webclient/index.html b/webclient/index.html
index 411c2762d3..f233919e3d 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -69,7 +69,7 @@
<span ng-show="currentCall.state == 'ringing' && currentCall && currentCall.type == 'voice'">Incoming Voice Call</span>
<span ng-show="currentCall.state == 'connecting'">Call Connecting...</span>
<span ng-show="currentCall.state == 'connected'">Call Connected</span>
- <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'ice_failed'">Media Connection Failed</span>
+ <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'ice_failed'">Media Connection Failed{{ haveTurn ? "" : " (VoIP relaying unsupported by Home Server)" }}</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
<span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'invite_timeout' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">User Not Responding</span>
diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html
index edfc1677eb..9cbdcd357a 100644
--- a/webclient/recents/recents.html
+++ b/webclient/recents/recents.html
@@ -2,7 +2,7 @@
<table class="recentsTable">
<tbody ng-repeat="(index, room) in events.rooms | orderRecents"
ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )"
- class ="recentsRoom"
+ class="recentsRoom"
ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
<tr>
<td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
@@ -19,6 +19,8 @@
{{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }}
{{ (lastMsg.ts) | date:'MMM d HH:mm' }}
+
+ <img ng-click="leave(room.room_id); $event.stopPropagation();" src="img/close.png" width="10" height="10" style="margin-bottom: -1px; margin-left: 2px;" alt="close"/>
</td>
</tr>
@@ -31,28 +33,35 @@
<div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
<div ng-switch-when="m.room.member">
- <span ng-if="'join' === lastMsg.content.membership">
- {{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined
- </span>
- <span ng-if="'leave' === lastMsg.content.membership">
- <span ng-if="lastMsg.user_id === lastMsg.state_key">
- {{lastMsg.state_key | mUserDisplayName: room.room_id }} left
- </span>
- <span ng-if="lastMsg.user_id !== lastMsg.state_key">
- {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
- {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
- {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
- </span>
- <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
- : {{ lastMsg.content.reason }}
+ <span ng-switch="lastMsg.changedKey">
+ <span ng-switch-when="membership">
+ <span ng-if="'join' === lastMsg.content.membership">
+ {{ lastMsg.state_key | mUserDisplayName: room.room_id }} joined
+ </span>
+ <span ng-if="'leave' === lastMsg.content.membership">
+ <span ng-if="lastMsg.user_id === lastMsg.state_key">
+ {{lastMsg.state_key | mUserDisplayName: room.room_id }} left
+ </span>
+ <span ng-if="lastMsg.user_id !== lastMsg.state_key">
+ {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+ {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
+ {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
+ </span>
+ <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
+ : {{ lastMsg.content.reason }}
+ </span>
+ </span>
+ <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
+ {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+ {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
+ {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
+ <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
+ : {{ lastMsg.content.reason }}
+ </span>
+ </span>
</span>
- </span>
- <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
- {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
- {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
- {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
- <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
- : {{ lastMsg.content.reason }}
+ <span ng-switch-when="displayname">
+ {{ lastMsg.user_id }} changed their display name from {{ lastMsg.prev_content.displayname }} to {{ lastMsg.content.displayname }}
</span>
</span>
</div>
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index c8104e39e6..d8c62c231e 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -400,6 +400,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Find the max power level
var maxPowerLevel = 0;
for (var i in $scope.members) {
+ if (!$scope.members.hasOwnProperty(i)) continue;
+
var member = $scope.members[i];
if (member.powerLevel) {
maxPowerLevel = Math.max(maxPowerLevel, member.powerLevel);
@@ -409,6 +411,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Normalized them on a 0..100% scale to be use in css width
if (maxPowerLevel) {
for (var i in $scope.members) {
+ if (!$scope.members.hasOwnProperty(i)) continue;
+
var member = $scope.members[i];
member.powerLevelNorm = (member.powerLevel * 100) / maxPowerLevel;
}
@@ -479,6 +483,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
else {
promise = matrixService.joinAlias(room_alias).then(
function(response) {
+ // TODO: factor out the common housekeeping whenever we try to join a room or alias
+ matrixService.roomState(response.room_id).then(
+ function(response) {
+ eventHandlerService.handleEvents(response.data, false, true);
+ },
+ function(error) {
+ $scope.feedback = "Failed to get room state for: " + response.room_id;
+ }
+ );
$location.url("room/" + room_alias);
},
function(error) {
@@ -702,19 +715,24 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// The room members is available in the data fetched by initialSync
if ($rootScope.events.rooms[$scope.room_id]) {
- // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
- if ($rootScope.events.rooms[$scope.room_id].messages.length) {
- $scope.state.first_pagination = false;
+ var messages = $rootScope.events.rooms[$scope.room_id].messages;
+
+ if (0 === messages.length
+ || (1 === messages.length && "m.room.member" === messages[0].type && "invite" === messages[0].content.membership && $scope.state.user_id === messages[0].state_key)) {
+ // If we just joined a room, we won't have this history from initial sync, so we should try to paginate it anyway
+ $scope.state.first_pagination = true;
}
else {
- // except if we just joined a room, we won't have this history from initial sync, so we should try to paginate it anyway
- $scope.state.first_pagination = true;
+ // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
+ $scope.state.first_pagination = false;
}
var members = $rootScope.events.rooms[$scope.room_id].members;
// Update the member list
for (var i in members) {
+ if (!members.hasOwnProperty(i)) continue;
+
var member = members[i];
updateMemberList(member);
}
@@ -732,6 +750,16 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
$scope.state.waiting_for_joined_event = true;
matrixService.join($scope.room_id).then(
function() {
+ // TODO: factor out the common housekeeping whenever we try to join a room or alias
+ matrixService.roomState($scope.room_id).then(
+ function(response) {
+ eventHandlerService.handleEvents(response.data, false, true);
+ },
+ function(error) {
+ console.error("Failed to get room state for: " + $scope.room_id);
+ }
+ );
+
// onInit3 will be called once the joined m.room.member event is received from the events stream
// This avoids to get the joined information twice in parallel:
// - one from the events stream
@@ -740,6 +768,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
},
function(reason) {
console.log("Can't join room: " + JSON.stringify(reason));
+ // FIXME: what if it wasn't a perms problem?
$scope.state.permission_denied = "You do not have permission to join this room";
});
}
@@ -809,7 +838,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
matrixService.leave($scope.room_id).then(
function(response) {
- console.log("Left room ");
+ console.log("Left room " + $scope.room_id);
$location.url("home");
},
function(error) {
diff --git a/webclient/room/room-directive.js b/webclient/room/room-directive.js
index e033b003e1..05382cfcd3 100644
--- a/webclient/room/room-directive.js
+++ b/webclient/room/room-directive.js
@@ -21,39 +21,62 @@ angular.module('RoomController')
return function (scope, element, attrs) {
element.bind("keydown keypress", function (event) {
// console.log("event: " + event.which);
- if (event.which === 9) {
+ var TAB = 9;
+ var SHIFT = 16;
+ var keypressCode = event.which;
+ if (keypressCode === TAB) {
if (!scope.tabCompleting) { // cache our starting text
- // console.log("caching " + element[0].value);
scope.tabCompleteOriginal = element[0].value;
scope.tabCompleting = true;
+ scope.tabCompleteIndex = 0;
}
+ // loop in the right direction
if (event.shiftKey) {
scope.tabCompleteIndex--;
if (scope.tabCompleteIndex < 0) {
- scope.tabCompleteIndex = 0;
+ // wrap to the last search match, and fix up to a real
+ // index value after we've matched
+ scope.tabCompleteIndex = Number.MAX_VALUE;
}
}
else {
scope.tabCompleteIndex++;
}
+
var searchIndex = 0;
var targetIndex = scope.tabCompleteIndex;
var text = scope.tabCompleteOriginal;
- // console.log("targetIndex: " + targetIndex + ", text=" + text);
+ // console.log("targetIndex: " + targetIndex + ",
+ // text=" + text);
- // FIXME: use the correct regexp to recognise userIDs
+ // FIXME: use the correct regexp to recognise userIDs --M
+ //
+ // XXX: I don't really know what the point of this is. You
+ // WANT to match freeform text given you want to match display
+ // names AND user IDs. Surely you just want to get the last
+ // word out of the input text and that's that?
+ // Am I missing something here? -- Kegan
+ //
+ // You're not missing anything - my point was that we should
+ // explicitly define the syntax for user IDs /somewhere/.
+ // Meanwhile as long as the delimeters are well defined, we
+ // could just pick "the last word". But to know what the
+ // correct delimeters are, we probably do need a formal
+ // syntax for user IDs to refer to... --Matthew
+
var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text);
- if (targetIndex === 0) {
- element[0].value = text;
-
- // Force angular to wake up and update the input ng-model by firing up input event
+
+ if (targetIndex === 0) { // 0 is always the original text
+ element[0].value = text;
+ // Force angular to wake up and update the input ng-model
+ // by firing up input event
angular.element(element[0]).triggerHandler('input');
}
else if (search && search[1]) {
- // console.log("search found: " + search);
+ // console.log("search found: " + search+" from "+text);
var expansion;
// FIXME: could do better than linear search here
@@ -68,6 +91,7 @@ angular.module('RoomController')
if (searchIndex < targetIndex) { // then search raw mxids
angular.forEach(scope.members, function(item, name) {
if (searchIndex < targetIndex) {
+ // === 1 because mxids are @username
if (name.toLowerCase().indexOf(search[1].toLowerCase()) === 1) {
expansion = name;
searchIndex++;
@@ -76,18 +100,22 @@ angular.module('RoomController')
});
}
- if (searchIndex === targetIndex) {
- // xchat-style tab complete
+ if (searchIndex === targetIndex ||
+ targetIndex === Number.MAX_VALUE) {
+ // xchat-style tab complete, add a colon if tab
+ // completing at the start of the text
if (search[0].length === text.length)
- expansion += " : ";
+ expansion += ": ";
else
expansion += " ";
element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion);
// cancel blink
element[0].className = "";
-
- // Force angular to wake up and update the input ng-model by firing up input event
- angular.element(element[0]).triggerHandler('input');
+ if (targetIndex === Number.MAX_VALUE) {
+ // wrap the index around to the last index found
+ scope.tabCompleteIndex = searchIndex;
+ targetIndex = searchIndex;
+ }
}
else {
// console.log("wrapped!");
@@ -97,23 +125,40 @@ angular.module('RoomController')
}, 150);
element[0].value = text;
scope.tabCompleteIndex = 0;
-
- // Force angular to wake up and update the input ng-model by firing up input event
- angular.element(element[0]).triggerHandler('input');
}
+
+ // Force angular to wak up and update the input ng-model by
+ // firing up input event
+ angular.element(element[0]).triggerHandler('input');
}
else {
scope.tabCompleteIndex = 0;
}
+ // prevent the default TAB operation (typically focus shifting)
event.preventDefault();
}
- else if (event.which !== 16 && scope.tabCompleting) {
+ else if (keypressCode !== SHIFT && scope.tabCompleting) {
scope.tabCompleting = false;
scope.tabCompleteIndex = 0;
}
});
};
}])
+.directive('commandHistory', [ function() {
+ return function (scope, element, attrs) {
+ element.bind("keydown", function (event) {
+ var keycodePressed = event.which;
+ var UP_ARROW = 38;
+ var DOWN_ARROW = 40;
+ if (keycodePressed === UP_ARROW) {
+ scope.history.goUp(event);
+ }
+ else if (keycodePressed === DOWN_ARROW) {
+ scope.history.goDown(event);
+ }
+ });
+ }
+}])
// A directive to anchor the scroller position at the bottom when the browser is resizing.
// When the screen resizes, the bottom of the element remains the same, not the top.
diff --git a/webclient/room/room.html b/webclient/room/room.html
index db3aa193c5..b99413cbbf 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -48,7 +48,15 @@
width="80" height="80"/>
<img class="userAvatarGradient" src="img/gradient.png" title="{{ member.id }}" width="80" height="24"/>
<div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div>
- <div class="userName">{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}<br/>{{ member.displayname ? "" : member.id.substr(member.id.indexOf(':')) }}</div>
+ <div class="userName">
+ <div ng-show="member.displayname">
+ {{ member.id | mUserDisplayName: room_id }}
+ </div>
+ <div ng-hide="member.displayname">
+ {{ member.id.substr(0, member.id.indexOf(':')) }}<br/>
+ {{ member.id.substr(member.id.indexOf(':')) }}
+ </div>
+ </div>
</td>
<td class="userPresence" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
<span ng-show="member.last_active_ago">{{ member.last_active_ago + (now - member.last_updated) | duration }}<br/>ago</span>
@@ -65,7 +73,7 @@
<tr ng-repeat="msg in events.rooms[room_id].messages"
ng-class="(events.rooms[room_id].messages[$index + 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
<td class="leftBlock">
- <div class="sender" ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id">{{ members[msg.user_id].displayname || msg.user_id }}</div>
+ <div class="sender" ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id"> {{ msg.user_id | mUserDisplayName: room_id }}</div>
<div class="timestamp"
ng-class="msg.echo_msg_state">
{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}
@@ -77,10 +85,10 @@
</td>
<td ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
<div class="bubble">
- <span ng-if="'join' === msg.content.membership">
+ <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
{{ members[msg.state_key].displayname || msg.state_key }} joined
</span>
- <span ng-if="'leave' === msg.content.membership">
+ <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'">
<span ng-if="msg.user_id === msg.state_key">
{{ members[msg.state_key].displayname || msg.state_key }} left
</span>
@@ -93,7 +101,8 @@
</span>
</span>
</span>
- <span ng-if="'invite' === msg.content.membership || 'ban' === msg.content.membership">
+ <span ng-if="'invite' === msg.content.membership && msg.changedKey === 'membership' ||
+ 'ban' === msg.content.membership && msg.changedKey === 'membership'">
{{ members[msg.user_id].displayname || msg.user_id }}
{{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
{{ members[msg.state_key].displayname || msg.state_key }}
@@ -101,6 +110,9 @@
: {{ msg.content.reason }}
</span>
</span>
+ <span ng-if="msg.changedKey === 'displayname'">
+ {{ msg.user_id }} changed their display name from {{ msg.prev_content.displayname }} to {{ msg.content.displayname }}
+ </span>
<span ng-show='msg.content.msgtype === "m.emote"'
ng-class="msg.echo_msg_state"
@@ -159,8 +171,7 @@
<td width="*">
<textarea id="mainInput" rows="1" ng-enter="send()"
ng-disabled="state.permission_denied"
- ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
- ng-focus="true" autocomplete="off" tab-complete/>
+ ng-focus="true" autocomplete="off" tab-complete command-history/>
</td>
<td id="buttonsCell">
<button ng-click="send()" ng-disabled="state.permission_denied">Send</button>
diff --git a/webclient/user/user-controller.js b/webclient/user/user-controller.js
index 3940db6683..0dbfa325d0 100644
--- a/webclient/user/user-controller.js
+++ b/webclient/user/user-controller.js
@@ -38,7 +38,8 @@ angular.module('UserController', ['matrixService'])
$scope.user.avatar_url = response.data.avatar_url;
}
);
-
+
+ // FIXME: factor this out between user-controller and home-controller etc.
$scope.messageUser = function() {
// FIXME: create a new room every time, for now
|