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

This commit is contained in:
Erik Johnston 2014-09-06 18:18:55 +01:00
commit d12feed623
58 changed files with 1476 additions and 376 deletions

3
.gitignore vendored
View File

@ -18,9 +18,12 @@ htmlcov
demo/*.db
demo/*.log
demo/*.pid
demo/etc
graph/*.svg
graph/*.png
graph/*.dot
webclient/config.js
uploads

View File

@ -1,3 +1,23 @@
Changes in synapse 0.2.2 (2014-09-06)
=====================================
Homeserver:
* When the server returns state events it now also includes the previous
content.
* Add support for inviting people when creating a new room.
* Make the homeserver inform the room via `m.room.aliases` when a new alias
is added for a room.
* Validate `m.room.power_level` events.
Webclient:
* Add support for captchas on registration.
* Handle `m.room.aliases` events.
* Asynchronously send messages and show a local echo.
* Inform the UI when a message failed to send.
* Only autoscroll on receiving a new message if the user was already at the
bottom of the screen.
* Add support for ban/kick reasons.
Changes in synapse 0.2.1 (2014-09-03)
=====================================

View File

@ -1 +1 @@
0.2.1
0.2.2

View File

@ -347,11 +347,12 @@ Receiving live updates on a client
Clients can receive new events by long-polling the home server. This will hold open the
HTTP connection for a short period of time waiting for new events, returning early if an
event occurs. This is called the `Event Stream`_. All events which are visible to the
client and match the client's query will appear in the event stream. When the request
client will appear in the event stream. When the request
returns, an ``end`` token is included in the response. This token can be used in the next
request to continue where the client left off.
.. TODO
How do we filter the event stream?
Do we ever return multiple events in a single request? Don't we get lots of request
setup RTT latency if we only do one event per request? Do we ever support streaming
requests? Why not websockets?
@ -417,6 +418,16 @@ which can be set when creating a room:
If this is included, an ``m.room.topic`` event will be sent into the room to indicate the
topic for the room. See `Room Events`_ for more information on ``m.room.topic``.
``invite``
Type:
List
Optional:
Yes
Value:
A list of user ids to invite.
Description:
This will tell the server to invite everyone in the list to the newly created room.
Example::
{
@ -473,7 +484,9 @@ action in a room a user must have a suitable power level.
Power levels for users are defined in ``m.room.power_levels``, where both
a default and specific users' power levels can be set. By default all users
have a power level of 0.
have a power level of 0, other than the room creator whose power level defaults to 100.
Power levels for users are tracked per-room even if the user is not present in
the room.
State events may contain a ``required_power_level`` key, which indicates the
minimum power a user must have before they can update that state key. The only
@ -483,11 +496,11 @@ To perform certain actions there are additional power level requirements
defined in the following state events:
- ``m.room.send_event_level`` defines the minimum level for sending non-state
events. Defaults to 5.
events. Defaults to 50.
- ``m.room.add_state_level`` defines the minimum level for adding new state,
rather than updating existing state. Defaults to 5.
rather than updating existing state. Defaults to 50.
- ``m.room.ops_level`` defines the minimum levels to ban and kick other users.
This defaults to a kick and ban levels of 5 each.
This defaults to a kick and ban levels of 50 each.
Joining rooms
@ -908,6 +921,22 @@ prefixed with ``m.``
``ban_level`` will be greater than or equal to ``kick_level`` since
banning is more severe than kicking.
``m.room.aliases``
Summary:
These state events are used to inform the room about what room aliases it has.
Type:
State event
JSON format:
``{ "aliases": ["string", ...] }``
Example:
``{ "aliases": ["#foo:example.com"] }``
Description:
A server `may` inform the room that it has added or removed an alias for
the room. This is purely for informational purposes and may become stale.
Clients `should` check that the room alias is still valid before using it.
The ``state_key`` of the event is the homeserver which owns the room
alias.
``m.room.message``
Summary:
A message.
@ -1124,18 +1153,103 @@ Typing notifications
Voice over IP
=============
.. NOTE::
This section is a work in progress.
Matrix can also be used to set up VoIP calls. This is part of the core specification,
although is still in a very early stage. Voice (and video) over Matrix is based on
the WebRTC standards.
Call events are sent to a room, like any other event. This means that clients
must only send call events to rooms with exactly two participants as currently
the WebRTC standard is based around two-party communication.
Events
------
``m.call.invite``
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.
Optional keys:
None.
Example:
``{ "version" : 0, "call_id": "12345", "offer": { "type" : "offer", "sdp" : "v=0\r\no=- 6584580628695956864 2 IN IP4 127.0.0.1[...]" } }``
``Offer Object``
Required keys:
- ``type`` : "string" - The type of session description, in this case 'offer'
- ``sdp`` : "string" - The SDP text of the session description
``m.call.candidate``
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
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.
``Candidate Object``
Required Keys:
- ``sdpMid`` : "string" - The SDP media type this candidate is intended for.
- ``sdpMLineIndex`` : "integer" - The index of the SDP 'm' line this
candidate is intended for
- ``candidate`` : "string" - The SDP 'a' line of the candidate
``m.call.answer``
Required keys:
- ``call_id`` : "string" - The ID of the call this event relates to
- ``version`` : "integer" - The version of the VoIP specification this messages
- ``answer`` : "answer object" - Object giving the SDK answer
``Answer Object``
Required keys:
- ``type`` : "string" - The type of session description. 'answer' in this case.
- ``sdp`` : "string" - The SDP text of the session description
``m.call.hangup``
Sent by either party to signal their termination of the call. This can be sent either once
the call has has been established or before to abort the call.
Required keys:
- ``call_id`` : "string" - The ID of the call this event relates to
- ``version`` : "integer" - The version of the VoIP specification this messages
Message Exchange
----------------
A call is set up with messages exchanged as follows:
::
Caller Callee
m.call.invite ----------->
m.call.candidate -------->
[more candidates events]
User answers call
<------ m.call.answer
[...]
<------ m.call.hangup
Or a rejected call:
::
Caller Callee
m.call.invite ----------->
m.call.candidate -------->
[more candidates events]
User rejects call
<------- m.call.hangup
Calls are negotiated according to the WebRTC specification.
.. TODO Dave
- what are the event types.
- what are the valid keys/values. What do they represent. Any gotchas?
- In what sequence should the events be sent?
- How do you accept / decline inbound calls? How do you make outbound calls?
Give examples.
- How does negotiation work? Give examples.
- How do you hang up?
- What does call log information look like e.g. duration of call?
Profiles
========
@ -1151,8 +1265,8 @@ Profiles
- Display name changes also generates m.room.member with displayname key f.e. room
the user is in.
Internally within Matrix users are referred to by their user ID, which is not a
human-friendly string. Profiles grant users the ability to see human-readable
Internally within Matrix users are referred to by their user ID, which is typically
a compact unique identifier. Profiles grant users the ability to see human-readable
names for other users that are in some way meaningful to them. Additionally,
profiles can publish additional information, such as the user's age or location.
@ -1466,17 +1580,19 @@ Federation is the term used to describe how to communicate between Matrix home
servers. Federation is a mechanism by which two home servers can exchange
Matrix event messages, both as a real-time push of current events, and as a
historic fetching mechanism to synchronise past history for clients to view. It
uses HTTP connections between each pair of servers involved as the underlying
uses HTTPS connections between each pair of servers involved as the underlying
transport. Messages are exchanged between servers in real-time by active pushing
from each server's HTTP client into the server of the other. Queries to fetch
historic data for the purpose of back-filling scrollback buffers and the like
can also be performed.
can also be performed. Currently routing of messages between homeservers is full
mesh (like email) - however, fan-out refinements to this design are currently
under consideration.
There are three main kinds of communication that occur between home servers:
:Queries:
These are single request/response interactions between a given pair of
servers, initiated by one side sending an HTTP GET request to obtain some
servers, initiated by one side sending an HTTPS GET request to obtain some
information, and responded by the other. They are not persisted and contain
no long-term significant history. They simply request a snapshot state at the
instant the query is made.
@ -1692,7 +1808,7 @@ by the same origin as the current one, or other origins.
Because of the distributed nature of participants in a Matrix conversation, it
is impossible to establish a globally-consistent total ordering on the events.
However, by annotating each outbound PDU at its origin with IDs of other PDUs it
has received, a partial ordering can be constructed allowing causallity
has received, a partial ordering can be constructed allowing causality
relationships to be preserved. A client can then display these messages to the
end-user in some order consistent with their content and ensure that no message
that is semantically in reply of an earlier one is ever displayed before it.
@ -1778,7 +1894,7 @@ Retrieves a sliding-window history of previous PDUs that occurred on the
given context. Starting from the PDU ID(s) given in the "v" argument, the
PDUs that preceeded it are retrieved, up to a total number given by the
"limit" argument. These are then returned in a new Transaction containing all
off the PDUs.
of the PDUs.
To stream events all the events::
@ -1858,6 +1974,10 @@ victim would then include in their view of the chatroom history. Other servers
in the chatroom would reject the invalid messages and potentially reject the
victims messages as well since they depended on the invalid messages.
.. TODO
Track trustworthiness of HS or users based on if they try to pretend they
haven't seen recent events, and fake a splitbrain... --M
Threat: Block Network Traffic
+++++++++++++++++++++++++++++
@ -1963,6 +2083,9 @@ The ``retry_after_ms`` key SHOULD be included to tell the client how long they h
in milliseconds before they can try again.
.. TODO
- Surely we should recommend an algorithm for the rate limiting, rather than letting every
homeserver come up with their own idea, causing totally unpredictable performance over
federated rooms?
- crypto (s-s auth)
- E2E
- Lawful intercept + Key Escrow
@ -1973,6 +2096,9 @@ Policy Servers
.. NOTE::
This section is a work in progress.
.. TODO
We should mention them in the Architecture section at least...
Content repository
==================
.. NOTE::
@ -2071,6 +2197,9 @@ Transaction:
A message which relates to the communication between a given pair of servers.
A transaction contains possibly-empty lists of PDUs and EDUs.
.. TODO
This glossary contradicts the terms used above - especially on State Events v. "State"
and Non-State Events v. "Events". We need better consistent names.
.. Links through the external API docs are below
.. =============================================
@ -2118,3 +2247,4 @@ Transaction:
.. _/join/<room_alias_or_id>: /docs/api/client-server/#!/-rooms/join
.. _`Event Stream`: /docs/api/client-server/#!/-events/get_event_stream

View File

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

View File

@ -18,8 +18,8 @@
from twisted.internet import defer
from synapse.api.constants import Membership, JoinRules
from synapse.api.errors import AuthError, StoreError, Codes
from synapse.api.events.room import RoomMemberEvent
from synapse.api.errors import AuthError, StoreError, Codes, SynapseError
from synapse.api.events.room import RoomMemberEvent, RoomPowerLevelsEvent
from synapse.util.logutils import log_function
import logging
@ -67,6 +67,9 @@ class Auth(object):
else:
yield self._can_send_event(event)
if event.type == RoomPowerLevelsEvent.TYPE:
yield self._check_power_levels(event)
defer.returnValue(True)
else:
raise AuthError(500, "Unknown event: %s" % event)
@ -172,7 +175,7 @@ class Auth(object):
if kick_level:
kick_level = int(kick_level)
else:
kick_level = 5
kick_level = 50
if user_level < kick_level:
raise AuthError(
@ -189,7 +192,7 @@ class Auth(object):
if ban_level:
ban_level = int(ban_level)
else:
ban_level = 5 # FIXME (erikj): What should we do here?
ban_level = 50 # FIXME (erikj): What should we do here?
if user_level < ban_level:
raise AuthError(403, "You don't have permission to ban")
@ -305,7 +308,9 @@ class Auth(object):
else:
user_level = 0
logger.debug("Checking power level for %s, %s", event.user_id, user_level)
logger.debug(
"Checking power level for %s, %s", event.user_id, user_level
)
if current_state and hasattr(current_state, "required_power_level"):
req = current_state.required_power_level
@ -315,3 +320,101 @@ class Auth(object):
403,
"You don't have permission to change that state"
)
@defer.inlineCallbacks
def _check_power_levels(self, event):
for k, v in event.content.items():
if k == "default":
continue
# FIXME (erikj): We don't want hsob_Ts in content.
if k == "hsob_ts":
continue
try:
self.hs.parse_userid(k)
except:
raise SynapseError(400, "Not a valid user_id: %s" % (k,))
try:
int(v)
except:
raise SynapseError(400, "Not a valid power level: %s" % (v,))
current_state = yield self.store.get_current_state(
event.room_id,
event.type,
event.state_key,
)
if not current_state:
return
else:
current_state = current_state[0]
user_level = yield self.store.get_power_level(
event.room_id,
event.user_id,
)
if user_level:
user_level = int(user_level)
else:
user_level = 0
old_list = current_state.content
# FIXME (erikj)
old_people = {k: v for k, v in old_list.items() if k.startswith("@")}
new_people = {
k: v for k, v in event.content.items()
if k.startswith("@")
}
removed = set(old_people.keys()) - set(new_people.keys())
added = set(old_people.keys()) - set(new_people.keys())
same = set(old_people.keys()) & set(new_people.keys())
for r in removed:
if int(old_list.content[r]) > user_level:
raise AuthError(
403,
"You don't have permission to remove user: %s" % (r, )
)
for n in added:
if int(event.content[n]) > user_level:
raise AuthError(
403,
"You don't have permission to add ops level greater "
"than your own"
)
for s in same:
if int(event.content[s]) != int(old_list[s]):
if int(event.content[s]) > user_level:
raise AuthError(
403,
"You don't have permission to add ops level greater "
"than your own"
)
if "default" in old_list:
old_default = int(old_list["default"])
if old_default > user_level:
raise AuthError(
403,
"You don't have permission to add ops level greater than "
"your own"
)
if "default" in event.content:
new_default = int(event.content["default"])
if new_default > user_level:
raise AuthError(
403,
"You don't have permission to add ops level greater "
"than your own"
)

View File

@ -29,6 +29,8 @@ class Codes(object):
NOT_FOUND = "M_NOT_FOUND"
UNKNOWN_TOKEN = "M_UNKNOWN_TOKEN"
LIMIT_EXCEEDED = "M_LIMIT_EXCEEDED"
CAPTCHA_NEEDED = "M_CAPTCHA_NEEDED"
CAPTCHA_INVALID = "M_CAPTCHA_INVALID"
class CodeMessageException(Exception):
@ -101,6 +103,19 @@ class StoreError(SynapseError):
pass
class InvalidCaptchaError(SynapseError):
def __init__(self, code=400, msg="Invalid captcha.", error_url=None,
errcode=Codes.CAPTCHA_INVALID):
super(InvalidCaptchaError, self).__init__(code, msg, errcode)
self.error_url = error_url
def error_dict(self):
return cs_error(
self.msg,
self.errcode,
error_url=self.error_url,
)
class LimitExceededError(SynapseError):
"""A client has sent too many requests and is being throttled.
"""

View File

@ -157,6 +157,11 @@ 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

@ -47,11 +47,14 @@ class EventFactory(object):
self._event_list[event_class.TYPE] = event_class
self.clock = hs.get_clock()
self.hs = hs
def create_event(self, etype=None, **kwargs):
kwargs["type"] = etype
if "event_id" not in kwargs:
kwargs["event_id"] = random_string(10)
kwargs["event_id"] = "%s@%s" % (
random_string(10), self.hs.hostname
)
if "ts" not in kwargs:
kwargs["ts"] = int(self.clock.time_msec())

View File

@ -173,3 +173,10 @@ class RoomOpsPowerLevelsEvent(SynapseStateEvent):
def get_content_template(self):
return {}
class RoomAliasesEvent(SynapseStateEvent):
TYPE = "m.room.aliases"
def get_content_template(self):
return {}

View File

@ -57,7 +57,7 @@ SCHEMAS = [
# Remember to update this number every time an incompatible change is made to
# database schema files, so the users will be informed on server restarts.
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
class SynapseHomeServer(HomeServer):

42
synapse/config/captcha.py Normal file
View File

@ -0,0 +1,42 @@
# 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 CaptchaConfig(Config):
def __init__(self, args):
super(CaptchaConfig, self).__init__(args)
self.recaptcha_private_key = args.recaptcha_private_key
self.enable_registration_captcha = args.enable_registration_captcha
self.captcha_ip_origin_is_x_forwarded = args.captcha_ip_origin_is_x_forwarded
@classmethod
def add_arguments(cls, parser):
super(CaptchaConfig, cls).add_arguments(parser)
group = parser.add_argument_group("recaptcha")
group.add_argument(
"--recaptcha-private-key", type=str, default="YOUR_PRIVATE_KEY",
help="The matching private key for the web client's public key."
)
group.add_argument(
"--enable-registration-captcha", type=bool, default=False,
help="Enables ReCaptcha checks when registering, preventing signup "+
"unless a captcha is answered. Requires a valid ReCaptcha public/private key."
)
group.add_argument(
"--captcha_ip_origin_is_x_forwarded", type=bool, default=False,
help="When checking captchas, use the X-Forwarded-For (XFF) header as the client IP "+
"and not the actual client IP."
)

View File

@ -19,9 +19,10 @@ from .logger import LoggingConfig
from .database import DatabaseConfig
from .ratelimiting import RatelimitConfig
from .repository import ContentRepositoryConfig
from .captcha import CaptchaConfig
class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
RatelimitConfig, ContentRepositoryConfig):
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig):
pass
if __name__=='__main__':

View File

@ -42,9 +42,6 @@ class BaseHandler(object):
retry_after_ms=int(1000*(time_allowed - time_now)),
)
class BaseRoomHandler(BaseHandler):
@defer.inlineCallbacks
def _on_new_room_event(self, event, snapshot, extra_destinations=[],
extra_users=[]):

View File

@ -19,8 +19,10 @@ from ._base import BaseHandler
from synapse.api.errors import SynapseError
from synapse.http.client import HttpClient
from synapse.api.events.room import RoomAliasesEvent
import logging
import sqlite3
logger = logging.getLogger(__name__)
@ -37,7 +39,8 @@ class DirectoryHandler(BaseHandler):
)
@defer.inlineCallbacks
def create_association(self, room_alias, room_id, servers=None):
def create_association(self, user_id, room_alias, room_id, servers=None):
# TODO(erikj): Do auth.
if not room_alias.is_mine:
@ -54,11 +57,36 @@ class DirectoryHandler(BaseHandler):
if not servers:
raise SynapseError(400, "Failed to get server list")
try:
yield self.store.create_room_alias_association(
room_alias,
room_id,
servers
)
except sqlite3.IntegrityError:
defer.returnValue("Already exists")
# TODO: Send the room event.
aliases = yield self.store.get_aliases_for_room(room_id)
event = self.event_factory.create_event(
etype=RoomAliasesEvent.TYPE,
state_key=self.hs.hostname,
room_id=room_id,
user_id=user_id,
content={"aliases": aliases},
)
snapshot = yield self.store.snapshot_room(
room_id=room_id,
user_id=user_id,
)
yield self.state_handler.handle_new_event(event, snapshot)
yield self._on_new_room_event(event, snapshot, extra_users=[user_id])
@defer.inlineCallbacks
def get_association(self, room_alias):

View File

@ -19,7 +19,7 @@ from synapse.api.constants import Membership
from synapse.api.events.room import RoomTopicEvent
from synapse.api.errors import RoomError
from synapse.streams.config import PaginationConfig
from ._base import BaseRoomHandler
from ._base import BaseHandler
import logging
@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
class MessageHandler(BaseRoomHandler):
class MessageHandler(BaseHandler):
def __init__(self, hs):
super(MessageHandler, self).__init__(hs)

View File

@ -796,11 +796,12 @@ class PresenceEventSource(object):
updates = []
# TODO(paul): use a DeferredList ? How to limit concurrency.
for observed_user in cachemap.keys():
if not (from_key < cachemap[observed_user].serial):
cached = cachemap[observed_user]
if not (from_key < cached.serial):
continue
if (yield self.is_visible(observer_user, observed_user)):
updates.append((observed_user, cachemap[observed_user]))
updates.append((observed_user, cached))
# TODO(paul): limit

View File

@ -17,7 +17,9 @@
from twisted.internet import defer
from synapse.types import UserID
from synapse.api.errors import SynapseError, RegistrationError
from synapse.api.errors import (
SynapseError, RegistrationError, InvalidCaptchaError
)
from ._base import BaseHandler
import synapse.util.stringutils as stringutils
from synapse.http.client import PlainHttpClient
@ -38,7 +40,8 @@ class RegistrationHandler(BaseHandler):
self.distributor.declare("registered_user")
@defer.inlineCallbacks
def register(self, localpart=None, password=None, threepidCreds=None):
def register(self, localpart=None, password=None, threepidCreds=None,
captcha_info={}):
"""Registers a new client on the server.
Args:
@ -51,10 +54,26 @@ 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'])
logger.info("validating theeepidcred sid %s on id server %s",
c['sid'], c['idServer'])
try:
threepid = yield self._threepid_from_creds(c)
except:
@ -63,7 +82,8 @@ class RegistrationHandler(BaseHandler):
if not threepid:
raise RegistrationError(400, "Couldn't validate 3pid")
logger.info("got threepid medium %s address %s", threepid['medium'], threepid['address'])
logger.info("got threepid medium %s address %s",
threepid['medium'], threepid['address'])
password_hash = None
if password:
@ -131,7 +151,8 @@ class RegistrationHandler(BaseHandler):
# XXX: make this configurable!
trustedIdServers = [ 'matrix.org:8090' ]
if not creds['idServer'] in trustedIdServers:
logger.warn('%s is not a trusted ID server: rejecting 3pid credentials', creds['idServer'])
logger.warn('%s is not a trusted ID server: rejecting 3pid '+
'credentials', creds['idServer'])
defer.returnValue(None)
data = yield httpCli.get_json(
creds['idServer'],
@ -149,9 +170,44 @@ 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)
# 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=%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,
'remoteip': ip_addr,
'challenge': challenge,
'response': response
}
)
defer.returnValue(data)

View File

@ -25,14 +25,14 @@ from synapse.api.events.room import (
RoomSendEventLevelEvent, RoomOpsPowerLevelsEvent, RoomNameEvent,
)
from synapse.util import stringutils
from ._base import BaseRoomHandler
from ._base import BaseHandler
import logging
logger = logging.getLogger(__name__)
class RoomCreationHandler(BaseRoomHandler):
class RoomCreationHandler(BaseHandler):
@defer.inlineCallbacks
def create_room(self, user_id, room_id, config):
@ -65,6 +65,13 @@ class RoomCreationHandler(BaseRoomHandler):
else:
room_alias = None
invite_list = config.get("invite", [])
for i in invite_list:
try:
self.hs.parse_userid(i)
except:
raise SynapseError(400, "Invalid user_id: %s" % (i,))
is_public = config.get("visibility", None) == "public"
if room_id:
@ -105,7 +112,9 @@ class RoomCreationHandler(BaseRoomHandler):
)
if room_alias:
yield self.store.create_room_alias_association(
directory_handler = self.hs.get_handlers().directory_handler
yield directory_handler.create_association(
user_id=user_id,
room_id=room_id,
room_alias=room_alias,
servers=[self.hs.hostname],
@ -132,7 +141,7 @@ class RoomCreationHandler(BaseRoomHandler):
etype=RoomNameEvent.TYPE,
room_id=room_id,
user_id=user_id,
required_power_level=5,
required_power_level=50,
content={"name": name},
)
@ -143,7 +152,7 @@ class RoomCreationHandler(BaseRoomHandler):
etype=RoomNameEvent.TYPE,
room_id=room_id,
user_id=user_id,
required_power_level=5,
required_power_level=50,
content={"name": name},
)
@ -155,7 +164,7 @@ class RoomCreationHandler(BaseRoomHandler):
etype=RoomTopicEvent.TYPE,
room_id=room_id,
user_id=user_id,
required_power_level=5,
required_power_level=50,
content={"topic": topic},
)
@ -176,6 +185,25 @@ class RoomCreationHandler(BaseRoomHandler):
do_auth=False
)
content = {"membership": Membership.INVITE}
for invitee in invite_list:
invite_event = self.event_factory.create_event(
etype=RoomMemberEvent.TYPE,
state_key=invitee,
room_id=room_id,
user_id=user_id,
content=content
)
yield self.hs.get_handlers().room_member_handler.change_membership(
invite_event,
do_auth=False
)
yield self.hs.get_handlers().room_member_handler.change_membership(
join_event,
do_auth=False
)
result = {"room_id": room_id}
if room_alias:
result["room_alias"] = room_alias.to_string()
@ -186,7 +214,7 @@ class RoomCreationHandler(BaseRoomHandler):
event_keys = {
"room_id": room_id,
"user_id": creator.to_string(),
"required_power_level": 10,
"required_power_level": 100,
}
def create(etype, **content):
@ -203,7 +231,7 @@ class RoomCreationHandler(BaseRoomHandler):
power_levels_event = self.event_factory.create_event(
etype=RoomPowerLevelsEvent.TYPE,
content={creator.to_string(): 10, "default": 0},
content={creator.to_string(): 100, "default": 0},
**event_keys
)
@ -215,7 +243,7 @@ class RoomCreationHandler(BaseRoomHandler):
add_state_event = create(
etype=RoomAddStateLevelEvent.TYPE,
level=10,
level=100,
)
send_event = create(
@ -225,8 +253,8 @@ class RoomCreationHandler(BaseRoomHandler):
ops = create(
etype=RoomOpsPowerLevelsEvent.TYPE,
ban_level=5,
kick_level=5,
ban_level=50,
kick_level=50,
)
return [
@ -239,7 +267,7 @@ class RoomCreationHandler(BaseRoomHandler):
]
class RoomMemberHandler(BaseRoomHandler):
class RoomMemberHandler(BaseHandler):
# TODO(paul): This handler currently contains a messy conflation of
# low-level API that works on UserID objects and so on, and REST-level
# API that takes ID strings and returns pagination chunks. These concerns
@ -560,7 +588,7 @@ class RoomMemberHandler(BaseRoomHandler):
extra_users=[target_user]
)
class RoomListHandler(BaseRoomHandler):
class RoomListHandler(BaseHandler):
@defer.inlineCallbacks
def get_public_room_list(self):

View File

@ -16,7 +16,7 @@
from twisted.internet import defer, reactor
from twisted.internet.error import DNSLookupError
from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer
from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer, PartialDownloadError
from twisted.web.http_headers import Headers
from synapse.http.endpoint import matrix_endpoint
@ -189,6 +189,32 @@ class TwistedHttpClient(HttpClient):
defer.returnValue(json.loads(body))
# XXX FIXME : I'm so sorry.
@defer.inlineCallbacks
def post_urlencoded_get_raw(self, destination, path, accept_partial=False, args={}):
if destination in _destination_mappings:
destination = _destination_mappings[destination]
query_bytes = urllib.urlencode(args, True)
response = yield self._create_request(
destination.encode("ascii"),
"POST",
path.encode("ascii"),
producer=FileBodyProducer(StringIO(urllib.urlencode(args))),
headers_dict={"Content-Type": ["application/x-www-form-urlencoded"]}
)
try:
body = yield readBody(response)
defer.returnValue(body)
except PartialDownloadError as e:
if accept_partial:
defer.returnValue(e.response)
else:
raise e
@defer.inlineCallbacks
def _create_request(self, destination, method, path_bytes, param_bytes=b"",
query_bytes=b"", producer=None, headers_dict={},

View File

@ -45,6 +45,8 @@ class ClientDirectoryServer(RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, room_alias):
user = yield self.auth.get_user_by_req(request)
content = _parse_json(request)
if not "room_id" in content:
raise SynapseError(400, "Missing room_id key",
@ -69,12 +71,13 @@ class ClientDirectoryServer(RestServlet):
try:
yield dir_handler.create_association(
room_alias, room_id, servers
user.to_string(), room_alias, room_id, servers
)
except SynapseError as e:
raise e
except:
logger.exception("Failed to create association")
raise
defer.returnValue((200, {}))

View File

@ -70,7 +70,7 @@ class LoginFallbackRestServlet(RestServlet):
def on_GET(self, request):
# TODO(kegan): This should be returning some HTML which is capable of
# hitting LoginRestServlet
return (200, "")
return (200, {})
def _parse_json(request):

View File

@ -51,7 +51,7 @@ class ProfileDisplaynameRestServlet(RestServlet):
yield self.handlers.profile_handler.set_displayname(
user, auth_user, new_name)
defer.returnValue((200, ""))
defer.returnValue((200, {}))
def on_OPTIONS(self, request, user_id):
return (200, {})
@ -86,7 +86,7 @@ class ProfileAvatarURLRestServlet(RestServlet):
yield self.handlers.profile_handler.set_avatar_url(
user, auth_user, new_name)
defer.returnValue((200, ""))
defer.returnValue((200, {}))
def on_OPTIONS(self, request, user_id):
return (200, {})

View File

@ -16,7 +16,7 @@
"""This module contains REST servlets to do with registration: /register"""
from twisted.internet import defer
from synapse.api.errors import SynapseError
from synapse.api.errors import SynapseError, Codes
from base import RestServlet, client_path_pattern
import json
@ -51,11 +51,43 @@ class RegisterRestServlet(RestServlet):
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
}
handler = self.handlers.registration_handler
(user_id, token) = yield handler.register(
localpart=desired_user_id,
password=password,
threepidCreds=threepidCreds)
threepidCreds=threepidCreds,
captcha_info=captcha)
result = {
"user_id": user_id,

View File

@ -154,14 +154,14 @@ class RoomStateEventRestServlet(RestServlet):
# membership events are special
handler = self.handlers.room_member_handler
yield handler.change_membership(event)
defer.returnValue((200, ""))
defer.returnValue((200, {}))
else:
# store random bits of state
msg_handler = self.handlers.message_handler
yield msg_handler.store_room_data(
event=event
)
defer.returnValue((200, ""))
defer.returnValue((200, {}))
# TODO: Needs unit testing for generic events + feedback
@ -249,7 +249,7 @@ class JoinRoomAliasServlet(RestServlet):
)
handler = self.handlers.room_member_handler
yield handler.change_membership(event)
defer.returnValue((200, ""))
defer.returnValue((200, {}))
@defer.inlineCallbacks
def on_PUT(self, request, room_identifier, txn_id):
@ -416,7 +416,7 @@ class RoomMembershipRestServlet(RestServlet):
)
handler = self.handlers.room_member_handler
yield handler.change_membership(event)
defer.returnValue((200, ""))
defer.returnValue((200, {}))
@defer.inlineCallbacks
def on_PUT(self, request, room_id, membership_action, txn_id):

View File

@ -16,7 +16,7 @@
from twisted.internet import defer
from synapse.federation.pdu_codec import encode_event_id
from synapse.federation.pdu_codec import encode_event_id, decode_event_id
from synapse.util.logutils import log_function
from collections import namedtuple
@ -87,9 +87,11 @@ class StateHandler(object):
# than the power level of the user
# power_level = self._get_power_level_for_event(event)
pdu_id, origin = decode_event_id(event.event_id, self.server_name)
yield self.store.update_current_state(
pdu_id=event.event_id,
origin=self.server_name,
pdu_id=pdu_id,
origin=origin,
context=key.context,
pdu_type=key.type,
state_key=key.state_key

View File

@ -81,7 +81,7 @@ class DataStore(RoomMemberStore, RoomStore,
defer.returnValue(latest)
@defer.inlineCallbacks
def get_event(self, event_id):
def get_event(self, event_id, allow_none=False):
events_dict = yield self._simple_select_one(
"events",
{"event_id": event_id},
@ -92,8 +92,12 @@ class DataStore(RoomMemberStore, RoomStore,
"content",
"unrecognized_keys"
],
allow_none=allow_none,
)
if not events_dict:
defer.returnValue(None)
event = self._parse_event_from_row(events_dict)
defer.returnValue(event)
@ -220,7 +224,8 @@ class DataStore(RoomMemberStore, RoomStore,
results = yield self._execute_and_decode(sql, *args)
defer.returnValue([self._parse_event_from_row(r) for r in results])
events = yield self._parse_events(results)
defer.returnValue(events)
@defer.inlineCallbacks
def _get_min_token(self):

View File

@ -312,6 +312,25 @@ class SQLBaseStore(object):
**d
)
def _parse_events(self, rows):
return self._db_pool.runInteraction(self._parse_events_txn, rows)
def _parse_events_txn(self, txn, rows):
events = [self._parse_event_from_row(r) for r in rows]
sql = "SELECT * FROM events WHERE event_id = ?"
for ev in events:
if hasattr(ev, "prev_state"):
# Load previous state_content.
# TODO: Should we be pulling this out above?
cursor = txn.execute(sql, (ev.prev_state,))
prevs = self.cursor_to_dict(cursor)
if prevs:
prev = self._parse_event_from_row(prevs[0])
ev.prev_content = prev.content
return events
class Table(object):
""" A base class used to store information about a particular table.

View File

@ -92,3 +92,10 @@ class DirectoryStore(SQLBaseStore):
"server": server,
}
)
def get_aliases_for_room(self, room_id):
return self._simple_select_onecol(
"room_aliases",
{"room_id": room_id},
"room_alias",
)

View File

@ -88,7 +88,7 @@ class RoomMemberStore(SQLBaseStore):
txn.execute(sql, (user_id, room_id))
rows = self.cursor_to_dict(txn)
if rows:
return self._parse_event_from_row(rows[0])
return self._parse_events_txn(txn, rows)[0]
else:
return None
@ -161,7 +161,7 @@ class RoomMemberStore(SQLBaseStore):
# logger.debug("_get_members_query Got rows %s", rows)
results = [self._parse_event_from_row(r) for r in rows]
results = yield self._parse_events(rows)
defer.returnValue(results)
@defer.inlineCallbacks

View File

@ -0,0 +1,27 @@
/* 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.
*/
CREATE INDEX IF NOT EXISTS room_aliases_alias ON room_aliases(room_alias);
CREATE INDEX IF NOT EXISTS room_aliases_id ON room_aliases(room_id);
CREATE INDEX IF NOT EXISTS room_alias_servers_alias ON room_alias_servers(room_alias);
DELETE FROM room_aliases WHERE rowid NOT IN (SELECT max(rowid) FROM room_aliases GROUP BY room_alias, room_id);
CREATE UNIQUE INDEX IF NOT EXISTS room_aliases_uniq ON room_aliases(room_alias, room_id);
PRAGMA user_version = 3;

