# HG changeset patch # User Marcin Lulek # Date 2016-08-02 14:18:44 # Node ID 1b57d2eeac4432998930386afdeb187cb563235e # Parent 7fdf0e8becc023327751c6dd78fe61996702c215 notifications: support real-time notifications with websockets via channelstream 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')}

    -
    - - - - - - %if c.user_email_map: - %for em in c.user_email_map: - - - - - %endfor - %else: - - - - %endif - -
    + +

    IMPORTANT: This feature requires enabled channelstream websocket server to function correctly.

    + + -
    - ${h.secure_form(url('my_account_emails'), method='post')} -
    - -
    -
    -
    - -
    -
    - ${h.text('new_email', class_='medium')} -
    -
    -
    - ${h.submit('save',_('Add'),class_="btn")} - ${h.reset('reset',_('Reset'),class_="btn")} -
    -
    -
    + ${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()"> ${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 @@ +