Merge branch 'develop' of github.com:matrix-org/synapse into federation_authorization

pull/12/head
Erik Johnston 2014-10-30 11:18:28 +00:00
commit ef9c4476a0
55 changed files with 9733 additions and 205 deletions

View File

@ -12,4 +12,3 @@
# 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.

View File

@ -58,4 +58,4 @@ class LoginType(object):
EMAIL_CODE = u"m.login.email.code"
EMAIL_URL = u"m.login.email.url"
EMAIL_IDENTITY = u"m.login.email.identity"
RECAPTCHA = u"m.login.recaptcha"
RECAPTCHA = u"m.login.recaptcha"

View File

@ -54,7 +54,7 @@ class SynapseError(CodeMessageException):
"""Constructs a synapse error.
Args:
code (int): The integer error code (typically an HTTP response code)
code (int): The integer error code (an HTTP response code)
msg (str): The human-readable error message.
err (str): The error code e.g 'M_FORBIDDEN'
"""
@ -67,6 +67,7 @@ class SynapseError(CodeMessageException):
self.errcode,
)
class RoomError(SynapseError):
"""An error raised when a room event fails."""
pass
@ -117,6 +118,7 @@ class InvalidCaptchaError(SynapseError):
error_url=self.error_url,
)
class LimitExceededError(SynapseError):
"""A client has sent too many requests and is being throttled.
"""

View File

@ -12,4 +12,3 @@
# 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.

View File

@ -116,18 +116,25 @@ class Config(object):
config = {}
for key, value in vars(args).items():
if (key not in set(["config_path", "generate_config"])
and value is not None):
and value is not None):
config[key] = value
with open(config_args.config_path, "w") as config_file:
# TODO(paul) it would be lovely if we wrote out vim- and emacs-
# style mode markers into the file, to hint to people that
# this is a YAML file.
yaml.dump(config, config_file, default_flow_style=False)
print "A config file has been generated in %s for server name '%s') with corresponding SSL keys and self-signed certificates. Please review this file and customise it to your needs." % (config_args.config_path, config['server_name'])
print "If this server name is incorrect, you will need to regenerate the SSL certificates"
print (
"A config file has been generated in %s for server name"
" '%s' with corresponding SSL keys and self-signed"
" certificates. Please review this file and customise it to"
" your needs."
) % (
config_args.config_path, config['server_name']
)
print (
"If this server name is incorrect, you will need to regenerate"
" the SSL certificates"
)
sys.exit(0)
return cls(args)

View File

@ -16,6 +16,7 @@
from ._base import Config
import os
class DatabaseConfig(Config):
def __init__(self, args):
super(DatabaseConfig, self).__init__(args)
@ -34,4 +35,3 @@ class DatabaseConfig(Config):
def generate_config(cls, args, config_dir_path):
super(DatabaseConfig, cls).generate_config(args, config_dir_path)
args.database_path = os.path.abspath(args.database_path)

View File

@ -35,5 +35,8 @@ class EmailConfig(Config):
email_group.add_argument(
"--email-smtp-server",
default="",
help="The SMTP server to send emails from (e.g. for password resets)."
)
help=(
"The SMTP server to send emails from (e.g. for password"
" resets)."
)
)

View File

@ -19,6 +19,7 @@ from twisted.python.log import PythonLoggingObserver
import logging
import logging.config
class LoggingConfig(Config):
def __init__(self, args):
super(LoggingConfig, self).__init__(args)
@ -51,7 +52,7 @@ class LoggingConfig(Config):
level = logging.INFO
if self.verbosity:
level = logging.DEBUG
level = logging.DEBUG
# FIXME: we need a logging.WARN for a -q quiet option

View File

@ -14,6 +14,7 @@
from ._base import Config
class RatelimitConfig(Config):
def __init__(self, args):

View File

@ -15,6 +15,7 @@
from ._base import Config
class ContentRepositoryConfig(Config):
def __init__(self, args):
super(ContentRepositoryConfig, self).__init__(args)

View File

@ -34,7 +34,7 @@ class ServerConfig(Config):
if not args.content_addr:
host = args.server_name
if ':' not in host:
host = "%s:%d" % (host, args.bind_port)
host = "%s:%d" % (host, args.bind_port)
args.content_addr = "https://%s" % (host,)
self.content_addr = args.content_addr

View File

@ -19,7 +19,7 @@ from OpenSSL import crypto
import subprocess
import os
GENERATE_DH_PARAMS=False
GENERATE_DH_PARAMS = False
class TlsConfig(Config):

View File

@ -33,7 +33,10 @@ class VoipConfig(Config):
)
group.add_argument(
"--turn-shared-secret", type=str, default=None,
help="The shared secret used to compute passwords for the TURN server"
help=(
"The shared secret used to compute passwords for the TURN"
" server"
)
)
group.add_argument(
"--turn-user-lifetime", type=int, default=(1000 * 60 * 60),

View File

@ -12,4 +12,3 @@
# 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.

View File

@ -20,6 +20,7 @@ import logging
logger = logging.getLogger(__name__)
class ServerContextFactory(ssl.ContextFactory):
"""Factory for PyOpenSSL SSL contexts that are used to handle incoming
connections and to make connections to remote servers."""
@ -43,4 +44,3 @@ class ServerContextFactory(ssl.ContextFactory):
def getContext(self):
return self._context

View File

@ -98,4 +98,3 @@ class SynapseKeyClientProtocol(HTTPClient):
class SynapseKeyClientFactory(Factory):
protocol = SynapseKeyClientProtocol

View File

@ -44,7 +44,7 @@ class Keyring(object):
raise SynapseError(
400,
"Not signed with a supported algorithm",
Codes.UNAUTHORIZED,
Codes.UNAUTHORIZED,
)
try:
verify_key = yield self.get_server_verify_key(server_name, key_ids)
@ -100,7 +100,7 @@ class Keyring(object):
)
if ("signatures" not in response
or server_name not in response["signatures"]):
or server_name not in response["signatures"]):
raise ValueError("Key response not signed by remote server")
if "tls_certificate" not in response:

