Merge branch 'release-v0.3.0' of github.com:matrix-org/synapse

pull/10/head v0.3.0
Erik Johnston 2014-09-18 13:05:07 +01:00
commit 704e7e9f44
68 changed files with 1811 additions and 627 deletions

View File

@ -1,3 +1,41 @@
Changes in synapse 0.3.0 (2014-09-18)
=====================================
See UPGRADE for information about changes to the client server API, including
breaking backwards compatibility with VoIP calls and registration API.
Homeserver:
* When a user changes their displayname or avatar the server will now update
all their join states to reflect this.
* The server now adds "age" key to events to indicate how old they are. This
is clock independent, so at no point does any server or webclient have to
assume their clock is in sync with everyone else.
* Fix bug where we didn't correctly pull in missing PDUs.
* Fix bug where prev_content key wasn't always returned.
* Add support for password resets.
Webclient:
* Improve page content loading.
* Join/parts now trigger desktop notifications.
* Always show room aliases in the UI if one is present.
* No longer show user-count in the recents side panel.
* Add up & down arrow support to the text box for message sending to step
through your sent history.
* Don't display notifications for our own messages.
* Emotes are now formatted correctly in desktop notifications.
* The recents list now differentiates between public & private rooms.
* Fix bug where when switching between rooms the pagination flickered before
the view jumped to the bottom of the screen.
* Add support for password resets.
* Add bing word support.
Registration API:
* The registration API has been overhauled to function like the login API. In
practice, this means registration requests must now include the following:
'type':'m.login.password'. See UPGRADE for more information on this.
* The 'user_id' key has been renamed to 'user' to better match the login API.
* There is an additional login type: 'm.login.email.identity'.
* The command client and web client have been updated to reflect these changes.
Changes in synapse 0.2.3 (2014-09-12)
=====================================
@ -17,6 +55,9 @@ Webclient:
* Add glare support for VoIP.
* Improvements to initial startup speed.
* Don't display duplicate join events.
* Local echo of messages.
* Differentiate sending and sent of local echo.
* Various minor bug fixes.
Changes in synapse 0.2.2 (2014-09-06)
=====================================

View File

@ -1,3 +1,34 @@
Upgrading to v0.3.0
===================
This registration API now closely matches the login API. This introduces a bit
more backwards and forwards between the HS and the client, but this improves
the overall flexibility of the API. You can now GET on /register to retrieve a list
of valid registration flows. Upon choosing one, they are submitted in the same
way as login, e.g::
{
type: m.login.password,
user: foo,
password: bar
}
The default HS supports 2 flows, with and without Identity Server email
authentication. Enabling captcha on the HS will add in an extra step to all
flows: ``m.login.recaptcha`` which must be completed before you can transition
to the next stage. There is a new login type: ``m.login.email.identity`` which
contains the ``threepidCreds`` key which were previously sent in the original
register request. For more information on this, see the specification.
Web Client
----------
The VoIP specification has changed between v0.2.0 and v0.3.0. Users should
refresh any browser tabs to get the latest web client code. Users on
v0.2.0 of the web client will not be able to call those on v0.3.0 and
vice versa.
Upgrading to v0.2.0
===================

View File

@ -1 +1 @@
0.2.3
0.3.0

View File

@ -145,35 +145,50 @@ class SynapseCmd(cmd.Cmd):
<noupdate> : Do not automatically clobber config values.
"""
args = self._parse(line, ["userid", "noupdate"])
path = "/register"
password = None
pwd = None
pwd2 = "_"
while pwd != pwd2:
pwd = getpass.getpass("(Optional) Type a password for this user: ")
if len(pwd) == 0:
print "Not using a password for this user."
break
pwd = getpass.getpass("Type a password for this user: ")
pwd2 = getpass.getpass("Retype the password: ")
if pwd != pwd2:
if pwd != pwd2 or len(pwd) == 0:
print "Password mismatch."
pwd = None
else:
password = pwd
body = {}
body = {
"type": "m.login.password"
}
if "userid" in args:
body["user_id"] = args["userid"]
body["user"] = args["userid"]
if password:
body["password"] = password
reactor.callFromThread(self._do_register, "POST", path, body,
reactor.callFromThread(self._do_register, body,
"noupdate" not in args)
@defer.inlineCallbacks
def _do_register(self, method, path, data, update_config):
url = self._url() + path
json_res = yield self.http_client.do_request(method, url, data=data)
def _do_register(self, data, update_config):
# check the registration flows
url = self._url() + "/register"
json_res = yield self.http_client.do_request("GET", url)
print json.dumps(json_res, indent=4)
passwordFlow = None
for flow in json_res["flows"]:
if flow["type"] == "m.login.recaptcha" or ("stages" in flow and "m.login.recaptcha" in flow["stages"]):
print "Unable to register: Home server requires captcha."
return
if flow["type"] == "m.login.password" and "stages" not in flow:
passwordFlow = flow
break
if not passwordFlow:
return
json_res = yield self.http_client.do_request("POST", url, data=data)
print json.dumps(json_res, indent=4)
if update_config and "user_id" in json_res:
self.config["user"] = json_res["user_id"]

View File

@ -3,35 +3,38 @@
"apis": [
{
"operations": [
{
"method": "GET",
"nickname": "get_registration_info",
"notes": "All login stages MUST be mentioned if there is >1 login type.",
"summary": "Get the login mechanism to use when registering.",
"type": "RegistrationFlows"
},
{
"method": "POST",
"nickname": "register",
"notes": "Volatile: This API is likely to change.",
"nickname": "submit_registration",
"notes": "If this is part of a multi-stage registration, there MUST be a 'session' key.",
"parameters": [
{
"description": "A registration request",
"description": "A registration submission",
"name": "body",
"paramType": "body",
"required": true,
"type": "RegistrationRequest"
"type": "RegistrationSubmission"
}
],
"responseMessages": [
{
"code": 400,
"message": "No JSON object."
"message": "Bad login type"
},
{
"code": 400,
"message": "User ID must only contain characters which do not require url encoding."
},
{
"code": 400,
"message": "User ID already taken."
"message": "Missing JSON keys"
}
],
"summary": "Register with the home server.",
"type": "RegistrationResponse"
"summary": "Submit a registration action.",
"type": "RegistrationResult"
}
],
"path": "/register"
@ -42,30 +45,68 @@
"application/json"
],
"models": {
"RegistrationResponse": {
"id": "RegistrationResponse",
"RegistrationFlows": {
"id": "RegistrationFlows",
"properties": {
"access_token": {
"description": "The access token for this user.",
"type": "string"
"flows": {
"description": "A list of valid registration flows.",
"type": "array",
"items": {
"$ref": "RegistrationInfo"
}
}
}
},
"RegistrationInfo": {
"id": "RegistrationInfo",
"properties": {
"stages": {
"description": "Multi-stage registration only: An array of all the login types required to registration.",
"items": {
"$ref": "string"
},
"type": "array"
},
"user_id": {
"description": "The fully-qualified user ID.",
"type": "string"
},
"home_server": {
"description": "The name of the home server.",
"type": {
"description": "The first login type that must be used when logging in.",
"type": "string"
}
}
},
"RegistrationRequest": {
"id": "RegistrationRequest",
"RegistrationResult": {
"id": "RegistrationResult",
"properties": {
"access_token": {
"description": "The access token for this user's registration if this is the final stage of the registration process.",
"type": "string"
},
"user_id": {
"description": "The desired user ID. If not specified, a random user ID will be allocated.",
"type": "string",
"required": false
"description": "The user's fully-qualified user ID.",
"type": "string"
},
"next": {
"description": "Multi-stage registration only: The next registration type to submit.",
"type": "string"
},
"session": {
"description": "Multi-stage registration only: The session token to send when submitting the next registration type.",
"type": "string"
}
}
},
"RegistrationSubmission": {
"id": "RegistrationSubmission",
"properties": {
"type": {
"description": "The type of registration being submitted.",
"type": "string"
},
"session": {
"description": "Multi-stage registration only: The session token from an earlier registration stage.",
"type": "string"
},
"_registration_type_defined_keys_": {
"description": "Keys as defined by the specified registration type, e.g. \"user\", \"password\""
}
}
}

View File

@ -1169,8 +1169,14 @@ This event is sent by the caller when they wish to establish a call.
Required keys:
- ``call_id`` : "string" - A unique identifier for the call
- ``offer`` : "offer object" - The session description
- ``version`` : "integer" - The version of the VoIP specification this message
adheres to. This specification is version 0.
- ``version`` : "integer" - The version of the VoIP specification this
message adheres to. This specification is
version 0.
- ``lifetime`` : "integer" - The time in milliseconds that the invite is
valid for. Once the invite age exceeds this
value, clients should discard it. They
should also no longer show the call as
awaiting an answer in the UI.
Optional keys:
None.
@ -1182,16 +1188,16 @@ This event is sent by the caller when they wish to establish a call.
- ``type`` : "string" - The type of session description, in this case 'offer'
- ``sdp`` : "string" - The SDP text of the session description
``m.call.candidate``
``m.call.candidates``
This event is sent by callers after sending an invite and by the callee after answering.
Its purpose is to give the other party an additional ICE candidate to try using to
Its purpose is to give the other party additional ICE candidates to try using to
communicate.
Required keys:
- ``call_id`` : "string" - The ID of the call this event relates to
- ``version`` : "integer" - The version of the VoIP specification this messages
adheres to. his specification is version 0.
- ``candidate`` : "candidate object" - Object describing the candidate.
- ``candidates`` : "array of candidate objects" - Array of object describing the candidates.
``Candidate Object``
@ -1305,12 +1311,6 @@ display name other than it being a valid unicode string.
Registration and login
======================
.. WARNING::
The registration API is likely to change.
.. TODO
- TODO Kegan : Make registration like login (just omit the "user" key on the
initial request?)
Clients must register with a home server in order to use Matrix. After
registering, the client will be given an access token which must be used in ALL
@ -1323,9 +1323,11 @@ a token sent to their email address, etc. This specification does not define how
home servers should authorise their users who want to login to their existing
accounts, but instead defines the standard interface which implementations
should follow so that ANY client can login to ANY home server. Clients login
using the |login|_ API.
using the |login|_ API. Clients register using the |register|_ API. Registration
follows the same procedure as login, but the path requests are sent to are
different.
The login process breaks down into the following:
The registration/login process breaks down into the following:
1. Determine the requirements for logging in.
2. Submit the login stage credentials.
3. Get credentials or be told the next stage in the login process and repeat
@ -1383,7 +1385,7 @@ This specification defines the following login types:
- ``m.login.oauth2``
- ``m.login.email.code``
- ``m.login.email.url``
- ``m.login.email.identity``
Password-based
--------------
@ -1531,6 +1533,31 @@ If the link has not been visited yet, a standard error response with an errcode
``M_LOGIN_EMAIL_URL_NOT_YET`` should be returned.
Email-based (identity server)
-----------------------------
:Type:
``m.login.email.identity``
:Description:
Login is supported by authorising an email address with an identity server.
Prior to submitting this, the client should authenticate with an identity server.
After authenticating, the session information should be submitted to the home server.
To respond to this type, reply with::
{
"type": "m.login.email.identity",
"threepidCreds": [
{
"sid": "<identity server session id>",
"clientSecret": "<identity server client secret>",
"idServer": "<url of identity server authed with, e.g. 'matrix.org:8090'>"
}
]
}
N-Factor Authentication
-----------------------
Multiple login stages can be combined to create N-factor authentication during login.
@ -2242,6 +2269,9 @@ Transaction:
.. |login| replace:: ``/login``
.. _login: /docs/api/client-server/#!/-login
.. |register| replace:: ``/register``
.. _register: /docs/api/client-server/#!/-registration
.. |/rooms/<room_id>/messages| replace:: ``/rooms/<room_id>/messages``
.. _/rooms/<room_id>/messages: /docs/api/client-server/#!/-rooms/get_messages

View File

@ -16,4 +16,4 @@
""" This is a reference implementation of a synapse home server.
"""
__version__ = "0.2.3"
__version__ = "0.3.0"

View File

@ -50,3 +50,12 @@ class JoinRules(object):
KNOCK = u"knock"
INVITE = u"invite"
PRIVATE = u"private"
class LoginType(object):
PASSWORD = u"m.login.password"
OAUTH = u"m.login.oauth2"
EMAIL_CODE = u"m.login.email.code"
EMAIL_URL = u"m.login.email.url"
EMAIL_IDENTITY = u"m.login.email.identity"
RECAPTCHA = u"m.login.recaptcha"

View File

@ -17,6 +17,19 @@ from synapse.api.errors import SynapseError, Codes
from synapse.util.jsonobject import JsonEncodedObject
def serialize_event(hs, e):
# FIXME(erikj): To handle the case of presence events and the like
if not isinstance(e, SynapseEvent):
return e
d = e.get_dict()
if "age_ts" in d:
d["age"] = int(hs.get_clock().time_msec()) - d["age_ts"]
del d["age_ts"]
return d
class SynapseEvent(JsonEncodedObject):
"""Base class for Synapse events. These are JSON objects which must abide
@ -43,6 +56,8 @@ class SynapseEvent(JsonEncodedObject):
"content", # HTTP body, JSON
"state_key",
"required_power_level",
"age_ts",
"prev_content",
]
internal_keys = [
@ -158,10 +173,6 @@ class SynapseEvent(JsonEncodedObject):
class SynapseStateEvent(SynapseEvent):
valid_keys = SynapseEvent.valid_keys + [
"prev_content",
]
def __init__(self, **kwargs):
if "state_key" not in kwargs:
kwargs["state_key"] = ""

View File

@ -59,6 +59,14 @@ class EventFactory(object):
if "ts" not in kwargs:
kwargs["ts"] = int(self.clock.time_msec())
# The "age" key is a delta timestamp that should be converted into an
# absolute timestamp the minute we see it.
if "age" in kwargs:
kwargs["age_ts"] = int(self.clock.time_msec()) - int(kwargs["age"])
del kwargs["age"]
elif "age_ts" not in kwargs:
kwargs["age_ts"] = int(self.clock.time_msec())
if etype in self._event_list:
handler = self._event_list[etype]
else:

39
synapse/config/email.py Normal file
View File

@ -0,0 +1,39 @@
# -*- 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 ._base import Config
class EmailConfig(Config):
def __init__(self, args):
super(EmailConfig, self).__init__(args)
self.email_from_address = args.email_from_address
self.email_smtp_server = args.email_smtp_server
@classmethod
def add_arguments(cls, parser):
super(EmailConfig, cls).add_arguments(parser)
email_group = parser.add_argument_group("email")
email_group.add_argument(
"--email-from-address",
default="FROM@EXAMPLE.COM",
help="The address to send emails from (e.g. for password resets)."
)
email_group.add_argument(
"--email-smtp-server",
default="",
help="The SMTP server to send emails from (e.g. for password resets)."
)

View File

@ -20,11 +20,15 @@ from .database import DatabaseConfig
from .ratelimiting import RatelimitConfig
from .repository import ContentRepositoryConfig
from .captcha import CaptchaConfig
from .email import EmailConfig
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig):
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
EmailConfig):
pass
if __name__=='__main__':
if __name__ == '__main__':
import sys
HomeServerConfig.load_config("Generate config", sys.argv[1:], "HomeServer")

