mirror of
https://mau.dev/maunium/synapse.git
synced 2024-10-01 01:36:05 -04:00
Merge branch 'release-v0.2.2' of github.com:matrix-org/synapse
This commit is contained in:
commit
d12feed623
3
.gitignore
vendored
3
.gitignore
vendored
@ -18,9 +18,12 @@ htmlcov
|
||||
demo/*.db
|
||||
demo/*.log
|
||||
demo/*.pid
|
||||
demo/etc
|
||||
|
||||
graph/*.svg
|
||||
graph/*.png
|
||||
graph/*.dot
|
||||
|
||||
webclient/config.js
|
||||
|
||||
uploads
|
||||
|
20
CHANGES.rst
20
CHANGES.rst
@ -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)
|
||||
=====================================
|
||||
|
||||
|
@ -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,19 +1153,104 @@ 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.
|
||||
|
||||
.. 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?
|
||||
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.
|
||||
|
||||
|
||||
Profiles
|
||||
========
|
||||
.. NOTE::
|
||||
@ -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
|
||||
|
||||
|
@ -16,4 +16,4 @@
|
||||
""" This is a reference implementation of a synapse home server.
|
||||
"""
|
||||
|
||||
__version__ = "0.2.1"
|
||||
__version__ = "0.2.2"
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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.
|
||||
"""
|
||||
|
@ -157,7 +157,12 @@ class SynapseEvent(JsonEncodedObject):
|
||||
|
||||
|
||||
class SynapseStateEvent(SynapseEvent):
|
||||
def __init__(self, **kwargs):
|
||||
|
||||
valid_keys = SynapseEvent.valid_keys + [
|
||||
"prev_content",
|
||||
]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
if "state_key" not in kwargs:
|
||||
kwargs["state_key"] = ""
|
||||
super(SynapseStateEvent, self).__init__(**kwargs)
|
||||
|
@ -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())
|
||||
|
@ -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 {}
|
||||
|
@ -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
42
synapse/config/captcha.py
Normal 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."
|
||||
)
|
@ -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__':
|
||||
|
@ -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=[]):
|
||||
|
@ -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,12 +57,37 @@ class DirectoryHandler(BaseHandler):
|
||||
if not servers:
|
||||
raise SynapseError(400, "Failed to get server list")
|
||||
|
||||
yield self.store.create_room_alias_association(
|
||||
room_alias,
|
||||
room_id,
|
||||
servers
|
||||
|
||||
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):
|
||||
room_id = None
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
@ -188,6 +188,32 @@ class TwistedHttpClient(HttpClient):
|
||||
body = yield readBody(response)
|
||||
|
||||
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"",
|
||||
|
@ -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, {}))
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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, {})
|
||||
|
@ -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
|
||||
@ -50,12 +50,44 @@ class RegisterRestServlet(RestServlet):
|
||||
threepidCreds = None
|
||||
if 'threepidCreds' in register_json:
|
||||
threepidCreds = register_json['threepidCreds']
|
||||
|
||||
captcha = {}
|
||||
if self.hs.config.enable_registration_captcha:
|
||||
challenge = None
|
||||
user_response = None
|
||||
try:
|
||||
captcha_type = register_json["captcha"]["type"]
|
||||
if captcha_type != "m.login.recaptcha":
|
||||
raise SynapseError(400, "Sorry, only m.login.recaptcha " +
|
||||
"requests are supported.")
|
||||
challenge = register_json["captcha"]["challenge"]
|
||||
user_response = register_json["captcha"]["response"]
|
||||
except KeyError:
|
||||
raise SynapseError(400, "Captcha response is required",
|
||||
errcode=Codes.CAPTCHA_NEEDED)
|
||||
|
||||
# TODO determine the source IP : May be an X-Forwarding-For header depending on config
|
||||
ip_addr = request.getClientIP()
|
||||
if self.hs.config.captcha_ip_origin_is_x_forwarded:
|
||||
# use the header
|
||||
if request.requestHeaders.hasHeader("X-Forwarded-For"):
|
||||
ip_addr = request.requestHeaders.getRawHeaders(
|
||||
"X-Forwarded-For")[0]
|
||||
|
||||
captcha = {
|
||||
"ip": ip_addr,
|
||||
"private_key": self.hs.config.recaptcha_private_key,
|
||||
"challenge": challenge,
|
||||
"response": user_response
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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",
|
||||
)
|
||||
|
@ -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
|
||||
|
27
synapse/storage/schema/delta/v3.sql
Normal file
27
synapse/storage/schema/delta/v3.sql
Normal 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;
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
46
webclient/CAPTCHA_SETUP
Normal 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
|
||||
|
||||
|
||||
|
@ -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/
|
||||
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}]);
|
||||
|
@ -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;
|
||||
};
|
||||
}]);
|
||||
}]);
|
@ -44,7 +44,49 @@ a:active { color: #000; }
|
||||
}
|
||||
|
||||
#callBar {
|
||||
float: left;
|
||||
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;
|
||||
@ -251,12 +297,14 @@ a:active { color: #000; }
|
||||
.userAvatar .userAvatarImage {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
object-fit: cover;
|
||||
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;
|
||||
|
@ -41,6 +41,11 @@ angular.module('eventHandlerService', [])
|
||||
$rootScope.events = {
|
||||
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 = {};
|
||||
|
||||
@ -66,11 +71,22 @@ 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) {
|
||||
$rootScope.events.rooms[event.room_id].messages.push(event);
|
||||
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) {
|
||||
@ -137,40 +161,55 @@ angular.module('eventHandlerService', [])
|
||||
POWERLEVEL_EVENT: POWERLEVEL_EVENT,
|
||||
CALL_EVENT: CALL_EVENT,
|
||||
NAME_EVENT: NAME_EVENT,
|
||||
|
||||
|
||||
handleEvent: function(event, isLiveEvent) {
|
||||
switch(event.type) {
|
||||
case "m.room.create":
|
||||
handleRoomCreate(event, isLiveEvent);
|
||||
break;
|
||||
case "m.room.message":
|
||||
handleMessage(event, isLiveEvent);
|
||||
break;
|
||||
case "m.room.member":
|
||||
handleRoomMember(event, isLiveEvent);
|
||||
break;
|
||||
case "m.presence":
|
||||
handlePresence(event, isLiveEvent);
|
||||
break;
|
||||
case 'm.room.ops_levels':
|
||||
case 'm.room.send_event_level':
|
||||
case 'm.room.add_state_level':
|
||||
case 'm.room.join_rules':
|
||||
case 'm.room.power_levels':
|
||||
handlePowerLevels(event, isLiveEvent);
|
||||
break;
|
||||
case 'm.room.name':
|
||||
handleRoomName(event, isLiveEvent);
|
||||
break;
|
||||
default:
|
||||
console.log("Unable to handle event type " + event.type);
|
||||
console.log(JSON.stringify(event, undefined, 4));
|
||||
break;
|
||||
// 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;
|
||||
case "m.room.member":
|
||||
handleRoomMember(event, isLiveEvent);
|
||||
break;
|
||||
case "m.presence":
|
||||
handlePresence(event, isLiveEvent);
|
||||
break;
|
||||
case 'm.room.ops_levels':
|
||||
case 'm.room.send_event_level':
|
||||
case 'm.room.add_state_level':
|
||||
case 'm.room.join_rules':
|
||||
case 'm.room.power_levels':
|
||||
handlePowerLevels(event, isLiveEvent);
|
||||
break;
|
||||
case 'm.room.name':
|
||||
handleRoomName(event, isLiveEvent);
|
||||
break;
|
||||
default:
|
||||
console.log("Unable to handle event type " + event.type);
|
||||
console.log(JSON.stringify(event, undefined, 4));
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// isLiveEvents determines whether notifications should be shown, whether
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
135
webclient/components/matrix/matrix-filter.js
Normal file
135
webclient/components/matrix/matrix-filter.js
Normal 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;
|
||||
};
|
||||
}]);
|
@ -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));
|
||||
@ -189,7 +211,20 @@ angular.module('matrixService', [])
|
||||
reason: reason
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
// 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 ******/
|
||||
|
||||
|
@ -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/>
|
||||
|
BIN
webclient/img/green_phone.png
Normal file
BIN
webclient/img/green_phone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 434 B |
BIN
webclient/img/red_phone.png
Normal file
BIN
webclient/img/red_phone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 378 B |
@ -10,12 +10,14 @@
|
||||
|
||||
<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='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 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' && !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>
|
||||
<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>
|
||||
</div>
|
||||
<a href id="headerUserId" ng-click='goToUserPage(user_id)'>{{ user_id }}</a>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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.";
|
||||
@ -142,6 +161,33 @@ 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();
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
|
||||
|
@ -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>
|
||||
@ -26,7 +25,10 @@
|
||||
<input ng-show="!wait_3pid_code" id="displayName" size="32" type="text" ng-model="account.displayName" placeholder="Display name (e.g. Bob Obson)"/>
|
||||
<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">
|
||||
|
@ -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) {
|
||||
|
@ -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' }}
|
||||
@ -16,27 +16,48 @@
|
||||
<tr>
|
||||
<td colspan="2" class="recentsRoomSummary">
|
||||
|
||||
<div ng-show="room.membership === 'invite'" >
|
||||
{{ room.inviter }} invited you
|
||||
<div ng-show="room.membership === 'invite'">
|
||||
{{ 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 || '') : '' }}
|
||||
|
||||
<div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type">
|
||||
<div ng-switch-when="m.room.member">
|
||||
<span ng-if="'join' === room.lastMsg.content.membership">
|
||||
{{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined
|
||||
</span>
|
||||
<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>
|
||||
|
@ -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,18 +42,25 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
|
||||
$scope.imageURLToSend = "";
|
||||
$scope.userIDToInvite = "";
|
||||
|
||||
var scrollToBottom = function() {
|
||||
var scrollToBottom = function(force) {
|
||||
console.log("Scrolling to bottom");
|
||||
$timeout(function() {
|
||||
var objDiv = document.getElementById("messageTableWrapper");
|
||||
objDiv.scrollTop = objDiv.scrollHeight;
|
||||
}, 0);
|
||||
|
||||
// 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();
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
if (window.Notification) {
|
||||
// Show notification when the user is idle
|
||||
if (matrixService.presence.offline === mPresence.getState()) {
|
||||
@ -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];
|
||||
|
||||
// Set his state in the room as leave
|
||||
promise = matrixService.setMembership($scope.room_id, user_id, "leave");
|
||||
}
|
||||
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(' ');
|
||||
// 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]);
|
||||
}
|
||||
promise = matrixService.ban($scope.room_id, user_id, reason);
|
||||
}
|
||||
|
||||
if (!promise) {
|
||||
$scope.feedback = "Usage: /kick <userId> [<reason>]";
|
||||
}
|
||||
break;
|
||||
|
||||
case "/ban":
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
// Reset the user membership to leave to unban him
|
||||
promise = matrixService.setMembership($scope.room_id, user_id, "leave");
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
promise = matrixService.setUserPowerLevel($scope.room_id, user_id, powerLevel);
|
||||
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);
|
||||
if (args) {
|
||||
var matches = args.match(/^(\S+)$/);
|
||||
if (matches) {
|
||||
promise = matrixService.setUserPowerLevel($scope.room_id, args, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (!promise) {
|
||||
$scope.feedback = "Usage: /deop <userId>";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$scope.feedback = ("Unrecognised IRC-style command: " + cmd);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!promise) {
|
||||
// Send the text message
|
||||
promise = matrixService.sendTextMessage($scope.room_id, $scope.textInput);
|
||||
// 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;
|
||||
}
|
||||
|
||||
promise.then(
|
||||
function() {
|
||||
console.log("Request successfully sent");
|
||||
$scope.textInput = "";
|
||||
$scope.state.sending = false;
|
||||
},
|
||||
function(error) {
|
||||
$scope.feedback = "Request failed: " + error.data.error;
|
||||
$scope.state.sending = false;
|
||||
});
|
||||
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 = "";
|
||||
/*
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
|
@ -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);
|
||||
@ -81,7 +84,10 @@ angular.module('RoomController')
|
||||
expansion += " ";
|
||||
element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion);
|
||||
// cancel blink
|
||||
element[0].className = "";
|
||||
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 {
|
||||
|
@ -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>
|
||||
<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 }}"/>
|
||||
|
@ -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,34 +146,44 @@ 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;
|
||||
}
|
||||
var config = matrixService.config();
|
||||
var emailList = {};
|
||||
if ("emailList" in config) {
|
||||
emailList = config.emailList;
|
||||
}
|
||||
emailList[response.address] = response;
|
||||
// save the new email list
|
||||
config.emailList = emailList;
|
||||
matrixService.setConfig(config);
|
||||
matrixService.saveConfig();
|
||||
// invalidate the email being authed and update UI.
|
||||
$scope.linkedEmails.emailBeingAuthed = undefined;
|
||||
$scope.emailFeedback = "";
|
||||
$scope.linkedEmails.linkedEmailList = emailList;
|
||||
$scope.linkedEmails.linkNewEmail = "";
|
||||
$scope.linkedEmails.emailCode = "";
|
||||
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[$scope.linkedEmails.emailBeingAuthed] = response;
|
||||
// save the new email list
|
||||
config.emailList = emailList;
|
||||
matrixService.setConfig(config);
|
||||
matrixService.saveConfig();
|
||||
// invalidate the email being authed and update UI.
|
||||
$scope.linkedEmails.emailBeingAuthed = undefined;
|
||||
$scope.emailFeedback = "";
|
||||
$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;
|
||||
@ -182,4 +209,4 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
|
||||
$scope.settings.notifications = permission;
|
||||
});
|
||||
};
|
||||
}]);
|
||||
}]);
|
||||
|
@ -23,14 +23,14 @@
|
||||
</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)">
|
||||
Link Email
|
||||
</button>
|
||||
{{ emailFeedback }}
|
||||
{{ emailFeedback }}
|
||||
</form>
|
||||
<form ng-hide="!linkedEmails.emailBeingAuthed">
|
||||
Enter validation token for {{ linkedEmails.emailBeingAuthed }}:
|
||||
@ -81,7 +81,7 @@
|
||||
<ul>
|
||||
<li>/nick <display_name>: change your display name</li>
|
||||
<li>/me <action>: send the action you are doing. /me will be replaced by your display name</li>
|
||||
<li>/kick <user_id>: kick the user</li>
|
||||
<li>/kick <user_id> [<reason>]: kick the user</li>
|
||||
<li>/ban <user_id> [<reason>]: ban the user</li>
|
||||
<li>/unban <user_id>: unban the user</li>
|
||||
<li>/op <user_id> <power_level>: set user power level</li>
|
||||
|
Loading…
Reference in New Issue
Block a user