View File

@ -54,7 +54,7 @@ class LoginHandler(BaseHandler):
# pull out the hash for this user if they exist
user_info = yield self.store.get_user_by_id(user_id=user)
if not user_info:
logger.warn("Attempted to login as %s but they do not exist.", user)
logger.warn("Attempted to login as %s but they do not exist", user)
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
stored_hash = user_info[0]["password_hash"]

View File

@ -114,8 +114,12 @@ class MessageHandler(BaseHandler):
user = self.hs.parse_userid(user_id)
events, next_token = yield data_source.get_pagination_rows(
user, pagin_config, room_id
events, next_key = yield data_source.get_pagination_rows(
user, pagin_config.get_source_config("room"), room_id
)
next_token = pagin_config.from_token.copy_and_replace(
"room_key", next_key
)
chunk = {
@ -264,7 +268,7 @@ class MessageHandler(BaseHandler):
presence_stream = self.hs.get_event_sources().sources["presence"]
pagination_config = PaginationConfig(from_token=now_token)
presence, _ = yield presence_stream.get_pagination_rows(
user, pagination_config, None
user, pagination_config.get_source_config("presence"), None
)
public_rooms = yield self.store.get_rooms(is_public=True)

View File

@ -76,9 +76,7 @@ class PresenceHandler(BaseHandler):
"stopped_user_eventstream", self.stopped_user_eventstream
)
distributor.observe("user_joined_room",
self.user_joined_room
)
distributor.observe("user_joined_room", self.user_joined_room)
distributor.declare("collect_presencelike_data")
@ -156,14 +154,12 @@ class PresenceHandler(BaseHandler):
defer.returnValue(True)
if (yield self.store.user_rooms_intersect(
[u.to_string() for u in observer_user, observed_user]
)):
[u.to_string() for u in observer_user, observed_user])):
defer.returnValue(True)
if (yield self.store.is_presence_visible(
observed_localpart=observed_user.localpart,
observer_userid=observer_user.to_string(),
)):
observed_localpart=observed_user.localpart,
observer_userid=observer_user.to_string())):
defer.returnValue(True)
defer.returnValue(False)
@ -171,7 +167,8 @@ class PresenceHandler(BaseHandler):
@defer.inlineCallbacks
def get_state(self, target_user, auth_user):
if target_user.is_mine:
visible = yield self.is_presence_visible(observer_user=auth_user,
visible = yield self.is_presence_visible(
observer_user=auth_user,
observed_user=target_user
)
@ -219,9 +216,9 @@ class PresenceHandler(BaseHandler):
)
if state["presence"] not in self.STATE_LEVELS:
raise SynapseError(400, "'%s' is not a valid presence state" %
state["presence"]
)
raise SynapseError(400, "'%s' is not a valid presence state" % (
state["presence"],
))
logger.debug("Updating presence state of %s to %s",
target_user.localpart, state["presence"])
@ -229,7 +226,7 @@ class PresenceHandler(BaseHandler):
state_to_store = dict(state)
state_to_store["state"] = state_to_store.pop("presence")
statuscache=self._get_or_offline_usercache(target_user)
statuscache = self._get_or_offline_usercache(target_user)
was_level = self.STATE_LEVELS[statuscache.get_state()["presence"]]
now_level = self.STATE_LEVELS[state["presence"]]
@ -649,8 +646,9 @@ class PresenceHandler(BaseHandler):
del state["user_id"]
if "presence" not in state:
logger.warning("Received a presence 'push' EDU from %s without"
+ " a 'presence' key", origin
logger.warning(
"Received a presence 'push' EDU from %s without a"
" 'presence' key", origin
)
continue
@ -745,7 +743,7 @@ class PresenceHandler(BaseHandler):
defer.returnValue((localusers, remote_domains))
def push_update_to_clients(self, observed_user, users_to_push=[],
room_ids=[], statuscache=None):
room_ids=[], statuscache=None):
self.notifier.on_new_user_event(
users_to_push,
room_ids,
@ -765,8 +763,7 @@ class PresenceEventSource(object):
presence = self.hs.get_handlers().presence_handler
if (yield presence.store.user_rooms_intersect(
[u.to_string() for u in observer_user, observed_user]
)):
[u.to_string() for u in observer_user, observed_user])):
defer.returnValue(True)
if observed_user.is_mine:
@ -823,15 +820,12 @@ class PresenceEventSource(object):
def get_pagination_rows(self, user, pagination_config, key):
# TODO (erikj): Does this make sense? Ordering?
from_token = pagination_config.from_token
to_token = pagination_config.to_token
observer_user = user
from_key = int(from_token.presence_key)
from_key = int(pagination_config.from_key)
if to_token:
to_key = int(to_token.presence_key)
if pagination_config.to_key:
to_key = int(pagination_config.to_key)
else:
to_key = -1
@ -841,7 +835,7 @@ class PresenceEventSource(object):
updates = []
# TODO(paul): use a DeferredList ? How to limit concurrency.
for observed_user in cachemap.keys():
if not (to_key < cachemap[observed_user].serial < from_key):
if not (to_key < cachemap[observed_user].serial <= from_key):
continue
if (yield self.is_visible(observer_user, observed_user)):
@ -849,30 +843,15 @@ class PresenceEventSource(object):
# TODO(paul): limit
updates = [(k, cachemap[k]) for k in cachemap
if to_key < cachemap[k].serial < from_key]
if updates:
clock = self.clock
earliest_serial = max([x[1].serial for x in updates])
data = [x[1].make_event(user=x[0], clock=clock) for x in updates]
if to_token:
next_token = to_token
else:
next_token = from_token
next_token = next_token.copy_and_replace(
"presence_key", earliest_serial
)
defer.returnValue((data, next_token))
defer.returnValue((data, earliest_serial))
else:
if not to_token:
to_token = from_token.copy_and_replace(
"presence_key", 0
)
defer.returnValue(([], to_token))
defer.returnValue(([], 0))
class UserPresenceCache(object):