View File

@ -188,7 +188,7 @@ class StreamStore(SQLBaseStore):
user_id, user_id, from_id, to_id
)
ret = [self._parse_event_from_row(r) for r in rows]
ret = yield self._parse_events(rows)
if rows:
key = "s%d" % max([r["stream_ordering"] for r in rows])
@ -243,9 +243,11 @@ class StreamStore(SQLBaseStore):
# TODO (erikj): We should work out what to do here instead.
next_token = to_key if to_key else from_key
events = yield self._parse_events(rows)
defer.returnValue(
(
[self._parse_event_from_row(r) for r in rows],
events,
next_token
)
)
@ -277,12 +279,11 @@ class StreamStore(SQLBaseStore):
else:
token = (end_token, end_token)
defer.returnValue(
(
[self._parse_event_from_row(r) for r in rows],
token
)
)
events = yield self._parse_events(rows)
ret = (events, token)
defer.returnValue(ret)
def get_room_events_max_id(self):
return self._db_pool.runInteraction(self._get_room_events_max_id_txn)

View File

@ -145,6 +145,7 @@ class EventStreamPermissionsTestCase(RestTestCase):
)
self.ratelimiter = hs.get_ratelimiter()
self.ratelimiter.send_message.return_value = (True, 0)
hs.config.enable_registration_captcha = False
hs.get_handlers().federation_handler = Mock()