View File

@ -291,6 +291,13 @@ class ReplicationLayer(object):
def on_incoming_transaction(self, transaction_data):
transaction = Transaction(**transaction_data)
for p in transaction.pdus:
if "age" in p:
p["age_ts"] = int(self._clock.time_msec()) - int(p["age"])
del p["age"]
pdu_list = [Pdu(**p) for p in transaction.pdus]
logger.debug("[%s] Got transaction", transaction.transaction_id)
response = yield self.transaction_actions.have_responded(transaction)
@ -303,8 +310,6 @@ class ReplicationLayer(object):
logger.debug("[%s] Transacition is new", transaction.transaction_id)
pdu_list = [Pdu(**p) for p in transaction.pdus]
dl = []
for pdu in pdu_list:
dl.append(self._handle_new_pdu(pdu))
@ -405,9 +410,14 @@ class ReplicationLayer(object):
"""Returns a new Transaction containing the given PDUs suitable for
transmission.
"""
pdus = [p.get_dict() for p in pdu_list]
for p in pdus:
if "age_ts" in pdus:
p["age"] = int(self.clock.time_msec()) - p["age_ts"]
return Transaction(
pdus=[p.get_dict() for p in pdu_list],
origin=self.server_name,
pdus=pdus,
ts=int(self._clock.time_msec()),
destination=None,
)
@ -593,8 +603,21 @@ class _TransactionQueue(object):
logger.debug("TX [%s] Sending transaction...", destination)
# Actually send the transaction
# FIXME (erikj): This is a bit of a hack to make the Pdu age
# keys work
def cb(transaction):
now = int(self._clock.time_msec())
if "pdus" in transaction:
for p in transaction["pdus"]:
if "age_ts" in p:
p["age"] = now - int(p["age_ts"])
return transaction
code, response = yield self.transport_layer.send_transaction(
transaction
transaction,
on_send_callback=cb,
)
logger.debug("TX [%s] Sent transaction", destination)

View File

@ -144,7 +144,7 @@ class TransportLayer(object):
@defer.inlineCallbacks
@log_function
def send_transaction(self, transaction):
def send_transaction(self, transaction, on_send_callback=None):
""" Sends the given Transaction to it's destination
Args:
@ -165,10 +165,23 @@ class TransportLayer(object):
data = transaction.get_dict()
# FIXME (erikj): This is a bit of a hack to make the Pdu age
# keys work
def cb(destination, method, path_bytes, producer):
if not on_send_callback:
return
transaction = json.loads(producer.body)
new_transaction = on_send_callback(transaction)
producer.reset(new_transaction)
code, response = yield self.client.put_json(
transaction.destination,
path=PREFIX + "/send/%s/" % transaction.transaction_id,
data=data
data=data,
on_send_callback=cb,
)
logger.debug(

View File

@ -15,7 +15,6 @@
from twisted.internet import defer
from synapse.api.events import SynapseEvent
from synapse.util.logutils import log_function
from ._base import BaseHandler
@ -71,10 +70,7 @@ class EventStreamHandler(BaseHandler):
auth_user, room_ids, pagin_config, timeout
)
chunks = [
e.get_dict() if isinstance(e, SynapseEvent) else e
for e in events
]
chunks = [self.hs.serialize_event(e) for e in events]
chunk = {
"chunk": chunks,
@ -92,7 +88,9 @@ class EventStreamHandler(BaseHandler):
# 10 seconds of grace to allow the client to reconnect again
# before we think they're gone
def _later():
logger.debug("_later stopped_user_eventstream %s", auth_user)
logger.debug(
"_later stopped_user_eventstream %s", auth_user
)
self.distributor.fire(
"stopped_user_eventstream", auth_user
)

View File

@ -93,22 +93,18 @@ class FederationHandler(BaseHandler):
"""
event = self.pdu_codec.event_from_pdu(pdu)
logger.debug("Got event: %s", event.event_id)
with (yield self.lock_manager.lock(pdu.context)):
if event.is_state and not backfilled:
is_new_state = yield self.state_handler.handle_new_state(
pdu
)
if not is_new_state:
return
else:
is_new_state = False
# TODO: Implement something in federation that allows us to
# respond to PDU.
if hasattr(event, "state_key") and not is_new_state:
logger.debug("Ignoring old state.")
return
target_is_mine = False
if hasattr(event, "target_host"):
target_is_mine = event.target_host == self.hs.hostname
@ -139,7 +135,11 @@ class FederationHandler(BaseHandler):
else:
with (yield self.room_lock.lock(event.room_id)):
yield self.store.persist_event(event, backfilled)
yield self.store.persist_event(
event,
backfilled,
is_new_state=is_new_state
)
room = yield self.store.get_room(event.room_id)

View File

@ -17,9 +17,13 @@ from twisted.internet import defer
from ._base import BaseHandler
from synapse.api.errors import LoginError, Codes
from synapse.http.client import PlainHttpClient
from synapse.util.emailutils import EmailException
import synapse.util.emailutils as emailutils
import bcrypt
import logging
import urllib
logger = logging.getLogger(__name__)
@ -62,4 +66,41 @@ class LoginHandler(BaseHandler):
defer.returnValue(token)
else:
logger.warn("Failed password login for user %s", user)
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
@defer.inlineCallbacks
def reset_password(self, user_id, email):
is_valid = yield self._check_valid_association(user_id, email)
logger.info("reset_password user=%s email=%s valid=%s", user_id, email,
is_valid)
if is_valid:
try:
# send an email out
emailutils.send_email(
smtp_server=self.hs.config.email_smtp_server,
from_addr=self.hs.config.email_from_address,
to_addr=email,
subject="Password Reset",
body="TODO."
)
except EmailException as e:
logger.exception(e)
@defer.inlineCallbacks
def _check_valid_association(self, user_id, email):
identity = yield self._query_email(email)
if identity and "mxid" in identity:
if identity["mxid"] == user_id:
defer.returnValue(True)
return
defer.returnValue(False)
@defer.inlineCallbacks
def _query_email(self, email):
httpCli = PlainHttpClient(self.hs)
data = yield httpCli.get_json(
'matrix.org:8090', # TODO FIXME This should be configurable.
"/_matrix/identity/api/v1/lookup?medium=email&address=" +
"%s" % urllib.quote(email)
)
defer.returnValue(data)

View File

@ -124,7 +124,7 @@ class MessageHandler(BaseHandler):
)
chunk = {
"chunk": [e.get_dict() for e in events],
"chunk": [self.hs.serialize_event(e) for e in events],
"start": pagin_config.from_token.to_string(),
"end": next_token.to_string(),
}
@ -268,6 +268,9 @@ class MessageHandler(BaseHandler):
user, pagination_config, None
)
public_rooms = yield self.store.get_rooms(is_public=True)
public_room_ids = [r["room_id"] for r in public_rooms]
limit = pagin_config.limit
if not limit:
limit = 10
@ -276,6 +279,8 @@ class MessageHandler(BaseHandler):
d = {
"room_id": event.room_id,
"membership": event.membership,
"visibility": ("public" if event.room_id in
public_room_ids else "private"),
}
if event.membership == Membership.INVITE:
@ -296,7 +301,7 @@ class MessageHandler(BaseHandler):
end_token = now_token.copy_and_replace("room_key", token[1])
d["messages"] = {
"chunk": [m.get_dict() for m in messages],
"chunk": [self.hs.serialize_event(m) for m in messages],
"start": start_token.to_string(),
"end": end_token.to_string(),
}
@ -304,7 +309,7 @@ class MessageHandler(BaseHandler):
current_state = yield self.store.get_current_state(
event.room_id
)
d["state"] = [c.get_dict() for c in current_state]
d["state"] = [self.hs.serialize_event(c) for c in current_state]
except:
logger.exception("Failed to get snapshot")

View File

@ -15,9 +15,9 @@
from twisted.internet import defer
from synapse.api.errors import SynapseError, AuthError
from synapse.api.errors import CodeMessageException
from synapse.api.errors import SynapseError, AuthError, CodeMessageException
from synapse.api.constants import Membership
from synapse.api.events.room import RoomMemberEvent
from ._base import BaseHandler
@ -97,6 +97,8 @@ class ProfileHandler(BaseHandler):
}
)
yield self._update_join_states(target_user)
@defer.inlineCallbacks
def get_avatar_url(self, target_user):
if target_user.is_mine:
@ -144,6 +146,8 @@ class ProfileHandler(BaseHandler):
}
)
yield self._update_join_states(target_user)
@defer.inlineCallbacks
def collect_presencelike_data(self, user, state):
if not user.is_mine:
@ -180,3 +184,39 @@ class ProfileHandler(BaseHandler):
)
defer.returnValue(response)
@defer.inlineCallbacks
def _update_join_states(self, user):
if not user.is_mine:
return
joins = yield self.store.get_rooms_for_user_where_membership_is(
user.to_string(),
[Membership.JOIN],
)
for j in joins:
snapshot = yield self.store.snapshot_room(
j.room_id, j.state_key, RoomMemberEvent.TYPE,
j.state_key
)
content = {
"membership": j.content["membership"],
"prev": j.content["membership"],
}
yield self.distributor.fire(
"collect_presencelike_data", user, content
)
new_event = self.event_factory.create_event(
etype=j.type,
room_id=j.room_id,
state_key=j.state_key,
content=content,
user_id=j.state_key,
)
yield self.state_handler.handle_new_event(new_event, snapshot)
yield self._on_new_room_event(new_event, snapshot)

View File

