notifications: support real-time notifications with websockets via channelstream

This commit is contained in:
Marcin Lulek 2016-08-02 16:18:44 +02:00
parent b70466f693
commit e9d5eb09c5
25 changed files with 1728 additions and 81 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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')

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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

View file

@ -112,6 +112,13 @@ def load_environment(global_conf, app_conf, initial=False,
# 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=',')

View file

@ -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(RoutesMiddleware):
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')

View file

@ -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}',

View file

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

View file

@ -86,6 +86,7 @@ class NotificationsController(BaseController):
return render('admin/notifications/notifications.html')
@auth.CSRFRequired()
def mark_all_read(self):
if request.is_xhr:

View file

@ -332,6 +332,7 @@ def attach_context_attributes(context, request):
'rhodecode_user': {
'username': None,
'email': None,
'notification_status': False
},
'visual': {
'default_renderer': None

View file

@ -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 <http://www.gnu.org/licenses/>.
#
# 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

View file

@ -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,8 +139,6 @@ class ChangesetCommentsModel(BaseModel):
Session().add(comment)
Session().flush()
if send_email:
kwargs = {
'user': user,
'renderer_type': renderer,
@ -211,7 +214,7 @@ class ChangesetCommentsModel(BaseModel):
'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 = '<strong>{}</strong> {} - ' \
'<a onclick="window.location=\'{}\';' \
'window.location.reload()">' \
'<strong>{}</strong></a>'
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):

View file

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

View file

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

View file

@ -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 = $('<div/>')
.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: '<button type="button">&times;</button>',
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 = $('<div/>');
var $titleElement = $('<div/>');
var $messageElement = $('<div/>');
var $progressElement = $('<div/>');
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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);
}
}));

View file

@ -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);
}
}
})();

View file

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

View file

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

View file

@ -39,6 +39,7 @@
<li class="${'active' if c.active=='watched' else ''}"><a href="${h.url('my_account_watched')}">${_('Watched')}</a></li>
<li class="${'active' if c.active=='pullrequests' else ''}"><a href="${h.url('my_account_pullrequests')}">${_('Pull Requests')}</a></li>
<li class="${'active' if c.active=='perms' else ''}"><a href="${h.url('my_account_perms')}">${_('My Permissions')}</a></li>
<li class="${'active' if c.active=='my_notifications' else ''}"><a href="${h.url('my_account_notifications')}">${_('My Live Notifications')}</a></li>
</ul>
</div>

View file

@ -0,0 +1,56 @@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">${_('Your live notification settings')}</h3>
</div>
<div class="panel-body">
<p><strong>IMPORTANT:</strong> This feature requires enabled channelstream websocket server to function correctly.</p>
<p class="hidden">Status of browser notifications permission: <strong id="browser-notification-status"></strong></p>
${h.secure_form(url('my_account_notifications_toggle_visibility'), method='post', id='notification-status')}
<button class="btn btn-default" type="submit">
${_('Notifications')} <strong>${_('Enabled') if c.rhodecode_user.get_instance().user_data.get('notification_status') else _('Disabled')}</strong>
</button>
${h.end_form()}
<a class="btn btn-info" id="test-notification">Test notification</a>
</div>
</div>
<script type="application/javascript">
function checkBrowserStatus(){
var browserStatus = 'Unknown';
if (!("Notification" in window)) {
browserStatus = 'Not supported'
}
else if(Notification.permission === 'denied'){
browserStatus = 'Denied';
$('.flash_msg').append('<div class="alert alert-error">Notifications are blocked on browser level - you need to enable them in your browser settings.</div>')
}
else if(Notification.permission === 'granted'){
browserStatus = 'Allowed';
}
$('#browser-notification-status').text(browserStatus);
};
checkBrowserStatus();
$('#test-notification').on('click', function(e){
var levels = ['info', 'error', 'warning', 'success'];
var level = levels[Math.floor(Math.random()*levels.length)];
var payload = {
message: {
message: 'This is a test notification.',
level: level,
testMessage: true
}
};
$.Topic('/notifications').publish(payload);
})
</script>

View file

@ -3,7 +3,7 @@ from pyramid.renderers import render as pyramid_render
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}

View file

@ -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_renderer'] = h.get_visual_attr(c, 'default
}
};
</script>
<!--[if lt IE 9]>
<script language="javascript" type="text/javascript" src="${h.asset('js/excanvas.min.js')}"></script>
<![endif]-->
@ -105,7 +105,6 @@ c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default
<%def name="head_extra()"></%def>
${self.head_extra()}
<%include file="/base/plugins_base.html"/>
## extra stuff

View file

@ -0,0 +1,24 @@
<script>
var CHANNELSTREAM_URLS = ${config['url_gen'](request)|n};
%if request.registry.rhodecode_plugins['channelstream']['enabled'] and c.rhodecode_user.username != h.DEFAULT_USER:
var CHANNELSTREAM_SETTINGS = {
'enabled': true,
'ws_location': '${request.registry.settings.get('channelstream.ws_url')}',
'webapp_location': '${h.url('/', qualified=True)[:-1]}'
};
%else:
var CHANNELSTREAM_SETTINGS = {
'enabled':false,
'ws_location': '',
'webapp_location': ''};
%endif
if (CHANNELSTREAM_SETTINGS.enabled) {
connCtrlr = new ConnectionController(
CHANNELSTREAM_SETTINGS.webapp_location,
CHANNELSTREAM_SETTINGS.ws_location,
CHANNELSTREAM_URLS
);
registerViewChannels();
}
</script>