View File

@ -240,6 +240,7 @@ class StateTestCase(unittest.TestCase):
@defer.inlineCallbacks
def test_new_event(self):
event = Mock()
event.event_id = "12123123@test"
state_pdu = new_fake_pdu_entry("C", "test", "mem", "x", "A", 20)

46
webclient/CAPTCHA_SETUP Normal file
View File

@ -0,0 +1,46 @@
Captcha can be enabled for this web client / home server. This file explains how to do that.
The captcha mechanism used is Google's ReCaptcha. This requires API keys from Google.
Getting keys
------------
Requires a public/private key pair from:
https://developers.google.com/recaptcha/
Setting Private ReCaptcha Key
-----------------------------
The private key is a config option on the home server config. If it is not
visible, you can generate it via --generate-config. Set the following value:
recaptcha_private_key: YOUR_PRIVATE_KEY
In addition, you MUST enable captchas via:
enable_registration_captcha: true
Setting Public ReCaptcha Key
----------------------------
The web client will look for the global variable webClientConfig for config
options. You should put your ReCaptcha public key there like so:
webClientConfig = {
useCaptcha: true,
recaptcha_public_key: "YOUR_PUBLIC_KEY"
}
This should be put in webclient/config.js which is already .gitignored, rather
than in the web client source files. You MUST set useCaptcha to true else a
ReCaptcha widget will not be generated.
Configuring IP used for auth
----------------------------
The ReCaptcha API requires that the IP address of the user who solved the
captcha is sent. If the client is connecting through a proxy or load balancer,
it may be required to use the X-Forwarded-For (XFF) header instead of the origin
IP address. This can be configured as an option on the home server like so:
captcha_ip_origin_is_x_forwarded: true