@ -40,8 +40,7 @@ class RegistrationHandler(BaseHandler):
self.distributor.declare("registered_user")
@defer.inlineCallbacks
def register(self, localpart=None, password=None, threepidCreds=None,
captcha_info={}):
def register(self, localpart=None, password=None):
"""Registers a new client on the server.
Args:
@ -54,37 +53,6 @@ class RegistrationHandler(BaseHandler):
Raises:
RegistrationError if there was a problem registering.
"""
if captcha_info:
captcha_response = yield self._validate_captcha(
captcha_info["ip"],
captcha_info["private_key"],
captcha_info["challenge"],
captcha_info["response"]
)
if not captcha_response["valid"]:
logger.info("Invalid captcha entered from %s. Error: %s",
captcha_info["ip"], captcha_response["error_url"])
raise InvalidCaptchaError(
error_url=captcha_response["error_url"]
)
else:
logger.info("Valid captcha entered from %s", captcha_info["ip"])
if threepidCreds:
for c in threepidCreds:
logger.info("validating theeepidcred sid %s on id server %s",
c['sid'], c['idServer'])
try:
threepid = yield self._threepid_from_creds(c)
except:
logger.err()
raise RegistrationError(400, "Couldn't validate 3pid")
if not threepid:
raise RegistrationError(400, "Couldn't validate 3pid")
logger.info("got threepid medium %s address %s",
threepid['medium'], threepid['address'])
password_hash = None
if password:
password_hash = bcrypt.hashpw(password, bcrypt.gensalt())
@ -126,15 +94,54 @@ class RegistrationHandler(BaseHandler):
raise RegistrationError(
500, "Cannot generate user ID.")
# Now we have a matrix ID, bind it to the threepids we were given
if threepidCreds:
for c in threepidCreds:
# XXX: This should be a deferred list, shouldn't it?
yield self._bind_threepid(c, user_id)
defer.returnValue((user_id, token))
@defer.inlineCallbacks
def check_recaptcha(self, ip, private_key, challenge, response):
"""Checks a recaptcha is correct."""
captcha_response = yield self._validate_captcha(
ip,
private_key,
challenge,
response
)
if not captcha_response["valid"]:
logger.info("Invalid captcha entered from %s. Error: %s",
ip, captcha_response["error_url"])
raise InvalidCaptchaError(
error_url=captcha_response["error_url"]
)
else:
logger.info("Valid captcha entered from %s", ip)
@defer.inlineCallbacks
def register_email(self, threepidCreds):
"""Registers emails with an identity server."""
for c in threepidCreds:
logger.info("validating theeepidcred sid %s on id server %s",
c['sid'], c['idServer'])
try:
threepid = yield self._threepid_from_creds(c)
except:
logger.err()
raise RegistrationError(400, "Couldn't validate 3pid")
if not threepid:
raise RegistrationError(400, "Couldn't validate 3pid")
logger.info("got threepid medium %s address %s",
threepid['medium'], threepid['address'])
@defer.inlineCallbacks
def bind_emails(self, user_id, threepidCreds):
"""Links emails with a user ID and informs an identity server."""
# Now we have a matrix ID, bind it to the threepids we were given
for c in threepidCreds:
# XXX: This should be a deferred list, shouldn't it?
yield self._bind_threepid(c, user_id)
def _generate_token(self, user_id):
# urlsafe variant uses _ and - so use . as the separator and replace
# all =s with .s so http clients don't quote =s when it is used as
@ -149,17 +156,17 @@ class RegistrationHandler(BaseHandler):
def _threepid_from_creds(self, creds):
httpCli = PlainHttpClient(self.hs)
# XXX: make this configurable!
trustedIdServers = [ 'matrix.org:8090' ]
trustedIdServers = ['matrix.org:8090']
if not creds['idServer'] in trustedIdServers:
logger.warn('%s is not a trusted ID server: rejecting 3pid '+
logger.warn('%s is not a trusted ID server: rejecting 3pid ' +
'credentials', creds['idServer'])
defer.returnValue(None)
data = yield httpCli.get_json(
creds['idServer'],
"/_matrix/identity/api/v1/3pid/getValidated3pid",
{ 'sid': creds['sid'], 'clientSecret': creds['clientSecret'] }
{'sid': creds['sid'], 'clientSecret': creds['clientSecret']}
)
if 'medium' in data:
defer.returnValue(data)
defer.returnValue(None)
@ -170,44 +177,45 @@ class RegistrationHandler(BaseHandler):
data = yield httpCli.post_urlencoded_get_json(
creds['idServer'],
"/_matrix/identity/api/v1/3pid/bind",
{ 'sid': creds['sid'], 'clientSecret': creds['clientSecret'],
'mxid':mxid }
{'sid': creds['sid'], 'clientSecret': creds['clientSecret'],
'mxid': mxid}
)
defer.returnValue(data)
@defer.inlineCallbacks
def _validate_captcha(self, ip_addr, private_key, challenge, response):
"""Validates the captcha provided.
Returns:
dict: Containing 'valid'(bool) and 'error_url'(str) if invalid.
"""
response = yield self._submit_captcha(ip_addr, private_key, challenge,
response = yield self._submit_captcha(ip_addr, private_key, challenge,
response)
# parse Google's response. Lovely format..
lines = response.split('\n')
json = {
"valid": lines[0] == 'true',
"error_url": "http://www.google.com/recaptcha/api/challenge?"+
"error_url": "http://www.google.com/recaptcha/api/challenge?" +
"error=%s" % lines[1]
}
defer.returnValue(json)
@defer.inlineCallbacks
def _submit_captcha(self, ip_addr, private_key, challenge, response):
client = PlainHttpClient(self.hs)
data = yield client.post_urlencoded_get_raw(
"www.google.com:80",
"/recaptcha/api/verify",
accept_partial=True, # twisted dislikes google's response, no content length.
args={
'privatekey': private_key,
# twisted dislikes google's response, no content length.
accept_partial=True,
args={
'privatekey': private_key,
'remoteip': ip_addr,
'challenge': challenge,
'response': response
}
)
defer.returnValue(data)

View File

@ -335,7 +335,7 @@ class RoomMemberHandler(BaseHandler):
member_list = yield self.store.get_room_members(room_id=room_id)
event_list = [
entry.get_dict()
self.hs.serialize_event(entry)
for entry in member_list
]
chunk_data = {

View File

@ -122,7 +122,7 @@ class TwistedHttpClient(HttpClient):
self.hs = hs
@defer.inlineCallbacks
def put_json(self, destination, path, data):
def put_json(self, destination, path, data, on_send_callback=None):
if destination in _destination_mappings:
destination = _destination_mappings[destination]
@ -131,7 +131,8 @@ class TwistedHttpClient(HttpClient):
"PUT",
path.encode("ascii"),
producer=_JsonProducer(data),
headers_dict={"Content-Type": ["application/json"]}
headers_dict={"Content-Type": ["application/json"]},
on_send_callback=on_send_callback,
)
logger.debug("Getting resp body")
@ -218,7 +219,7 @@ class TwistedHttpClient(HttpClient):
@defer.inlineCallbacks
def _create_request(self, destination, method, path_bytes, param_bytes=b"",
query_bytes=b"", producer=None, headers_dict={},
retry_on_dns_fail=True):
retry_on_dns_fail=True, on_send_callback=None):
""" Creates and sends a request to the given url
"""
headers_dict[b"User-Agent"] = [b"Synapse"]
@ -242,6 +243,9 @@ class TwistedHttpClient(HttpClient):
endpoint = self._getEndpoint(reactor, destination);
while True:
if on_send_callback:
on_send_callback(destination, method, path_bytes, producer)
try:
response = yield self.agent.request(
destination,
@ -310,6 +314,9 @@ class _JsonProducer(object):
""" Used by the twisted http client to create the HTTP body from json
"""
def __init__(self, jsn):
self.reset(jsn)
def reset(self, jsn):
self.body = encode_canonical_json(jsn)
self.length = len(self.body)

View File

@ -59,7 +59,7 @@ class EventRestServlet(RestServlet):
event = yield handler.get_event(auth_user, event_id)
if event:
defer.returnValue((200, event.get_dict()))
defer.returnValue((200, self.hs.serialize_event(event)))
else:
defer.returnValue((404, "Event not found."))

View File

@ -73,6 +73,27 @@ class LoginFallbackRestServlet(RestServlet):
return (200, {})
class PasswordResetRestServlet(RestServlet):
PATTERN = client_path_pattern("/login/reset")
@defer.inlineCallbacks
def on_POST(self, request):
reset_info = _parse_json(request)
try:
email = reset_info["email"]
user_id = reset_info["user_id"]
handler = self.handlers.login_handler
yield handler.reset_password(user_id, email)
# purposefully give no feedback to avoid people hammering different
# combinations.
defer.returnValue((200, {}))
except KeyError:
raise SynapseError(
400,
"Missing keys. Requires 'email' and 'user_id'."
)
def _parse_json(request):
try:
content = json.loads(request.content.read())
@ -85,3 +106,4 @@ def _parse_json(request):
def register_servlets(hs, http_server):
LoginRestServlet(hs).register(http_server)
# TODO PasswordResetRestServlet(hs).register(http_server)

View File

@ -17,89 +17,218 @@
from twisted.internet import defer
from synapse.api.errors import SynapseError, Codes
from synapse.api.constants import LoginType
from base import RestServlet, client_path_pattern
import synapse.util.stringutils as stringutils
import json
import logging
import urllib
logger = logging.getLogger(__name__)
class RegisterRestServlet(RestServlet):
"""Handles registration with the home server.
This servlet is in control of the registration flow; the registration
handler doesn't have a concept of multi-stages or sessions.
"""
PATTERN = client_path_pattern("/register$")
def __init__(self, hs):
super(RegisterRestServlet, self).__init__(hs)
# sessions are stored as:
# self.sessions = {
# "session_id" : { __session_dict__ }
# }
# TODO: persistent storage
self.sessions = {}
def on_GET(self, request):
if self.hs.config.enable_registration_captcha:
return (200, {
"flows": [
{
"type": LoginType.RECAPTCHA,
"stages": ([LoginType.RECAPTCHA,
LoginType.EMAIL_IDENTITY,
LoginType.PASSWORD])
},
{
"type": LoginType.RECAPTCHA,
"stages": [LoginType.RECAPTCHA, LoginType.PASSWORD]
}
]
})
else:
return (200, {
"flows": [
{
"type": LoginType.EMAIL_IDENTITY,
"stages": ([LoginType.EMAIL_IDENTITY,
LoginType.PASSWORD])
},
{
"type": LoginType.PASSWORD
}
]
})
@defer.inlineCallbacks
def on_POST(self, request):
desired_user_id = None
password = None
register_json = _parse_json(request)
session = (register_json["session"] if "session" in register_json
else None)
login_type = None
if "type" not in register_json:
raise SynapseError(400, "Missing 'type' key.")
try:
register_json = json.loads(request.content.read())
if "password" in register_json:
password = register_json["password"].encode("utf-8")
if type(register_json["user_id"]) == unicode:
desired_user_id = register_json["user_id"].encode("utf-8")
if urllib.quote(desired_user_id) != desired_user_id:
raise SynapseError(
400,
"User ID must only contain characters which do not " +
"require URL encoding.")
except ValueError:
defer.returnValue((400, "No JSON object."))
except KeyError:
pass # user_id is optional
threepidCreds = None
if 'threepidCreds' in register_json:
threepidCreds = register_json['threepidCreds']
captcha = {}
if self.hs.config.enable_registration_captcha:
challenge = None
user_response = None
try:
captcha_type = register_json["captcha"]["type"]
if captcha_type != "m.login.recaptcha":
raise SynapseError(400, "Sorry, only m.login.recaptcha " +
"requests are supported.")
challenge = register_json["captcha"]["challenge"]
user_response = register_json["captcha"]["response"]
except KeyError:
raise SynapseError(400, "Captcha response is required",
errcode=Codes.CAPTCHA_NEEDED)
# TODO determine the source IP : May be an X-Forwarding-For header depending on config
ip_addr = request.getClientIP()
if self.hs.config.captcha_ip_origin_is_x_forwarded:
# use the header
if request.requestHeaders.hasHeader("X-Forwarded-For"):
ip_addr = request.requestHeaders.getRawHeaders(
"X-Forwarded-For")[0]
captcha = {
"ip": ip_addr,
"private_key": self.hs.config.recaptcha_private_key,
"challenge": challenge,
"response": user_response
login_type = register_json["type"]
stages = {
LoginType.RECAPTCHA: self._do_recaptcha,
LoginType.PASSWORD: self._do_password,
LoginType.EMAIL_IDENTITY: self._do_email_identity
}
session_info = self._get_session_info(request, session)
logger.debug("%s : session info %s request info %s",
login_type, session_info, register_json)
response = yield stages[login_type](
request,
register_json,
session_info
)
if "access_token" not in response:
# isn't a final response
response["session"] = session_info["id"]
defer.returnValue((200, response))
except KeyError as e:
logger.exception(e)
raise SynapseError(400, "Missing JSON keys for login type %s." % login_type)
def on_OPTIONS(self, request):
return (200, {})
def _get_session_info(self, request, session_id):
if not session_id:
# create a new session
while session_id is None or session_id in self.sessions:
session_id = stringutils.random_string(24)
self.sessions[session_id] = {
"id": session_id,
LoginType.EMAIL_IDENTITY: False,
LoginType.RECAPTCHA: False
}
return self.sessions[session_id]
def _save_session(self, session):
# TODO: Persistent storage
logger.debug("Saving session %s", session)
self.sessions[session["id"]] = session
def _remove_session(self, session):
logger.debug("Removing session %s", session)
self.sessions.pop(session["id"])
@defer.inlineCallbacks
def _do_recaptcha(self, request, register_json, session):
if not self.hs.config.enable_registration_captcha:
raise SynapseError(400, "Captcha not required.")
challenge = None
user_response = None
try:
challenge = register_json["challenge"]
user_response = register_json["response"]
except KeyError:
raise SynapseError(400, "Captcha response is required",
errcode=Codes.CAPTCHA_NEEDED)
# May be an X-Forwarding-For header depending on config
ip_addr = request.getClientIP()
if self.hs.config.captcha_ip_origin_is_x_forwarded:
# use the header
if request.requestHeaders.hasHeader("X-Forwarded-For"):
ip_addr = request.requestHeaders.getRawHeaders(
"X-Forwarded-For")[0]
handler = self.handlers.registration_handler
yield handler.check_recaptcha(
ip_addr,
self.hs.config.recaptcha_private_key,
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):
if (self.hs.config.enable_registration_captcha and
not session[LoginType.RECAPTCHA]):
raise SynapseError(400, "Captcha is required.")
threepidCreds = register_json['threepidCreds']
handler = self.handlers.registration_handler
yield handler.register_email(threepidCreds)
session["threepidCreds"] = threepidCreds # store creds for next stage
session[LoginType.EMAIL_IDENTITY] = True # mark email as done
self._save_session(session)
defer.returnValue({
"next": LoginType.PASSWORD
})
@defer.inlineCallbacks
def _do_password(self, request, register_json, session):
if (self.hs.config.enable_registration_captcha and
not session[LoginType.RECAPTCHA]):
# captcha should've been done by this stage!
raise SynapseError(400, "Captcha is required.")
password = register_json["password"].encode("utf-8")
desired_user_id = (register_json["user"].encode("utf-8") if "user"
in register_json else None)
if desired_user_id and urllib.quote(desired_user_id) != desired_user_id:
raise SynapseError(
400,
"User ID must only contain characters which do not " +
"require URL encoding.")
handler = self.handlers.registration_handler
(user_id, token) = yield handler.register(
localpart=desired_user_id,
password=password,
threepidCreds=threepidCreds,
captcha_info=captcha)
password=password
)
if session[LoginType.EMAIL_IDENTITY]:
yield handler.bind_emails(user_id, session["threepidCreds"])
result = {
"user_id": user_id,
"access_token": token,
"home_server": self.hs.hostname,
}
defer.returnValue(
(200, result)
)
self._remove_session(session)
defer.returnValue(result)
def on_OPTIONS(self, request):
return (200, {})
def _parse_json(request):
try:
content = json.loads(request.content.read())
if type(content) != dict:
raise SynapseError(400, "Content must be a JSON object.")
return content
except ValueError:
raise SynapseError(400, "Content not JSON.")
def register_servlets(hs, http_server):

View File

@ -378,7 +378,7 @@ class RoomTriggerBackfill(RestServlet):
handler = self.handlers.federation_handler
events = yield handler.backfill(remote_server, room_id, limit)
res = [event.get_dict() for event in events]
res = [self.hs.serialize_event(event) for event in events]
defer.returnValue((200, res))

View File

@ -20,6 +20,7 @@
# Imports required for the default HomeServer() implementation
from synapse.federation import initialize_http_replication
from synapse.api.events import serialize_event
from synapse.api.events.factory import EventFactory
from synapse.notifier import Notifier
from synapse.api.auth import Auth
@ -138,6 +139,9 @@ class BaseHomeServer(object):
object."""
return RoomID.from_string(s, hs=self)
def serialize_event(self, e):
return serialize_event(self, e)
# Build magic accessors for every dependency
for depname in BaseHomeServer.DEPENDENCIES:
BaseHomeServer._make_dependency_method(depname)

View File

@ -68,7 +68,8 @@ class DataStore(RoomMemberStore, RoomStore,
@defer.inlineCallbacks
@log_function
def persist_event(self, event=None, backfilled=False, pdu=None):
def persist_event(self, event=None, backfilled=False, pdu=None,
is_new_state=True):
stream_ordering = None
if backfilled:
if not self.min_token_deferred.called:
@ -83,6 +84,7 @@ class DataStore(RoomMemberStore, RoomStore,
event=event,
backfilled=backfilled,
stream_ordering=stream_ordering,
is_new_state=is_new_state,
)
except _RollbackButIsFineException as e:
pass
@ -109,12 +111,14 @@ class DataStore(RoomMemberStore, RoomStore,
defer.returnValue(event)
def _persist_pdu_event_txn(self, txn, pdu=None, event=None,
backfilled=False, stream_ordering=None):
backfilled=False, stream_ordering=None,
is_new_state=True):
if pdu is not None:
self._persist_event_pdu_txn(txn, pdu)
if event is not None:
return self._persist_event_txn(
txn, event, backfilled, stream_ordering
txn, event, backfilled, stream_ordering,
is_new_state=is_new_state,
)
def _persist_event_pdu_txn(self, txn, pdu):
@ -141,7 +145,8 @@ class DataStore(RoomMemberStore, RoomStore,
self._update_min_depth_for_context_txn(txn, pdu.context, pdu.depth)
@log_function
def _persist_event_txn(self, txn, event, backfilled, stream_ordering=None):
def _persist_event_txn(self, txn, event, backfilled, stream_ordering=None,
is_new_state=True):
if event.type == RoomMemberEvent.TYPE:
self._store_room_member_txn(txn, event)
elif event.type == FeedbackEvent.TYPE:
@ -195,7 +200,7 @@ class DataStore(RoomMemberStore, RoomStore,
)
raise _RollbackButIsFineException("_persist_event")
if not backfilled and hasattr(event, "state_key"):
if is_new_state and hasattr(event, "state_key"):
vals = {
"event_id": event.event_id,
"room_id": event.room_id,

View File

@ -315,6 +315,10 @@ class SQLBaseStore(object):
d["content"] = json.loads(d["content"])
del d["unrecognized_keys"]
if "age_ts" not in d:
# For compatibility
d["age_ts"] = d["ts"] if "ts" in d else 0
return self.event_factory.create_event(
etype=d["type"],
**d

View File

@ -0,0 +1,71 @@
# -*- 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.
""" This module allows you to send out emails.
"""
import email.utils
import smtplib
import twisted.python.log
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import logging
logger = logging.getLogger(__name__)
class EmailException(Exception):
pass
def send_email(smtp_server, from_addr, to_addr, subject, body):
"""Sends an email.
Args:
smtp_server(str): The SMTP server to use.
from_addr(str): The address to send from.
to_addr(str): The address to send to.
subject(str): The subject of the email.
body(str): The plain text body of the email.
Raises:
EmailException if there was a problem sending the mail.
"""
if not smtp_server or not from_addr or not to_addr:
raise EmailException("Need SMTP server, from and to addresses. Check " +
"the config to set these.")
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = from_addr
msg['To'] = to_addr
plain_part = MIMEText(body)
msg.attach(plain_part)
raw_from = email.utils.parseaddr(from_addr)[1]
raw_to = email.utils.parseaddr(to_addr)[1]
if not raw_from or not raw_to:
raise EmailException("Couldn't parse from/to address.")
logger.info("Sending email to %s on server %s with subject %s",
to_addr, smtp_server, subject)
try:
smtp = smtplib.SMTP(smtp_server)
smtp.sendmail(raw_from, raw_to, msg.as_string())
smtp.quit()
except Exception as origException:
twisted.python.log.err()
ese = EmailException()
ese.cause = origException
raise ese

View File

@ -1,6 +1,6 @@
from synapse.api.ratelimiting import Ratelimiter
import unittest
from tests import unittest
class TestRatelimiter(unittest.TestCase):

View File

@ -15,7 +15,7 @@
from synapse.api.events import SynapseEvent
import unittest
from tests import unittest
class SynapseTemplateCheckTestCase(unittest.TestCase):

View File

@ -14,11 +14,10 @@
# trial imports
from twisted.internet import defer
from twisted.trial import unittest
from tests import unittest
# python imports
from mock import Mock
import logging
from mock import Mock, ANY
from ..utils import MockHttpResource, MockClock
@ -28,9 +27,6 @@ from synapse.federation.units import Pdu
from synapse.storage.pdu import PduTuple, PduEntry
logging.getLogger().addHandler(logging.NullHandler())
def make_pdu(prev_pdus=[], **kwargs):
"""Provide some default fields for making a PduTuple."""
pdu_fields = {
@ -185,7 +181,8 @@ class FederationTestCase(unittest.TestCase):
"depth": 1,
},
]
}
},
on_send_callback=ANY,
)
@defer.inlineCallbacks
@ -216,7 +213,9 @@ class FederationTestCase(unittest.TestCase):
"content": {"testing": "content here"},
}
],
})
},
on_send_callback=ANY,
)
@defer.inlineCallbacks
def test_recv_edu(self):

View File

@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.trial import unittest
from tests import unittest
from synapse.federation.pdu_codec import (
PduCodec, encode_event_id, decode_event_id

View File

@ -14,11 +14,10 @@
# limitations under the License.
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock
import logging
from synapse.server import HomeServer
from synapse.http.client import HttpClient
@ -26,9 +25,6 @@ from synapse.handlers.directory import DirectoryHandler
from synapse.storage.directory import RoomAliasMapping
logging.getLogger().addHandler(logging.NullHandler())
class DirectoryHandlers(object):
def __init__(self, hs):
self.directory_handler = DirectoryHandler(hs)

View File

@ -14,7 +14,7 @@
from twisted.internet import defer
from twisted.trial import unittest
from tests import unittest
from synapse.api.events.room import (
InviteJoinEvent, MessageEvent, RoomMemberEvent
@ -26,12 +26,8 @@ from synapse.federation.units import Pdu
from mock import NonCallableMock, ANY
import logging
from ..utils import get_mock_call_args
logging.getLogger().addHandler(logging.NullHandler())
class FederationTestCase(unittest.TestCase):
@ -78,7 +74,9 @@ class FederationTestCase(unittest.TestCase):
yield self.handlers.federation_handler.on_receive_pdu(pdu, False)
self.datastore.persist_event.assert_called_once_with(ANY, False)
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)
@defer.inlineCallbacks

View File

@ -14,11 +14,10 @@
# limitations under the License.
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer, reactor
from mock import Mock, call, ANY
import logging
import json
from ..utils import MockHttpResource, MockClock, DeferredMockCallable
@ -34,9 +33,6 @@ UNAVAILABLE = PresenceState.UNAVAILABLE
ONLINE = PresenceState.ONLINE
logging.getLogger().addHandler(logging.NullHandler())
def _expect_edu(destination, edu_type, content, origin="test"):
return {
"origin": origin,
@ -92,7 +88,6 @@ class PresenceStateTestCase(unittest.TestCase):
# Mock the RoomMemberHandler
room_member_handler = Mock(spec=[])
hs.handlers.room_member_handler = room_member_handler
logging.getLogger().debug("Mocking room_member_handler=%r", room_member_handler)
# Some local users to test with
self.u_apple = hs.parse_userid("@apple:test")
@ -324,7 +319,8 @@ class PresenceInvitesTestCase(unittest.TestCase):
"observer_user": "@apple:test",
"observed_user": "@cabbage:elsewhere",
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -350,7 +346,8 @@ class PresenceInvitesTestCase(unittest.TestCase):
"observer_user": "@cabbage:elsewhere",
"observed_user": "@apple:test",
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -381,7 +378,8 @@ class PresenceInvitesTestCase(unittest.TestCase):
"observer_user": "@cabbage:elsewhere",
"observed_user": "@durian:test",
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -770,7 +768,8 @@ class PresencePushTestCase(unittest.TestCase):
"last_active_ago": 0},
],
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -785,7 +784,8 @@ class PresencePushTestCase(unittest.TestCase):
"last_active_ago": 0},
],
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -911,6 +911,7 @@ class PresencePushTestCase(unittest.TestCase):
],
}
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -925,6 +926,7 @@ class PresencePushTestCase(unittest.TestCase):
],
}
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -954,6 +956,7 @@ class PresencePushTestCase(unittest.TestCase):
],
}
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -1150,6 +1153,7 @@ class PresencePollingTestCase(unittest.TestCase):
"poll": [ "@potato:remote" ],
},
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -1162,6 +1166,7 @@ class PresencePollingTestCase(unittest.TestCase):
"push": [ {"user_id": "@clementine:test" }],
},
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -1190,6 +1195,7 @@ class PresencePollingTestCase(unittest.TestCase):
"push": [ {"user_id": "@fig:test" }],
},
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -1222,6 +1228,7 @@ class PresencePollingTestCase(unittest.TestCase):
"unpoll": [ "@potato:remote" ],
},
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -1253,6 +1260,7 @@ class PresencePollingTestCase(unittest.TestCase):
],
},
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)

View File

@ -16,11 +16,10 @@
"""This file contains tests of the "presence-like" data that is shared between
presence and profiles; namely, the displayname and avatar_url."""
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock, call, ANY
import logging
from ..utils import MockClock
@ -35,9 +34,6 @@ UNAVAILABLE = PresenceState.UNAVAILABLE
ONLINE = PresenceState.ONLINE
logging.getLogger().addHandler(logging.NullHandler())
class MockReplication(object):
def __init__(self):
self.edu_handlers = {}
@ -69,6 +65,8 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
"is_presence_visible",
"set_profile_displayname",
"get_rooms_for_user_where_membership_is",
]),
handlers=None,
resource_for_federation=Mock(),
@ -136,6 +134,10 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
# Remote user
self.u_potato = hs.parse_userid("@potato:remote")
self.mock_get_joined = (
self.datastore.get_rooms_for_user_where_membership_is
)
@defer.inlineCallbacks
def test_set_my_state(self):
self.presence_list = [
@ -156,6 +158,11 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
@defer.inlineCallbacks
def test_push_local(self):
def get_joined(*args):
return defer.succeed([])
self.mock_get_joined.side_effect = get_joined
self.presence_list = [
{"observed_user_id": "@banana:test"},
{"observed_user_id": "@clementine:test"},

View File

@ -14,18 +14,15 @@
# limitations under the License.
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock
import logging
from synapse.api.errors import AuthError
from synapse.server import HomeServer
from synapse.handlers.profile import ProfileHandler
logging.getLogger().addHandler(logging.NullHandler())
from synapse.api.constants import Membership
class ProfileHandlers(object):
@ -54,6 +51,7 @@ class ProfileTestCase(unittest.TestCase):
"set_profile_displayname",
"get_profile_avatar_url",
"set_profile_avatar_url",
"get_rooms_for_user_where_membership_is",
]),
handlers=None,
resource_for_federation=Mock(),
@ -69,6 +67,10 @@ class ProfileTestCase(unittest.TestCase):
self.handler = hs.get_handlers().profile_handler
self.mock_get_joined = (
self.datastore.get_rooms_for_user_where_membership_is
)
# TODO(paul): Icky signal declarings.. booo
hs.get_distributor().declare("changed_presencelike_data")
@ -87,8 +89,15 @@ class ProfileTestCase(unittest.TestCase):
mocked_set = self.datastore.set_profile_displayname
mocked_set.return_value = defer.succeed(())
self.mock_get_joined.return_value = defer.succeed([])
yield self.handler.set_displayname(self.frank, self.frank, "Frank Jr.")
self.mock_get_joined.assert_called_once_with(
self.frank.to_string(),
[Membership.JOIN]
)
mocked_set.assert_called_with("1234ABCD", "Frank Jr.")
@defer.inlineCallbacks
@ -139,7 +148,15 @@ class ProfileTestCase(unittest.TestCase):
mocked_set = self.datastore.set_profile_avatar_url
mocked_set.return_value = defer.succeed(())
self.mock_get_joined.return_value = defer.succeed([])
yield self.handler.set_avatar_url(self.frank, self.frank,
"http://my.server/pic.gif")
self.mock_get_joined.assert_called_once_with(
self.frank.to_string(),
[Membership.JOIN]
)
mocked_set.assert_called_with("1234ABCD", "http://my.server/pic.gif")

View File

@ -15,7 +15,7 @@
from twisted.internet import defer
from twisted.trial import unittest
from tests import unittest
from synapse.api.events.room import (
InviteJoinEvent, RoomMemberEvent, RoomConfigEvent
@ -27,10 +27,6 @@ from synapse.server import HomeServer
from mock import Mock, NonCallableMock
import logging
logging.getLogger().addHandler(logging.NullHandler())
class RoomMemberHandlerTestCase(unittest.TestCase):

View File

@ -14,12 +14,11 @@
# limitations under the License.
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock, call, ANY
import json
import logging
from ..utils import MockHttpResource, MockClock, DeferredMockCallable
@ -27,9 +26,6 @@ from synapse.server import HomeServer
from synapse.handlers.typing import TypingNotificationHandler
logging.getLogger().addHandler(logging.NullHandler())
def _expect_edu(destination, edu_type, content, origin="test"):
return {
"origin": origin,
@ -173,7 +169,8 @@ class TypingNotificationsTestCase(unittest.TestCase):
"user_id": self.u_apple.to_string(),
"typing": True,
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)
@ -223,7 +220,8 @@ class TypingNotificationsTestCase(unittest.TestCase):
"user_id": self.u_apple.to_string(),
"typing": False,
}
)
),
on_send_callback=ANY,
),
defer.succeed((200, "OK"))
)

View File

@ -14,7 +14,7 @@
# limitations under the License.
""" Tests REST events for /events paths."""
from twisted.trial import unittest
from tests import unittest
# twisted imports
from twisted.internet import defer
@ -27,14 +27,12 @@ from synapse.server import HomeServer
# python imports
import json
import logging
from ..utils import MockHttpResource, MemoryDataStore
from .utils import RestTestCase
from mock import Mock, NonCallableMock
logging.getLogger().addHandler(logging.NullHandler())
PATH_PREFIX = "/_matrix/client/api/v1"

View File

@ -15,11 +15,10 @@
"""Tests REST events for /presence paths."""
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock
import logging
from ..utils import MockHttpResource
@ -28,9 +27,6 @@ from synapse.handlers.presence import PresenceHandler
from synapse.server import HomeServer
logging.getLogger().addHandler(logging.NullHandler())
OFFLINE = PresenceState.OFFLINE
UNAVAILABLE = PresenceState.UNAVAILABLE
ONLINE = PresenceState.ONLINE

View File

@ -15,7 +15,7 @@
"""Tests REST events for /profile paths."""
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock
@ -28,6 +28,7 @@ from synapse.server import HomeServer
myid = "@1234ABCD:test"
PATH_PREFIX = "/_matrix/client/api/v1"
class ProfileTestCase(unittest.TestCase):
""" Tests profile management. """

View File

@ -17,7 +17,7 @@
from twisted.internet import defer
# trial imports
from twisted.trial import unittest
from tests import unittest
from synapse.api.constants import Membership
@ -95,8 +95,14 @@ class RestTestCase(unittest.TestCase):
@defer.inlineCallbacks
def register(self, user_id):
(code, response) = yield self.mock_resource.trigger("POST", "/register",
'{"user_id":"%s"}' % user_id)
(code, response) = yield self.mock_resource.trigger(
"POST",
"/register",
json.dumps({
"user": user_id,
"password": "test",
"type": "m.login.password"
}))
self.assertEquals(200, code)
defer.returnValue(response)

View File

@ -14,7 +14,7 @@
# limitations under the License.
from twisted.trial import unittest
from tests import unittest
from twisted.internet import defer
from mock import Mock, call

View File

@ -13,8 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from tests import unittest
from twisted.internet import defer
from twisted.trial import unittest
from mock import Mock, patch

View File

@ -13,8 +13,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from tests import unittest
from twisted.internet import defer
from twisted.trial import unittest
from twisted.python.log import PythonLoggingObserver
from synapse.state import StateHandler
@ -26,7 +26,6 @@ from collections import namedtuple
from mock import Mock
import logging
import mock

View File

@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest
from tests import unittest
from synapse.server import BaseHomeServer
from synapse.types import UserID, RoomAlias

79
tests/unittest.py Normal file
View File

@ -0,0 +1,79 @@
# -*- 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.trial import unittest
import logging
# logging doesn't have a "don't log anything at all EVARRRR setting,
# but since the highest value is 50, 1000000 should do ;)
NEVER = 1000000
logging.getLogger().addHandler(logging.StreamHandler())
logging.getLogger().setLevel(NEVER)
def around(target):
"""A CLOS-style 'around' modifier, which wraps the original method of the
given instance with another piece of code.
@around(self)
def method_name(orig, *args, **kwargs):
return orig(*args, **kwargs)
"""
def _around(code):
name = code.__name__
orig = getattr(target, name)
def new(*args, **kwargs):
return code(orig, *args, **kwargs)
setattr(target, name, new)
return _around
class TestCase(unittest.TestCase):
"""A subclass of twisted.trial's TestCase which looks for 'loglevel'
attributes on both itself and its individual test methods, to override the
root logger's logging level while that test (case|method) runs."""
def __init__(self, methodName, *args, **kwargs):
super(TestCase, self).__init__(methodName, *args, **kwargs)
method = getattr(self, methodName)
level = getattr(method, "loglevel",
getattr(self, "loglevel",
NEVER))
@around(self)
def setUp(orig):
old_level = logging.getLogger().level
if old_level != level:
@around(self)
def tearDown(orig):
ret = orig()
logging.getLogger().setLevel(old_level)
return ret
logging.getLogger().setLevel(level)
return orig()
def DEBUG(target):
"""A decorator to set the .loglevel attribute to logging.DEBUG.
Can apply to either a TestCase or an individual test method."""
target.loglevel = logging.DEBUG
return target

View File

@ -15,7 +15,7 @@
from twisted.internet import defer
from twisted.trial import unittest
from tests import unittest
from synapse.util.lockutils import LockManager
@ -105,4 +105,4 @@ class LockManagerTestCase(unittest.TestCase):
pass
with (yield self.lock_manager.lock(key)):
pass
pass

View File

@ -130,6 +130,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
angular.element('#ringAudio')[0].pause();
angular.element('#ringbackAudio')[0].pause();
angular.element('#busyAudio')[0].play();
} else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'local' && $rootScope.currentCall.hangupReason == 'invite_timeout') {
angular.element('#ringAudio')[0].pause();
angular.element('#ringbackAudio')[0].pause();
angular.element('#busyAudio')[0].play();
} else if (oldVal == 'invite_sent') {
angular.element('#ringbackAudio')[0].pause();
} else if (oldVal == 'ringing') {

View File

@ -528,9 +528,8 @@ a:active { color: #000; }
}
.bubble .message {
/* Break lines when encountering CR+LF */
/* FIXME: this breaks wordwrapping. We need to s#CRLF#<br/>#g instead */
/* white-space: pre; */
/* Wrap words and break lines on CR+LF */
white-space: pre-wrap;
}
.bubble .messagePending {
opacity: 0.3
@ -539,6 +538,10 @@ a:active { color: #000; }
color: #F00;
}
.messageBing {
color: #00F;
}
#room-fullscreen-image {
position: absolute;
top: 0px;
@ -600,7 +603,11 @@ a:active { color: #000; }
width: auto;
}
.recentsRoomSummaryTS {
.recentsPublicRoom {
font-weight: bold;
}
.recentsRoomSummaryUsersCount, .recentsRoomSummaryTS {
color: #888;
font-size: 12px;
width: 7em;
@ -613,6 +620,11 @@ a:active { color: #000; }
padding-bottom: 5px;
}
/* Do not show users count in the recents fragment displayed on the room page */
#roomPage .recentsRoomSummaryUsersCount {
width: 0em;
}
/*** Recents in the room page ***/
#roomRecentsTableWrapper {

View File

@ -27,7 +27,8 @@ Typically, this service will store events or broadcast them to any listeners
if typically all the $on method would do is update its own $scope.
*/
angular.module('eventHandlerService', [])
.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) {
.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence',
function(matrixService, $rootScope, $q, $timeout, mPresence) {
var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
var MSG_EVENT = "MSG_EVENT";
var MEMBER_EVENT = "MEMBER_EVENT";
@ -38,6 +39,51 @@ angular.module('eventHandlerService', [])
var TOPIC_EVENT = "TOPIC_EVENT";
var RESET_EVENT = "RESET_EVENT"; // eventHandlerService has been resetted
// used for dedupping events - could be expanded in future...
// FIXME: means that we leak memory over time (along with lots of the rest
// of the app, given we never try to reap memory yet)
var eventMap = {};
$rootScope.presence = {};
// TODO: This is attached to the rootScope so .html can just go containsBingWord
// for determining classes so it is easy to highlight bing messages. It seems a
// bit strange to put the impl in this service though, but I can't think of a better
// file to put it in.
$rootScope.containsBingWord = function(content) {
if (!content || $.type(content) != "string") {
return false;
}
var bingWords = matrixService.config().bingWords;
var shouldBing = false;
// case-insensitive name check for user_id OR display_name if they exist
var myUserId = matrixService.config().user_id;
if (myUserId) {
myUserId = myUserId.toLocaleLowerCase();
}
var myDisplayName = matrixService.config().display_name;
if (myDisplayName) {
myDisplayName = myDisplayName.toLocaleLowerCase();
}
if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) ||
(myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) {
shouldBing = true;
}
// bing word list check
if (bingWords && !shouldBing) {
for (var i=0; i<bingWords.length; i++) {
var re = RegExp(bingWords[i]);
if (content.search(re) != -1) {
shouldBing = true;
break;
}
}
}
return shouldBing;
};
var initialSyncDeferred;
var reset = function() {
@ -46,26 +92,24 @@ angular.module('eventHandlerService', [])
$rootScope.events = {
rooms: {} // will contain roomId: { messages:[], members:{userid1: event} }
};
}
$rootScope.presence = {};
eventMap = {};
};
reset();
// used for dedupping events - could be expanded in future...
// FIXME: means that we leak memory over time (along with lots of the rest
// of the app, given we never try to reap memory yet)
var eventMap = {};
$rootScope.presence = {};
var initRoom = function(room_id) {
if (!(room_id in $rootScope.events.rooms)) {
console.log("Creating new handler entry for " + room_id);
$rootScope.events.rooms[room_id] = {};
$rootScope.events.rooms[room_id].messages = [];
$rootScope.events.rooms[room_id].members = {};
// Pagination information
$rootScope.events.rooms[room_id].pagination = {
earliest_token: "END" // how far back we've paginated
$rootScope.events.rooms[room_id] = {
room_id: room_id,
messages: [],
members: {},
// Pagination information
pagination: {
earliest_token: "END" // how far back we've paginated
}
};
}
};
@ -132,6 +176,48 @@ angular.module('eventHandlerService', [])
else {
$rootScope.events.rooms[event.room_id].messages.push(event);
}
if (window.Notification && event.user_id != matrixService.config().user_id) {
var shouldBing = $rootScope.containsBingWord(event.content.body);
// TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly?
// Unfortunately document.hidden = false on ubuntu chrome if chrome is minimised / does not have focus;
// true when you swap tabs though. However, for the case where the chat screen is OPEN and there is
// another window on top, we want to be notifying for those events. This DOES mean that there will be
// notifications when currently viewing the chat screen though, but that is preferable to the alternative imo.
var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState());
// always bing if there are 0 bing words... apparently.
var bingWords = matrixService.config().bingWords;
if (bingWords && bingWords.length === 0) {
shouldBing = true;
}
if (shouldBing) {
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 message = event.content.body;
if (event.content.msgtype === "m.emote") {
message = "* " + displayname + " " + message;
}
var notification = new window.Notification(
(displayname || event.user_id) +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": message,
"icon": member ? member.avatar_url : undefined
});
$timeout(function() {
notification.close();
}, 5 * 1000);
}
}
}
else {
$rootScope.events.rooms[event.room_id].messages.unshift(event);
@ -157,8 +243,9 @@ angular.module('eventHandlerService', [])
// add membership changes as if they were a room message if something interesting changed
// 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) {
// with other other room messages XXX This is no longer true, you only get a single event, not a room message event.
// FIXME: This possibly reintroduces multiple join messages.
if (event.content.prev !== event.content.membership) { // && !isStateEvent
if (isLiveEvent) {
$rootScope.events.rooms[event.room_id].messages.push(event);
}
@ -204,7 +291,7 @@ angular.module('eventHandlerService', [])
var handleCallEvent = function(event, isLiveEvent) {
$rootScope.$broadcast(CALL_EVENT, event, isLiveEvent);
if (event.type == 'm.call.invite') {
if (event.type === 'm.call.invite') {
$rootScope.events.rooms[event.room_id].messages.push(event);
}
};
@ -231,7 +318,7 @@ angular.module('eventHandlerService', [])
}
}
return index;
}
};
return {
ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
@ -253,7 +340,9 @@ angular.module('eventHandlerService', [])
// FIXME: /initialSync on a particular room is not yet available
// So initRoom on a new room is not called. Make sure the room data is initialised here
initRoom(event.room_id);
if (event.room_id) {
initRoom(event.room_id);
}
// Avoid duplicated events
// Needed for rooms where initialSync has not been done.
@ -287,6 +376,7 @@ angular.module('eventHandlerService', [])
handleMessage(event, isLiveEvent);
break;
case "m.room.member":
isStateEvent = true;
handleRoomMember(event, isLiveEvent, isStateEvent);
break;
case "m.presence":
@ -316,19 +406,39 @@ angular.module('eventHandlerService', [])
// isLiveEvents determines whether notifications should be shown, whether
// messages get appended to the start/end of lists, etc.
handleEvents: function(events, isLiveEvents, isStateEvents) {
// XXX FIXME TODO: isStateEvents is being left as undefined sometimes. It makes no sense
// to have isStateEvents as an arg, since things like m.room.member are ALWAYS state events.
for (var i=0; i<events.length; i++) {
this.handleEvent(events[i], isLiveEvents, isStateEvents);
}
},
// Handle messages from /initialSync or /messages
handleRoomMessages: function(room_id, messages, isLiveEvents) {
handleRoomMessages: function(room_id, messages, isLiveEvents, dir) {
initRoom(room_id);
this.handleEvents(messages.chunk, isLiveEvents);
// Store how far back we've paginated
// This assumes the paginations requests are contiguous and in reverse chronological order
$rootScope.events.rooms[room_id].pagination.earliest_token = messages.end;
var events = messages.chunk;
// Handles messages according to their time order
if (dir && 'b' === dir) {
// paginateBackMessages requests messages to be in reverse chronological order
for (var i=0; i<events.length; i++) {
// FIXME: Being live != being state
this.handleEvent(events[i], isLiveEvents, isLiveEvents);
}
// Store how far back we've paginated
$rootScope.events.rooms[room_id].pagination.earliest_token = messages.end;
}
else {
// InitialSync returns messages in chronological order
for (var i=events.length - 1; i>=0; i--) {
// FIXME: Being live != being state
this.handleEvent(events[i], isLiveEvents, isLiveEvents);
}
// Store where to start pagination
$rootScope.events.rooms[room_id].pagination.earliest_token = messages.start;
}
},
handleInitialSyncDone: function(initialSyncData) {
@ -343,6 +453,82 @@ angular.module('eventHandlerService', [])
resetRoomMessages: function(room_id) {
resetRoomMessages(room_id);
},
/**
* Return the last message event of a room
* @param {String} room_id the room id
* @param {Boolean} filterFake true to not take into account fake messages
* @returns {undefined | Event} the last message event if available
*/
getLastMessage: function(room_id, filterEcho) {
var lastMessage;
var room = $rootScope.events.rooms[room_id];
if (room) {
for (var i = room.messages.length - 1; i >= 0; i--) {
var message = room.messages[i];
if (!filterEcho || undefined === message.echo_msg_state) {
lastMessage = message;
break;
}
}
}
return lastMessage;
},
/**
* Compute the room users number, ie the number of members who has joined the room.
* @param {String} room_id the room id
* @returns {undefined | Number} the room users number if available
*/
getUsersCountInRoom: function(room_id) {
var memberCount;
var room = $rootScope.events.rooms[room_id];
if (room) {
memberCount = 0;
for (var i in room.members) {
var member = room.members[i];
if ("join" === member.membership) {
memberCount = memberCount + 1;
}
}
}
return memberCount;
},
/**
* 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
*/
getMember: function(room_id, user_id) {
var member;
var room = $rootScope.events.rooms[room_id];
if (room) {
member = room.members[user_id];
}
return member;
},
setRoomVisibility: function(room_id, visible) {
if (!visible) {
return;
}
initRoom(room_id);
var room = $rootScope.events.rooms[room_id];
if (room) {
room.visibility = visible;
}
}
};
}]);

