diff --git a/Gruntfile.js b/Gruntfile.js
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -32,6 +32,7 @@ module.exports = function(grunt) {
'<%= dirs.js.src %>/plugins/jquery.mark.js',
'<%= dirs.js.src %>/plugins/jquery.timeago.js',
'<%= dirs.js.src %>/plugins/jquery.timeago-extension.js',
+ '<%= dirs.js.src %>/plugins/toastr.js',
// Select2
'<%= dirs.js.src %>/select2/select2.js',
@@ -64,6 +65,7 @@ module.exports = function(grunt) {
// Rhodecode components
'<%= dirs.js.src %>/rhodecode/init.js',
+ '<%= dirs.js.src %>/rhodecode/connection_controller.js',
'<%= dirs.js.src %>/rhodecode/codemirror.js',
'<%= dirs.js.src %>/rhodecode/comments.js',
'<%= dirs.js.src %>/rhodecode/constants.js',
@@ -78,6 +80,7 @@ module.exports = function(grunt) {
'<%= dirs.js.src %>/rhodecode/select2_widgets.js',
'<%= dirs.js.src %>/rhodecode/tooltips.js',
'<%= dirs.js.src %>/rhodecode/users.js',
+ '<%= dirs.js.src %>/rhodecode/utils/notifications.js',
'<%= dirs.js.src %>/rhodecode/appenlight.js',
// Rhodecode main module
diff --git a/configs/development.ini b/configs/development.ini
--- a/configs/development.ini
+++ b/configs/development.ini
@@ -391,6 +391,17 @@ beaker.session.auto = false
search.module = rhodecode.lib.index.whoosh
search.location = %(here)s/data/index
+########################################
+### CHANNELSTREAM CONFIG ####
+########################################
+
+channelstream.enabled = true
+# location of channelstream server on the backend
+channelstream.server = 127.0.0.1:9800
+# location of the channelstream server from outside world
+channelstream.ws_url = ws://127.0.0.1:9800
+channelstream.secret = secret
+
###################################
## APPENLIGHT CONFIG ##
###################################
diff --git a/configs/production.ini b/configs/production.ini
--- a/configs/production.ini
+++ b/configs/production.ini
@@ -365,6 +365,17 @@ beaker.session.auto = false
search.module = rhodecode.lib.index.whoosh
search.location = %(here)s/data/index
+########################################
+### CHANNELSTREAM CONFIG ####
+########################################
+
+channelstream.enabled = true
+# location of channelstream server on the backend
+channelstream.server = 127.0.0.1:9800
+# location of the channelstream server from outside world
+channelstream.ws_url = ws://127.0.0.1:9800
+channelstream.secret = secret
+
###################################
## APPENLIGHT CONFIG ##
###################################
diff --git a/default.nix b/default.nix
--- a/default.nix
+++ b/default.nix
@@ -166,6 +166,7 @@ let
ln -s ${self.supervisor}/bin/supervisor* $out/bin/
ln -s ${self.gunicorn}/bin/gunicorn $out/bin/
ln -s ${self.PasteScript}/bin/paster $out/bin/
+ ln -s ${self.channelstream}/bin/channelstream $out/bin/
ln -s ${self.pyramid}/bin/* $out/bin/ #*/
# rhodecode-tools
diff --git a/rhodecode/channelstream/__init__.py b/rhodecode/channelstream/__init__.py
new file mode 100644
--- /dev/null
+++ b/rhodecode/channelstream/__init__.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2010-2016 RhodeCode GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License, version 3
+# (only), as published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+# This program is dual-licensed. If you wish to learn more about the
+# RhodeCode Enterprise Edition, including its added features, Support services,
+# and proprietary license terms, please see https://rhodecode.com/licenses/
+
+import os
+
+from pyramid.settings import asbool
+
+from rhodecode.config.routing import ADMIN_PREFIX
+from rhodecode.lib.ext_json import json
+
+
+def url_gen(request):
+ urls = {
+ 'connect': request.route_url('channelstream_connect'),
+ 'subscribe': request.route_url('channelstream_subscribe')
+ }
+ return json.dumps(urls)
+
+
+PLUGIN_DEFINITION = {
+ 'name': 'channelstream',
+ 'config': {
+ 'javascript': [],
+ 'css': [],
+ 'template_hooks': {
+ 'plugin_init_template': 'rhodecode:templates/channelstream/plugin_init.html'
+ },
+ 'url_gen': url_gen,
+ 'static': None,
+ 'enabled': False,
+ 'server': '',
+ 'secret': ''
+ }
+}
+
+
+def includeme(config):
+ settings = config.registry.settings
+ PLUGIN_DEFINITION['config']['enabled'] = asbool(
+ settings.get('channelstream.enabled'))
+ PLUGIN_DEFINITION['config']['server'] = settings.get(
+ 'channelstream.server', '')
+ PLUGIN_DEFINITION['config']['secret'] = settings.get(
+ 'channelstream.secret', '')
+ PLUGIN_DEFINITION['config']['history.location'] = settings.get(
+ 'channelstream.history.location', '')
+ config.register_rhodecode_plugin(
+ PLUGIN_DEFINITION['name'],
+ PLUGIN_DEFINITION['config']
+ )
+ # create plugin history location
+ history_dir = PLUGIN_DEFINITION['config']['history.location']
+ if history_dir and not os.path.exists(history_dir):
+ os.makedirs(history_dir, 0750)
+
+ config.add_route(
+ name='channelstream_connect',
+ pattern=ADMIN_PREFIX + '/channelstream/connect')
+ config.add_route(
+ name='channelstream_subscribe',
+ pattern=ADMIN_PREFIX + '/channelstream/subscribe')
+ config.scan('rhodecode.channelstream')
diff --git a/rhodecode/channelstream/views.py b/rhodecode/channelstream/views.py
new file mode 100644
--- /dev/null
+++ b/rhodecode/channelstream/views.py
@@ -0,0 +1,177 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2010-2016 RhodeCode GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License, version 3
+# (only), as published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+# This program is dual-licensed. If you wish to learn more about the
+# RhodeCode Enterprise Edition, including its added features, Support services,
+# and proprietary license terms, please see https://rhodecode.com/licenses/
+
+"""
+Channel Stream controller for rhodecode
+
+:created_on: Oct 10, 2015
+:author: marcinl
+:copyright: (c) 2013-2015 RhodeCode GmbH.
+:license: Commercial License, see LICENSE for more details.
+"""
+
+import logging
+import uuid
+
+from pylons import tmpl_context as c
+from pyramid.settings import asbool
+from pyramid.view import view_config
+from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPBadGateway
+
+from rhodecode.lib.channelstream import (
+ channelstream_request,
+ ChannelstreamConnectionException,
+ ChannelstreamPermissionException,
+ check_channel_permissions,
+ get_connection_validators,
+ get_user_data,
+ parse_channels_info,
+ update_history_from_logs,
+ STATE_PUBLIC_KEYS)
+from rhodecode.lib.auth import NotAnonymous
+from rhodecode.lib.utils2 import str2bool
+
+log = logging.getLogger(__name__)
+
+
+class ChannelstreamView(object):
+ def __init__(self, context, request):
+ self.context = context
+ self.request = request
+
+ # Some of the decorators rely on this attribute to be present
+ # on the class of the decorated method.
+ self._rhodecode_user = request.user
+ registry = request.registry
+ self.channelstream_config = registry.rhodecode_plugins['channelstream']
+ if not self.channelstream_config.get('enabled'):
+ log.exception('Channelstream plugin is disabled')
+ raise HTTPBadRequest()
+
+ @NotAnonymous()
+ @view_config(route_name='channelstream_connect', renderer='json')
+ def connect(self):
+ """ handle authorization of users trying to connect """
+ try:
+ json_body = self.request.json_body
+ except Exception:
+ log.exception('Failed to decode json from request')
+ raise HTTPBadRequest()
+ try:
+ channels = check_channel_permissions(
+ json_body.get('channels'),
+ get_connection_validators(self.request.registry))
+ except ChannelstreamPermissionException:
+ log.error('Incorrect permissions for requested channels')
+ raise HTTPForbidden()
+
+ user = c.rhodecode_user
+ if user.user_id:
+ user_data = get_user_data(user.user_id)
+ else:
+ user_data = {
+ 'id': None,
+ 'username': None,
+ 'first_name': None,
+ 'last_name': None,
+ 'icon_link': None,
+ 'display_name': None,
+ 'display_link': None,
+ }
+ payload = {
+ 'username': user.username,
+ 'user_state': user_data,
+ 'conn_id': str(uuid.uuid4()),
+ 'channels': channels,
+ 'channel_configs': {},
+ 'state_public_keys': STATE_PUBLIC_KEYS,
+ 'info': {
+ 'exclude_channels': ['broadcast']
+ }
+ }
+ filtered_channels = [channel for channel in channels
+ if channel != 'broadcast']
+ for channel in filtered_channels:
+ payload['channel_configs'][channel] = {
+ 'notify_presence': True,
+ 'history_size': 100,
+ 'store_history': True,
+ 'broadcast_presence_with_user_lists': True
+ }
+ # connect user to server
+ try:
+ connect_result = channelstream_request(self.channelstream_config,
+ payload, '/connect')
+ except ChannelstreamConnectionException:
+ log.exception('Channelstream service is down')
+ return HTTPBadGateway()
+
+ connect_result['channels'] = channels
+ connect_result['channels_info'] = parse_channels_info(
+ connect_result['channels_info'],
+ include_channel_info=filtered_channels)
+ update_history_from_logs(self.channelstream_config,
+ filtered_channels, connect_result)
+ return connect_result
+
+ @NotAnonymous()
+ @view_config(route_name='channelstream_subscribe', renderer='json')
+ def subscribe(self):
+ """ can be used to subscribe specific connection to other channels """
+ try:
+ json_body = self.request.json_body
+ except Exception:
+ log.exception('Failed to decode json from request')
+ raise HTTPBadRequest()
+ try:
+ channels = check_channel_permissions(
+ json_body.get('channels'),
+ get_connection_validators(self.request.registry))
+ except ChannelstreamPermissionException:
+ log.error('Incorrect permissions for requested channels')
+ raise HTTPForbidden()
+ payload = {'conn_id': json_body.get('conn_id', ''),
+ 'channels': channels,
+ 'channel_configs': {},
+ 'info': {
+ 'exclude_channels': ['broadcast']}
+ }
+ filtered_channels = [chan for chan in channels if chan != 'broadcast']
+ for channel in filtered_channels:
+ payload['channel_configs'][channel] = {
+ 'notify_presence': True,
+ 'history_size': 100,
+ 'store_history': True,
+ 'broadcast_presence_with_user_lists': True
+ }
+ try:
+ connect_result = channelstream_request(
+ self.channelstream_config, payload, '/subscribe')
+ except ChannelstreamConnectionException:
+ log.exception('Channelstream service is down')
+ return HTTPBadGateway()
+ # include_channel_info will limit history only to new channel
+ # to not overwrite histories on other channels in client
+ connect_result['channels_info'] = parse_channels_info(
+ connect_result['channels_info'],
+ include_channel_info=filtered_channels)
+ update_history_from_logs(self.channelstream_config,
+ filtered_channels, connect_result)
+ return connect_result
diff --git a/rhodecode/config/environment.py b/rhodecode/config/environment.py
--- a/rhodecode/config/environment.py
+++ b/rhodecode/config/environment.py
@@ -112,6 +112,13 @@ def load_environment(global_conf, app_co
# sets the c attribute access when don't existing attribute are accessed
config['pylons.strict_tmpl_context'] = True
+ # configure channelstream
+ config['channelstream_config'] = {
+ 'enabled': asbool(config.get('channelstream.enabled', False)),
+ 'server': config.get('channelstream.server'),
+ 'secret': config.get('channelstream.secret')
+ }
+
# Limit backends to "vcs.backends" from configuration
backends = config['vcs.backends'] = aslist(
config.get('vcs.backends', 'hg,git'), sep=',')
diff --git a/rhodecode/config/middleware.py b/rhodecode/config/middleware.py
--- a/rhodecode/config/middleware.py
+++ b/rhodecode/config/middleware.py
@@ -22,6 +22,7 @@
Pylons middleware initialization
"""
import logging
+from collections import OrderedDict
from paste.registry import RegistryManager
from paste.gzipper import make_gzip_middleware
@@ -68,6 +69,12 @@ class SkippableRoutesMiddleware(RoutesMi
def __call__(self, environ, start_response):
for prefix in self.skip_prefixes:
if environ['PATH_INFO'].startswith(prefix):
+ # added to avoid the case when a missing /_static route falls
+ # through to pylons and causes an exception as pylons is
+ # expecting wsgiorg.routingargs to be set in the environ
+ # by RoutesMiddleware.
+ if 'wsgiorg.routing_args' not in environ:
+ environ['wsgiorg.routing_args'] = (None, {})
return self.app(environ, start_response)
return super(SkippableRoutesMiddleware, self).__call__(
@@ -228,7 +235,7 @@ def includeme(config):
settings = config.registry.settings
# plugin information
- config.registry.rhodecode_plugins = {}
+ config.registry.rhodecode_plugins = OrderedDict()
config.add_directive(
'register_rhodecode_plugin', register_rhodecode_plugin)
@@ -239,6 +246,7 @@ def includeme(config):
# Includes which are required. The application would fail without them.
config.include('pyramid_mako')
config.include('pyramid_beaker')
+ config.include('rhodecode.channelstream')
config.include('rhodecode.admin')
config.include('rhodecode.authentication')
config.include('rhodecode.integrations')
diff --git a/rhodecode/config/routing.py b/rhodecode/config/routing.py
--- a/rhodecode/config/routing.py
+++ b/rhodecode/config/routing.py
@@ -560,6 +560,13 @@ def make_map(config):
action='my_account_auth_tokens_add', conditions={'method': ['POST']})
m.connect('my_account_auth_tokens', '/my_account/auth_tokens',
action='my_account_auth_tokens_delete', conditions={'method': ['DELETE']})
+ m.connect('my_account_notifications', '/my_account/notifications',
+ action='my_notifications',
+ conditions={'method': ['GET']})
+ m.connect('my_account_notifications_toggle_visibility',
+ '/my_account/toggle_visibility',
+ action='my_notifications_toggle_visibility',
+ conditions={'method': ['POST']})
# NOTIFICATION REST ROUTES
with rmap.submapper(path_prefix=ADMIN_PREFIX,
@@ -568,7 +575,6 @@ def make_map(config):
action='index', conditions={'method': ['GET']})
m.connect('notifications_mark_all_read', '/notifications/mark_all_read',
action='mark_all_read', conditions={'method': ['POST']})
-
m.connect('/notifications/{notification_id}',
action='update', conditions={'method': ['PUT']})
m.connect('/notifications/{notification_id}',
diff --git a/rhodecode/controllers/admin/my_account.py b/rhodecode/controllers/admin/my_account.py
--- a/rhodecode/controllers/admin/my_account.py
+++ b/rhodecode/controllers/admin/my_account.py
@@ -346,3 +346,17 @@ class MyAccountController(BaseController
h.flash(_("Auth token successfully deleted"), category='success')
return redirect(url('my_account_auth_tokens'))
+
+ def my_notifications(self):
+ c.active = 'notifications'
+ return render('admin/my_account/my_account.html')
+
+ @auth.CSRFRequired()
+ def my_notifications_toggle_visibility(self):
+ user = c.rhodecode_user.get_instance()
+ user_data = user.user_data
+ status = user_data.get('notification_status', False)
+ user_data['notification_status'] = not status
+ user.user_data = user_data
+ Session().commit()
+ return redirect(url('my_account_notifications'))
diff --git a/rhodecode/controllers/admin/notifications.py b/rhodecode/controllers/admin/notifications.py
--- a/rhodecode/controllers/admin/notifications.py
+++ b/rhodecode/controllers/admin/notifications.py
@@ -86,6 +86,7 @@ class NotificationsController(BaseContro
return render('admin/notifications/notifications.html')
+
@auth.CSRFRequired()
def mark_all_read(self):
if request.is_xhr:
diff --git a/rhodecode/lib/base.py b/rhodecode/lib/base.py
--- a/rhodecode/lib/base.py
+++ b/rhodecode/lib/base.py
@@ -332,6 +332,7 @@ def attach_context_attributes(context, r
'rhodecode_user': {
'username': None,
'email': None,
+ 'notification_status': False
},
'visual': {
'default_renderer': None
diff --git a/rhodecode/lib/channelstream.py b/rhodecode/lib/channelstream.py
new file mode 100644
--- /dev/null
+++ b/rhodecode/lib/channelstream.py
@@ -0,0 +1,219 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2016-2016 RhodeCode GmbH
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License, version 3
+# (only), as published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+# This program is dual-licensed. If you wish to learn more about the
+# RhodeCode Enterprise Edition, including its added features, Support services,
+# and proprietary license terms, please see https://rhodecode.com/licenses/
+
+import logging
+import os
+
+import itsdangerous
+import requests
+
+from dogpile.core import ReadWriteMutex
+
+import rhodecode.lib.helpers as h
+
+from rhodecode.lib.auth import HasRepoPermissionAny
+from rhodecode.lib.ext_json import json
+from rhodecode.model.db import User
+
+log = logging.getLogger(__name__)
+
+LOCK = ReadWriteMutex()
+
+STATE_PUBLIC_KEYS = ['id', 'username', 'first_name', 'last_name',
+ 'icon_link', 'display_name', 'display_link']
+
+
+class ChannelstreamException(Exception):
+ pass
+
+
+class ChannelstreamConnectionException(Exception):
+ pass
+
+
+class ChannelstreamPermissionException(Exception):
+ pass
+
+
+def channelstream_request(config, payload, endpoint, raise_exc=True):
+ signer = itsdangerous.TimestampSigner(config['secret'])
+ sig_for_server = signer.sign(endpoint)
+ secret_headers = {'x-channelstream-secret': sig_for_server,
+ 'x-channelstream-endpoint': endpoint,
+ 'Content-Type': 'application/json'}
+ req_url = 'http://{}{}'.format(config['server'], endpoint)
+ response = None
+ try:
+ response = requests.post(req_url, data=json.dumps(payload),
+ headers=secret_headers).json()
+ except requests.ConnectionError:
+ log.exception('ConnectionError happened')
+ if raise_exc:
+ raise ChannelstreamConnectionException()
+ except Exception:
+ log.exception('Exception related to channelstream happened')
+ if raise_exc:
+ raise ChannelstreamConnectionException()
+ return response
+
+
+def get_user_data(user_id):
+ user = User.get(user_id)
+ return {
+ 'id': user.user_id,
+ 'username': user.username,
+ 'first_name': user.name,
+ 'last_name': user.lastname,
+ 'icon_link': h.gravatar_url(user.email, 14),
+ 'display_name': h.person(user, 'username_or_name_or_email'),
+ 'display_link': h.link_to_user(user),
+ }
+
+
+def broadcast_validator(channel_name):
+ """ checks if user can access the broadcast channel """
+ if channel_name == 'broadcast':
+ return True
+
+
+def repo_validator(channel_name):
+ """ checks if user can access the broadcast channel """
+ channel_prefix = '/repo$'
+ if channel_name.startswith(channel_prefix):
+ elements = channel_name[len(channel_prefix):].split('$')
+ repo_name = elements[0]
+ can_access = HasRepoPermissionAny(
+ 'repository.read',
+ 'repository.write',
+ 'repository.admin')(repo_name)
+ log.debug('permission check for {} channel '
+ 'resulted in {}'.format(repo_name, can_access))
+ if can_access:
+ return True
+ return False
+
+
+def check_channel_permissions(channels, plugin_validators, should_raise=True):
+ valid_channels = []
+
+ validators = [broadcast_validator, repo_validator]
+ if plugin_validators:
+ validators.extend(plugin_validators)
+ for channel_name in channels:
+ is_valid = False
+ for validator in validators:
+ if validator(channel_name):
+ is_valid = True
+ break
+ if is_valid:
+ valid_channels.append(channel_name)
+ else:
+ if should_raise:
+ raise ChannelstreamPermissionException()
+ return valid_channels
+
+
+def get_channels_info(self, channels):
+ payload = {'channels': channels}
+ # gather persistence info
+ return channelstream_request(self._config(), payload, '/info')
+
+
+def parse_channels_info(info_result, include_channel_info=None):
+ """
+ Returns data that contains only secure information that can be
+ presented to clients
+ """
+ include_channel_info = include_channel_info or []
+
+ user_state_dict = {}
+ for userinfo in info_result['users']:
+ user_state_dict[userinfo['user']] = {
+ k: v for k, v in userinfo['state'].items()
+ if k in STATE_PUBLIC_KEYS
+ }
+
+ channels_info = {}
+
+ for c_name, c_info in info_result['channels'].items():
+ if c_name not in include_channel_info:
+ continue
+ connected_list = []
+ for userinfo in c_info['users']:
+ connected_list.append({
+ 'user': userinfo['user'],
+ 'state': user_state_dict[userinfo['user']]
+ })
+ channels_info[c_name] = {'users': connected_list,
+ 'history': c_info['history']}
+
+ return channels_info
+
+
+def log_filepath(history_location, channel_name):
+ filename = '{}.log'.format(channel_name.encode('hex'))
+ filepath = os.path.join(history_location, filename)
+ return filepath
+
+
+def read_history(history_location, channel_name):
+ filepath = log_filepath(history_location, channel_name)
+ if not os.path.exists(filepath):
+ return []
+ history_lines_limit = -100
+ history = []
+ with open(filepath, 'rb') as f:
+ for line in f.readlines()[history_lines_limit:]:
+ try:
+ history.append(json.loads(line))
+ except Exception:
+ log.exception('Failed to load history')
+ return history
+
+
+def update_history_from_logs(config, channels, payload):
+ history_location = config.get('history.location')
+ for channel in channels:
+ history = read_history(history_location, channel)
+ payload['channels_info'][channel]['history'] = history
+
+
+def write_history(config, message):
+ """ writes a messge to a base64encoded filename """
+ history_location = config.get('history.location')
+ if not os.path.exists(history_location):
+ return
+ try:
+ LOCK.acquire_write_lock()
+ filepath = log_filepath(history_location, message['channel'])
+ with open(filepath, 'ab') as f:
+ json.dump(message, f)
+ f.write('\n')
+ finally:
+ LOCK.release_write_lock()
+
+
+def get_connection_validators(registry):
+ validators = []
+ for k, config in registry.rhodecode_plugins.iteritems():
+ validator = config.get('channelstream', {}).get('connect_validator')
+ if validator:
+ validators.append(validator)
+ return validators
diff --git a/rhodecode/model/comment.py b/rhodecode/model/comment.py
--- a/rhodecode/model/comment.py
+++ b/rhodecode/model/comment.py
@@ -26,10 +26,15 @@ import logging
import traceback
import collections
+from datetime import datetime
+
+from pylons.i18n.translation import _
+from pyramid.threadlocal import get_current_registry
from sqlalchemy.sql.expression import null
from sqlalchemy.sql.functions import coalesce
from rhodecode.lib import helpers as h, diffs
+from rhodecode.lib.channelstream import channelstream_request
from rhodecode.lib.utils import action_logger
from rhodecode.lib.utils2 import extract_mentioned_users
from rhodecode.model import BaseModel
@@ -134,84 +139,82 @@ class ChangesetCommentsModel(BaseModel):
Session().add(comment)
Session().flush()
-
- if send_email:
- kwargs = {
- 'user': user,
- 'renderer_type': renderer,
- 'repo_name': repo.repo_name,
- 'status_change': status_change,
- 'comment_body': text,
- 'comment_file': f_path,
- 'comment_line': line_no,
- }
+ kwargs = {
+ 'user': user,
+ 'renderer_type': renderer,
+ 'repo_name': repo.repo_name,
+ 'status_change': status_change,
+ 'comment_body': text,
+ 'comment_file': f_path,
+ 'comment_line': line_no,
+ }
- if commit_obj:
- recipients = ChangesetComment.get_users(
- revision=commit_obj.raw_id)
- # add commit author if it's in RhodeCode system
- cs_author = User.get_from_cs_author(commit_obj.author)
- if not cs_author:
- # use repo owner if we cannot extract the author correctly
- cs_author = repo.user
- recipients += [cs_author]
+ if commit_obj:
+ recipients = ChangesetComment.get_users(
+ revision=commit_obj.raw_id)
+ # add commit author if it's in RhodeCode system
+ cs_author = User.get_from_cs_author(commit_obj.author)
+ if not cs_author:
+ # use repo owner if we cannot extract the author correctly
+ cs_author = repo.user
+ recipients += [cs_author]
- commit_comment_url = self.get_url(comment)
+ commit_comment_url = self.get_url(comment)
- target_repo_url = h.link_to(
- repo.repo_name,
- h.url('summary_home',
- repo_name=repo.repo_name, qualified=True))
+ target_repo_url = h.link_to(
+ repo.repo_name,
+ h.url('summary_home',
+ repo_name=repo.repo_name, qualified=True))
- # commit specifics
- kwargs.update({
- 'commit': commit_obj,
- 'commit_message': commit_obj.message,
- 'commit_target_repo': target_repo_url,
- 'commit_comment_url': commit_comment_url,
- })
+ # commit specifics
+ kwargs.update({
+ 'commit': commit_obj,
+ 'commit_message': commit_obj.message,
+ 'commit_target_repo': target_repo_url,
+ 'commit_comment_url': commit_comment_url,
+ })
- elif pull_request_obj:
- # get the current participants of this pull request
- recipients = ChangesetComment.get_users(
- pull_request_id=pull_request_obj.pull_request_id)
- # add pull request author
- recipients += [pull_request_obj.author]
+ elif pull_request_obj:
+ # get the current participants of this pull request
+ recipients = ChangesetComment.get_users(
+ pull_request_id=pull_request_obj.pull_request_id)
+ # add pull request author
+ recipients += [pull_request_obj.author]
- # add the reviewers to notification
- recipients += [x.user for x in pull_request_obj.reviewers]
+ # add the reviewers to notification
+ recipients += [x.user for x in pull_request_obj.reviewers]
- pr_target_repo = pull_request_obj.target_repo
- pr_source_repo = pull_request_obj.source_repo
+ pr_target_repo = pull_request_obj.target_repo
+ pr_source_repo = pull_request_obj.source_repo
- pr_comment_url = h.url(
- 'pullrequest_show',
- repo_name=pr_target_repo.repo_name,
- pull_request_id=pull_request_obj.pull_request_id,
- anchor='comment-%s' % comment.comment_id,
- qualified=True,)
+ pr_comment_url = h.url(
+ 'pullrequest_show',
+ repo_name=pr_target_repo.repo_name,
+ pull_request_id=pull_request_obj.pull_request_id,
+ anchor='comment-%s' % comment.comment_id,
+ qualified=True,)
- # set some variables for email notification
- pr_target_repo_url = h.url(
- 'summary_home', repo_name=pr_target_repo.repo_name,
- qualified=True)
+ # set some variables for email notification
+ pr_target_repo_url = h.url(
+ 'summary_home', repo_name=pr_target_repo.repo_name,
+ qualified=True)
- pr_source_repo_url = h.url(
- 'summary_home', repo_name=pr_source_repo.repo_name,
- qualified=True)
+ pr_source_repo_url = h.url(
+ 'summary_home', repo_name=pr_source_repo.repo_name,
+ qualified=True)
- # pull request specifics
- kwargs.update({
- 'pull_request': pull_request_obj,
- 'pr_id': pull_request_obj.pull_request_id,
- 'pr_target_repo': pr_target_repo,
- 'pr_target_repo_url': pr_target_repo_url,
- 'pr_source_repo': pr_source_repo,
- 'pr_source_repo_url': pr_source_repo_url,
- 'pr_comment_url': pr_comment_url,
- 'pr_closing': closing_pr,
- })
-
+ # pull request specifics
+ kwargs.update({
+ 'pull_request': pull_request_obj,
+ 'pr_id': pull_request_obj.pull_request_id,
+ 'pr_target_repo': pr_target_repo,
+ 'pr_target_repo_url': pr_target_repo_url,
+ 'pr_source_repo': pr_source_repo,
+ 'pr_source_repo_url': pr_source_repo_url,
+ 'pr_comment_url': pr_comment_url,
+ 'pr_closing': closing_pr,
+ })
+ if send_email:
# pre-generate the subject for notification itself
(subject,
_h, _e, # we don't care about those
@@ -240,6 +243,44 @@ class ChangesetCommentsModel(BaseModel):
)
action_logger(user, action, comment.repo)
+ registry = get_current_registry()
+ rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
+ channelstream_config = rhodecode_plugins.get('channelstream')
+ msg_url = ''
+ if commit_obj:
+ msg_url = commit_comment_url
+ repo_name = repo.repo_name
+ elif pull_request_obj:
+ msg_url = pr_comment_url
+ repo_name = pr_target_repo.repo_name
+
+ if channelstream_config:
+ message = '{} {} - ' \
+ '' \
+ '{}'
+ message = message.format(
+ user.username, _('made a comment'), msg_url,
+ _('Refresh page'))
+ if channelstream_config:
+ channel = '/repo${}$/pr/{}'.format(
+ repo_name,
+ pull_request_id
+ )
+ payload = {
+ 'type': 'message',
+ 'timestamp': datetime.utcnow(),
+ 'user': 'system',
+ 'channel': channel,
+ 'message': {
+ 'message': message,
+ 'level': 'info',
+ 'topic': '/notifications'
+ }
+ }
+ channelstream_request(channelstream_config, [payload],
+ '/message', raise_exc=False)
+
return comment
def delete(self, comment):
diff --git a/rhodecode/public/css/main.less b/rhodecode/public/css/main.less
--- a/rhodecode/public/css/main.less
+++ b/rhodecode/public/css/main.less
@@ -25,6 +25,7 @@
@import 'comments';
@import 'panels-bootstrap';
@import 'panels';
+@import 'toastr';
@import 'deform';
@@ -1615,6 +1616,10 @@ BIN_FILENODE = 7
float: right;
}
+#notification-status{
+ display: inline;
+}
+
// Repositories
#summary.fields{
diff --git a/rhodecode/public/css/toastr.less b/rhodecode/public/css/toastr.less
new file mode 100644
--- /dev/null
+++ b/rhodecode/public/css/toastr.less
@@ -0,0 +1,268 @@
+// Mix-ins
+.borderRadius(@radius) {
+ -moz-border-radius: @radius;
+ -webkit-border-radius: @radius;
+ border-radius: @radius;
+}
+
+.boxShadow(@boxShadow) {
+ -moz-box-shadow: @boxShadow;
+ -webkit-box-shadow: @boxShadow;
+ box-shadow: @boxShadow;
+}
+
+.opacity(@opacity) {
+ @opacityPercent: @opacity * 100;
+ opacity: @opacity;
+ -ms-filter: ~"progid:DXImageTransform.Microsoft.Alpha(Opacity=@{opacityPercent})";
+ filter: ~"alpha(opacity=@{opacityPercent})";
+}
+
+.wordWrap(@wordWrap: break-word) {
+ -ms-word-wrap: @wordWrap;
+ word-wrap: @wordWrap;
+}
+
+// Variables
+@black: #000000;
+@grey: #999999;
+@light-grey: #CCCCCC;
+@white: #FFFFFF;
+@near-black: #030303;
+@green: #51A351;
+@red: #BD362F;
+@blue: #2F96B4;
+@orange: #F89406;
+@default-container-opacity: .8;
+
+// Styles
+.toast-title {
+ font-weight: bold;
+}
+
+.toast-message {
+ .wordWrap();
+
+ a,
+ label {
+ color: @near-black;
+ }
+
+ a:hover {
+ color: @light-grey;
+ text-decoration: none;
+ }
+}
+
+.toast-close-button {
+ position: relative;
+ right: -0.3em;
+ top: -0.3em;
+ float: right;
+ font-size: 20px;
+ font-weight: bold;
+ color: @black;
+ -webkit-text-shadow: 0 1px 0 rgba(255,255,255,1);
+ text-shadow: 0 1px 0 rgba(255,255,255,1);
+ .opacity(0.8);
+
+ &:hover,
+ &:focus {
+ color: @black;
+ text-decoration: none;
+ cursor: pointer;
+ .opacity(0.4);
+ }
+}
+
+/*Additional properties for button version
+ iOS requires the button element instead of an anchor tag.
+ If you want the anchor version, it requires `href="#"`.*/
+button.toast-close-button {
+ padding: 0;
+ cursor: pointer;
+ background: transparent;
+ border: 0;
+ -webkit-appearance: none;
+}
+
+//#endregion
+
+.toast-top-center {
+ top: 0;
+ right: 0;
+ width: 100%;
+}
+
+.toast-bottom-center {
+ bottom: 0;
+ right: 0;
+ width: 100%;
+}
+
+.toast-top-full-width {
+ top: 0;
+ right: 0;
+ width: 100%;
+}
+
+.toast-bottom-full-width {
+ bottom: 0;
+ right: 0;
+ width: 100%;
+}
+
+.toast-top-left {
+ top: 12px;
+ left: 12px;
+}
+
+.toast-top-right {
+ top: 12px;
+ right: 12px;
+}
+
+.toast-bottom-right {
+ right: 12px;
+ bottom: 12px;
+}
+
+.toast-bottom-left {
+ bottom: 12px;
+ left: 12px;
+}
+
+#toast-container {
+ position: fixed;
+ z-index: 999999;
+ // The container should not be clickable.
+ pointer-events: none;
+ * {
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ }
+
+ > div {
+ position: relative;
+ // The toast itself should be clickable.
+ pointer-events: auto;
+ overflow: hidden;
+ margin: 0 0 6px;
+ padding: 15px;
+ width: 300px;
+ .borderRadius(1px 1px 1px 1px);
+ background-position: 15px center;
+ background-repeat: no-repeat;
+ color: @near-black;
+ .opacity(@default-container-opacity);
+ }
+
+ > :hover {
+ .opacity(1);
+ cursor: pointer;
+ }
+
+ > .toast-info {
+ //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important;
+ }
+
+ > .toast-error {
+ //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAHOSURBVEhLrZa/SgNBEMZzh0WKCClSCKaIYOED+AAKeQQLG8HWztLCImBrYadgIdY+gIKNYkBFSwu7CAoqCgkkoGBI/E28PdbLZmeDLgzZzcx83/zZ2SSXC1j9fr+I1Hq93g2yxH4iwM1vkoBWAdxCmpzTxfkN2RcyZNaHFIkSo10+8kgxkXIURV5HGxTmFuc75B2RfQkpxHG8aAgaAFa0tAHqYFfQ7Iwe2yhODk8+J4C7yAoRTWI3w/4klGRgR4lO7Rpn9+gvMyWp+uxFh8+H+ARlgN1nJuJuQAYvNkEnwGFck18Er4q3egEc/oO+mhLdKgRyhdNFiacC0rlOCbhNVz4H9FnAYgDBvU3QIioZlJFLJtsoHYRDfiZoUyIxqCtRpVlANq0EU4dApjrtgezPFad5S19Wgjkc0hNVnuF4HjVA6C7QrSIbylB+oZe3aHgBsqlNqKYH48jXyJKMuAbiyVJ8KzaB3eRc0pg9VwQ4niFryI68qiOi3AbjwdsfnAtk0bCjTLJKr6mrD9g8iq/S/B81hguOMlQTnVyG40wAcjnmgsCNESDrjme7wfftP4P7SP4N3CJZdvzoNyGq2c/HWOXJGsvVg+RA/k2MC/wN6I2YA2Pt8GkAAAAASUVORK5CYII=") !important;
+ }
+
+ > .toast-success {
+ //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important;
+ }
+
+ > .toast-warning {
+ //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important;
+ }
+
+ /*overrides*/
+ &.toast-top-center > div,
+ &.toast-bottom-center > div {
+ width: 400px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ &.toast-top-full-width > div,
+ &.toast-bottom-full-width > div {
+ width: 96%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+.toast {
+ border-color: @near-black;
+ border-style: solid;
+ border-width: 2px 2px 2px 25px;
+ background-color: @white;
+}
+
+.toast-success {
+ border-color: @green;
+}
+
+.toast-error {
+ border-color: @red;
+}
+
+.toast-info {
+ border-color: @blue;
+}
+
+.toast-warning {
+ border-color: @orange;
+}
+
+.toast-progress {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 4px;
+ background-color: @black;
+ .opacity(0.4);
+}
+
+/*Responsive Design*/
+
+@media all and (max-width: 240px) {
+ #toast-container {
+
+ > div {
+ padding: 8px;
+ width: 11em;
+ }
+
+ & .toast-close-button {
+ right: -0.2em;
+ top: -0.2em;
+ }
+ }
+}
+
+@media all and (min-width: 241px) and (max-width: 480px) {
+ #toast-container {
+ > div {
+ padding: 8px;
+ width: 18em;
+ }
+
+ & .toast-close-button {
+ right: -0.2em;
+ top: -0.2em;
+ }
+ }
+}
+
+@media all and (min-width: 481px) and (max-width: 768px) {
+ #toast-container {
+ > div {
+ padding: 15px;
+ width: 25em;
+ }
+ }
+}
diff --git a/rhodecode/public/js/src/plugins/toastr.js b/rhodecode/public/js/src/plugins/toastr.js
new file mode 100644
--- /dev/null
+++ b/rhodecode/public/js/src/plugins/toastr.js
@@ -0,0 +1,435 @@
+/*
+ * Toastr
+ * Copyright 2012-2015
+ * Authors: John Papa, Hans FjÀllemark, and Tim Ferrell.
+ * All Rights Reserved.
+ * Use, reproduction, distribution, and modification of this code is subject to the terms and
+ * conditions of the MIT license, available at http://www.opensource.org/licenses/mit-license.php
+ *
+ * ARIA Support: Greta Krafsig
+ *
+ * Project: https://github.com/CodeSeven/toastr
+ */
+/* global define */
+(function (define) {
+ define(['jquery'], function ($) {
+ return (function () {
+ var $container;
+ var listener;
+ var toastId = 0;
+ var toastType = {
+ error: 'error',
+ info: 'info',
+ success: 'success',
+ warning: 'warning'
+ };
+
+ var toastr = {
+ clear: clear,
+ remove: remove,
+ error: error,
+ getContainer: getContainer,
+ info: info,
+ options: {},
+ subscribe: subscribe,
+ success: success,
+ version: '2.1.2',
+ warning: warning
+ };
+
+ var previousToast;
+
+ return toastr;
+
+ ////////////////
+
+ function error(message, title, optionsOverride) {
+ return notify({
+ type: toastType.error,
+ iconClass: getOptions().iconClasses.error,
+ message: message,
+ optionsOverride: optionsOverride,
+ title: title
+ });
+ }
+
+ function getContainer(options, create) {
+ if (!options) { options = getOptions(); }
+ $container = $('#' + options.containerId);
+ if ($container.length) {
+ return $container;
+ }
+ if (create) {
+ $container = createContainer(options);
+ }
+ return $container;
+ }
+
+ function info(message, title, optionsOverride) {
+ return notify({
+ type: toastType.info,
+ iconClass: getOptions().iconClasses.info,
+ message: message,
+ optionsOverride: optionsOverride,
+ title: title
+ });
+ }
+
+ function subscribe(callback) {
+ listener = callback;
+ }
+
+ function success(message, title, optionsOverride) {
+ return notify({
+ type: toastType.success,
+ iconClass: getOptions().iconClasses.success,
+ message: message,
+ optionsOverride: optionsOverride,
+ title: title
+ });
+ }
+
+ function warning(message, title, optionsOverride) {
+ return notify({
+ type: toastType.warning,
+ iconClass: getOptions().iconClasses.warning,
+ message: message,
+ optionsOverride: optionsOverride,
+ title: title
+ });
+ }
+
+ function clear($toastElement, clearOptions) {
+ var options = getOptions();
+ if (!$container) { getContainer(options); }
+ if (!clearToast($toastElement, options, clearOptions)) {
+ clearContainer(options);
+ }
+ }
+
+ function remove($toastElement) {
+ var options = getOptions();
+ if (!$container) { getContainer(options); }
+ if ($toastElement && $(':focus', $toastElement).length === 0) {
+ removeToast($toastElement);
+ return;
+ }
+ if ($container.children().length) {
+ $container.remove();
+ }
+ }
+
+ // internal functions
+
+ function clearContainer (options) {
+ var toastsToClear = $container.children();
+ for (var i = toastsToClear.length - 1; i >= 0; i--) {
+ clearToast($(toastsToClear[i]), options);
+ }
+ }
+
+ function clearToast ($toastElement, options, clearOptions) {
+ var force = clearOptions && clearOptions.force ? clearOptions.force : false;
+ if ($toastElement && (force || $(':focus', $toastElement).length === 0)) {
+ $toastElement[options.hideMethod]({
+ duration: options.hideDuration,
+ easing: options.hideEasing,
+ complete: function () { removeToast($toastElement); }
+ });
+ return true;
+ }
+ return false;
+ }
+
+ function createContainer(options) {
+ $container = $('
')
+ .attr('id', options.containerId)
+ .addClass(options.positionClass)
+ .attr('aria-live', 'polite')
+ .attr('role', 'alert');
+
+ $container.appendTo($(options.target));
+ return $container;
+ }
+
+ function getDefaults() {
+ return {
+ tapToDismiss: true,
+ toastClass: 'toast',
+ containerId: 'toast-container',
+ debug: false,
+
+ showMethod: 'fadeIn', //fadeIn, slideDown, and show are built into jQuery
+ showDuration: 300,
+ showEasing: 'swing', //swing and linear are built into jQuery
+ onShown: undefined,
+ hideMethod: 'fadeOut',
+ hideDuration: 1000,
+ hideEasing: 'swing',
+ onHidden: undefined,
+ closeMethod: false,
+ closeDuration: false,
+ closeEasing: false,
+
+ extendedTimeOut: 1000,
+ iconClasses: {
+ error: 'toast-error',
+ info: 'toast-info',
+ success: 'toast-success',
+ warning: 'toast-warning'
+ },
+ iconClass: 'toast-info',
+ positionClass: 'toast-top-right',
+ timeOut: 5000, // Set timeOut and extendedTimeOut to 0 to make it sticky
+ titleClass: 'toast-title',
+ messageClass: 'toast-message',
+ escapeHtml: false,
+ target: 'body',
+ closeHtml: '',
+ newestOnTop: true,
+ preventDuplicates: false,
+ progressBar: false
+ };
+ }
+
+ function publish(args) {
+ if (!listener) { return; }
+ listener(args);
+ }
+
+ function notify(map) {
+ var options = getOptions();
+ var iconClass = map.iconClass || options.iconClass;
+
+ if (typeof (map.optionsOverride) !== 'undefined') {
+ options = $.extend(options, map.optionsOverride);
+ iconClass = map.optionsOverride.iconClass || iconClass;
+ }
+
+ if (shouldExit(options, map)) { return; }
+
+ toastId++;
+
+ $container = getContainer(options, true);
+
+ var intervalId = null;
+ var $toastElement = $('');
+ var $titleElement = $('');
+ var $messageElement = $('');
+ var $progressElement = $('');
+ var $closeElement = $(options.closeHtml);
+ var progressBar = {
+ intervalId: null,
+ hideEta: null,
+ maxHideTime: null
+ };
+ var response = {
+ toastId: toastId,
+ state: 'visible',
+ startTime: new Date(),
+ options: options,
+ map: map
+ };
+
+ personalizeToast();
+
+ displayToast();
+
+ handleEvents();
+
+ publish(response);
+
+ if (options.debug && console) {
+ console.log(response);
+ }
+
+ return $toastElement;
+
+ function escapeHtml(source) {
+ if (source == null)
+ source = "";
+
+ return new String(source)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(//g, '>');
+ }
+
+ function personalizeToast() {
+ setIcon();
+ setTitle();
+ setMessage();
+ setCloseButton();
+ setProgressBar();
+ setSequence();
+ }
+
+ function handleEvents() {
+ $toastElement.hover(stickAround, delayedHideToast);
+ if (!options.onclick && options.tapToDismiss) {
+ $toastElement.click(hideToast);
+ }
+
+ if (options.closeButton && $closeElement) {
+ $closeElement.click(function (event) {
+ if (event.stopPropagation) {
+ event.stopPropagation();
+ } else if (event.cancelBubble !== undefined && event.cancelBubble !== true) {
+ event.cancelBubble = true;
+ }
+ hideToast(true);
+ });
+ }
+
+ if (options.onclick) {
+ $toastElement.click(function (event) {
+ options.onclick(event);
+ hideToast();
+ });
+ }
+ }
+
+ function displayToast() {
+ $toastElement.hide();
+
+ $toastElement[options.showMethod](
+ {duration: options.showDuration, easing: options.showEasing, complete: options.onShown}
+ );
+
+ if (options.timeOut > 0) {
+ intervalId = setTimeout(hideToast, options.timeOut);
+ progressBar.maxHideTime = parseFloat(options.timeOut);
+ progressBar.hideEta = new Date().getTime() + progressBar.maxHideTime;
+ if (options.progressBar) {
+ progressBar.intervalId = setInterval(updateProgress, 10);
+ }
+ }
+ }
+
+ function setIcon() {
+ if (map.iconClass) {
+ $toastElement.addClass(options.toastClass).addClass(iconClass);
+ }
+ }
+
+ function setSequence() {
+ if (options.newestOnTop) {
+ $container.prepend($toastElement);
+ } else {
+ $container.append($toastElement);
+ }
+ }
+
+ function setTitle() {
+ if (map.title) {
+ $titleElement.append(!options.escapeHtml ? map.title : escapeHtml(map.title)).addClass(options.titleClass);
+ $toastElement.append($titleElement);
+ }
+ }
+
+ function setMessage() {
+ if (map.message) {
+ $messageElement.append(!options.escapeHtml ? map.message : escapeHtml(map.message)).addClass(options.messageClass);
+ $toastElement.append($messageElement);
+ }
+ }
+
+ function setCloseButton() {
+ if (options.closeButton) {
+ $closeElement.addClass('toast-close-button').attr('role', 'button');
+ $toastElement.prepend($closeElement);
+ }
+ }
+
+ function setProgressBar() {
+ if (options.progressBar) {
+ $progressElement.addClass('toast-progress');
+ $toastElement.prepend($progressElement);
+ }
+ }
+
+ function shouldExit(options, map) {
+ if (options.preventDuplicates) {
+ if (map.message === previousToast) {
+ return true;
+ } else {
+ previousToast = map.message;
+ }
+ }
+ return false;
+ }
+
+ function hideToast(override) {
+ var method = override && options.closeMethod !== false ? options.closeMethod : options.hideMethod;
+ var duration = override && options.closeDuration !== false ?
+ options.closeDuration : options.hideDuration;
+ var easing = override && options.closeEasing !== false ? options.closeEasing : options.hideEasing;
+ if ($(':focus', $toastElement).length && !override) {
+ return;
+ }
+ clearTimeout(progressBar.intervalId);
+ return $toastElement[method]({
+ duration: duration,
+ easing: easing,
+ complete: function () {
+ removeToast($toastElement);
+ if (options.onHidden && response.state !== 'hidden') {
+ options.onHidden();
+ }
+ response.state = 'hidden';
+ response.endTime = new Date();
+ publish(response);
+ }
+ });
+ }
+
+ function delayedHideToast() {
+ if (options.timeOut > 0 || options.extendedTimeOut > 0) {
+ intervalId = setTimeout(hideToast, options.extendedTimeOut);
+ progressBar.maxHideTime = parseFloat(options.extendedTimeOut);
+ progressBar.hideEta = new Date().getTime() + progressBar.maxHideTime;
+ }
+ }
+
+ function stickAround() {
+ clearTimeout(intervalId);
+ progressBar.hideEta = 0;
+ $toastElement.stop(true, true)[options.showMethod](
+ {duration: options.showDuration, easing: options.showEasing}
+ );
+ }
+
+ function updateProgress() {
+ var percentage = ((progressBar.hideEta - (new Date().getTime())) / progressBar.maxHideTime) * 100;
+ $progressElement.width(percentage + '%');
+ }
+ }
+
+ function getOptions() {
+ return $.extend({}, getDefaults(), toastr.options);
+ }
+
+ function removeToast($toastElement) {
+ if (!$container) { $container = getContainer(); }
+ if ($toastElement.is(':visible')) {
+ return;
+ }
+ $toastElement.remove();
+ $toastElement = null;
+ if ($container.children().length === 0) {
+ $container.remove();
+ previousToast = undefined;
+ }
+ }
+
+ })();
+ });
+}(typeof define === 'function' && define.amd ? define : function (deps, factory) {
+ if (typeof module !== 'undefined' && module.exports) { //Node
+ module.exports = factory(require('jquery'));
+ } else {
+ window.toastr = factory(window.jQuery);
+ }
+}));
diff --git a/rhodecode/public/js/src/rhodecode/connection_controller.js b/rhodecode/public/js/src/rhodecode/connection_controller.js
new file mode 100644
--- /dev/null
+++ b/rhodecode/public/js/src/rhodecode/connection_controller.js
@@ -0,0 +1,219 @@
+"use strict";
+/** leak object to top level scope **/
+var ccLog = undefined;
+// global code-mirror logger;, to enable run
+// Logger.get('ConnectionController').setLevel(Logger.DEBUG)
+ccLog = Logger.get('ConnectionController');
+ccLog.setLevel(Logger.OFF);
+
+var ConnectionController;
+var connCtrlr;
+var registerViewChannels;
+
+(function () {
+ ConnectionController = function (webappUrl, serverUrl, urls) {
+ var self = this;
+
+ var channels = ['broadcast'];
+ this.state = {
+ open: false,
+ webappUrl: webappUrl,
+ serverUrl: serverUrl,
+ connId: null,
+ socket: null,
+ channels: channels,
+ heartbeat: null,
+ channelsInfo: {},
+ urls: urls
+ };
+ this.channelNameParsers = [];
+
+ this.addChannelNameParser = function (fn) {
+ if (this.channelNameParsers.indexOf(fn) === -1) {
+ this.channelNameParsers.push(fn);
+ }
+ };
+
+ this.listen = function () {
+ if (window.WebSocket) {
+ ccLog.debug('attempting to create socket');
+ var socket_url = self.state.serverUrl + "/ws?conn_id=" + self.state.connId;
+ var socket_conf = {
+ url: socket_url,
+ handleAs: 'json',
+ headers: {
+ "Accept": "application/json",
+ "Content-Type": "application/json"
+ }
+ };
+ self.state.socket = new WebSocket(socket_conf.url);
+
+ self.state.socket.onopen = function (event) {
+ ccLog.debug('open event', event);
+ if (self.state.heartbeat === null) {
+ self.state.heartbeat = setInterval(function () {
+ if (self.state.socket.readyState === WebSocket.OPEN) {
+ self.state.socket.send('heartbeat');
+ }
+ }, 10000)
+ }
+ };
+ self.state.socket.onmessage = function (event) {
+ var data = $.parseJSON(event.data);
+ for (var i = 0; i < data.length; i++) {
+ if (data[i].message.topic) {
+ ccLog.debug('publishing',
+ data[i].message.topic, data[i]);
+ $.Topic(data[i].message.topic).publish(data[i])
+ }
+ else {
+ cclog.warning('unhandled message', data);
+ }
+ }
+ };
+ self.state.socket.onclose = function (event) {
+ ccLog.debug('closed event', event);
+ setTimeout(function () {
+ self.connect(true);
+ }, 5000);
+ };
+
+ self.state.socket.onerror = function (event) {
+ ccLog.debug('error event', event);
+ };
+ }
+ else {
+ ccLog.debug('attempting to create long polling connection');
+ var poolUrl = self.state.serverUrl + "/listen?conn_id=" + self.state.connId;
+ self.state.socket = $.ajax({
+ url: poolUrl
+ }).done(function (data) {
+ ccLog.debug('data', data);
+ var data = $.parseJSON(data);
+ for (var i = 0; i < data.length; i++) {
+ if (data[i].message.topic) {
+ ccLog.info('publishing',
+ data[i].message.topic, data[i]);
+ $.Topic(data[i].message.topic).publish(data[i])
+ }
+ else {
+ cclog.warning('unhandled message', data);
+ }
+ }
+ self.listen();
+ }).fail(function () {
+ ccLog.debug('longpoll error');
+ setTimeout(function () {
+ self.connect(true);
+ }, 5000);
+ });
+ }
+
+ };
+
+ this.connect = function (create_new_socket) {
+ var connReq = {'channels': self.state.channels};
+ ccLog.debug('try obtaining connection info', connReq);
+ $.ajax({
+ url: self.state.urls.connect,
+ type: "POST",
+ contentType: "application/json",
+ data: JSON.stringify(connReq),
+ dataType: "json"
+ }).done(function (data) {
+ ccLog.debug('Got connection:', data.conn_id);
+ self.state.channels = data.channels;
+ self.state.channelsInfo = data.channels_info;
+ self.state.connId = data.conn_id;
+ if (create_new_socket) {
+ self.listen();
+ }
+ self.update();
+ }).fail(function () {
+ setTimeout(function () {
+ self.connect(create_new_socket);
+ }, 5000);
+ });
+ self.update();
+ };
+
+ this.subscribeToChannels = function (channels) {
+ var new_channels = [];
+ for (var i = 0; i < channels.length; i++) {
+ var channel = channels[i];
+ if (self.state.channels.indexOf(channel)) {
+ self.state.channels.push(channel);
+ new_channels.push(channel)
+ }
+ }
+ /**
+ * only execute the request if socket is present because subscribe
+ * can actually add channels before initial app connection
+ **/
+ if (new_channels && self.state.socket !== null) {
+ var connReq = {
+ 'channels': self.state.channels,
+ 'conn_id': self.state.connId
+ };
+ $.ajax({
+ url: self.state.urls.subscribe,
+ type: "POST",
+ contentType: "application/json",
+ data: JSON.stringify(connReq),
+ dataType: "json"
+ }).done(function (data) {
+ self.state.channels = data.channels;
+ self.state.channelsInfo = data.channels_info;
+ self.update();
+ });
+ }
+ self.update();
+ };
+
+ this.update = function () {
+ for (var key in this.state.channelsInfo) {
+ if (this.state.channelsInfo.hasOwnProperty(key)) {
+ // update channels with latest info
+ $.Topic('/connection_controller/channel_update').publish(
+ {channel: key, state: this.state.channelsInfo[key]});
+ }
+ }
+ /**
+ * checks current channel list in state and if channel is not present
+ * converts them into executable "commands" and pushes them on topics
+ */
+ for (var i = 0; i < this.state.channels.length; i++) {
+ var channel = this.state.channels[i];
+ for (var j = 0; j < this.channelNameParsers.length; j++) {
+ this.channelNameParsers[j](channel);
+ }
+ }
+ };
+
+ this.run = function () {
+ this.connect(true);
+ };
+
+ $.Topic('/connection_controller/subscribe').subscribe(
+ self.subscribeToChannels);
+ };
+
+ $.Topic('/plugins/__REGISTER__').subscribe(function (data) {
+ // enable chat controller
+ if (window.CHANNELSTREAM_SETTINGS && window.CHANNELSTREAM_SETTINGS.enabled) {
+ $(document).ready(function () {
+ connCtrlr.run();
+ });
+ }
+ });
+
+registerViewChannels = function (){
+ // subscribe to PR repo channel for PR's'
+ if (templateContext.pull_request_data.pull_request_id) {
+ var channelName = '/repo$' + templateContext.repo_name + '$/pr/' +
+ String(templateContext.pull_request_data.pull_request_id);
+ connCtrlr.state.channels.push(channelName);
+ }
+}
+
+})();
diff --git a/rhodecode/public/js/src/rhodecode/utils/notifications.js b/rhodecode/public/js/src/rhodecode/utils/notifications.js
new file mode 100644
--- /dev/null
+++ b/rhodecode/public/js/src/rhodecode/utils/notifications.js
@@ -0,0 +1,60 @@
+"use strict";
+
+toastr.options = {
+ "closeButton": true,
+ "debug": false,
+ "newestOnTop": false,
+ "progressBar": false,
+ "positionClass": "toast-top-center",
+ "preventDuplicates": false,
+ "onclick": null,
+ "showDuration": "300",
+ "hideDuration": "300",
+ "timeOut": "0",
+ "extendedTimeOut": "0",
+ "showEasing": "swing",
+ "hideEasing": "linear",
+ "showMethod": "fadeIn",
+ "hideMethod": "fadeOut"
+};
+
+function notifySystem(data) {
+ var notification = new Notification(data.message.level + ': ' + data.message.message);
+};
+
+function notifyToaster(data){
+ toastr[data.message.level](data.message.message);
+}
+
+function handleNotifications(data) {
+
+ if (!templateContext.rhodecode_user.notification_status && !data.testMessage) {
+ // do not act if notifications are disabled
+ return
+ }
+ // use only js notifications for now
+ var onlyJS = true;
+ if (!("Notification" in window) || onlyJS) {
+ // use legacy notificartion
+ notifyToaster(data);
+ }
+ else {
+ // Let's check whether notification permissions have already been granted
+ if (Notification.permission === "granted") {
+ notifySystem(data);
+ }
+ // Otherwise, we need to ask the user for permission
+ else if (Notification.permission !== 'denied') {
+ Notification.requestPermission(function (permission) {
+ if (permission === "granted") {
+ notifySystem(data);
+ }
+ });
+ }
+ else{
+ notifyToaster(data);
+ }
+ }
+};
+
+$.Topic('/notifications').subscribe(handleNotifications);
diff --git a/rhodecode/public/js/topics_list.txt b/rhodecode/public/js/topics_list.txt
--- a/rhodecode/public/js/topics_list.txt
+++ b/rhodecode/public/js/topics_list.txt
@@ -1,3 +1,4 @@
/plugins/__REGISTER__ - launched after the onDomReady() code from rhodecode.js is executed
/ui/plugins/code/anchor_focus - launched when rc starts to scroll on load to anchor on PR/Codeview
/ui/plugins/code/comment_form_built - launched when injectInlineForm() is executed and the form object is created
+/notifications - shows new event notifications
\ No newline at end of file
diff --git a/rhodecode/templates/admin/my_account/my_account.html b/rhodecode/templates/admin/my_account/my_account.html
--- a/rhodecode/templates/admin/my_account/my_account.html
+++ b/rhodecode/templates/admin/my_account/my_account.html
@@ -39,6 +39,7 @@
${_('Watched')}
${_('Pull Requests')}
${_('My Permissions')}
+ ${_('My Live Notifications')}
diff --git a/rhodecode/templates/admin/my_account/my_account_emails.html b/rhodecode/templates/admin/my_account/my_account_notifications.html
copy from rhodecode/templates/admin/my_account/my_account_emails.html
copy to rhodecode/templates/admin/my_account/my_account_notifications.html
--- a/rhodecode/templates/admin/my_account/my_account_emails.html
+++ b/rhodecode/templates/admin/my_account/my_account_notifications.html
@@ -1,72 +1,56 @@
-<%namespace name="base" file="/base/base.html"/>
-
-
${_('Account Emails')}
+ ${_('Your live notification settings')}
-
-
-
-
- ${base.gravatar(c.user.email, 16)}
- ${c.user.email}
- |
-
- ${_('Primary')}
- |
-
- %if c.user_email_map:
- %for em in c.user_email_map:
-
-
- ${base.gravatar(em.email, 16)}
- ${em.email}
- |
-
- ${h.secure_form(url('my_account_emails'),method='delete')}
- ${h.hidden('del_email_id',em.email_id)}
-
- ${h.end_form()}
- |
-
- %endfor
- %else:
-
-
-
- ${_('No additional emails specified')}
-
- |
-
- %endif
-
-
+
+
IMPORTANT: This feature requires enabled channelstream websocket server to function correctly.
+
+
Status of browser notifications permission:
-
- ${h.secure_form(url('my_account_emails'), method='post')}
-
+ ${h.secure_form(url('my_account_notifications_toggle_visibility'), method='post', id='notification-status')}
+
${h.end_form()}
-
+
+
Test notification
+
+
+
diff --git a/rhodecode/templates/base/plugins_base.html b/rhodecode/templates/base/plugins_base.html
--- a/rhodecode/templates/base/plugins_base.html
+++ b/rhodecode/templates/base/plugins_base.html
@@ -3,7 +3,7 @@ from pyramid.renderers import render as
from pyramid.threadlocal import get_current_registry, get_current_request
pyramid_registry = get_current_registry()
%>
-% for plugin, config in pyramid_registry.rhodecode_plugins.items():
+% for plugin, config in getattr(pyramid_registry, 'rhodecode_plugins', {}).items():
% if config['template_hooks'].get('plugin_init_template'):
${pyramid_render(config['template_hooks'].get('plugin_init_template'),
{'config':config}, request=get_current_request(), package='rc_ae')|n}
diff --git a/rhodecode/templates/base/root.html b/rhodecode/templates/base/root.html
--- a/rhodecode/templates/base/root.html
+++ b/rhodecode/templates/base/root.html
@@ -11,6 +11,7 @@ if hasattr(c, 'rhodecode_db_repo'):
if getattr(c, 'rhodecode_user', None) and c.rhodecode_user.user_id:
c.template_context['rhodecode_user']['username'] = c.rhodecode_user.username
c.template_context['rhodecode_user']['email'] = c.rhodecode_user.email
+ c.template_context['rhodecode_user']['notification_status'] = c.rhodecode_user.get_instance().user_data.get('notification_status', True)
c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer')
%>
@@ -77,7 +78,6 @@ c.template_context['visual']['default_re
}
};
-
@@ -105,7 +105,6 @@ c.template_context['visual']['default_re
<%def name="head_extra()">%def>
${self.head_extra()}
-
<%include file="/base/plugins_base.html"/>
## extra stuff
diff --git a/rhodecode/templates/channelstream/plugin_init.html b/rhodecode/templates/channelstream/plugin_init.html
new file mode 100644
--- /dev/null
+++ b/rhodecode/templates/channelstream/plugin_init.html
@@ -0,0 +1,24 @@
+