View File

@ -1,12 +1,13 @@
Basic Usage
-----------
The Synapse web client needs to be hosted by a basic HTTP server.
You can use the Python simple HTTP server::
The web client should automatically run when running the home server. Alternatively, you can run
it stand-alone:
$ python -m SimpleHTTPServer
Then, open this URL in a WEB browser::
http://127.0.0.1:8000/

View File

@ -21,8 +21,8 @@ limitations under the License.
'use strict';
angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'eventStreamService'])
.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventStreamService', 'matrixPhoneService',
function($scope, $location, $rootScope, matrixService, mPresence, eventStreamService, matrixPhoneService) {
.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', '$timeout', '$animate', 'matrixService', 'mPresence', 'eventStreamService', 'matrixPhoneService',
function($scope, $location, $rootScope, $timeout, $animate, matrixService, mPresence, eventStreamService, matrixPhoneService) {
// Check current URL to avoid to display the logout button on the login page
$scope.location = $location.path();
@ -89,6 +89,23 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
$scope.user_id = matrixService.config().user_id;
};
$rootScope.$watch('currentCall', function(newVal, oldVal) {
if (!$rootScope.currentCall) return;
var roomMembers = angular.copy($rootScope.events.rooms[$rootScope.currentCall.room_id].members);
delete roomMembers[matrixService.config().user_id];
$rootScope.currentCall.user_id = Object.keys(roomMembers)[0];
matrixService.getProfile($rootScope.currentCall.user_id).then(
function(response) {
$rootScope.currentCall.userProfile = response.data;
},
function(error) {
$scope.feedback = "Can't load user profile";
}
);
});
$rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) {
console.trace("incoming call");
call.onError = $scope.onCallError;
@ -97,12 +114,19 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
});
$scope.answerCall = function() {
$scope.currentCall.answer();
$rootScope.currentCall.answer();
};
$scope.hangupCall = function() {
$scope.currentCall.hangup();
$scope.currentCall = undefined;
$rootScope.currentCall.hangup();
$timeout(function() {
var icon = angular.element('#callEndedIcon');
$animate.addClass(icon, 'callIconRotate');
$timeout(function(){
$rootScope.currentCall = undefined;
}, 2000);
}, 100);
};
$rootScope.onCallError = function(errStr) {
@ -110,5 +134,12 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
}
$rootScope.onCallHangup = function() {
$timeout(function() {
var icon = angular.element('#callEndedIcon');
$animate.addClass(icon, 'callIconRotate');
$timeout(function(){
$rootScope.currentCall = undefined;
}, 2000);
}, 100);
}
}]);

View File

@ -79,85 +79,4 @@ angular.module('matrixWebClient')
return function(text) {
return $sce.trustAsHtml(text);
};
}])
// Compute the room name according to information we have
.filter('roomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) {
return function(room_id) {
var roomName;
// 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) {
// Else, build the name from its users
var room = $rootScope.events.rooms[room_id];
if (room) {
var room_name_event = room["m.room.name"];
if (room_name_event) {
roomName = room_name_event.content.name;
}
else if (room.members) {
// 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;
}
}
}
}
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];
// Try to resolve his displayname in presence global data
if (userID in $rootScope.presence) {
roomName = $rootScope.presence[userID].content.displayname;
}
else {
roomName = userID;
}
}
}
}
}
}
if (undefined === roomName) {
// By default, use the room ID
roomName = room_id;
}
return roomName;
};
}]);

View File

@ -45,6 +45,48 @@ a:active { color: #000; }
#callBar {
float: left;
height: 32px;
margin: auto;
text-align: right;
line-height: 16px;
}
.callIcon {
margin-left: 4px;
margin-right: 4px;
margin-top: 8px;
-webkit-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
-moz-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
-o-transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
transition:all cubic-bezier(0.250, 0.460, 0.450, 0.940) 0.5s;
}
.callIconRotate {
-webkit-transform: rotateZ(45deg);
-moz-transform: rotateZ(45deg);
-ms-transform: rotateZ(45deg);
-o-transform: rotateZ(45deg);
transform: rotateZ(45deg);
}
#callPeerImage {
width: 32px;
height: 32px;
border: none;
float: left;
}
#callPeerNameAndState {
float: left;
margin-left: 4px;
}
#callState {
font-size: 60%;
}
#callPeerName {
font-size: 80%;
}
#headerContent {
@ -105,6 +147,10 @@ a:active { color: #000; }
text-align: center;
}
#recaptcha_area {
margin: auto
}
#loginForm {
text-align: left;
padding: 1em;
@ -252,11 +298,13 @@ a:active { color: #000; }
position: absolute;
top: 0px;
object-fit: cover;
width: 100%;
}
.userAvatar .userAvatarGradient {
position: absolute;
bottom: 20px;
width: 100%;
}
.userAvatar .userName {
@ -417,6 +465,13 @@ a:active { color: #000; }
text-align: left ! important;
}
.bubble .messagePending {
opacity: 0.3
}
.messageUnSent {
color: #F00;
}
#room-fullscreen-image {
position: absolute;
top: 0px;

View File

@ -42,6 +42,11 @@ angular.module('eventHandlerService', [])
rooms: {} // will contain roomId: { messages:[], members:{userid1: event} }
};
// 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) {
@ -66,12 +71,23 @@ angular.module('eventHandlerService', [])
$rootScope.$broadcast(ROOM_CREATE_EVENT, event, isLiveEvent);
};
var handleRoomAliases = function(event, isLiveEvent) {
matrixService.createRoomIdToAliasMapping(event.room_id, event.content.aliases[0]);
};
var handleMessage = function(event, isLiveEvent) {
initRoom(event.room_id);
if (isLiveEvent) {
if (event.user_id === matrixService.config().user_id &&
(event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) {
// assume we've already echoed it
// FIXME: track events by ID and ungrey the right message to show it's been delivered
}
else {
$rootScope.events.rooms[event.room_id].messages.push(event);
}
}
else {
$rootScope.events.rooms[event.room_id].messages.unshift(event);
}
@ -87,6 +103,14 @@ angular.module('eventHandlerService', [])
var handleRoomMember = function(event, isLiveEvent) {
initRoom(event.room_id);
// if the server is stupidly re-relaying a no-op join, discard it.
if (event.prev_content &&
event.content.membership === "join" &&
event.content.membership === event.prev_content.membership)
{
return;
}
// add membership changes as if they were a room message if something interesting changed
if (event.content.prev !== event.content.membership) {
if (isLiveEvent) {
@ -138,12 +162,29 @@ angular.module('eventHandlerService', [])
CALL_EVENT: CALL_EVENT,
NAME_EVENT: NAME_EVENT,
handleEvent: function(event, isLiveEvent) {
// FIXME: event duplication suppression is all broken as the code currently expect to handles
// events multiple times to get their side-effects...
/*
if (eventMap[event.event_id]) {
console.log("discarding duplicate event: " + JSON.stringify(event));
return;
}
else {
eventMap[event.event_id] = 1;
}
*/
if (event.type.indexOf('m.call.') === 0) {
handleCallEvent(event, isLiveEvent);
}
else {
switch(event.type) {
case "m.room.create":
handleRoomCreate(event, isLiveEvent);
break;
case "m.room.aliases":
handleRoomAliases(event, isLiveEvent);
break;
case "m.room.message":
handleMessage(event, isLiveEvent);
break;
@ -168,8 +209,6 @@ angular.module('eventHandlerService', [])
console.log(JSON.stringify(event, undefined, 4));
break;
}
if (event.type.indexOf('m.call.') === 0) {
handleCallEvent(event, isLiveEvent);
}
},

View File

@ -110,6 +110,7 @@ angular.module('eventStreamService', [])
var rooms = response.data.rooms;
for (var i = 0; i < rooms.length; ++i) {
var room = rooms[i];
// console.log("got room: " + room.room_id);
if ("state" in room) {
eventHandlerService.handleEvents(room.state, false);
}

View File

@ -41,6 +41,7 @@ angular.module('MatrixCall', [])
this.room_id = room_id;
this.call_id = "c" + new Date().getTime();
this.state = 'fledgling';
this.didConnect = false;
}
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
@ -52,6 +53,7 @@ angular.module('MatrixCall', [])
matrixPhoneService.callPlaced(this);
navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); });
self.state = 'wait_local_media';
this.direction = 'outbound';
};
MatrixCall.prototype.initWithInvite = function(msg) {
@ -64,6 +66,7 @@ angular.module('MatrixCall', [])
this.peerConn.onaddstream = function(s) { self.onAddStream(s); };
this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError);
this.state = 'ringing';
this.direction = 'inbound';
};
MatrixCall.prototype.answer = function() {
@ -204,10 +207,12 @@ angular.module('MatrixCall', [])
};
MatrixCall.prototype.onIceConnectionStateChanged = function() {
if (this.state == 'ended') return; // because ICE can still complete as we're ending the call
console.trace("Ice connection state changed to: "+this.peerConn.iceConnectionState);
// ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet
if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') {
this.state = 'connected';
this.didConnect = true;
$rootScope.$apply();
}
};

View File

@ -0,0 +1,135 @@
/*
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.
*/
'use strict';
angular.module('matrixFilter', [])
// Compute the room name according to information we have
.filter('mRoomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) {
return function(room_id) {
var roomName;
// 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) {
// Else, build the name from its users
var room = $rootScope.events.rooms[room_id];
if (room) {
var room_name_event = room["m.room.name"];
if (room_name_event) {
roomName = room_name_event.content.name;
}
else if (room.members) {
// 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;
}
}
}
}
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];
// Try to resolve his displayname in presence global data
if (userID in $rootScope.presence) {
roomName = $rootScope.presence[userID].content.displayname;
}
else {
roomName = userID;
}
}
}
}
}
}
if (undefined === roomName) {
// By default, use the room ID
roomName = room_id;
}
return roomName;
};
}])
// Compute the user display name in a room according to the data already downloaded
.filter('mUserDisplayName', ['$rootScope', function($rootScope) {
return function(user_id, room_id) {
var displayName;
// Try to find the user name among presence data
// Warning: that means we have received before a presence event for this
// user which cannot be guaranted.
// However, if we get the info by this way, we are sure this is the latest user display name
// See FIXME comment below
if (user_id in $rootScope.presence) {
displayName = $rootScope.presence[user_id].content.displayname;
}
// FIXME: Would like to use the display name as defined in room members of the room.
// But this information is the display name of the user when he has joined the room.
// It does not take into account user display name update
if (room_id) {
var room = $rootScope.events.rooms[room_id];
if (room && (user_id in room.members)) {
var member = room.members[user_id];
if (member.content.displayname) {
displayName = member.content.displayname;
}
}
}
if (undefined === displayName) {
// By default, use the user ID
displayName = user_id;
}
return displayName;
};
}]);

View File

@ -36,6 +36,9 @@ angular.module('matrixService', [])
*/
var config;
var roomIdToAlias = {};
var aliasToRoomId = {};
// Current version of permanent storage
var configVersion = 0;
var prefixPath = "/_matrix/client/api/v1";
@ -84,15 +87,32 @@ angular.module('matrixService', [])
prefix: prefixPath,
// Register an user
register: function(user_name, password, threepidCreds) {
register: function(user_name, password, threepidCreds, useCaptcha) {
// The REST path spec
var path = "/register";
return doRequest("POST", path, undefined, {
var data = {
user_id: user_name,
password: password,
threepidCreds: threepidCreds
});
};
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);
},
// Create a room
@ -168,18 +188,20 @@ angular.module('matrixService', [])
},
// Change the membership of an another user
setMembership: function(room_id, user_id, membershipValue) {
setMembership: function(room_id, user_id, membershipValue, reason) {
// The REST path spec
var path = "/rooms/$room_id/state/m.room.member/$user_id";
path = path.replace("$room_id", encodeURIComponent(room_id));
path = path.replace("$user_id", user_id);
return doRequest("PUT", path, undefined, {
membership: membershipValue
membership : membershipValue,
reason: reason
});
},
// Bans a user from from a room
// Bans a user from a room
ban: function(room_id, user_id, reason) {
var path = "/rooms/$room_id/ban";
path = path.replace("$room_id", encodeURIComponent(room_id));
@ -190,6 +212,19 @@ angular.module('matrixService', [])
});
},
// Unbans a user in a room
unban: function(room_id, user_id) {
// FIXME: To update when there will be homeserver API for unban
// For now, do an unban by resetting the user membership to "leave"
return this.setMembership(room_id, user_id, "leave");
},
// Kicks a user from a room
kick: function(room_id, user_id, reason) {
// Set the user membership to "leave" to kick him
return this.setMembership(room_id, user_id, "leave", reason);
},
// Retrieves the room ID corresponding to a room alias
resolveRoomAlias:function(room_alias) {
var path = "/_matrix/client/api/v1/directory/room/$room_alias";
@ -280,6 +315,11 @@ angular.module('matrixService', [])
return doRequest("GET", path);
},
// get a user's profile
getProfile: function(userId) {
return this.getProfileInfo(userId);
},
// get a display name for this user ID
getDisplayName: function(userId) {
return this.getProfileInfo(userId, "displayname");
@ -313,8 +353,8 @@ angular.module('matrixService', [])
},
getProfileInfo: function(userId, info_segment) {
var path = "/profile/$user_id/" + info_segment;
path = path.replace("$user_id", userId);
var path = "/profile/"+userId
if (info_segment) path += '/' + info_segment;
return doRequest("GET", path);
},
@ -485,18 +525,20 @@ angular.module('matrixService', [])
room_alias: undefined,
room_display_name: undefined
};
var alias = this.getRoomIdToAliasMapping(room.room_id);
if (alias) {
// use the existing alias from storage
result.room_alias = alias;
result.room_display_name = alias;
}
// XXX: this only lets us learn aliases from our local HS - we should
// make the client stop returning this if we can trust m.room.aliases state events
else if (room.aliases && room.aliases[0]) {
// save the mapping
// TODO: select the smarter alias from the array
this.createRoomIdToAliasMapping(room.room_id, room.aliases[0]);
result.room_display_name = room.aliases[0];
result.room_alias = room.aliases[0];
}
else if (room.membership === "invite" && "inviter" in room) {
result.room_display_name = room.inviter + "'s room";
@ -509,13 +551,22 @@ angular.module('matrixService', [])
},
createRoomIdToAliasMapping: function(roomId, alias) {
localStorage.setItem(MAPPING_PREFIX+roomId, alias);
roomIdToAlias[roomId] = alias;
aliasToRoomId[alias] = roomId;
// localStorage.setItem(MAPPING_PREFIX+roomId, alias);
},
getRoomIdToAliasMapping: function(roomId) {
return localStorage.getItem(MAPPING_PREFIX+roomId);
var alias = roomIdToAlias[roomId]; // was localStorage.getItem(MAPPING_PREFIX+roomId)
//console.log("looking for alias for " + roomId + "; found: " + alias);
return alias;
},
getAliasToRoomIdMapping: function(alias) {
var roomId = aliasToRoomId[alias];
//console.log("looking for roomId for " + alias + "; found: " + roomId);
return roomId;
},
/****** Power levels management ******/

View File

@ -26,7 +26,7 @@
<div class="public_rooms" ng-repeat="room in public_rooms">
<div>
<a href="#/room/{{ room.room_alias ? room.room_alias : room.room_id }}" >{{ room.room_alias }}</a>
<a href="#/room/{{ room.room_alias ? room.room_alias : room.room_id }}" >{{ room.room_display_name }}</a>
</div>
</div>
<br/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

BIN
webclient/img/red_phone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

View File

@ -11,11 +11,13 @@
<meta name="viewport" content="width=device-width">
<script type='text/javascript' src='js/jquery-1.8.3.min.js'></script>
<script type="text/javascript" src="https://www.google.com/recaptcha/api/js/recaptcha_ajax.js"></script>
<script src="js/angular.min.js"></script>
<script src="js/angular-route.min.js"></script>
<script src="js/angular-sanitize.min.js"></script>
<script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
<script src="app.js"></script>
<script src="config.js"></script>
<script src="app-controller.js"></script>
<script src="app-directive.js"></script>
<script src="app-filter.js"></script>
@ -29,6 +31,7 @@
<script src="settings/settings-controller.js"></script>
<script src="user/user-controller.js"></script>
<script src="components/matrix/matrix-service.js"></script>
<script src="components/matrix/matrix-filter.js"></script>
<script src="components/matrix/matrix-call.js"></script>
<script src="components/matrix/matrix-phone-service.js"></script>
<script src="components/matrix/event-stream-service.js"></script>
@ -44,18 +47,29 @@
<div id="header">
<!-- Do not show buttons on the login page -->
<div id="headerContent" ng-hide="'/login' == location || '/register' == location">
<div id="callBar">
<div ng-show="currentCall.state == 'ringing'">
Incoming call from {{ currentCall.user_id }}
<button ng-click="answerCall()">Answer</button>
<button ng-click="hangupCall()">Reject</button>
</div>
<button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing' && currentCall.state != 'ended' && currentCall.state != 'fledgling'">Hang up</button>
<div id="callBar" ng-show="currentCall">
<img id="callPeerImage" ng-show="currentCall.userProfile.avatar_url" ngSrc="{{ currentCall.userProfile.avatar_url }}" />
<img class="callIcon" src="img/green_phone.png" ng-show="currentCall.state != 'ended'" />
<img class="callIcon" id="callEndedIcon" src="img/red_phone.png" ng-show="currentCall.state == 'ended'" />
<div id="callPeerNameAndState">
<span id="callPeerName">{{ currentCall.userProfile.displayname }}</span>
<br />
<span id="callState">
<span ng-show="currentCall.state == 'invite_sent'">Calling...</span>
<span ng-show="currentCall.state == 'connecting'">Call Connecting...</span>
<span ng-show="currentCall.state == 'connected'">Call Connected</span>
<span ng-show="currentCall.state == 'ended'">Call Ended</span>
<span style="display: none; ">{{ currentCall.state }}</span>
<span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound'">Call Rejected</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>
<span ng-show="currentCall.state == 'wait_local_media'">Waiting for media permission...</span>
</span>
</div>
<span ng-show="currentCall.state == 'ringing'">
<button ng-click="answerCall()">Answer</button>
<button ng-click="hangupCall()">Reject</button>
</span>
<button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing' && currentCall.state != 'ended' && currentCall.state != 'fledgling'">Hang up</button>
</div>
<a href id="headerUserId" ng-click='goToUserPage(user_id)'>{{ user_id }}</a>
&nbsp;

View File

@ -39,8 +39,8 @@
Only http://matrix.org:8090 currently exists.</div>
<br/>
<br/>
<a href="#/register" style="padding-right: 3em">Create account</a>
<a href="#/reset_password">Forgotten password?</a>
<a href="#/register" style="padding-right: 0em">Create account</a>
<a href="#/reset_password" style="display: none; ">Forgotten password?</a>
</div>
</div>
</form>

View File

@ -19,6 +19,12 @@ angular.module('RegisterController', ['matrixService'])
function($scope, $rootScope, $location, matrixService, eventStreamService) {
'use strict';
var config = window.webClientConfig;
var useCaptcha = true;
if (config !== undefined) {
useCaptcha = config.useCaptcha;
}
// FIXME: factor out duplication with login-controller.js
// Assume that this is hosted on the home server, in which case the URL
@ -87,9 +93,12 @@ angular.module('RegisterController', ['matrixService'])
};
$scope.registerWithMxidAndPassword = function(mxid, password, threepidCreds) {
matrixService.register(mxid, password, threepidCreds).then(
matrixService.register(mxid, password, threepidCreds, useCaptcha).then(
function(response) {
$scope.feedback = "Success";
if (useCaptcha) {
Recaptcha.destroy();
}
// Update the current config
var config = matrixService.config();
angular.extend(config, {
@ -116,11 +125,21 @@ angular.module('RegisterController', ['matrixService'])
},
function(error) {
console.trace("Registration error: "+error);
if (useCaptcha) {
Recaptcha.reload();
}
if (error.data) {
if (error.data.errcode === "M_USER_IN_USE") {
$scope.feedback = "Username already taken.";
$scope.reenter_username = true;
}
else if (error.data.errcode == "M_CAPTCHA_INVALID") {
$scope.feedback = "Failed captcha.";
}
else if (error.data.errcode == "M_CAPTCHA_NEEDED") {
$scope.feedback = "Captcha is required on this home " +
"server.";
}
}
else if (error.status === 0) {
$scope.feedback = "Unable to talk to the server.";
@ -143,5 +162,32 @@ angular.module('RegisterController', ['matrixService'])
);
};
var setupCaptcha = function() {
console.log("Setting up ReCaptcha")
var config = window.webClientConfig;
var public_key = undefined;
if (config === undefined) {
console.error("Couldn't find webClientConfig. Cannot get public key for captcha.");
}
else {
public_key = webClientConfig.recaptcha_public_key;
if (public_key === undefined) {
console.error("No public key defined for captcha!")
}
}
Recaptcha.create(public_key,
"regcaptcha",
{
theme: "red",
callback: Recaptcha.focus_response_field
});
};
$scope.init = function() {
if (useCaptcha) {
setupCaptcha();
}
};
}]);

View File

@ -12,7 +12,6 @@
<div style="text-align: center">
<br/>
<input ng-show="!wait_3pid_code" id="email" size="32" type="text" ng-focus="true" ng-model="account.email" placeholder="Email address (optional)"/>
<div ng-show="!wait_3pid_code" class="smallPrint">Specifying an email address lets other users find you on Matrix more easily,<br/>
and will give you a way to reset your password in the future</div>
@ -27,6 +26,9 @@
<br ng-show="!wait_3pid_code" />
<br ng-show="!wait_3pid_code" />
<div id="regcaptcha" ng-init="init()" />
<button ng-show="!wait_3pid_code" ng-click="register()" ng-disabled="!account.desired_user_id || !account.homeserver || !account.pwd1 || !account.pwd2 || account.pwd1 !== account.pwd2">Sign up</button>
<div ng-show="wait_3pid_code">

View File

@ -16,7 +16,7 @@
'use strict';
angular.module('RecentsController', ['matrixService', 'eventHandlerService'])
angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService'])
.controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService',
function($scope, matrixService, eventHandlerService) {
$scope.rooms = {};
@ -28,13 +28,8 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService'])
var listenToEventStream = function() {
// Refresh the list on matrix invitation and message event
$scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
var config = matrixService.config();
if (isLive && event.state_key === config.user_id && event.content.membership === "invite") {
console.log("Invited to room " + event.room_id);
// FIXME push membership to top level key to match /im/sync
event.membership = event.content.membership;
$scope.rooms[event.room_id] = event;
if (isLive) {
$scope.rooms[event.room_id].lastMsg = event;
}
});
$scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {

View File

@ -6,7 +6,7 @@
ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
<tr>
<td class="recentsRoomName">
{{ room.room_id | roomName }}
{{ room.room_id | mRoomName }}
</td>
<td class="recentsRoomSummaryTS">
{{ (room.lastMsg.ts) | date:'MMM d HH:mm' }}
@ -17,26 +17,47 @@
<td colspan="2" class="recentsRoomSummary">
<div ng-show="room.membership === 'invite'">
{{ room.inviter }} invited you
{{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you
</div>
<div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type">
<div ng-switch-when="m.room.member">
{{ room.lastMsg.user_id }}
{{ {"join": "joined", "leave": "left", "invite": "invited", "ban": "banned"}[msg.content.membership] }}
{{ (msg.content.membership === "invite" || msg.content.membership === "ban") ? (msg.state_key || '') : '' }}
<span ng-if="'join' === room.lastMsg.content.membership">
{{ room.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>
<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>
<span ng-if="'join' === room.lastMsg.content.prev && room.lastMsg.content.reason">
: {{ room.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>
</span>
</div>
<div ng-switch-when="m.room.message">
<div ng-switch="room.lastMsg.content.msgtype">
<div ng-switch-when="m.text">
{{ room.lastMsg.user_id }} :
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} :
<span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'">
</span>
</div>
<div ng-switch-when="m.image">
{{ room.lastMsg.user_id }} sent an image
{{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
</div>
<div ng-switch-when="m.emote">
@ -51,7 +72,7 @@
</div>
<div ng-switch-default>
<div ng-if="room.lastMsg.type.indexOf('m.call.') == 0">
<div ng-if="room.lastMsg.type.indexOf('m.call.') === 0">
Call
</div>
</div>

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
angular.module('RoomController', ['ngSanitize', 'mFileInput'])
angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
.controller('RoomController', ['$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall',
function($scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) {
'use strict';
@ -32,9 +32,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
first_pagination: true, // this is toggled off when the first pagination is done
can_paginate: true, // this is toggled off 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
// FIXME: sending has been disabled, as surely messages should be sent in the background rather than locking the UI synchronously --Matthew
sending: false // true when a message is being sent. It helps to disable the UI when a process is running
stream_failure: undefined // the response when the stream fails
};
$scope.members = {};
$scope.autoCompleting = false;
@ -44,16 +42,23 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
$scope.imageURLToSend = "";
$scope.userIDToInvite = "";
var scrollToBottom = function() {
var scrollToBottom = function(force) {
console.log("Scrolling to bottom");
$timeout(function() {
// Do not autoscroll to the bottom to display the new event if the user is not at the bottom.
// Exception: in case where the event is from the user, we want to force scroll to the bottom
var objDiv = document.getElementById("messageTableWrapper");
if ((objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) {
$timeout(function() {
objDiv.scrollTop = objDiv.scrollHeight;
}, 0);
}
};
$scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
if (isLive && event.room_id === $scope.room_id) {
scrollToBottom();
if (window.Notification) {
@ -76,6 +81,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
$scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
if (isLive) {
scrollToBottom();
updateMemberList(event);
}
});
@ -169,16 +175,18 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
var updateMemberList = function(chunk) {
if (chunk.room_id != $scope.room_id) return;
// Ignore banned and kicked (leave) people
if ("ban" === chunk.membership || "leave" === chunk.membership) {
return;
}
// set target_user_id to keep things clear
var target_user_id = chunk.state_key;
var isNewMember = !(target_user_id in $scope.members);
if (isNewMember) {
// Ignore banned and kicked (leave) people
if ("ban" === chunk.membership || "leave" === chunk.membership) {
return;
}
// FIXME: why are we copying these fields around inside chunk?
if ("presence" in chunk.content) {
chunk.presence = chunk.content.presence;
@ -202,6 +210,13 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
}
else {
// selectively update membership and presence else it will nuke the picture and displayname too :/
// Remove banned and kicked (leave) people
if ("ban" === chunk.membership || "leave" === chunk.membership) {
delete $scope.members[target_user_id];
return;
}
var member = $scope.members[target_user_id];
member.membership = chunk.content.membership;
if ("presence" in chunk.content) {
@ -256,7 +271,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
normaliseMembersPowerLevels();
}
}
};
// Normalise users power levels so that the user with the higher power level
// will have a bar covering 100% of the width of his avatar
@ -277,104 +292,225 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
member.powerLevelNorm = (member.powerLevel * 100) / maxPowerLevel;
}
}
}
};
$scope.send = function() {
if ($scope.textInput === "") {
return;
}
$scope.state.sending = true;
scrollToBottom(true);
var promise;
var cmd;
var args;
var echo = false;
// Check for IRC style commands first
if ($scope.textInput.indexOf("/") === 0) {
var args = $scope.textInput.split(' ');
var cmd = args[0];
var line = $scope.textInput;
// trim any trailing whitespace, as it can confuse the parser for IRC-style commands
line = line.replace(/\s+$/, "");
if (line[0] === "/" && line[1] !== "/") {
var bits = line.match(/^(\S+?)( +(.*))?$/);
cmd = bits[1];
args = bits[3];
console.log("cmd: " + cmd + ", args: " + args);
switch (cmd) {
case "/me":
var emoteMsg = args.slice(1).join(' ');
promise = matrixService.sendEmoteMessage($scope.room_id, emoteMsg);
promise = matrixService.sendEmoteMessage($scope.room_id, args);
echo = true;
break;
case "/nick":
// Change user display name
if (2 === args.length) {
promise = matrixService.setDisplayName(args[1]);
if (args) {
promise = matrixService.setDisplayName(args);
}
else {
$scope.feedback = "Usage: /nick <display_name>";
}
break;
case "/join":
// Join a room
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
var room_alias = matches[1];
if (room_alias.indexOf(':') == -1) {
// FIXME: actually track the :domain style name of our homeserver
// with or without port as is appropriate and append it at this point
}
var room_id = matrixService.getAliasToRoomIdMapping(room_alias);
console.log("joining " + room_alias + " id=" + room_id);
if ($rootScope.events.rooms[room_id]) {
// don't send a join event for a room you're already in.
$location.url("room/" + room_alias);
}
else {
promise = matrixService.joinAlias(room_alias).then(
function(response) {
$location.url("room/" + room_alias);
},
function(error) {
$scope.feedback = "Can't join room: " + JSON.stringify(error.data);
}
);
}
}
}
else {
$scope.feedback = "Usage: /join <room_alias>";
}
break;
case "/kick":
// Kick a user from the room
if (2 === args.length) {
var user_id = args[1];
// Kick a user from the room with an optional reason
if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
promise = matrixService.kick($scope.room_id, matches[1], matches[3]);
}
}
// Set his state in the room as leave
promise = matrixService.setMembership($scope.room_id, user_id, "leave");
if (!promise) {
$scope.feedback = "Usage: /kick <userId> [<reason>]";
}
break;
case "/ban":
// Ban a user from the room
if (2 <= args.length) {
// TODO: The user may have entered the display name
// Need display name -> user_id resolution. Pb: how to manage user with same display names?
var user_id = args[1];
// Does the user provide a reason?
if (3 <= args.length) {
var reason = args.slice(2).join(' ');
// Ban a user from the room with an optional reason
if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
promise = matrixService.ban($scope.room_id, matches[1], matches[3]);
}
promise = matrixService.ban($scope.room_id, user_id, reason);
}
if (!promise) {
$scope.feedback = "Usage: /ban <userId> [<reason>]";
}
break;
case "/unban":
// Unban a user from the room
if (2 === args.length) {
var user_id = args[1];
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
promise = matrixService.unban($scope.room_id, matches[1]);
}
}
// Reset the user membership to leave to unban him
promise = matrixService.setMembership($scope.room_id, user_id, "leave");
if (!promise) {
$scope.feedback = "Usage: /unban <userId>";
}
break;
case "/op":
// Define the power level of a user
if (3 === args.length) {
var user_id = args[1];
var powerLevel = parseInt(args[2]);
if (args) {
var matches = args.match(/^(\S+?)( +(\d+))?$/);
var powerLevel = 50; // default power level for op
if (matches) {
var user_id = matches[1];
if (matches.length === 4) {
powerLevel = parseInt(matches[3]);
}
if (powerLevel !== NaN) {
promise = matrixService.setUserPowerLevel($scope.room_id, user_id, powerLevel);
}
}
}
if (!promise) {
$scope.feedback = "Usage: /op <userId> [<power level>]";
}
break;
case "/deop":
// Reset the power level of a user
if (2 === args.length) {
var user_id = args[1];
promise = matrixService.setUserPowerLevel($scope.room_id, user_id, undefined);
}
break;
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
promise = matrixService.setUserPowerLevel($scope.room_id, args, undefined);
}
}
if (!promise) {
// Send the text message
promise = matrixService.sendTextMessage($scope.room_id, $scope.textInput);
$scope.feedback = "Usage: /deop <userId>";
}
break;
default:
$scope.feedback = ("Unrecognised IRC-style command: " + cmd);
break;
}
}
// By default send this as a message unless it's an IRC-style command
if (!promise && !cmd) {
// Make the request
promise = matrixService.sendTextMessage($scope.room_id, line);
echo = true;
}
if (echo) {
// Echo the message to the room
// To do so, create a minimalist fake text message event and add it to the in-memory list of room messages
var echoMessage = {
content: {
body: (cmd === "/me" ? args : line),
hsob_ts: new Date().getTime(), // fake a timestamp
msgtype: (cmd === "/me" ? "m.emote" : "m.text"),
},
room_id: $scope.room_id,
type: "m.room.message",
user_id: $scope.state.user_id,
// FIXME: re-enable echo_msg_state when we have a nice way to turn the field off again
// echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML
};
$scope.textInput = "";
$rootScope.events.rooms[$scope.room_id].messages.push(echoMessage);
scrollToBottom();
}
if (promise) {
promise.then(
function() {
console.log("Request successfully sent");
$scope.textInput = "";
$scope.state.sending = false;
/*
if (echoMessage) {
// Remove the fake echo message from the room messages
// It will be replaced by the one acknowledged by the server
// ...except this causes a nasty flicker. So don't swap messages for now. --matthew
// var index = $rootScope.events.rooms[$scope.room_id].messages.indexOf(echoMessage);
// if (index > -1) {
// $rootScope.events.rooms[$scope.room_id].messages.splice(index, 1);
// }
}
else {
$scope.textInput = "";
}
*/
},
function(error) {
$scope.feedback = "Request failed: " + error.data.error;
$scope.state.sending = false;
if (echoMessage) {
// Mark the message as unsent for the rest of the page life
echoMessage.content.hsob_ts = "Unsent";
echoMessage.echo_msg_state = "messageUnSent";
}
});
}
};
$scope.onInit = function() {
@ -531,25 +667,20 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
};
$scope.sendImage = function(url, body) {
$scope.state.sending = true;
scrollToBottom(true);
matrixService.sendImageMessage($scope.room_id, url, body).then(
function() {
console.log("Image sent");
$scope.state.sending = false;
},
function(error) {
$scope.feedback = "Failed to send image: " + error.data.error;
$scope.state.sending = false;
});
};
$scope.imageFileToSend;
$scope.$watch("imageFileToSend", function(newValue, oldValue) {
if ($scope.imageFileToSend) {
$scope.state.sending = true;
// Upload this image with its thumbnail to Internet
mFileUpload.uploadImageAndThumbnail($scope.imageFileToSend, THUMBNAIL_SIZE).then(
function(imageMessage) {
@ -557,16 +688,13 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
matrixService.sendMessage($scope.room_id, undefined, imageMessage).then(
function() {
console.log("Image message sent");
$scope.state.sending = false;
},
function(error) {
$scope.feedback = "Failed to send image message: " + error.data.error;
$scope.state.sending = false;
});
},
function(error) {
$scope.feedback = "Can't upload image";
$scope.state.sending = false;
}
);
}
@ -582,6 +710,6 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
call.onHangup = $rootScope.onCallHangup;
call.placeCall();
$rootScope.currentCall = call;
}
};
}]);

View File

@ -48,6 +48,9 @@ angular.module('RoomController')
var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text);
if (targetIndex === 0) {
element[0].value = text;
// Force angular to wake up and update the input ng-model by firing up input event
angular.element(element[0]).triggerHandler('input');
}
else if (search && search[1]) {
// console.log("search found: " + search);
@ -82,6 +85,9 @@ angular.module('RoomController')
element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion);
// cancel blink
element[0].className = "";
// Force angular to wake up and update the input ng-model by firing up input event
angular.element(element[0]).triggerHandler('input');
}
else {
// console.log("wrapped!");
@ -91,6 +97,9 @@ angular.module('RoomController')
}, 150);
element[0].value = text;
scope.tabCompleteIndex = 0;
// Force angular to wake up and update the input ng-model by firing up input event
angular.element(element[0]).triggerHandler('input');
}
}
else {

View File

@ -3,7 +3,7 @@
<div id="roomHeader">
<a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
<div id="roomName">
{{ room_id | roomName }}
{{ room_id | mRoomName }}
</div>
</div>
@ -40,7 +40,10 @@
ng-class="(events.rooms[room_id].messages[$index + 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
<td class="leftBlock">
<div class="sender" ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id">{{ members[msg.user_id].displayname || msg.user_id }}</div>
<div class="timestamp">{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}</div>
<div class="timestamp"
ng-class="msg.echo_msg_state">
{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}
</div>
</td>
<td class="avatar">
<img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
@ -59,15 +62,24 @@
{{ members[msg.user_id].displayname || msg.user_id }}
{{ {"join": "kicked", "ban": "unbanned"}[msg.content.prev] }}
{{ members[msg.state_key].displayname || msg.state_key }}
<span ng-if="'join' === msg.content.prev && msg.content.reason">
: {{ msg.content.reason }}
</span>
</span>
</span>
<span ng-if="'invite' === msg.content.membership || 'ban' === msg.content.membership">
{{ members[msg.user_id].displayname || msg.user_id }}
{{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
{{ members[msg.state_key].displayname || msg.state_key }}
<span ng-if="'ban' === msg.content.prev && msg.content.reason">
: {{ msg.content.reason }}
</span>
</span>
<span ng-show='msg.content.msgtype === "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/>
<span ng-show='msg.content.msgtype === "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
<span ng-show='msg.content.msgtype === "m.text"'
ng-class="msg.echo_msg_state"
ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
<div ng-show='msg.content.msgtype === "m.image"'>
<div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}">
<img class="image" ng-src="{{ msg.content.url }}"/>

View File

@ -19,6 +19,17 @@ limitations under the License.
angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInput'])
.controller('SettingsController', ['$scope', 'matrixService', 'mFileUpload',
function($scope, matrixService, mFileUpload) {
// XXX: duplicated from register
var generateClientSecret = function() {
var ret = "";
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (var i = 0; i < 32; i++) {
ret += chars.charAt(Math.floor(Math.random() * chars.length));
}
return ret;
};
$scope.config = matrixService.config();
$scope.profile = {
@ -106,16 +117,22 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
$scope.linkedEmails = {
linkNewEmail: "", // the email entry box
emailBeingAuthed: undefined, // to populate verification text
authTokenId: undefined, // the token id from the IS
authSid: undefined, // the token id from the IS
emailCode: "", // the code entry box
linkedEmailList: matrixService.config().emailList // linked email list
};
$scope.linkEmail = function(email) {
matrixService.linkEmail(email).then(
if (email != $scope.linkedEmails.emailBeingAuthed) {
$scope.linkedEmails.emailBeingAuthed = email;
$scope.clientSecret = generateClientSecret();
$scope.sendAttempt = 0;
}
$scope.sendAttempt++;
matrixService.linkEmail(email, $scope.clientSecret, $scope.sendAttempt).then(
function(response) {
if (response.data.success === true) {
$scope.linkedEmails.authTokenId = response.data.tokenId;
$scope.linkedEmails.authSid = response.data.sid;
$scope.emailFeedback = "You have been sent an email.";
$scope.linkedEmails.emailBeingAuthed = email;
}
@ -129,24 +146,30 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
);
};
$scope.submitEmailCode = function(code) {
var tokenId = $scope.linkedEmails.authTokenId;
$scope.submitEmailCode = function() {
var tokenId = $scope.linkedEmails.authSid;
if (tokenId === undefined) {
$scope.emailFeedback = "You have not requested a code with this email.";
return;
}
matrixService.authEmail(matrixService.config().user_id, tokenId, code).then(
matrixService.authEmail($scope.clientSecret, $scope.linkedEmails.authSid, $scope.linkedEmails.emailCode).then(
function(response) {
if ("success" in response.data && response.data.success === false) {
if ("errcode" in response.data) {
$scope.emailFeedback = "Failed to authenticate email.";
return;
}
matrixService.bindEmail(matrixService.config().user_id, tokenId, $scope.clientSecret).then(
function(response) {
if ('errcode' in response.data) {
$scope.emailFeedback = "Failed to link email.";
return;
}
var config = matrixService.config();
var emailList = {};
if ("emailList" in config) {
emailList = config.emailList;
}
emailList[response.address] = response;
emailList[$scope.linkedEmails.emailBeingAuthed] = response;
// save the new email list
config.emailList = emailList;
matrixService.setConfig(config);
@ -157,6 +180,10 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
$scope.linkedEmails.linkedEmailList = emailList;
$scope.linkedEmails.linkNewEmail = "";
$scope.linkedEmails.emailCode = "";
}, function(reason) {
$scope.emailFeedback = "Failed to link email: " + reason;
}
);
},
function(reason) {
$scope.emailFeedback = "Failed to auth email: " + reason;

View File

@ -23,8 +23,8 @@
</div>
<br/>
<h3 style="display: none; ">Linked emails</h3>
<div class="section" style="display: none; ">
<h3>Linked emails</h3>
<div class="section">
<form>
<input size="40" ng-model="linkedEmails.linkNewEmail" ng-enter="linkEmail(linkedEmails.linkNewEmail)" />
<button ng-disabled="!linkedEmails.linkNewEmail" ng-click="linkEmail(linkedEmails.linkNewEmail)">
@ -81,7 +81,7 @@
<ul>
<li>/nick &lt;display_name&gt;: change your display name</li>
<li>/me &lt;action&gt;: send the action you are doing. /me will be replaced by your display name</li>
<li>/kick &lt;user_id&gt;: kick the user</li>
<li>/kick &lt;user_id&gt; [&lt;reason&gt;]: kick the user</li>
<li>/ban &lt;user_id&gt; [&lt;reason&gt;]: ban the user</li>
<li>/unban &lt;user_id&gt;: unban the user</li>
<li>/op &lt;user_id&gt; &lt;power_level&gt;: set user power level</li>