View File

@ -104,8 +104,10 @@ angular.module('eventStreamService', [])
settings.isActive = true;
var deferred = $q.defer();
// Initial sync: get all information and the last message of all rooms of the user
matrixService.initialSync(1, false).then(
// Initial sync: get all information and the last 30 messages of all rooms of the user
// 30 messages should be enough to display a full page of messages in a room
// without requiring to make an additional request
matrixService.initialSync(30, false).then(
function(response) {
var rooms = response.data.rooms;
for (var i = 0; i < rooms.length; ++i) {
@ -118,6 +120,8 @@ angular.module('eventStreamService', [])
if ("state" in room) {
eventHandlerService.handleEvents(room.state, false, true);
}
eventHandlerService.setRoomVisibility(room.room_id, room.visibility);
}
var presence = response.data.presence;

View File

@ -47,13 +47,19 @@ angular.module('MatrixCall', [])
this.call_id = "c" + new Date().getTime();
this.state = 'fledgling';
this.didConnect = false;
// a queue for candidates waiting to go out. We try to amalgamate candidates into a single candidate message where possible
this.candidateSendQueue = [];
this.candidateSendTries = 0;
}
MatrixCall.CALL_TIMEOUT = 60000;
MatrixCall.prototype.createPeerConnection = function() {
var stunServer = 'stun:stun.l.google.com:19302';
var pc;
if (window.mozRTCPeerConnection) {
pc = window.mozRTCPeerConnection({'url': stunServer});
pc = new window.mozRTCPeerConnection({'url': stunServer});
} else {
pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
}
@ -74,12 +80,30 @@ angular.module('MatrixCall', [])
this.config = config;
};
MatrixCall.prototype.initWithInvite = function(msg) {
this.msg = msg;
MatrixCall.prototype.initWithInvite = function(event) {
this.msg = event.content;
this.peerConn = this.createPeerConnection();
this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
this.state = 'ringing';
this.direction = 'inbound';
var self = this;
$timeout(function() {
if (self.state == 'ringing') {
self.state = 'ended';
self.hangupParty = 'remote'; // effectively
self.stopAllMedia();
if (self.peerConn.signalingState != 'closed') self.peerConn.close();
if (self.onHangup) self.onHangup(self);
}
}, this.msg.lifetime - event.age);
};
// perverse as it may seem, sometimes we want to instantiate a call with a hangup message
// (because when getting the state of the room on load, events come in reverse order and
// we want to remember that a call has been hung up)
MatrixCall.prototype.initWithHangup = function(event) {
this.msg = event.content;
this.state = 'ended';
};
MatrixCall.prototype.answer = function() {
@ -174,12 +198,7 @@ angular.module('MatrixCall', [])
MatrixCall.prototype.gotLocalIceCandidate = function(event) {
console.log(event);
if (event.candidate) {
var content = {
version: 0,
call_id: this.call_id,
candidate: event.candidate
};
this.sendEventWithRetry('m.call.candidate', content);
this.sendCandidate(event.candidate);
}
}
@ -189,14 +208,12 @@ angular.module('MatrixCall', [])
console.log("Ignoring remote ICE candidate because call has ended");
return;
}
var candidateObject = new RTCIceCandidate({
sdpMLineIndex: cand.label,
candidate: cand.candidate
});
this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {});
this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {});
};
MatrixCall.prototype.receivedAnswer = function(msg) {
if (this.state == 'ended') return;
this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
this.state = 'connecting';
};
@ -214,11 +231,19 @@ angular.module('MatrixCall', [])
var content = {
version: 0,
call_id: this.call_id,
offer: description
offer: description,
lifetime: MatrixCall.CALL_TIMEOUT
};
this.sendEventWithRetry('m.call.invite', content);
var self = this;
$timeout(function() {
if (self.state == 'invite_sent') {
self.hangupReason = 'invite_timeout';
self.hangup();
}
}, MatrixCall.CALL_TIMEOUT);
$rootScope.$apply(function() {
self.state = 'invite_sent';
});
@ -370,5 +395,53 @@ angular.module('MatrixCall', [])
}, delayMs);
};
// Sends candidates with are sent in a special way because we try to amalgamate them into one message
MatrixCall.prototype.sendCandidate = function(content) {
this.candidateSendQueue.push(content);
var self = this;
if (this.candidateSendTries == 0) $timeout(function() { self.sendCandidateQueue(); }, 100);
};
MatrixCall.prototype.sendCandidateQueue = function(content) {
if (this.candidateSendQueue.length == 0) return;
var cands = this.candidateSendQueue;
this.candidateSendQueue = [];
++this.candidateSendTries;
var content = {
version: 0,
call_id: this.call_id,
candidates: cands
};
var self = this;
console.log("Attempting to send "+cands.length+" candidates");
matrixService.sendEvent(self.room_id, 'm.call.candidates', undefined, content).then(function() { self.candsSent(); }, function(error) { self.candsSendFailed(cands, error); } );
};
MatrixCall.prototype.candsSent = function() {
this.candidateSendTries = 0;
this.sendCandidateQueue();
};
MatrixCall.prototype.candsSendFailed = function(cands, error) {
for (var i = 0; i < cands.length; ++i) {
this.candidateSendQueue.push(cands[i]);
}
if (this.candidateSendTries > 5) {
console.log("Failed to send candidates on attempt "+ev.tries+". Giving up for now.");
this.candidateSendTries = 0;
return;
}
var delayMs = 500 * Math.pow(2, this.candidateSendTries);
++this.candidateSendTries;
console.log("Failed to send candidates. Retrying in "+delayMs+"ms");
var self = this;
$timeout(function() {
self.sendCandidateQueue();
}, delayMs);
};
return MatrixCall;
}]);

