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

This commit is contained in:
Erik Johnston 2014-10-31 17:48:05 +00:00
commit b63691f6e2
66 changed files with 10511 additions and 284 deletions

View file

@ -1,3 +1,19 @@
Changes in synapse 0.4.2 (2014-10-31)
=====================================
Homeserver:
* Fix bugs where we did not notify users of correct presence updates.
* Fix bug where we did not handle sub second event stream timeouts.
Webclient:
* Add ability to click on messages to see JSON.
* Add ability to redact messages.
* Add ability to view and edit all room state JSON.
* Handle incoming redactions.
* Improve feedback on errors.
* Fix bugs in mobile CSS.
* Fix bugs with desktop notifications.
Changes in synapse 0.4.1 (2014-10-17)
=====================================
Webclient:

View file

@ -1 +1 @@
0.4.1
0.4.2

View file

@ -14,3 +14,4 @@ fi
find "$DIR" -name "*.log" -delete
find "$DIR" -name "*.db" -delete
rm -rf $DIR/etc

View file

@ -8,6 +8,14 @@ cd "$DIR/.."
mkdir -p demo/etc
# Check the --no-rate-limit param
PARAMS=""
if [ $# -eq 1 ]; then
if [ $1 = "--no-rate-limit" ]; then
PARAMS="--rc-messages-per-second 1000 --rc-message-burst-count 1000"
fi
fi
for port in 8080 8081 8082; do
echo "Starting server on port $port... "
@ -23,7 +31,8 @@ for port in 8080 8081 8082; do
-d "$DIR/$port.db" \
-D --pid-file "$DIR/$port.pid" \
--manhole $((port + 1000)) \
--tls-dh-params-path "demo/demo.tls.dh"
--tls-dh-params-path "demo/demo.tls.dh" \
$PARAMS
python -m synapse.app.homeserver \
--config-path "demo/etc/$port.config" \

280
pylint.cfg Normal file
View file

@ -0,0 +1,280 @@
[MASTER]
# Specify a configuration file.
#rcfile=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
#init-hook=
# Profiled execution.
profile=no
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
# Pickle collected data for later comparisons.
persistent=yes
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
[MESSAGES CONTROL]
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time. See also the "--disable" option for examples.
#enable=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once).You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=missing-docstring
[REPORTS]
# Set the output format. Available formats are text, parseable, colorized, msvs
# (visual studio) and html. You can also give a reporter class, eg
# mypackage.mymodule.MyReporterClass.
output-format=text
# Put messages in a separate file for each module / package specified on the
# command line instead of printing them on stdout. Reports (if any) will be
# written in a file name "pylint_global.[txt|html]".
files-output=no
# Tells whether to display a full report or only the messages
reports=yes
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Add a comment according to your evaluation note. This is used by the global
# evaluation report (RP0004).
comment=no
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details
#msg-template=
[TYPECHECK]
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
ignored-classes=SQLObject
# When zope mode is activated, add a predefined set of Zope acquired attributes
# to generated-members.
zope=no
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E0201 when accessed. Python regular
# expressions are accepted.
generated-members=REQUEST,acl_users,aq_parent
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,XXX,TODO
[SIMILARITIES]
# Minimum lines number of a similarity.
min-similarity-lines=4
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
[VARIABLES]
# Tells whether we should check for unused import in __init__ files.
init-import=no
# A regular expression matching the beginning of the name of dummy variables
# (i.e. not used).
dummy-variables-rgx=_$|dummy
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid to define new builtins when possible.
additional-builtins=
[BASIC]
# Required attributes for module, separated by a comma
required-attributes=
# List of builtins function names that should not be used, separated by a comma
bad-functions=map,filter,apply,input
# Regular expression which should only match correct module names
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Regular expression which should only match correct module level names
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# Regular expression which should only match correct class names
class-rgx=[A-Z_][a-zA-Z0-9]+$
# Regular expression which should only match correct function names
function-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct method names
method-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct instance attribute names
attr-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct argument names
argument-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct variable names
variable-rgx=[a-z_][a-z0-9_]{2,30}$
# Regular expression which should only match correct attribute names in class
# bodies
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Regular expression which should only match correct list comprehension /
# generator expression variable names
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
# Good variable names which should always be accepted, separated by a comma
good-names=i,j,k,ex,Run,_
# Bad variable names which should always be refused, separated by a comma
bad-names=foo,bar,baz,toto,tutu,tata
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=__.*__
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
[FORMAT]
# Maximum number of characters on a single line.
max-line-length=80
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
# List of optional constructs for which whitespace checking is disabled
no-space-check=trailing-comma,dict-separator
# Maximum number of lines in a module
max-module-lines=1000
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
[DESIGN]
# Maximum number of arguments for function / method
max-args=5
# Argument names that match this expression will be ignored. Default to name
# with leading underscore
ignored-argument-names=_.*
# Maximum number of locals for function / method body
max-locals=15
# Maximum number of return / yield for function / method body
max-returns=6
# Maximum number of branch for function / method body
max-branches=12
# Maximum number of statements in function / method body
max-statements=50
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
[IMPORTS]
# Deprecated modules which should not be used, separated by a comma
deprecated-modules=regsub,TERMIOS,Bastion,rexec
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled)
import-graph=
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled)
int-import-graph=
[CLASSES]
# List of interface methods to ignore, separated by a comma. This is used for
# instance to not check methods defines in Zope's Interface base class.
ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,__new__,setUp
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=Exception

View file

@ -34,6 +34,7 @@ setup(
"syutil==0.0.2",
"Twisted>=14.0.0",
"service_identity>=1.0.0",
"pyopenssl>=0.14",
"pyyaml",
"pyasn1",
"pynacl",

View file

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

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

@ -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

@ -123,11 +123,18 @@ class Config(object):
# 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)

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

@ -74,7 +74,7 @@ class ServerConfig(Config):
return syutil.crypto.signing_key.read_signing_keys(
signing_keys.splitlines(True)
)
except Exception as e:
except Exception:
raise ConfigError(
"Error reading signing_key."
" Try running again with --generate-config"

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

@ -16,6 +16,10 @@ from twisted.internet import ssl
from OpenSSL import SSL
from twisted.internet._sslverify import _OpenSSLECCurve, _defaultCurveName
import logging
logger = logging.getLogger(__name__)
class ServerContextFactory(ssl.ContextFactory):
"""Factory for PyOpenSSL SSL contexts that are used to handle incoming
@ -31,7 +35,7 @@ class ServerContextFactory(ssl.ContextFactory):
_ecCurve = _OpenSSLECCurve(_defaultCurveName)
_ecCurve.addECKeyToContext(context)
except:
pass
logger.exception("Failed to enable eliptic curve for TLS")
context.set_options(SSL.OP_NO_SSLv2 | SSL.OP_NO_SSLv3)
context.use_certificate(config.tls_certificate)
context.use_privatekey(config.tls_private_key)
@ -40,4 +44,3 @@ class ServerContextFactory(ssl.ContextFactory):
def getContext(self):
return self._context

View file

@ -17,7 +17,6 @@
from twisted.web.http import HTTPClient
from twisted.internet.protocol import Factory
from twisted.internet import defer, reactor
from twisted.internet.endpoints import connectProtocol
from synapse.http.endpoint import matrix_endpoint
import json
import logging
@ -99,4 +98,3 @@ class SynapseKeyClientProtocol(HTTPClient):
class SynapseKeyClientFactory(Factory):
protocol = SynapseKeyClientProtocol

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

@ -115,8 +115,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 = {
@ -271,7 +275,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(),
)):
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
@ -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
defer.returnValue((data, earliest_serial))
else:
next_token = from_token
next_token = next_token.copy_and_replace(
"presence_key", earliest_serial
)
defer.returnValue((data, next_token))
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

@ -169,11 +169,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(
@ -617,23 +612,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(
@ -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

@ -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,
"stages": [
LoginType.RECAPTCHA,
LoginType.EMAIL_IDENTITY,
LoginType.PASSWORD])
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,7 +100,7 @@ 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/" +
state_key = ("/rooms/(?P<room_id>[^/]*)/state/"
"(?P<event_type>[^/]*)/(?P<state_key>[^/]*)$")
http_server.register_path("GET",
@ -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)
@ -344,7 +353,7 @@ class RoomInitialSyncRestServlet(RestServlet):
@defer.inlineCallbacks
def on_GET(self, request, room_id):
user = yield self.auth.get_user_by_req(request)
yield self.auth.get_user_by_req(request)
# TODO: Get all the initial sync data for this room and return in the
# same format as initial sync, that is:
# {
@ -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,7 +397,7 @@ class RoomMembershipRestServlet(RestServlet):
def register(self, http_server):
# /rooms/$roomid/[invite|join|leave]
PATTERN = ("/rooms/(?P<room_id>[^/]*)/" +
PATTERN = ("/rooms/(?P<room_id>[^/]*)/"
"(?P<membership_action>join|invite|leave|ban|kick)")
register_txn_path(self, PATTERN, http_server)
@ -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

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

@ -452,9 +452,10 @@ def prepare_database(db_conn):
db_conn.commit()
else:
sql_script = "BEGIN TRANSACTION;"
for sql_loc in SCHEMAS:
sql_script = read_schema(sql_loc)
sql_script += read_schema(sql_loc)
sql_script += "COMMIT TRANSACTION;"
c.executescript(sql_script)
db_conn.commit()
c.execute("PRAGMA user_version = %d" % SCHEMA_VERSION)

View file

@ -21,6 +21,7 @@ import OpenSSL
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
"""
@ -104,7 +105,6 @@ class KeyStore(SQLBaseStore):
ts_now_ms (int): The time now in milliseconds
verification_key (VerifyKey): The NACL verify key.
"""
verify_key_bytes = verify_key.encode()
return self._simple_insert(
table="server_signature_keys",
values={

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

@ -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

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

@ -40,4 +40,45 @@ angular.module('matrixWebClient')
}
}
};
}]);
}])
.directive('asjson', function() {
return {
restrict: 'A',
require: 'ngModel',
link: function (scope, element, attrs, ngModelCtrl) {
function isValidJson(model) {
var flag = true;
try {
angular.fromJson(model);
} catch (err) {
flag = false;
}
return flag;
};
function string2JSON(text) {
try {
var j = angular.fromJson(text);
ngModelCtrl.$setValidity('json', true);
return j;
} catch (err) {
//returning undefined results in a parser error as of angular-1.3-rc.0, and will not go through $validators
//return undefined
ngModelCtrl.$setValidity('json', false);
return text;
}
};
function JSON2String(object) {
return angular.toJson(object, true);
};
//$validators is an object, where key is the error
//ngModelCtrl.$validators.json = isValidJson;
//array pipelines
ngModelCtrl.$parsers.push(string2JSON);
ngModelCtrl.$formatters.push(JSON2String);
}
}
});

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,40 @@ textarea, input {
}
.roomHeaderInfo {
text-align: right;
float: right;
margin-top: 15px;
width: 50%;
}
/*** Room Info Dialog ***/
.room-info {
border-collapse: collapse;
width: 100%;
}
.room-info-event {
border-bottom: 1pt solid black;
}
.room-info-event-meta {
padding-top: 1em;
padding-bottom: 1em;
}
.room-info-event-content {
padding-top: 1em;
padding-bottom: 1em;
}
.monospace {
font-family: monospace;
}
.room-info-textarea-content {
height: auto;
width: 100%;
resize: vertical;
}
/*** Participant list ***/

View file

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

5081
webclient/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -27,8 +27,8 @@ Typically, this service will store events or broadcast them to any listeners
if typically all the $on method would do is update its own $scope.
*/
angular.module('eventHandlerService', [])
.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence',
function(matrixService, $rootScope, $q, $timeout, mPresence) {
.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', 'notificationService',
function(matrixService, $rootScope, $q, $timeout, mPresence, notificationService) {
var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
var MSG_EVENT = "MSG_EVENT";
var MEMBER_EVENT = "MEMBER_EVENT";
@ -46,44 +46,6 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
$rootScope.presence = {};
// TODO: This is attached to the rootScope so .html can just go containsBingWord
// for determining classes so it is easy to highlight bing messages. It seems a
// bit strange to put the impl in this service though, but I can't think of a better
// file to put it in.
$rootScope.containsBingWord = function(content) {
if (!content || $.type(content) != "string") {
return false;
}
var bingWords = matrixService.config().bingWords;
var shouldBing = false;
// case-insensitive name check for user_id OR display_name if they exist
var myUserId = matrixService.config().user_id;
if (myUserId) {
myUserId = myUserId.toLocaleLowerCase();
}
var myDisplayName = matrixService.config().display_name;
if (myDisplayName) {
myDisplayName = myDisplayName.toLocaleLowerCase();
}
if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) ||
(myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) {
shouldBing = true;
}
// bing word list check
if (bingWords && !shouldBing) {
for (var i=0; i<bingWords.length; i++) {
var re = RegExp(bingWords[i]);
if (content.search(re) != -1) {
shouldBing = true;
break;
}
}
}
return shouldBing;
};
var initialSyncDeferred;
var reset = function() {
@ -172,6 +134,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") ) {
@ -190,7 +163,12 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
}
if (window.Notification && event.user_id != matrixService.config().user_id) {
var shouldBing = $rootScope.containsBingWord(event.content.body);
var shouldBing = notificationService.containsBingWord(
matrixService.config().user_id,
matrixService.config().display_name,
matrixService.config().bingWords,
event.content.body
);
// Ideally we would notify only when the window is hidden (i.e. document.hidden = true).
//
@ -220,17 +198,29 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
if (event.content.msgtype === "m.emote") {
message = "* " + displayname + " " + message;
}
else if (event.content.msgtype === "m.image") {
message = displayname + " sent an image.";
}
var notification = new window.Notification(
displayname +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": message,
"icon": member ? member.avatar_url : undefined
});
$timeout(function() {
notification.close();
}, 5 * 1000);
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;
}
notificationService.showNotification(
displayname + " (" + roomTitle + ")",
message,
member ? member.avatar_url : undefined,
function() {
console.log("notification.onclick() room=" + event.room_id);
$rootScope.goToPage('room/' + event.room_id);
}
);
}
}
}
@ -256,7 +246,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.content.prev !== 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)) {
@ -320,6 +310,31 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
}
};
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
* @param {type} room_id the room id
@ -481,7 +496,17 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
case 'm.room.topic':
handleRoomTopic(event, isLiveEvent, isStateEvent);
break;
case 'm.room.redaction':
handleRedaction(event, isLiveEvent);
break;
default:
// if it is a state event, then just add it in so it
// displays on the Room Info screen.
if (typeof(event.state_key) === "string") { // incls. 0-len strings
if (event.room_id) {
handleRoomDateEvent(event, isLiveEvent, false);
}
}
console.log("Unable to handle event type " + event.type);
console.log(JSON.stringify(event, undefined, 4));
break;

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

@ -0,0 +1,104 @@
/*
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';
/*
This service manages notifications: enabling, creating and showing them. This
also contains 'bing word' logic.
*/
angular.module('notificationService', [])
.factory('notificationService', ['$timeout', function($timeout) {
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;
};
return {
containsBingWord: function(userId, displayName, bingWords, content) {
// case-insensitive name check for user_id OR display_name if they exist
var userRegex = "";
if (userId) {
var localpart = getLocalPartFromUserId(userId);
if (localpart) {
localpart = localpart.toLocaleLowerCase();
userRegex += "\\b" + localpart + "\\b";
}
}
if (displayName) {
displayName = displayName.toLocaleLowerCase();
if (userRegex.length > 0) {
userRegex += "|";
}
userRegex += "\\b" + displayName + "\\b";
}
var regexList = [new RegExp(userRegex, 'i')];
// bing word list check
if (bingWords && bingWords.length > 0) {
for (var i=0; i<bingWords.length; i++) {
var re = RegExp(bingWords[i], 'i');
regexList.push(re);
}
}
return this.hasMatch(regexList, content);
},
hasMatch: function(regExps, content) {
if (!content || $.type(content) != "string") {
return false;
}
if (regExps && regExps.length > 0) {
for (var i=0; i<regExps.length; i++) {
if (content.search(regExps[i]) != -1) {
return true;
}
}
}
return false;
},
showNotification: function(title, body, icon, onclick) {
var notification = new window.Notification(
title,
{
"body": body,
"icon": icon
}
);
if (onclick) {
notification.onclick = onclick;
}
$timeout(function() {
notification.close();
}, 5 * 1000);
}
};
}]);

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,8 +17,10 @@
<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 type='text/javascript' src='js/elastic.js'></script>
<script src="app.js"></script>
<script src="config.js"></script>
<script src="app-controller.js"></script>
@ -38,6 +41,7 @@
<script src="components/matrix/matrix-phone-service.js"></script>
<script src="components/matrix/event-stream-service.js"></script>
<script src="components/matrix/event-handler-service.js"></script>
<script src="components/matrix/notification-service.js"></script>
<script src="components/matrix/presence-service.js"></script>
<script src="components/fileInput/file-input-directive.js"></script>
<script src="components/fileUpload/file-upload-service.js"></script>

216
webclient/js/elastic.js Normal file
View file

@ -0,0 +1,216 @@
/*
* angular-elastic v2.4.0
* (c) 2014 Monospaced http://monospaced.com
* License: MIT
*/
angular.module('monospaced.elastic', [])
.constant('msdElasticConfig', {
append: ''
})
.directive('msdElastic', [
'$timeout', '$window', 'msdElasticConfig',
function($timeout, $window, config) {
'use strict';
return {
require: 'ngModel',
restrict: 'A, C',
link: function(scope, element, attrs, ngModel) {
// cache a reference to the DOM element
var ta = element[0],
$ta = element;
// ensure the element is a textarea, and browser is capable
if (ta.nodeName !== 'TEXTAREA' || !$window.getComputedStyle) {
return;
}
// set these properties before measuring dimensions
$ta.css({
'overflow': 'hidden',
'overflow-y': 'hidden',
'word-wrap': 'break-word'
});
// force text reflow
var text = ta.value;
ta.value = '';
ta.value = text;
var append = attrs.msdElastic ? attrs.msdElastic.replace(/\\n/g, '\n') : config.append,
$win = angular.element($window),
mirrorInitStyle = 'position: absolute; top: -999px; right: auto; bottom: auto;' +
'left: 0; overflow: hidden; -webkit-box-sizing: content-box;' +
'-moz-box-sizing: content-box; box-sizing: content-box;' +
'min-height: 0 !important; height: 0 !important; padding: 0;' +
'word-wrap: break-word; border: 0;',
$mirror = angular.element('<textarea tabindex="-1" ' +
'style="' + mirrorInitStyle + '"/>').data('elastic', true),
mirror = $mirror[0],
taStyle = getComputedStyle(ta),
resize = taStyle.getPropertyValue('resize'),
borderBox = taStyle.getPropertyValue('box-sizing') === 'border-box' ||
taStyle.getPropertyValue('-moz-box-sizing') === 'border-box' ||
taStyle.getPropertyValue('-webkit-box-sizing') === 'border-box',
boxOuter = !borderBox ? {width: 0, height: 0} : {
width: parseInt(taStyle.getPropertyValue('border-right-width'), 10) +
parseInt(taStyle.getPropertyValue('padding-right'), 10) +
parseInt(taStyle.getPropertyValue('padding-left'), 10) +
parseInt(taStyle.getPropertyValue('border-left-width'), 10),
height: parseInt(taStyle.getPropertyValue('border-top-width'), 10) +
parseInt(taStyle.getPropertyValue('padding-top'), 10) +
parseInt(taStyle.getPropertyValue('padding-bottom'), 10) +
parseInt(taStyle.getPropertyValue('border-bottom-width'), 10)
},
minHeightValue = parseInt(taStyle.getPropertyValue('min-height'), 10),
heightValue = parseInt(taStyle.getPropertyValue('height'), 10),
minHeight = Math.max(minHeightValue, heightValue) - boxOuter.height,
maxHeight = parseInt(taStyle.getPropertyValue('max-height'), 10),
mirrored,
active,
copyStyle = ['font-family',
'font-size',
'font-weight',
'font-style',
'letter-spacing',
'line-height',
'text-transform',
'word-spacing',
'text-indent'];
// exit if elastic already applied (or is the mirror element)
if ($ta.data('elastic')) {
return;
}
// Opera returns max-height of -1 if not set
maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
// append mirror to the DOM
if (mirror.parentNode !== document.body) {
angular.element(document.body).append(mirror);
}
// set resize and apply elastic
$ta.css({
'resize': (resize === 'none' || resize === 'vertical') ? 'none' : 'horizontal'
}).data('elastic', true);
/*
* methods
*/
function initMirror() {
var mirrorStyle = mirrorInitStyle;
mirrored = ta;
// copy the essential styles from the textarea to the mirror
taStyle = getComputedStyle(ta);
angular.forEach(copyStyle, function(val) {
mirrorStyle += val + ':' + taStyle.getPropertyValue(val) + ';';
});
mirror.setAttribute('style', mirrorStyle);
}
function adjust() {
var taHeight,
taComputedStyleWidth,
mirrorHeight,
width,
overflow;
if (mirrored !== ta) {
initMirror();
}
// active flag prevents actions in function from calling adjust again
if (!active) {
active = true;
mirror.value = ta.value + append; // optional whitespace to improve animation
mirror.style.overflowY = ta.style.overflowY;
taHeight = ta.style.height === '' ? 'auto' : parseInt(ta.style.height, 10);
taComputedStyleWidth = getComputedStyle(ta).getPropertyValue('width');
// ensure getComputedStyle has returned a readable 'used value' pixel width
if (taComputedStyleWidth.substr(taComputedStyleWidth.length - 2, 2) === 'px') {
// update mirror width in case the textarea width has changed
width = parseInt(taComputedStyleWidth, 10) - boxOuter.width;
mirror.style.width = width + 'px';
}
mirrorHeight = mirror.scrollHeight;
if (mirrorHeight > maxHeight) {
mirrorHeight = maxHeight;
overflow = 'scroll';
} else if (mirrorHeight < minHeight) {
mirrorHeight = minHeight;
}
mirrorHeight += boxOuter.height;
ta.style.overflowY = overflow || 'hidden';
if (taHeight !== mirrorHeight) {
ta.style.height = mirrorHeight + 'px';
scope.$emit('elastic:resize', $ta);
}
// small delay to prevent an infinite loop
$timeout(function() {
active = false;
}, 1);
}
}
function forceAdjust() {
active = false;
adjust();
}
/*
* initialise
*/
// listen
if ('onpropertychange' in ta && 'oninput' in ta) {
// IE9
ta['oninput'] = ta.onkeyup = adjust;
} else {
ta['oninput'] = adjust;
}
$win.bind('resize', forceAdjust);
scope.$watch(function() {
return ngModel.$modelValue;
}, function(newValue) {
forceAdjust();
});
scope.$on('elastic:adjust', function() {
initMirror();
forceAdjust();
});
$timeout(adjust);
/*
* destroy
*/
scope.$on('$destroy', function() {
$mirror.remove();
$win.unbind('resize', forceAdjust);
});
}
};
}
]);

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

@ -42,12 +42,12 @@
<span ng-if="lastMsg.user_id === lastMsg.state_key">
{{lastMsg.state_key | mUserDisplayName: room.room_id }} left
</span>
<span ng-if="lastMsg.user_id !== lastMsg.state_key">
<span ng-if="lastMsg.user_id !== lastMsg.state_key && lastMsg.prev_content">
{{ lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
{{ {"invite": "kicked", "join": "kicked", "ban": "unbanned"}[lastMsg.prev_content.membership] }}
{{ lastMsg.state_key | mUserDisplayName: room.room_id }}
</span>
<span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
<span ng-if="lastMsg.prev_content && 'join' === lastMsg.prev_content.membership && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
</span>
</span>
@ -55,7 +55,7 @@
{{ lastMsg.user_id | mUserDisplayName: room.room_id }}
{{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
{{ lastMsg.state_key | mUserDisplayName: room.room_id }}
<span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
<span ng-if="lastMsg.prev_content && 'ban' === lastMsg.prev_content.membership && lastMsg.content.reason">
: {{ lastMsg.content.reason }}
</span>
</span>

View file

@ -15,12 +15,22 @@ 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', 'notificationService',
function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, notificationService) {
'use strict';
var MESSAGES_PER_PAGINATION = 30;
var THUMBNAIL_SIZE = 320;
// .html needs this
$scope.containsBingWord = function(content) {
return notificationService.containsBingWord(
matrixService.config().user_id,
matrixService.config().display_name,
matrixService.config().bingWords,
content
);
};
// Room ids. Computed and resolved in onInit
$scope.room_id = undefined;
$scope.room_alias = undefined;
@ -133,7 +143,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;
@ -189,16 +201,20 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
// Notify when a user joins
if ((document.hidden || matrixService.presence.unavailable === mPresence.getState())
&& event.state_key !== $scope.state.user_id && "join" === event.membership) {
var notification = new window.Notification(
event.content.displayname +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
{
"body": event.content.displayname + " joined",
"icon": event.content.avatar_url ? event.content.avatar_url : undefined
});
$timeout(function() {
notification.close();
}, 5 * 1000);
var userName = event.content.displayname;
if (!userName) {
userName = event.state_key;
}
notificationService.showNotification(
userName +
" (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")",
userName + " joined",
event.content.avatar_url ? event.content.avatar_url : undefined,
function() {
console.log("notification.onclick() room=" + event.room_id);
$rootScope.goToPage('room/' + event.room_id);
}
);
}
}
}
@ -830,7 +846,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
$scope.userIDToInvite = "";
},
function(reason) {
$scope.feedback = "Failure: " + reason;
$scope.feedback = "Failure: " + reason.data.error;
});
};
@ -982,4 +998,88 @@ 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() {
$scope.roomInfo = {};
$scope.roomInfo.newEvent = {
content: {},
type: "",
state_key: ""
};
var stateFilter = $filter("stateEventsFilter");
var stateEvents = stateFilter($scope.events.rooms[$scope.room_id]);
// The modal dialog will 2-way bind this field, so we MUST make a deep
// copy of the state events else we will be *actually adjusing our view
// of the world* when fiddling with the JSON!! Apparently parse/stringify
// is faster than jQuery's extend when doing deep copies.
$scope.roomInfo.stateEvents = JSON.parse(JSON.stringify(stateEvents));
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, matrixService) {
console.log("Displaying room info.");
$scope.submit = function(event) {
if (event.content) {
console.log("submit >>> " + JSON.stringify(event.content));
matrixService.sendStateEvent($scope.room_id, event.type,
event.content, event.state_key).then(function(response) {
$modalInstance.dismiss();
}, function(err) {
$scope.feedback = err.data.error;
}
);
}
};
$scope.dismiss = $modalInstance.dismiss;
});

View file

@ -1,5 +1,59 @@
<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 class="room-info">
<tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event">
<td class="room-info-event-meta" width="30%">
<span class="monospace">{{ key }}</span>
<br/>
{{ (event.origin_server_ts) | date:'MMM d HH:mm' }}
<br/>
Set by: <span class="monospace">{{ event.user_id }}</span>
<br/>
<span ng-show="event.required_power_level >= 0">Required power level: {{event.required_power_level}}<br/></span>
<button ng-click="submit(event)" type="button" class="btn btn-success" ng-disabled="!event.content">
Submit
</button>
</td>
<td class="room-info-event-content" width="70%">
<textarea class="room-info-textarea-content" msd-elastic ng-model="event.content" asjson></textarea>
</td>
</tr>
<tr>
<td class="room-info-event-meta" width="30%">
<input ng-model="roomInfo.newEvent.type" placeholder="your.event.type" />
<br/>
<button ng-click="submit(roomInfo.newEvent)" type="button" class="btn btn-success" ng-disabled="!roomInfo.newEvent.content || !roomInfo.newEvent.type">
Submit
</button>
</td>
<td class="room-info-event-content" width="70%">
<textarea class="room-info-textarea-content" msd-elastic ng-model="roomInfo.newEvent.content" asjson></textarea>
</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">
@ -79,11 +133,11 @@
</div>
</td>
<td class="avatar">
<img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
<img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}"
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>
@ -91,11 +145,11 @@
<span ng-if="msg.user_id === msg.state_key">
{{ members[msg.state_key].displayname || msg.state_key }} left
</span>
<span ng-if="msg.user_id !== msg.state_key">
<span ng-if="msg.user_id !== msg.state_key && msg.prev_content">
{{ members[msg.user_id].displayname || msg.user_id }}
{{ {"join": "kicked", "ban": "unbanned"}[msg.content.prev] }}
{{ {"invite": "kicked", "join": "kicked", "ban": "unbanned"}[msg.prev_content.membership] }}
{{ members[msg.state_key].displayname || msg.state_key }}
<span ng-if="'join' === msg.content.prev && msg.content.reason">
<span ng-if="'join' === msg.prev_content.membership && msg.content.reason">
: {{ msg.content.reason }}
</span>
</span>
@ -105,7 +159,7 @@
{{ members[msg.user_id].displayname || msg.user_id }}
{{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
{{ members[msg.state_key].displayname || msg.state_key }}
<span ng-if="'ban' === msg.content.prev && msg.content.reason">
<span ng-if="msg.prev_content && 'ban' === msg.prev_content.membership && msg.content.reason">
: {{ msg.content.reason }}
</span>
</span>
@ -115,7 +169,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 +188,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 +257,9 @@
>
Video Call
</button>
<button ng-click="openRoomInfo()">
Room Info
</button>
</div>
{{ feedback }}