diff options
39 files changed, 824 insertions, 213 deletions
diff --git a/.gitignore b/.gitignore index dfe8dfedbf..b91b52b615 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ graph/*.png graph/*.dot webclient/config.js +webclient/test/environment-protractor.js uploads diff --git a/CHANGES.rst b/CHANGES.rst index dd188cf86a..400ded0f15 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,23 @@ +Changes in synapse 0.3.3 (2014-09-22) +===================================== + +Homeserver: + * Fix bug where you continued to get events for rooms you had left. + +Webclient: + * Add support for video calls with basic UI. + * Fix bug where one to one chats were named after your display name rather + than the other person's. + * Fix bug which caused lag when typing in the textarea. + * Refuse to run on browsers we know won't work. + * Trigger pagination when joining new rooms. + * Fix bug where we sometimes didn't display invitations in recents. + * Automatically join room when accepting a VoIP call. + * Disable outgoing and reject incoming calls on browsers we don't support + VoIP in. + * Don't display desktop notifications for messages in the room you are + non-idle and speaking in. + Changes in synapse 0.3.2 (2014-09-18) ===================================== diff --git a/VERSION b/VERSION index d15723fbe8..1c09c74e22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.2 +0.3.3 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..4f097c391e --- /dev/null +++ b/docs/turn-howto.rst @@ -0,0 +1,92 @@ +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 are:: + + lt-cred-mech + use-auth-secret + static-auth-secret=[your secret key here] + + 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 comma-separated + 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 0dbad9a0de..bba551b2c4 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.2" +__version__ = "0.3.3" diff --git a/synapse/api/auth.py b/synapse/api/auth.py index 9c65d47fb4..9bfd25c86e 100644 --- a/synapse/api/auth.py +++ b/synapse/api/auth.py @@ -400,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/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..c5131d9bcd --- /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.split(",") if args.turn_uris else None + 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 b7a9d77ca2..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"] 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 399084ae2a..a01dab1b8e 100644 --- a/synapse/rest/room.py +++ b/synapse/rest/room.py @@ -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 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 38f5ec0ac5..15919eb580 100644 --- a/synapse/storage/__init__.py +++ b/synapse/storage/__init__.py @@ -221,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, 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/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/login/login.html b/webclient/login/login.html index 6297ec4d42..6b321f8fc5 100644 --- a/webclient/login/login.html +++ b/webclient/login/login.html @@ -23,7 +23,7 @@ <br/> <input id="password" size="32" type="password" ng-model="account.password" placeholder="Password"/> <br/><br/> - <button ng-click="login()" ng-disabled="!account.user_id || !account.password || !account.homeserver">Login</button> + <button id="login" ng-click="login()" ng-disabled="!account.user_id || !account.password || !account.homeserver">Login</button> <br/><br/> </div> 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/test/README b/webclient/test/README index b1e0d7adea..1a7bc832c7 100644 --- a/webclient/test/README +++ b/webclient/test/README @@ -1,9 +1,33 @@ Requires: - - npm + - nodejs/npm - npm install karma - npm install jasmine + - npm install protractor (e2e testing) -Setting up continuous integration / run the tests: +Setting up continuous integration / run the unit tests (make sure you're in +this directory so it can find the config file): karma start +Setting up e2e tests (only if you don't have a selenium server to run the tests +on. If you do, edit the config to point to that url): + + webdriver-manager update + webdriver-manager start + + Create a file "environment-protractor.js" in this directory and type: + module.exports = { + seleniumAddress: 'http://localhost:4444/wd/hub', + baseUrl: "http://localhost:8008", + username: "YOUR_TEST_USERNAME", + password: "YOUR_TEST_PASSWORD" + } + +Running e2e tests: + protractor protractor.conf.js + +NOTE: This will create a public room on the target home server. + + + + diff --git a/webclient/test/e2e/home.spec.js b/webclient/test/e2e/home.spec.js new file mode 100644 index 0000000000..470237d557 --- /dev/null +++ b/webclient/test/e2e/home.spec.js @@ -0,0 +1,16 @@ +var env = require("../environment-protractor.js"); + +describe("home page", function() { + + beforeEach(function() { + ptor = protractor.getInstance(); + // FIXME we use longpoll on the event stream, and I can't get $interval + // playing nicely with it. Patches welcome to fix this. + ptor.ignoreSynchronization = true; + }); + + it("should have a title", function() { + browser.get(env.baseUrl); + expect(browser.getTitle()).toEqual("[matrix]"); + }); +}); diff --git a/webclient/test/protractor.conf.js b/webclient/test/protractor.conf.js new file mode 100644 index 0000000000..76ae7b712b --- /dev/null +++ b/webclient/test/protractor.conf.js @@ -0,0 +1,18 @@ +var env = require("./environment-protractor.js"); +exports.config = { + seleniumAddress: env.seleniumAddress, + specs: ['e2e/*.spec.js'], + onPrepare: function() { + browser.driver.get(env.baseUrl); + browser.driver.findElement(by.id("user_id")).sendKeys(env.username); + browser.driver.findElement(by.id("password")).sendKeys(env.password); + browser.driver.findElement(by.id("login")).click(); + + // wait till the login is done, detect via url change + browser.driver.wait(function() { + return browser.driver.getCurrentUrl().then(function(url) { + return !(/login/.test(url)) + }); + }); + } +} diff --git a/webclient/test/unit/user-controller.spec.js b/webclient/test/unit/user-controller.spec.js index 217559114b..798cc4de48 100644 --- a/webclient/test/unit/user-controller.spec.js +++ b/webclient/test/unit/user-controller.spec.js @@ -21,13 +21,12 @@ describe("UserCtrl", function() { getDisplayName: function(uid) { var d = $q.defer(); - // FIXME: everything goes into fire here d.resolve({ data: { displayname: displayName } }); - return d; + return d.promise; }, getProfilePictureUrl: function(uid) { @@ -37,7 +36,7 @@ describe("UserCtrl", function() { avatar_url: avatarUrl } }); - return d; + return d.promise; } }; scope = $rootScope.$new(); @@ -49,7 +48,6 @@ describe("UserCtrl", function() { '$routeParams': routeParams, 'matrixService': matrixService }); - console.log("end inject"); }); }); 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 |