View File

@ -64,9 +64,11 @@ class RegistrationHandler(BaseHandler):
user_id = user.to_string()
token = self._generate_token(user_id)
yield self.store.register(user_id=user_id,
yield self.store.register(
user_id=user_id,
token=token,
password_hash=password_hash)
password_hash=password_hash
)
self.distributor.fire("registered_user", user)
else:
@ -181,8 +183,11 @@ 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)
@ -223,5 +228,3 @@ class RegistrationHandler(BaseHandler):
}
)
defer.returnValue(data)

View File

@ -170,11 +170,6 @@ class RoomCreationHandler(BaseHandler):
content=content
)
yield self.hs.get_handlers().room_member_handler.change_membership(
join_event,
do_auth=False
)
content = {"membership": Membership.INVITE}
for invitee in invite_list:
invite_event = self.event_factory.create_event(
@ -616,23 +611,14 @@ class RoomEventSource(object):
return self.store.get_room_events_max_id()
@defer.inlineCallbacks
def get_pagination_rows(self, user, pagination_config, key):
from_token = pagination_config.from_token
to_token = pagination_config.to_token
limit = pagination_config.limit
direction = pagination_config.direction
to_key = to_token.room_key if to_token else None
def get_pagination_rows(self, user, config, key):
events, next_key = yield self.store.paginate_room_events(
room_id=key,
from_key=from_token.room_key,
to_key=to_key,
direction=direction,
limit=limit,
from_key=config.from_key,
to_key=config.to_key,
direction=config.direction,
limit=config.limit,
with_feedback=True
)
next_token = from_token.copy_and_replace("room_key", next_key)
defer.returnValue((events, next_token))
defer.returnValue((events, next_key))

View File

@ -96,9 +96,10 @@ class TypingNotificationHandler(BaseHandler):
remotedomains = set()
rm_handler = self.homeserver.get_handlers().room_member_handler
yield rm_handler.fetch_room_distributions_into(room_id,
localusers=localusers, remotedomains=remotedomains,
ignore_user=user)
yield rm_handler.fetch_room_distributions_into(
room_id, localusers=localusers, remotedomains=remotedomains,
ignore_user=user
)
for u in localusers:
self.push_update_to_clients(
@ -130,8 +131,9 @@ class TypingNotificationHandler(BaseHandler):
localusers = set()
rm_handler = self.homeserver.get_handlers().room_member_handler
yield rm_handler.fetch_room_distributions_into(room_id,
localusers=localusers)
yield rm_handler.fetch_room_distributions_into(
room_id, localusers=localusers
)
for u in localusers:
self.push_update_to_clients(
@ -142,7 +144,7 @@ class TypingNotificationHandler(BaseHandler):
)
def push_update_to_clients(self, room_id, observer_user, observed_user,
typing):
typing):
# TODO(paul) steal this from presence.py
pass
@ -158,4 +160,4 @@ class TypingNotificationEventSource(object):
return 0
def get_pagination_rows(self, user, pagination_config, key):
return ([], pagination_config.from_token)
return ([], pagination_config.from_key)

View File

@ -12,4 +12,3 @@
# 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.

View File

@ -16,7 +16,9 @@
from twisted.internet import defer, reactor
from twisted.internet.error import DNSLookupError
from twisted.web.client import _AgentBase, _URI, readBody, FileBodyProducer, PartialDownloadError
from twisted.web.client import (
_AgentBase, _URI, readBody, FileBodyProducer, PartialDownloadError
)
from twisted.web.http_headers import Headers
from synapse.http.endpoint import matrix_endpoint
@ -97,7 +99,7 @@ class BaseHttpClient(object):
retries_left = 5
endpoint = self._getEndpoint(reactor, destination);
endpoint = self._getEndpoint(reactor, destination)
while True:
@ -181,7 +183,7 @@ class MatrixHttpClient(BaseHttpClient):
auth_headers = []
for key,sig in request["signatures"][self.server_name].items():
for key, sig in request["signatures"][self.server_name].items():
auth_headers.append(bytes(
"X-Matrix origin=%s,key=\"%s\",sig=\"%s\"" % (
self.server_name, key, sig,
@ -276,7 +278,6 @@ class MatrixHttpClient(BaseHttpClient):
defer.returnValue(json.loads(body))
def _getEndpoint(self, reactor, destination):
return matrix_endpoint(
reactor, destination, timeout=10,
@ -351,6 +352,7 @@ class IdentityServerHttpClient(BaseHttpClient):
defer.returnValue(json.loads(body))
class CaptchaServerHttpClient(MatrixHttpClient):
"""Separate HTTP client for talking to google's captcha servers"""
@ -384,6 +386,7 @@ class CaptchaServerHttpClient(MatrixHttpClient):
else:
raise e
def _print_ex(e):
if hasattr(e, "reasons") and e.reasons:
for ex in e.reasons:

View File

@ -38,8 +38,8 @@ class ContentRepoResource(resource.Resource):
Uploads are POSTed to wherever this Resource is linked to. This resource
returns a "content token" which can be used to GET this content again. The
token is typically a path, but it may not be. Tokens can expire, be one-time
uses, etc.
token is typically a path, but it may not be. Tokens can expire, be
one-time uses, etc.
In this case, the token is a path to the file and contains 3 interesting
sections:
@ -175,10 +175,9 @@ class ContentRepoResource(resource.Resource):
with open(fname, "wb") as f:
f.write(request.content.read())
# FIXME (erikj): These should use constants.
file_name = os.path.basename(fname)
# FIXME: we can't assume what the public mounted path of the repo is
# FIXME: we can't assume what the repo's public mounted path is
# ...plus self-signed SSL won't work to remote clients anyway
# ...and we can't assume that it's SSL anyway, as we might want to
# server it via the non-SSL listener...
@ -201,6 +200,3 @@ class ContentRepoResource(resource.Resource):
500,
json.dumps({"error": "Internal server error"}),
send_cors=True)

View File

@ -167,7 +167,8 @@ class Notifier(object):
)
def eb(failure):
logger.error("Failed to notify listener",
logger.error(
"Failed to notify listener",
exc_info=(
failure.type,
failure.value,
@ -207,7 +208,7 @@ class Notifier(object):
)
if timeout:
reactor.callLater(timeout/1000, self._timeout_listener, listener)
reactor.callLater(timeout/1000.0, self._timeout_listener, listener)
self._register_with_keys(listener)

View File

@ -108,9 +108,9 @@ class ProfileRestServlet(RestServlet):
)
defer.returnValue((200, {
"displayname": displayname,
"avatar_url": avatar_url
}))
"displayname": displayname,
"avatar_url": avatar_url
}))
def register_servlets(hs, http_server):

View File

@ -60,40 +60,45 @@ class RegisterRestServlet(RestServlet):
def on_GET(self, request):
if self.hs.config.enable_registration_captcha:
return (200, {
"flows": [
return (
200,
{"flows": [
{
"type": LoginType.RECAPTCHA,
"stages": ([LoginType.RECAPTCHA,
LoginType.EMAIL_IDENTITY,
LoginType.PASSWORD])
"stages": [
LoginType.RECAPTCHA,
LoginType.EMAIL_IDENTITY,
LoginType.PASSWORD
]
},
{
"type": LoginType.RECAPTCHA,
"stages": [LoginType.RECAPTCHA, LoginType.PASSWORD]
}
]
})
]}
)
else:
return (200, {
"flows": [
return (
200,
{"flows": [
{
"type": LoginType.EMAIL_IDENTITY,
"stages": ([LoginType.EMAIL_IDENTITY,
LoginType.PASSWORD])
"stages": [
LoginType.EMAIL_IDENTITY, LoginType.PASSWORD
]
},
{
"type": LoginType.PASSWORD
}
]
})
]}
)
@defer.inlineCallbacks
def on_POST(self, request):
register_json = _parse_json(request)
session = (register_json["session"] if "session" in register_json
else None)
session = (register_json["session"]
if "session" in register_json else None)
login_type = None
if "type" not in register_json:
raise SynapseError(400, "Missing 'type' key.")
@ -122,7 +127,9 @@ class RegisterRestServlet(RestServlet):
defer.returnValue((200, response))
except KeyError as e:
logger.exception(e)
raise SynapseError(400, "Missing JSON keys for login type %s." % login_type)
raise SynapseError(400, "Missing JSON keys for login type %s." % (
login_type,
))
def on_OPTIONS(self, request):
return (200, {})
@ -183,8 +190,10 @@ class RegisterRestServlet(RestServlet):
session["user"] = register_json["user"]
defer.returnValue(None)
else:
raise SynapseError(400, "Captcha bypass HMAC incorrect",
errcode=Codes.CAPTCHA_NEEDED)
raise SynapseError(
400, "Captcha bypass HMAC incorrect",
errcode=Codes.CAPTCHA_NEEDED
)
challenge = None
user_response = None
@ -230,12 +239,15 @@ class RegisterRestServlet(RestServlet):
if ("user" in session and "user" in register_json and
session["user"] != register_json["user"]):
raise SynapseError(400, "Cannot change user ID during registration")
raise SynapseError(
400, "Cannot change user ID during registration"
)
password = register_json["password"].encode("utf-8")
desired_user_id = (register_json["user"].encode("utf-8") if "user"
in register_json else None)
if desired_user_id and urllib.quote(desired_user_id) != desired_user_id:
desired_user_id = (register_json["user"].encode("utf-8")
if "user" in register_json else None)
if (desired_user_id
and urllib.quote(desired_user_id) != desired_user_id):
raise SynapseError(
400,
"User ID must only contain characters which do not " +

View File

@ -48,7 +48,9 @@ class RoomCreateRestServlet(RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
defer.returnValue(
self.txns.get_client_transaction(request, txn_id)
)
except KeyError:
pass
@ -98,8 +100,8 @@ class RoomStateEventRestServlet(RestServlet):
no_state_key = "/rooms/(?P<room_id>[^/]*)/state/(?P<event_type>[^/]*)$"
# /room/$roomid/state/$eventtype/$statekey
state_key = ("/rooms/(?P<room_id>[^/]*)/state/" +
"(?P<event_type>[^/]*)/(?P<state_key>[^/]*)$")
state_key = ("/rooms/(?P<room_id>[^/]*)/state/"
"(?P<event_type>[^/]*)/(?P<state_key>[^/]*)$")
http_server.register_path("GET",
client_path_pattern(state_key),
@ -133,7 +135,9 @@ class RoomStateEventRestServlet(RestServlet):
)
if not data:
raise SynapseError(404, "Event not found.", errcode=Codes.NOT_FOUND)
raise SynapseError(
404, "Event not found.", errcode=Codes.NOT_FOUND
)
defer.returnValue((200, data[0].get_dict()["content"]))
@defer.inlineCallbacks
@ -195,7 +199,9 @@ class RoomSendEventRestServlet(RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, room_id, event_type, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
defer.returnValue(
self.txns.get_client_transaction(request, txn_id)
)
except KeyError:
pass
@ -254,7 +260,9 @@ class JoinRoomAliasServlet(RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, room_identifier, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
defer.returnValue(
self.txns.get_client_transaction(request, txn_id)
)
except KeyError:
pass
@ -293,7 +301,8 @@ class RoomMemberListRestServlet(RestServlet):
target_user = self.hs.parse_userid(event["user_id"])
# Presence is an optional cache; don't fail if we can't fetch it
try:
presence_state = yield self.handlers.presence_handler.get_state(
presence_handler = self.handlers.presence_handler
presence_state = yield presence_handler.get_state(
target_user=target_user, auth_user=user
)
event["content"].update(presence_state)
@ -359,11 +368,11 @@ class RoomInitialSyncRestServlet(RestServlet):
# { state event } , { state event }
# ]
# }
# Probably worth keeping the keys room_id and membership for parity with
# /initialSync even though they must be joined to sync this and know the
# room ID, so clients can reuse the same code (room_id and membership
# are MANDATORY for /initialSync, so the code will expect it to be
# there)
# Probably worth keeping the keys room_id and membership for parity
# with /initialSync even though they must be joined to sync this and
# know the room ID, so clients can reuse the same code (room_id and
# membership are MANDATORY for /initialSync, so the code will expect
# it to be there)
defer.returnValue((200, {}))
@ -388,8 +397,8 @@ class RoomMembershipRestServlet(RestServlet):
def register(self, http_server):
# /rooms/$roomid/[invite|join|leave]
PATTERN = ("/rooms/(?P<room_id>[^/]*)/" +
"(?P<membership_action>join|invite|leave|ban|kick)")
PATTERN = ("/rooms/(?P<room_id>[^/]*)/"
"(?P<membership_action>join|invite|leave|ban|kick)")
register_txn_path(self, PATTERN, http_server)
@defer.inlineCallbacks
@ -422,7 +431,9 @@ class RoomMembershipRestServlet(RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, room_id, membership_action, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
defer.returnValue(
self.txns.get_client_transaction(request, txn_id)
)
except KeyError:
pass
@ -431,6 +442,7 @@ class RoomMembershipRestServlet(RestServlet):
self.txns.store_client_transaction(request, txn_id, response)
defer.returnValue(response)
class RoomRedactEventRestServlet(RestServlet):
def register(self, http_server):
PATTERN = ("/rooms/(?P<room_id>[^/]*)/redact/(?P<event_id>[^/]*)")
@ -457,7 +469,9 @@ class RoomRedactEventRestServlet(RestServlet):
@defer.inlineCallbacks
def on_PUT(self, request, room_id, event_id, txn_id):
try:
defer.returnValue(self.txns.get_client_transaction(request, txn_id))
defer.returnValue(
self.txns.get_client_transaction(request, txn_id)
)
except KeyError:
pass
@ -503,10 +517,10 @@ def register_txn_path(servlet, regex_string, http_server, with_get=False):
)
if with_get:
http_server.register_path(
"GET",
client_path_pattern(regex_string + "/(?P<txn_id>[^/]*)$"),
servlet.on_GET
)
"GET",
client_path_pattern(regex_string + "/(?P<txn_id>[^/]*)$"),
servlet.on_GET
)
def register_servlets(hs, http_server):

View File

@ -30,9 +30,9 @@ class HttpTransactionStore(object):
"""Retrieve a response for this request.
Args:
key (str): A transaction-independent key for this request. Typically
this is a combination of the path (without the transaction id) and
the user's access token.
key (str): A transaction-independent key for this request. Usually
this is a combination of the path (without the transaction id)
and the user's access token.
txn_id (str): The transaction ID for this request
Returns:
A tuple of (HTTP response code, response content) or None.
@ -51,9 +51,9 @@ class HttpTransactionStore(object):
"""Stores an HTTP response tuple.
Args:
key (str): A transaction-independent key for this request. Typically
this is a combination of the path (without the transaction id) and
the user's access token.
key (str): A transaction-independent key for this request. Usually
this is a combination of the path (without the transaction id)
and the user's access token.
txn_id (str): The transaction ID for this request.
response (tuple): A tuple of (HTTP response code, response content)
"""
@ -92,5 +92,3 @@ class HttpTransactionStore(object):
token = request.args["access_token"][0]
path_without_txn_id = request.path.rsplit("/", 1)[0]
return path_without_txn_id + "/" + token

View File

@ -34,23 +34,23 @@ class VoipRestServlet(RestServlet):
turnSecret = self.hs.config.turn_shared_secret
userLifetime = self.hs.config.turn_user_lifetime
if not turnUris or not turnSecret or not userLifetime:
defer.returnValue( (200, {}) )
defer.returnValue((200, {}))
expiry = (self.hs.get_clock().time_msec() + userLifetime) / 1000
username = "%d:%s" % (expiry, auth_user.to_string())
mac = hmac.new(turnSecret, msg=username, digestmod=hashlib.sha1)
# We need to use standard base64 encoding here, *not* syutil's encode_base64
# because we need to add the standard padding to get the same result as the
# TURN server.
# We need to use standard base64 encoding here, *not* syutil's
# encode_base64 because we need to add the standard padding to get the
# same result as the TURN server.
password = base64.b64encode(mac.digest())
defer.returnValue( (200, {
defer.returnValue((200, {
'username': username,
'password': password,
'ttl': userLifetime / 1000,
'uris': turnUris,
}) )
}))
def on_OPTIONS(self, request):
return (200, {})

View File

@ -18,9 +18,10 @@ from _base import SQLBaseStore
from twisted.internet import defer
import OpenSSL
from syutil.crypto.signing_key import decode_verify_key_bytes
from syutil.crypto.signing_key import decode_verify_key_bytes
import hashlib
class KeyStore(SQLBaseStore):
"""Persistence for signature verification keys and tls X.509 certificates
"""

View File

@ -33,7 +33,9 @@ class RoomMemberStore(SQLBaseStore):
target_user_id = event.state_key
domain = self.hs.parse_userid(target_user_id).domain
except:
logger.exception("Failed to parse target_user_id=%s", target_user_id)
logger.exception(
"Failed to parse target_user_id=%s", target_user_id
)
raise
logger.debug(
@ -65,7 +67,8 @@ class RoomMemberStore(SQLBaseStore):
# Check if this was the last person to have left.
member_events = self._get_members_query_txn(
txn,
where_clause="c.room_id = ? AND m.membership = ? AND m.user_id != ?",
where_clause=("c.room_id = ? AND m.membership = ?"
" AND m.user_id != ?"),
where_values=(event.room_id, Membership.JOIN, target_user_id,)
)
@ -120,7 +123,6 @@ class RoomMemberStore(SQLBaseStore):
else:
return None
def get_room_members(self, room_id, membership=None):
"""Retrieve the current room member list for a room.

View File

@ -22,6 +22,19 @@ import logging
logger = logging.getLogger(__name__)
class SourcePaginationConfig(object):
"""A configuration object which stores pagination parameters for a
specific event source."""
def __init__(self, from_key=None, to_key=None, direction='f',
limit=0):
self.from_key = from_key
self.to_key = to_key
self.direction = 'f' if direction == 'f' else 'b'
self.limit = int(limit)
class PaginationConfig(object):
"""A configuration object which stores pagination parameters."""
@ -82,3 +95,13 @@ class PaginationConfig(object):
"<PaginationConfig from_tok=%s, to_tok=%s, "
"direction=%s, limit=%s>"
) % (self.from_token, self.to_token, self.direction, self.limit)
def get_source_config(self, source_name):
keyname = "%s_key" % source_name
return SourcePaginationConfig(
from_key=getattr(self.from_token, keyname),
to_key=getattr(self.to_token, keyname) if self.to_token else None,
direction=self.direction,
limit=self.limit,
)

View File

@ -35,7 +35,7 @@ class NullSource(object):
return defer.succeed(0)
def get_pagination_rows(self, user, pagination_config, key):
return defer.succeed(([], pagination_config.from_token))
return defer.succeed(([], pagination_config.from_key))
class EventSources(object):

View File

@ -1 +0,0 @@
import an_unused_module

View File

@ -42,7 +42,8 @@ class Distributor(object):
if name in self.signals:
raise KeyError("%r already has a signal named %s" % (self, name))
self.signals[name] = Signal(name,
self.signals[name] = Signal(
name,
suppress_failures=self.suppress_failures,
)

View File

@ -42,8 +42,8 @@ def send_email(smtp_server, from_addr, to_addr, subject, body):
EmailException if there was a problem sending the mail.
"""
if not smtp_server or not from_addr or not to_addr:
raise EmailException("Need SMTP server, from and to addresses. Check " +
"the config to set these.")
raise EmailException("Need SMTP server, from and to addresses. Check"
" the config to set these.")
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
@ -68,4 +68,4 @@ def send_email(smtp_server, from_addr, to_addr, subject, body):
twisted.python.log.err()
ese = EmailException()
ese.cause = origException
raise ese
raise ese

View File

@ -13,9 +13,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import copy
class JsonEncodedObject(object):
""" A common base class for defining protocol units that are represented
as JSON.
@ -89,6 +89,7 @@ class JsonEncodedObject(object):
def __str__(self):
return "(%s, %s)" % (self.__class__.__name__, repr(self.__dict__))
def _encode(obj):
if type(obj) is list:
return [_encode(o) for o in obj]

View File

@ -29,6 +29,7 @@ from synapse.server import HomeServer
from synapse.api.constants import PresenceState
from synapse.api.errors import SynapseError
from synapse.handlers.presence import PresenceHandler, UserPresenceCache
from synapse.streams.config import SourcePaginationConfig
OFFLINE = PresenceState.OFFLINE
@ -676,6 +677,21 @@ class PresencePushTestCase(unittest.TestCase):
msg="Presence event should be visible to self-reflection"
)
config = SourcePaginationConfig(from_key=1, to_key=0)
(chunk, _) = yield self.event_source.get_pagination_rows(
self.u_apple, config, None
)
self.assertEquals(chunk,
[
{"type": "m.presence",
"content": {
"user_id": "@apple:test",
"presence": ONLINE,
"last_active_ago": 0,
}},
]
)
# Banana sees it because of presence subscription
(events, _) = yield self.event_source.get_new_events_for_user(
self.u_banana, 0, None

View File

@ -53,7 +53,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
* Open a given page.
* @param {String} url url of the page
*/
$scope.goToPage = function(url) {
$rootScope.goToPage = function(url) {
$location.url(url);
};

View File

@ -76,6 +76,17 @@ angular.module('matrixWebClient')
return filtered;
};
})
.filter('stateEventsFilter', function($sce) {
return function(events) {
var filtered = {};
angular.forEach(events, function(value, key) {
if (value && typeof(value.state_key) === "string") {
filtered[key] = value;
}
});
return filtered;
};
})
.filter('unsafe', ['$sce', function($sce) {
return function(text) {
return $sce.trustAsHtml(text);

View File

@ -403,6 +403,7 @@ textarea, input {
}
.roomNameSection, .roomTopicSection {
text-align: right;
float: right;
width: 100%;
}
@ -412,9 +413,9 @@ textarea, input {
}
.roomHeaderInfo {
text-align: right;
float: right;
margin-top: 15px;
width: 50%;
}
/*** Participant list ***/

View File

@ -30,7 +30,8 @@ var matrixWebClient = angular.module('matrixWebClient', [
'MatrixCall',
'eventStreamService',
'eventHandlerService',
'infinite-scroll'
'infinite-scroll',
'ui.bootstrap'
]);
matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',

5081
webclient/bootstrap.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -58,14 +58,29 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
var shouldBing = false;
// case-insensitive name check for user_id OR display_name if they exist
var userRegex = "";
var myUserId = matrixService.config().user_id;
if (myUserId) {
myUserId = myUserId.toLocaleLowerCase();
var localpart = getLocalPartFromUserId(myUserId);
if (localpart) {
localpart = localpart.toLocaleLowerCase();
userRegex += "\\b" + localpart + "\\b";
}
}
var myDisplayName = matrixService.config().display_name;
if (myDisplayName) {
myDisplayName = myDisplayName.toLocaleLowerCase();
if (userRegex.length > 0) {
userRegex += "|";
}
userRegex += "\\b" + myDisplayName + "\\b";
}
var r = new RegExp(userRegex, 'i');
if (content.search(r) >= 0) {
shouldBing = true;
}
if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) ||
(myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) {
shouldBing = true;
@ -84,6 +99,18 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
return shouldBing;
};
var getLocalPartFromUserId = function(user_id) {
if (!user_id) {
return null;
}
var localpartRegex = /@(.*):\w+/i
var results = localpartRegex.exec(user_id);
if (results && results.length == 2) {
return results[1];
}
return null;
};
var initialSyncDeferred;
var reset = function() {
@ -172,6 +199,17 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
};
var handleMessage = function(event, isLiveEvent) {
// Check for empty event content
var hasContent = false;
for (var prop in event.content) {
hasContent = true;
break;
}
if (!hasContent) {
// empty json object is a redacted event, so ignore.
return;
}
if (isLiveEvent) {
if (event.user_id === matrixService.config().user_id &&
(event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) {
@ -221,13 +259,29 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
message = "* " + displayname + " " + message;
}
var roomTitle = matrixService.getRoomIdToAliasMapping(event.room_id);
var theRoom = $rootScope.events.rooms[event.room_id];
if (!roomTitle && theRoom && theRoom["m.room.name"] && theRoom["m.room.name"].content) {
roomTitle = theRoom["m.room.name"].content.name;
}
if (!roomTitle) {
roomTitle = event.room_id;
}
var notification = new window.Notification(
displayname +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
" (" + roomTitle + ")",
{
"body": message,
"icon": member ? member.avatar_url : undefined
});
notification.onclick = function() {
console.log("notification.onclick() room=" + event.room_id);
$rootScope.goToPage('room/' + (event.room_id));
};
$timeout(function() {
notification.close();
}, 5 * 1000);
@ -256,7 +310,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
// could be a membership change, display name change, etc.
// Find out which one.
var memberChanges = undefined;
if (event.prev_content && (event.prev_content.membership !== event.content.membership)) {
if ((event.prev_content === undefined && event.content.membership) || (event.prev_content && (event.prev_content.membership !== event.content.membership))) {
memberChanges = "membership";
}
else if (event.prev_content && (event.prev_content.displayname !== event.content.displayname)) {
@ -319,6 +373,31 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
$rootScope.events.rooms[event.room_id].messages.push(event);
}
};
var handleRedaction = function(event, isLiveEvent) {
if (!isLiveEvent) {
// we have nothing to remove, so just ignore it.
console.log("Received redacted event: "+JSON.stringify(event));
return;
}
// we need to remove something possibly: do we know the redacted
// event ID?
if (eventMap[event.redacts]) {
// remove event from list of messages in this room.
var eventList = $rootScope.events.rooms[event.room_id].messages;
for (var i=0; i<eventList.length; i++) {
if (eventList[i].event_id === event.redacts) {
console.log("Removing event " + event.redacts);
eventList.splice(i, 1);
break;
}
}
// broadcast the redaction so controllers can nuke this
console.log("Redacted an event.");
}
}
/**
* Get the index of the event in $rootScope.events.rooms[room_id].messages
@ -481,6 +560,9 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
case 'm.room.topic':
handleRoomTopic(event, isLiveEvent, isStateEvent);
break;
case 'm.room.redaction':
handleRedaction(event, isLiveEvent);
break;
default:
console.log("Unable to handle event type " + event.type);
console.log(JSON.stringify(event, undefined, 4));

View File

@ -47,7 +47,6 @@ angular.module('matrixFilter', [])
else if (room.members && !isPublicRoom) { // Do not rename public room
var user_id = matrixService.config().user_id;
// Else, build the name from its users
// Limit the room renaming to 1:1 room
if (2 === Object.keys(room.members).length) {
@ -65,8 +64,16 @@ angular.module('matrixFilter', [])
var otherUserId;
if (Object.keys(room.members)[0] && Object.keys(room.members)[0] !== user_id) {
if (Object.keys(room.members)[0]) {
otherUserId = Object.keys(room.members)[0];
// this could be an invite event (from event stream)
if (otherUserId === user_id &&
room.members[user_id].content.membership === "invite") {
// this is us being invited to this room, so the
// *user_id* is the other user ID and not the state
// key.
otherUserId = room.members[user_id].user_id;
}
}
else {
// it's got to be an invite, or failing that a self-chat;

View File

@ -438,6 +438,14 @@ angular.module('matrixService', [])
return this.sendMessage(room_id, msg_id, content);
},
redactEvent: function(room_id, event_id) {
var path = "/rooms/$room_id/redact/$event_id";
path = path.replace("$room_id", room_id);
path = path.replace("$event_id", event_id);
var content = {};
return doRequest("POST", path, undefined, content);
},
// get a snapshot of the members in a room.
getMemberList: function(room_id) {
// Like the cmd client, escape room ids

View File

@ -5,6 +5,7 @@
<link rel="stylesheet" href="app.css">
<link rel="stylesheet" href="mobile.css">
<link rel="stylesheet" href="bootstrap.css">
<link rel="icon" href="favicon.ico">
@ -16,6 +17,7 @@
<script src="js/angular-route.min.js"></script>
<script src="js/angular-sanitize.min.js"></script>
<script src="js/angular-animate.min.js"></script>
<script type='text/javascript' src="js/ui-bootstrap-tpls-0.11.2.js"></script>
<script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
<script type='text/javascript' src='js/autofill-event.js'></script>
<script src="app.js"></script>

File diff suppressed because it is too large Load Diff

View File

@ -140,6 +140,9 @@ angular.module('RegisterController', ['matrixService'])
$scope.feedback = "Captcha is required on this home " +
"server.";
}
else if (error.data.error) {
$scope.feedback = error.data.error;
}
}
else if (error.status === 0) {
$scope.feedback = "Unable to talk to the server.";

View File

@ -65,13 +65,16 @@
}
#roomName {
float: left;
font-size: 14px ! important;
font-size: 12px ! important;
margin-top: 0px ! important;
}
.roomTopicSection {
display: none;
}
#roomPage {
top: 35px ! important;
top: 40px ! important;
left: 5px ! important;
right: 5px ! important;
bottom: 70px ! important;

View File

@ -15,8 +15,8 @@ limitations under the License.
*/
angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
'use strict';
var MESSAGES_PER_PAGINATION = 30;
var THUMBNAIL_SIZE = 320;
@ -133,7 +133,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// 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) {
// add a 10px buffer to this check so if the message list is not *quite*
// at the bottom it still scrolls since it basically is at the bottom.
if ((10 + objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) {
$timeout(function() {
objDiv.scrollTop = objDiv.scrollHeight;
@ -830,7 +832,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
$scope.userIDToInvite = "";
},
function(reason) {
$scope.feedback = "Failure: " + reason;
$scope.feedback = "Failure: " + reason.data.error;
});
};
@ -982,4 +984,65 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
}
};
}]);
$scope.openJson = function(content) {
$scope.event_selected = content;
// scope this so the template can check power levels and enable/disable
// buttons
$scope.pow = matrixService.getUserPowerLevel;
var modalInstance = $modal.open({
templateUrl: 'eventInfoTemplate.html',
controller: 'EventInfoController',
scope: $scope
});
modalInstance.result.then(function(action) {
if (action === "redact") {
var eventId = $scope.event_selected.event_id;
console.log("Redacting event ID " + eventId);
matrixService.redactEvent(
$scope.event_selected.room_id,
eventId
).then(function(response) {
console.log("Redaction = " + JSON.stringify(response));
}, function(error) {
console.error("Failed to redact event: "+JSON.stringify(error));
if (error.data.error) {
$scope.feedback = error.data.error;
}
});
}
}, function() {
// any dismiss code
});
};
$scope.openRoomInfo = function() {
var modalInstance = $modal.open({
templateUrl: 'roomInfoTemplate.html',
controller: 'RoomInfoController',
size: 'lg',
scope: $scope
});
};
}])
.controller('EventInfoController', function($scope, $modalInstance) {
console.log("Displaying modal dialog for >>>> " + JSON.stringify($scope.event_selected));
$scope.redact = function() {
console.log("User level = "+$scope.pow($scope.room_id, $scope.state.user_id)+
" Redact level = "+$scope.events.rooms[$scope.room_id]["m.room.ops_levels"].content.redact_level);
console.log("Redact event >> " + JSON.stringify($scope.event_selected));
$modalInstance.close("redact");
};
})
.controller('RoomInfoController', function($scope, $modalInstance, $filter) {
console.log("Displaying room info.");
$scope.submitState = function(eventType, content) {
console.log("Submitting " + eventType + " with " + content);
}
$scope.dismiss = $modalInstance.dismiss;
});

View File

@ -1,5 +1,46 @@
<div ng-controller="RoomController" data-ng-init="onInit()" class="room" style="height: 100%;">
<script type="text/ng-template" id="eventInfoTemplate.html">
<div class="modal-body">
<pre> {{event_selected | json}} </pre>
</div>
<div class="modal-footer">
<button ng-click="redact()" type="button" class="btn btn-danger"
ng-disabled="!events.rooms[room_id]['m.room.ops_levels'].content.redact_level || !pow(room_id, state.user_id) || pow(room_id, state.user_id) < events.rooms[room_id]['m.room.ops_levels'].content.redact_level"
title="Delete this event on all home servers. This cannot be undone.">
Redact
</button>
</div>
</script>
<script type="text/ng-template" id="roomInfoTemplate.html">
<div class="modal-body">
<table id="roomInfoTable">
<tr>
<th>
Event Type
</th>
<th>
Content
</th>
</tr>
<tr ng-repeat="(key, event) in events.rooms[room_id] | stateEventsFilter">
<td>
<pre>{{ key }}</pre>
</td>
<td>
<pre>{{ event.content | json }}</pre>
</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button ng-click="dismiss()" type="button" class="btn">
Close
</button>
</div>
</script>
<div id="roomHeader">
<a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
<div class="roomHeaderInfo">
@ -83,7 +124,7 @@
ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
</td>
<td ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
<div class="bubble">
<div class="bubble" ng-click="openJson(msg)">
<span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
{{ members[msg.state_key].displayname || msg.state_key }} joined
</span>
@ -115,7 +156,8 @@
<span ng-show='msg.content.msgtype === "m.emote"'
ng-class="msg.echo_msg_state"
ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/>
ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"
/>
<span ng-show='msg.content.msgtype === "m.text"'
class="message"
@ -133,7 +175,7 @@
</div>
<div ng-show='msg.content.thumbnail_url' ng-style="{ 'height' : msg.content.thumbnail_info.h }">
<img class="image mouse-pointer" ng-src="{{ msg.content.thumbnail_url }}"
ng-click="$parent.fullScreenImageURL = msg.content.url"/>
ng-click="$parent.fullScreenImageURL = msg.content.url; $event.stopPropagation();"/>
</div>
</div>
@ -202,6 +244,9 @@
>
Video Call
</button>
<button ng-click="openRoomInfo()">
Room Info
</button>
</div>
{{ feedback }}