View File

@ -26,72 +26,74 @@ angular.module('matrixFilter', [])
// If there is an alias, use it
// TODO: only one alias is managed for now
var alias = matrixService.getRoomIdToAliasMapping(room_id);
if (alias) {
roomName = alias;
}
if (undefined === roomName) {
var room = $rootScope.events.rooms[room_id];
if (room) {
// Get name from room state date
var room_name_event = room["m.room.name"];
if (room_name_event) {
roomName = room_name_event.content.name;
}
else if (alias) {
roomName = alias;
}
else if (room.members) {
// Else, build the name from its users
// FIXME: Is it still required?
// Limit the room renaming to 1:1 room
if (2 === Object.keys(room.members).length) {
for (var i in room.members) {
var member = room.members[i];
if (member.state_key !== matrixService.config().user_id) {
var room = $rootScope.events.rooms[room_id];
if (room) {
// Get name from room state date
var room_name_event = room["m.room.name"];
if (room_name_event) {
roomName = room_name_event.content.name;
}
else if (room.members) {
// Else, build the name from its users
// FIXME: Is it still required?
// Limit the room renaming to 1:1 room
if (2 === Object.keys(room.members).length) {
for (var i in room.members) {
var member = room.members[i];
if (member.state_key !== matrixService.config().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;
}
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;
}
}
}
else if (1 === Object.keys(room.members).length) {
// The other member may be in the invite list, get all invited users
var invitedUserIDs = [];
for (var i in room.messages) {
var message = room.messages[i];
if ("m.room.member" === message.type && "invite" === message.membership) {
// Make sure there is no duplicate user
if (-1 === invitedUserIDs.indexOf(message.state_key)) {
invitedUserIDs.push(message.state_key);
}
}
}
// For now, only 1:1 room needs to be renamed. It means only 1 invited user
if (1 === invitedUserIDs.length) {
var userID = invitedUserIDs[0];
}
else if (1 === Object.keys(room.members).length) {
// The other member may be in the invite list, get all invited users
var invitedUserIDs = [];
for (var i in room.messages) {
var message = room.messages[i];
if ("m.room.member" === message.type && "invite" === message.membership) {
// Make sure there is no duplicate user
if (-1 === invitedUserIDs.indexOf(message.state_key)) {
invitedUserIDs.push(message.state_key);
}
}
}
// Try to resolve his displayname in presence global data
if (userID in $rootScope.presence) {
roomName = $rootScope.presence[userID].content.displayname;
}
else {
roomName = userID;
}
// For now, only 1:1 room needs to be renamed. It means only 1 invited user
if (1 === invitedUserIDs.length) {
var userID = invitedUserIDs[0];
// Try to resolve his displayname in presence global data
if (userID in $rootScope.presence) {
roomName = $rootScope.presence[userID].content.displayname;
}
else {
roomName = userID;
}
}
}
}
}
// Always show the alias in the room displayed name
if (roomName && alias && alias !== roomName) {
roomName += " (" + alias + ")";
}
if (undefined === roomName) {
// By default, use the room ID
roomName = room_id;

View File

@ -24,22 +24,52 @@ angular.module('matrixPhoneService', [])
matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT";
matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT";
matrixPhoneService.allCalls = {};
// a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards)
matrixPhoneService.candidatesByCall = {};
matrixPhoneService.callPlaced = function(call) {
matrixPhoneService.allCalls[call.call_id] = call;
};
$rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
if (!isLive) return; // until matrix supports expiring messages
if (event.user_id == matrixService.config().user_id) return;
var msg = event.content;
if (event.type == 'm.call.invite') {
if (event.age == undefined || msg.lifetime == undefined) {
// if the event doesn't have either an age (the HS is too old) or a lifetime
// (the sending client was too old when it sent it) then fall back to old behaviour
if (!isLive) return; // until matrix supports expiring messages
}
if (event.age > msg.lifetime) {
console.log("Ignoring expired call event of type "+event.type);
return;
}
var call = undefined;
if (!isLive) {
// if this event wasn't live then this call may already be over
call = matrixPhoneService.allCalls[msg.call_id];
if (call && call.state == 'ended') {
return;
}
}
var MatrixCall = $injector.get('MatrixCall');
var call = new MatrixCall(event.room_id);
call.call_id = msg.call_id;
call.initWithInvite(msg);
call.initWithInvite(event);
matrixPhoneService.allCalls[call.call_id] = call;
// if we stashed candidate events for that call ID, play them back now
if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) {
for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) {
call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]);
}
}
// Were we trying to call that user (room)?
var existingCall;
var callIds = Object.keys(matrixPhoneService.allCalls);
@ -77,21 +107,37 @@ angular.module('matrixPhoneService', [])
return;
}
call.receivedAnswer(msg);
} else if (event.type == 'm.call.candidate') {
} else if (event.type == 'm.call.candidates') {
var call = matrixPhoneService.allCalls[msg.call_id];
if (!call) {
console.log("Got candidate for unknown call ID "+msg.call_id);
if (!call && isLive) {
console.log("Got candidates for unknown call ID "+msg.call_id);
return;
} else if (!call) {
if (matrixPhoneService.candidatesByCall[msg.call_id] == undefined) {
matrixPhoneService.candidatesByCall[msg.call_id] = [];
}
matrixPhoneService.candidatesByCall[msg.call_id] = matrixPhoneService.candidatesByCall[msg.call_id].concat(msg.candidates);
} else {
for (var i = 0; i < msg.candidates.length; ++i) {
call.gotRemoteIceCandidate(msg.candidates[i]);
}
}
call.gotRemoteIceCandidate(msg.candidate);
} else if (event.type == 'm.call.hangup') {
var call = matrixPhoneService.allCalls[msg.call_id];
if (!call) {
if (!call && isLive) {
console.log("Got hangup for unknown call ID "+msg.call_id);
return;
} else if (!call) {
// if not live, store the fact that the call has ended because we're probably getting events backwards so
// the hangup will come before the invite
var MatrixCall = $injector.get('MatrixCall');
var call = new MatrixCall(event.room_id);
call.call_id = msg.call_id;
call.initWithHangup(event);
matrixPhoneService.allCalls[msg.call_id] = call;
} else {
call.onHangupReceived();
delete(matrixPhoneService.allCalls[msg.call_id]);
}
call.onHangupReceived();
delete(matrixPhoneService.allCalls[msg.call_id]);
}
});

View File

@ -81,38 +81,155 @@ angular.module('matrixService', [])
return $http(request);
};
var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) {
var data = {};
if (loginType === "m.login.recaptcha") {
var challengeToken = Recaptcha.get_challenge();
var captchaEntry = Recaptcha.get_response();
data = {
type: "m.login.recaptcha",
challenge: challengeToken,
response: captchaEntry
};
}
else if (loginType === "m.login.email.identity") {
data = {
threepidCreds: threepidCreds
};
}
else if (loginType === "m.login.password") {
data = {
user: userName,
password: password
};
}
if (sessionId) {
data.session = sessionId;
}
data.type = loginType;
console.log("doRegisterLogin >>> " + loginType);
return doRequest("POST", path, undefined, data);
};
return {
/****** Home server API ******/
prefix: prefixPath,
// Register an user
register: function(user_name, password, threepidCreds, useCaptcha) {
// The REST path spec
register: function(user_name, password, threepidCreds, useCaptcha) {
// registration is composed of multiple requests, to check you can
// register, then to actually register. This deferred will fire when
// all the requests are done, along with the final response.
var deferred = $q.defer();
var path = "/register";
var data = {
user_id: user_name,
password: password,
threepidCreds: threepidCreds
};
// check we can actually register with this HS.
doRequest("GET", path, undefined, undefined).then(
function(response) {
console.log("/register [1] : "+JSON.stringify(response));
var flows = response.data.flows;
var knownTypes = [
"m.login.password",
"m.login.recaptcha",
"m.login.email.identity"
];
// if they entered 3pid creds, we want to use a flow which uses it.
var useThreePidFlow = threepidCreds != undefined;
var flowIndex = 0;
var firstRegType = undefined;
for (var i=0; i<flows.length; i++) {
var isThreePidFlow = false;
if (flows[i].stages) {
for (var j=0; j<flows[i].stages.length; j++) {
var regType = flows[i].stages[j];
if (knownTypes.indexOf(regType) === -1) {
deferred.reject("Unknown type: "+regType);
return;
}
if (regType == "m.login.email.identity") {
isThreePidFlow = true;
}
if (!useCaptcha && regType == "m.login.recaptcha") {
console.error("Web client setup to not use captcha, but HS demands a captcha.");
deferred.reject({
data: {
errcode: "M_CAPTCHA_NEEDED",
error: "Home server requires a captcha."
}
});
return;
}
}
}
if ( (isThreePidFlow && useThreePidFlow) || (!isThreePidFlow && !useThreePidFlow) ) {
flowIndex = i;
}
if (knownTypes.indexOf(flows[i].type) == -1) {
deferred.reject("Unknown type: "+flows[i].type);
return;
}
}
// looks like we can register fine, go ahead and do it.
console.log("Using flow " + JSON.stringify(flows[flowIndex]));
firstRegType = flows[flowIndex].type;
var sessionId = undefined;
// generic response processor so it can loop as many times as required
var loginResponseFunc = function(response) {
if (response.data.session) {
sessionId = response.data.session;
}
console.log("login response: " + JSON.stringify(response.data));
if (response.data.access_token) {
deferred.resolve(response);
}
else if (response.data.next) {
var nextType = response.data.next;
if (response.data.next instanceof Array) {
for (var i=0; i<response.data.next.length; i++) {
if (useThreePidFlow && response.data.next[i] == "m.login.email.identity") {
nextType = response.data.next[i];
break;
}
else if (!useThreePidFlow && response.data.next[i] != "m.login.email.identity") {
nextType = response.data.next[i];
break;
}
}
}
return doRegisterLogin(path, nextType, sessionId, user_name, password, threepidCreds).then(
loginResponseFunc,
function(err) {
deferred.reject(err);
}
);
}
else {
deferred.reject("Unknown continuation: "+JSON.stringify(response));
}
};
// set the ball rolling
doRegisterLogin(path, firstRegType, undefined, user_name, password, threepidCreds).then(
loginResponseFunc,
function(err) {
deferred.reject(err);
}
);
},
function(err) {
deferred.reject(err);
}
);
if (useCaptcha) {
// Not all home servers will require captcha on signup, but if this flag is checked,
// send captcha information.
// TODO: Might be nice to make this a bit more flexible..
var challengeToken = Recaptcha.get_challenge();
var captchaEntry = Recaptcha.get_response();
var captchaType = "m.login.recaptcha";
data.captcha = {
type: captchaType,
challenge: challengeToken,
response: captchaEntry
};
}
return doRequest("POST", path, undefined, data);
return deferred.promise;
},
// Create a room

View File

@ -53,6 +53,8 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
// Add room_alias & room_display_name members
angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
eventHandlerService.setRoomVisibility(room.room_id, "public");
}
}
);
@ -117,6 +119,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
matrixService.getDisplayName($scope.config.user_id).then(
function(response) {
$scope.profile.displayName = response.data.displayname;
var config = matrixService.config();
config.display_name = response.data.displayname;
matrixService.setConfig(config);
matrixService.saveConfig();
},
function(error) {
$scope.feedback = "Can't load display name";

View File

@ -62,7 +62,8 @@
<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.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == undefined">Call Canceled</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == 'invite_timeout'">User Not Responding</span>
<span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span>
<span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span>

View File

@ -16,134 +16,16 @@
'use strict';
angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService'])
.controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService',
function($rootScope, $scope, matrixService, eventHandlerService) {
// FIXME: Angularjs reloads the controller (and resets its $scope) each time
// the page URL changes, use $rootScope to avoid to have to reload data
$rootScope.rooms;
angular.module('RecentsController', ['matrixService', 'matrixFilter'])
.controller('RecentsController', ['$rootScope', '$scope', 'eventHandlerService',
function($rootScope, $scope, eventHandlerService) {
// Expose the service to the view
$scope.eventHandlerService = eventHandlerService;
// $rootScope of the parent where the recents component is included can override this value
// in order to highlight a specific room in the list
$rootScope.recentsSelectedRoomID;
var listenToEventStream = function() {
// Refresh the list on matrix invitation and message event
$rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
if (!$rootScope.rooms[event.room_id]) {
// The user has joined a new room, which we do not have data yet. The reason is that
// the room has appeared in the scope of the user rooms after the global initialSync
// FIXME: an initialSync on this specific room should be done
$rootScope.rooms[event.room_id] = {
room_id:event.room_id
};
}
else if (event.state_key === matrixService.config().user_id && "invite" !== event.membership && "join" !== event.membership) {
// The user has been kicked or banned from the room, remove this room from the recents
delete $rootScope.rooms[event.room_id];
}
if ($rootScope.rooms[event.room_id]) {
$rootScope.rooms[event.room_id].lastMsg = event;
}
// Update room users count
$rootScope.rooms[event.room_id].numUsersInRoom = getUsersCountInRoom(event.room_id);
}
});
$rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
$rootScope.rooms[event.room_id].lastMsg = event;
}
});
$rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
$rootScope.rooms[event.room_id].lastMsg = event;
}
});
$rootScope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
$rootScope.rooms[event.room_id] = event;
}
});
$rootScope.$on(eventHandlerService.NAME_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
$rootScope.rooms[event.room_id].lastMsg = event;
}
});
$rootScope.$on(eventHandlerService.TOPIC_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
$rootScope.rooms[event.room_id].lastMsg = event;
}
});
};
/**
* Compute the room users number, ie the number of members who has joined the room.
* @param {String} room_id the room id
* @returns {undefined | Number} the room users number if available
*/
var getUsersCountInRoom = function(room_id) {
var memberCount;
var room = $rootScope.events.rooms[room_id];
if (room) {
memberCount = 0;
for (var i in room.members) {
var member = room.members[i];
if ("join" === member.membership) {
memberCount = memberCount + 1;
}
}
}
return memberCount;
};
$scope.onInit = function() {
// Init recents list only once
if ($rootScope.rooms) {
return;
}
$rootScope.rooms = {};
// Use initialSync data to init the recents list
eventHandlerService.waitForInitialSyncCompletion().then(
function(initialSyncData) {
var rooms = initialSyncData.data.rooms;
for (var i=0; i<rooms.length; i++) {
var room = rooms[i];
// Add room_alias & room_display_name members
$rootScope.rooms[room.room_id] = angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
// Create a shortcut for the last message of this room
if (room.messages && room.messages.chunk && room.messages.chunk[0]) {
$rootScope.rooms[room.room_id].lastMsg = room.messages.chunk[0];
}
$rootScope.rooms[room.room_id].numUsersInRoom = getUsersCountInRoom(room.room_id);
}
// From now, update recents from the stream
listenToEventStream();
},
function(error) {
$rootScope.feedback = "Failure: " + error.data;
}
);
};
// Clean data when user logs out
$scope.$on(eventHandlerService.RESET_EVENT, function() {
delete $rootScope.rooms;
});
}]);

View File

@ -17,31 +17,47 @@
'use strict';
angular.module('RecentsController')
.filter('orderRecents', function() {
.filter('orderRecents', ["matrixService", "eventHandlerService", function(matrixService, eventHandlerService) {
return function(rooms) {
var user_id = matrixService.config().user_id;
// Transform the dict into an array
// The key, room_id, is already in value objects
var filtered = [];
angular.forEach(rooms, function(value, key) {
filtered.push( value );
angular.forEach(rooms, function(room, room_id) {
// Show the room only if the user has joined it or has been invited
// (ie, do not show it if he has been banned)
var member = eventHandlerService.getMember(room_id, user_id);
if (member && ("invite" === member.membership || "join" === member.membership)) {
// Count users here
// TODO: Compute it directly in eventHandlerService
room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id);
}
filtered.push(room);
});
// And time sort them
// The room with the lastest message at first
filtered.sort(function (a, b) {
filtered.sort(function (roomA, roomB) {
var lastMsgRoomA = eventHandlerService.getLastMessage(roomA.room_id, true);
var lastMsgRoomB = eventHandlerService.getLastMessage(roomB.room_id, true);
// Invite message does not have a body message nor ts
// Puth them at the top of the list
if (undefined === a.lastMsg) {
if (undefined === lastMsgRoomA) {
return -1;
}
else if (undefined === b.lastMsg) {
else if (undefined === lastMsgRoomB) {
return 1;
}
else {
return b.lastMsg.ts - a.lastMsg.ts;
return lastMsgRoomB.ts - lastMsgRoomA.ts;
}
});
return filtered;
};
});
}]);

View File

@ -1,20 +1,24 @@
<div ng-controller="RecentsController" data-ng-init="onInit()">
<div ng-controller="RecentsController">
<table class="recentsTable">
<tbody ng-repeat="(rm_id, room) in rooms | orderRecents"
<tbody ng-repeat="(index, room) in events.rooms | orderRecents"
ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )"
class ="recentsRoom"
ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
<tr>
<td class="recentsRoomName">
<td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
{{ room.room_id | mRoomName }}
</td>
<td class="recentsRoomSummaryTS">
<td class="recentsRoomSummaryUsersCount">
<span ng-show="undefined !== room.numUsersInRoom">
{{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }}
</span>
</td>
<td class="recentsRoomSummaryTS">
{{ (room.lastMsg.ts) | date:'MMM d HH:mm' }}
<!-- Use a temp var as alias to the last room message.
Declaring it in this way ensures the data-binding -->
{{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }}
{{ (lastMsg.ts) | date:'MMM d HH:mm' }}
</td>
</tr>
@ -22,70 +26,70 @@
<td colspan="3" class="recentsRoomSummary">
<div ng-show="room.membership === 'invite'">
{{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you
{{ room.inviter | mUserDisplayName: room.room_id }} invited you
</div>
<div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type">
<div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
<div ng-switch-when="m.room.member">
<span ng-if="'join' === room.lastMsg.content.membership">
{{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined
<span ng-if="'join' === lastMsg.content.membership">
{{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined
</span>
<span ng-if="'leave' === room.lastMsg.content.membership">
<span ng-if="room.lastMsg.user_id === room.lastMsg.state_key">
{{room.lastMsg.state_key | mUserDisplayName: room.room_id }} left
<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="room.lastMsg.user_id !== room.lastMsg.state_key">
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"join": "kicked", "ban": "unbanned"}[room.lastMsg.content.prev] }}
{{ room.lastMsg.state_key | mUserDisplayName: room.room_id }}
<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' === room.lastMsg.content.prev && room.lastMsg.content.reason">
: {{ room.lastMsg.content.reason }}
<span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
</span>
</span>
<span ng-if="'invite' === room.lastMsg.content.membership || 'ban' === room.lastMsg.content.membership">
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"invite": "invited", "ban": "banned"}[room.lastMsg.content.membership] }}
{{ room.lastMsg.state_key | mUserDisplayName: room.room_id }}
<span ng-if="'ban' === room.lastMsg.content.prev && room.lastMsg.content.reason">
: {{ room.lastMsg.content.reason }}
<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>
</div>
<div ng-switch-when="m.room.message">
<div ng-switch="room.lastMsg.content.msgtype">
<div ng-switch="lastMsg.content.msgtype">
<div ng-switch-when="m.text">
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} :
<span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'">
{{ lastMsg.user_id | mUserDisplayName: room.room_id }} :
<span ng-bind-html="(lastMsg.content.body) | linky:'_blank'">
</span>
</div>
<div ng-switch-when="m.image">
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
{{ lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
</div>
<div ng-switch-when="m.emote">
<span ng-bind-html="'* ' + (room.lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + room.lastMsg.content.body | linky:'_blank'">
<span ng-bind-html="'* ' + (lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + lastMsg.content.body | linky:'_blank'">
</span>
</div>
<div ng-switch-default>
{{ room.lastMsg.content }}
{{ lastMsg.content }}
</div>
</div>
</div>
<div ng-switch-when="m.room.topic">
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }}
{{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ lastMsg.content.topic }}
</div>
<div ng-switch-when="m.room.name">
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ room.lastMsg.content.name }}
{{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ lastMsg.content.name }}
</div>
<div ng-switch-default>
<div ng-if="room.lastMsg.type.indexOf('m.call.') === 0">
<div ng-if="lastMsg.type.indexOf('m.call.') === 0">
Call
</div>
</div>

View File

@ -15,8 +15,8 @@ limitations under the License.
*/
angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall',
function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) {
.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
'use strict';
var MESSAGES_PER_PAGINATION = 30;
var THUMBNAIL_SIZE = 320;
@ -32,7 +32,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
can_paginate: false, // this is toggled off when we are not ready yet to paginate or when we run out of items
paginating: false, // used to avoid concurrent pagination requests pulling in dup contents
stream_failure: undefined, // the response when the stream fails
waiting_for_joined_event: false // true when the join request is pending. Back to false once the corresponding m.room.member event is received
waiting_for_joined_event: false, // true when the join request is pending. Back to false once the corresponding m.room.member event is received
messages_visibility: "hidden" // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
};
$scope.members = {};
$scope.autoCompleting = false;
@ -53,8 +54,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
return;
};
// Use the filter applied in html to set the input value
$scope.name.newNameText = $filter('mRoomName')($scope.room_id);
var nameEvent = $rootScope.events.rooms[$scope.room_id]['m.room.name'];
if (nameEvent) {
$scope.name.newNameText = nameEvent.content.name;
}
else {
$scope.name.newNameText = "";
}
// Force focus to the input
$timeout(function() {
@ -131,6 +137,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
$timeout(function() {
objDiv.scrollTop = objDiv.scrollHeight;
// Show the message table once the first scrolldown is done
if ("visible" !== $scope.state.messages_visibility) {
$timeout(function() {
$scope.state.messages_visibility = "visible";
}, 0);
}
}, 0);
}
};
@ -139,27 +152,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
if (isLive && event.room_id === $scope.room_id) {
scrollToBottom();
if (window.Notification) {
// Show notification when the window is hidden, or the user is idle
if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) {
var notification = new window.Notification(
($scope.members[event.user_id].displayname || event.user_id) +
" (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": event.content.body,
"icon": $scope.members[event.user_id].avatar_url
});
$timeout(function() {
notification.close();
}, 5 * 1000);
}
}
}
});
$scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
if (isLive && event.room_id === $scope.room_id) {
if ($scope.state.waiting_for_joined_event) {
// The user has successfully joined the room, we can getting data for this room
$scope.state.waiting_for_joined_event = false;
@ -177,19 +174,33 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
else {
user = event.user_id;
}
if ("ban" === event.membership) {
$scope.state.permission_denied = "You have been banned by " + user;
}
else {
$scope.state.permission_denied = "You have been kicked by " + user;
}
}
}
else {
scrollToBottom();
updateMemberList(event);
// Notify when a user joins
if ((document.hidden || matrixService.presence.unavailable === mPresence.getState())
&& event.state_key !== $scope.state.user_id && "join" === event.membership) {
debugger;
var notification = new window.Notification(
event.content.displayname +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": event.content.displayname + " joined",
"icon": event.content.avatar_url ? event.content.avatar_url : undefined
});
$timeout(function() {
notification.close();
}, 5 * 1000);
}
}
}
});
@ -235,7 +246,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then(
function(response) {
eventHandlerService.handleRoomMessages($scope.room_id, response.data, false);
eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b');
if (response.data.chunk.length < MESSAGES_PER_PAGINATION) {
// no more messages to paginate. this currently never gets turned true again, as we never
// expire paginated contents in the current implementation.
@ -406,12 +417,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
};
$scope.send = function() {
if ($scope.textInput === "") {
if (undefined === $scope.textInput || $scope.textInput === "") {
return;
}
scrollToBottom(true);
// Store the command in the history
history.push($scope.textInput);
var promise;
var cmd;
var args;
@ -676,6 +690,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
var onInit2 = function() {
console.log("onInit2");
// Scroll down as soon as possible so that we point to the last message
// if it already exists in memory
scrollToBottom(true);
// Make sure the initialSync has been before going further
eventHandlerService.waitForInitialSyncCompletion().then(
function() {
@ -684,6 +702,10 @@ 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)
$scope.state.first_pagination = false;
var members = $rootScope.events.rooms[$scope.room_id].members;
// Update the member list
@ -729,7 +751,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Make recents highlight the current room
$scope.recentsSelectedRoomID = $scope.room_id;
// Get the up-to-date the current member list
// Init the history for this room
history.init();
// Get the up-to-date the current member list
matrixService.getMemberList($scope.room_id).then(
function(response) {
for (var i = 0; i < response.data.chunk.length; i++) {
@ -743,9 +768,18 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Arm list timing update timer
updateMemberListPresenceAge();
// Start pagination
// Allow pagination
$scope.state.can_paginate = true;
paginate(MESSAGES_PER_PAGINATION);
// Do a first pagination only if it is required
// FIXME: Should be no more require when initialSync/{room_id} will be available
if ($scope.state.first_pagination) {
paginate(MESSAGES_PER_PAGINATION);
}
else {
// There are already messages, go to the last message
scrollToBottom(true);
}
},
function(error) {
$scope.feedback = "Failed get member list: " + error.data.error;
@ -832,4 +866,82 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
$rootScope.currentCall = call;
};
// Manage history of typed messages
// History is saved in sessionStoratge so that it survives when the user
// navigates through the rooms and when it refreshes the page
var history = {
// The list of typed messages. Index 0 is the more recents
data: [],
// The position in the history currently displayed
position: -1,
// The message the user has started to type before going into the history
typingMessage: undefined,
// Init/load data for the current room
init: function() {
var data = sessionStorage.getItem("history_" + $scope.room_id);
if (data) {
this.data = JSON.parse(data);
}
},
// Store a message in the history
push: function(message) {
this.data.unshift(message);
// Update the session storage
sessionStorage.setItem("history_" + $scope.room_id, JSON.stringify(this.data));
// Reset history position
this.position = -1;
this.typingMessage = undefined;
},
// Move in the history
go: function(offset) {
if (-1 === this.position) {
// User starts to go to into the history, save the current line
this.typingMessage = $scope.textInput;
}
else {
// If the user modified this line in history, keep the change
this.data[this.position] = $scope.textInput;
}
// Bounds the new position to valid data
var newPosition = this.position + offset;
newPosition = Math.max(-1, newPosition);
newPosition = Math.min(newPosition, this.data.length - 1);
this.position = newPosition;
if (-1 !== this.position) {
// Show the message from the history
$scope.textInput = this.data[this.position];
}
else if (undefined !== this.typingMessage) {
// Go back to the message the user started to type
$scope.textInput = this.typingMessage;
}
}
};
// Make history singleton methods available from HTML
$scope.history = {
goUp: function($event) {
if ($scope.room_id) {
history.go(1);
}
$event.preventDefault();
},
goDown: function($event) {
if ($scope.room_id) {
history.go(-1);
}
$event.preventDefault();
}
};
}]);

View File

@ -9,7 +9,7 @@
{{ room_id | mRoomName }}
</div>
<form ng-submit="name.updateName()" ng-show="name.isEditing" class="roomNameForm">
<input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" />
<input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" placeholder="Room name"/>
</form>
</div>
@ -23,7 +23,7 @@
{{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}}
</div>
<form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm">
<input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" />
<input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" placeholder="Topic"/>
</form>
</div>
</div>
@ -56,7 +56,10 @@
</table>
</div>
<div id="messageTableWrapper" ng-hide="state.permission_denied" keep-scroll>
<div id="messageTableWrapper"
ng-hide="state.permission_denied"
ng-style="{ 'visibility': state.messages_visibility }"
keep-scroll>
<!-- FIXME: need to have better timestamp semantics than the (msg.content.hsob_ts || msg.ts) hack below -->
<table id="messageTable" infinite-scroll="paginateMore()">
<tr ng-repeat="msg in events.rooms[room_id].messages"
@ -105,7 +108,7 @@
<span ng-show='msg.content.msgtype === "m.text"'
class="message"
ng-class="msg.echo_msg_state"
ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
<span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call</span>
@ -156,7 +159,8 @@
<td width="*">
<textarea id="mainInput" rows="1" ng-model="textInput" ng-enter="send()"
ng-disabled="state.permission_denied"
ng-focus="true" autocomplete="off" tab-complete/>
ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
ng-focus="true" autocomplete="off" tab-complete/>
</td>
<td id="buttonsCell">
<button ng-click="send()" ng-disabled="state.permission_denied">Send</button>

View File

@ -194,7 +194,16 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
/*** Desktop notifications section ***/
$scope.settings = {
notifications: undefined
notifications: undefined,
bingWords: matrixService.config().bingWords
};
$scope.saveBingWords = function() {
console.log("Saving words: "+JSON.stringify($scope.settings.bingWords));
var config = matrixService.config();
config.bingWords = $scope.settings.bingWords;
matrixService.setConfig(config);
matrixService.saveConfig();
};
// If the browser supports it, check the desktop notification state

View File

@ -51,7 +51,16 @@
<h3>Desktop notifications</h3>
<div class="section" ng-switch="settings.notifications">
<div ng-switch-when="granted">
Notifications are enabled.
Notifications are enabled. You will be alerted when a message contains your user ID or display name.
<div class="section">
<h4>Additional words to alert on:</h4>
<p>Leave blank to alert on all messages.</p>
<input size=40 name="bingWords" ng-model="settings.bingWords" ng-list placeholder="Enter words separated with , (supports regex)"
ng-blur="saveBingWords()"/>
<ul>
<li ng-repeat="word in settings.bingWords">{{word}}</li>
</ul>
</div>
</div>
<div ng-switch-when="denied">
You have denied permission for notifications.<br/>