notifications: support real-time notifications with websockets via channelstream
This commit is contained in:
parent
b70466f693
commit
e9d5eb09c5
25 changed files with 1728 additions and 81 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
###################################
|
||||
|
|
|
|||
|
|
@ -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 ##
|
||||
###################################
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
rhodecode/channelstream/__init__.py
Normal file
79
rhodecode/channelstream/__init__.py
Normal 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')
|
||||
177
rhodecode/channelstream/views.py
Normal file
177
rhodecode/channelstream/views.py
Normal 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
|
||||
|
|
@ -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=',')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ class NotificationsController(BaseController):
|
|||
|
||||
return render('admin/notifications/notifications.html')
|
||||
|
||||
|
||||
@auth.CSRFRequired()
|
||||
def mark_all_read(self):
|
||||
if request.is_xhr:
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ def attach_context_attributes(context, request):
|
|||
'rhodecode_user': {
|
||||
'username': None,
|
||||
'email': None,
|
||||
'notification_status': False
|
||||
},
|
||||
'visual': {
|
||||
'default_renderer': None
|
||||
|
|
|
|||
219
rhodecode/lib/channelstream.py
Normal file
219
rhodecode/lib/channelstream.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
268
rhodecode/public/css/toastr.less
Normal file
268
rhodecode/public/css/toastr.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
435
rhodecode/public/js/src/plugins/toastr.js
Normal file
435
rhodecode/public/js/src/plugins/toastr.js
Normal 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">×</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, '&')
|
||||
.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);
|
||||
}
|
||||
}));
|
||||
219
rhodecode/public/js/src/rhodecode/connection_controller.js
Normal file
219
rhodecode/public/js/src/rhodecode/connection_controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
60
rhodecode/public/js/src/rhodecode/utils/notifications.js
Normal file
60
rhodecode/public/js/src/rhodecode/utils/notifications.js
Normal 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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
rhodecode/templates/channelstream/plugin_init.html
Normal file
24
rhodecode/templates/channelstream/plugin_init.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue