Show More
@@ -0,0 +1,79 b'' | |||
|
1 | # -*- coding: utf-8 -*- | |
|
2 | ||
|
3 | # Copyright (C) 2010-2016 RhodeCode GmbH | |
|
4 | # | |
|
5 | # This program is free software: you can redistribute it and/or modify | |
|
6 | # it under the terms of the GNU Affero General Public License, version 3 | |
|
7 | # (only), as published by the Free Software Foundation. | |
|
8 | # | |
|
9 | # This program is distributed in the hope that it will be useful, | |
|
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
|
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
|
12 | # GNU General Public License for more details. | |
|
13 | # | |
|
14 | # You should have received a copy of the GNU Affero General Public License | |
|
15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
|
16 | # | |
|
17 | # This program is dual-licensed. If you wish to learn more about the | |
|
18 | # RhodeCode Enterprise Edition, including its added features, Support services, | |
|
19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ | |
|
20 | ||
|
21 | import os | |
|
22 | ||
|
23 | from pyramid.settings import asbool | |
|
24 | ||
|
25 | from rhodecode.config.routing import ADMIN_PREFIX | |
|
26 | from rhodecode.lib.ext_json import json | |
|
27 | ||
|
28 | ||
|
29 | def url_gen(request): | |
|
30 | urls = { | |
|
31 | 'connect': request.route_url('channelstream_connect'), | |
|
32 | 'subscribe': request.route_url('channelstream_subscribe') | |
|
33 | } | |
|
34 | return json.dumps(urls) | |
|
35 | ||
|
36 | ||
|
37 | PLUGIN_DEFINITION = { | |
|
38 | 'name': 'channelstream', | |
|
39 | 'config': { | |
|
40 | 'javascript': [], | |
|
41 | 'css': [], | |
|
42 | 'template_hooks': { | |
|
43 | 'plugin_init_template': 'rhodecode:templates/channelstream/plugin_init.html' | |
|
44 | }, | |
|
45 | 'url_gen': url_gen, | |
|
46 | 'static': None, | |
|
47 | 'enabled': False, | |
|
48 | 'server': '', | |
|
49 | 'secret': '' | |
|
50 | } | |
|
51 | } | |
|
52 | ||
|
53 | ||
|
54 | def includeme(config): | |
|
55 | settings = config.registry.settings | |
|
56 | PLUGIN_DEFINITION['config']['enabled'] = asbool( | |
|
57 | settings.get('channelstream.enabled')) | |
|
58 | PLUGIN_DEFINITION['config']['server'] = settings.get( | |
|
59 | 'channelstream.server', '') | |
|
60 | PLUGIN_DEFINITION['config']['secret'] = settings.get( | |
|
61 | 'channelstream.secret', '') | |
|
62 | PLUGIN_DEFINITION['config']['history.location'] = settings.get( | |
|
63 | 'channelstream.history.location', '') | |
|
64 | config.register_rhodecode_plugin( | |
|
65 | PLUGIN_DEFINITION['name'], | |
|
66 | PLUGIN_DEFINITION['config'] | |
|
67 | ) | |
|
68 | # create plugin history location | |
|
69 | history_dir = PLUGIN_DEFINITION['config']['history.location'] | |
|
70 | if history_dir and not os.path.exists(history_dir): | |
|
71 | os.makedirs(history_dir, 0750) | |
|
72 | ||
|
73 | config.add_route( | |
|
74 | name='channelstream_connect', | |
|
75 | pattern=ADMIN_PREFIX + '/channelstream/connect') | |
|
76 | config.add_route( | |
|
77 | name='channelstream_subscribe', | |
|
78 | pattern=ADMIN_PREFIX + '/channelstream/subscribe') | |
|
79 | config.scan('rhodecode.channelstream') |
@@ -0,0 +1,177 b'' | |||
|
1 | # -*- coding: utf-8 -*- | |
|
2 | ||
|
3 | # Copyright (C) 2010-2016 RhodeCode GmbH | |
|
4 | # | |
|
5 | # This program is free software: you can redistribute it and/or modify | |
|
6 | # it under the terms of the GNU Affero General Public License, version 3 | |
|
7 | # (only), as published by the Free Software Foundation. | |
|
8 | # | |
|
9 | # This program is distributed in the hope that it will be useful, | |
|
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
|
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
|
12 | # GNU General Public License for more details. | |
|
13 | # | |
|
14 | # You should have received a copy of the GNU Affero General Public License | |
|
15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
|
16 | # | |
|
17 | # This program is dual-licensed. If you wish to learn more about the | |
|
18 | # RhodeCode Enterprise Edition, including its added features, Support services, | |
|
19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ | |
|
20 | ||
|
21 | """ | |
|
22 | Channel Stream controller for rhodecode | |
|
23 | ||
|
24 | :created_on: Oct 10, 2015 | |
|
25 | :author: marcinl | |
|
26 | :copyright: (c) 2013-2015 RhodeCode GmbH. | |
|
27 | :license: Commercial License, see LICENSE for more details. | |
|
28 | """ | |
|
29 | ||
|
30 | import logging | |
|
31 | import uuid | |
|
32 | ||
|
33 | from pylons import tmpl_context as c | |
|
34 | from pyramid.settings import asbool | |
|
35 | from pyramid.view import view_config | |
|
36 | from webob.exc import HTTPBadRequest, HTTPForbidden, HTTPBadGateway | |
|
37 | ||
|
38 | from rhodecode.lib.channelstream import ( | |
|
39 | channelstream_request, | |
|
40 | ChannelstreamConnectionException, | |
|
41 | ChannelstreamPermissionException, | |
|
42 | check_channel_permissions, | |
|
43 | get_connection_validators, | |
|
44 | get_user_data, | |
|
45 | parse_channels_info, | |
|
46 | update_history_from_logs, | |
|
47 | STATE_PUBLIC_KEYS) | |
|
48 | from rhodecode.lib.auth import NotAnonymous | |
|
49 | from rhodecode.lib.utils2 import str2bool | |
|
50 | ||
|
51 | log = logging.getLogger(__name__) | |
|
52 | ||
|
53 | ||
|
54 | class ChannelstreamView(object): | |
|
55 | def __init__(self, context, request): | |
|
56 | self.context = context | |
|
57 | self.request = request | |
|
58 | ||
|
59 | # Some of the decorators rely on this attribute to be present | |
|
60 | # on the class of the decorated method. | |
|
61 | self._rhodecode_user = request.user | |
|
62 | registry = request.registry | |
|
63 | self.channelstream_config = registry.rhodecode_plugins['channelstream'] | |
|
64 | if not self.channelstream_config.get('enabled'): | |
|
65 | log.exception('Channelstream plugin is disabled') | |
|
66 | raise HTTPBadRequest() | |
|
67 | ||
|
68 | @NotAnonymous() | |
|
69 | @view_config(route_name='channelstream_connect', renderer='json') | |
|
70 | def connect(self): | |
|
71 | """ handle authorization of users trying to connect """ | |
|
72 | try: | |
|
73 | json_body = self.request.json_body | |
|
74 | except Exception: | |
|
75 | log.exception('Failed to decode json from request') | |
|
76 | raise HTTPBadRequest() | |
|
77 | try: | |
|
78 | channels = check_channel_permissions( | |
|
79 | json_body.get('channels'), | |
|
80 | get_connection_validators(self.request.registry)) | |
|
81 | except ChannelstreamPermissionException: | |
|
82 | log.error('Incorrect permissions for requested channels') | |
|
83 | raise HTTPForbidden() | |
|
84 | ||
|
85 | user = c.rhodecode_user | |
|
86 | if user.user_id: | |
|
87 | user_data = get_user_data(user.user_id) | |
|
88 | else: | |
|
89 | user_data = { | |
|
90 | 'id': None, | |
|
91 | 'username': None, | |
|
92 | 'first_name': None, | |
|
93 | 'last_name': None, | |
|
94 | 'icon_link': None, | |
|
95 | 'display_name': None, | |
|
96 | 'display_link': None, | |
|
97 | } | |
|
98 | payload = { | |
|
99 | 'username': user.username, | |
|
100 | 'user_state': user_data, | |
|
101 | 'conn_id': str(uuid.uuid4()), | |
|
102 | 'channels': channels, | |
|
103 | 'channel_configs': {}, | |
|
104 | 'state_public_keys': STATE_PUBLIC_KEYS, | |
|
105 | 'info': { | |
|
106 | 'exclude_channels': ['broadcast'] | |
|
107 | } | |
|
108 | } | |
|
109 | filtered_channels = [channel for channel in channels | |
|
110 | if channel != 'broadcast'] | |
|
111 | for channel in filtered_channels: | |
|
112 | payload['channel_configs'][channel] = { | |
|
113 | 'notify_presence': True, | |
|
114 | 'history_size': 100, | |
|
115 | 'store_history': True, | |
|
116 | 'broadcast_presence_with_user_lists': True | |
|
117 | } | |
|
118 | # connect user to server | |
|
119 | try: | |
|
120 | connect_result = channelstream_request(self.channelstream_config, | |
|
121 | payload, '/connect') | |
|
122 | except ChannelstreamConnectionException: | |
|
123 | log.exception('Channelstream service is down') | |
|
124 | return HTTPBadGateway() | |
|
125 | ||
|
126 | connect_result['channels'] = channels | |
|
127 | connect_result['channels_info'] = parse_channels_info( | |
|
128 | connect_result['channels_info'], | |
|
129 | include_channel_info=filtered_channels) | |
|
130 | update_history_from_logs(self.channelstream_config, | |
|
131 | filtered_channels, connect_result) | |
|
132 | return connect_result | |
|
133 | ||
|
134 | @NotAnonymous() | |
|
135 | @view_config(route_name='channelstream_subscribe', renderer='json') | |
|
136 | def subscribe(self): | |
|
137 | """ can be used to subscribe specific connection to other channels """ | |
|
138 | try: | |
|
139 | json_body = self.request.json_body | |
|
140 | except Exception: | |
|
141 | log.exception('Failed to decode json from request') | |
|
142 | raise HTTPBadRequest() | |
|
143 | try: | |
|
144 | channels = check_channel_permissions( | |
|
145 | json_body.get('channels'), | |
|
146 | get_connection_validators(self.request.registry)) | |
|
147 | except ChannelstreamPermissionException: | |
|
148 | log.error('Incorrect permissions for requested channels') | |
|
149 | raise HTTPForbidden() | |
|
150 | payload = {'conn_id': json_body.get('conn_id', ''), | |
|
151 | 'channels': channels, | |
|
152 | 'channel_configs': {}, | |
|
153 | 'info': { | |
|
154 | 'exclude_channels': ['broadcast']} | |
|
155 | } | |
|
156 | filtered_channels = [chan for chan in channels if chan != 'broadcast'] | |
|
157 | for channel in filtered_channels: | |
|
158 | payload['channel_configs'][channel] = { | |
|
159 | 'notify_presence': True, | |
|
160 | 'history_size': 100, | |
|
161 | 'store_history': True, | |
|
162 | 'broadcast_presence_with_user_lists': True | |
|
163 | } | |
|
164 | try: | |
|
165 | connect_result = channelstream_request( | |
|
166 | self.channelstream_config, payload, '/subscribe') | |
|
167 | except ChannelstreamConnectionException: | |
|
168 | log.exception('Channelstream service is down') | |
|
169 | return HTTPBadGateway() | |
|
170 | # include_channel_info will limit history only to new channel | |
|
171 | # to not overwrite histories on other channels in client | |
|
172 | connect_result['channels_info'] = parse_channels_info( | |
|
173 | connect_result['channels_info'], | |
|
174 | include_channel_info=filtered_channels) | |
|
175 | update_history_from_logs(self.channelstream_config, | |
|
176 | filtered_channels, connect_result) | |
|
177 | return connect_result |
@@ -0,0 +1,219 b'' | |||
|
1 | # -*- coding: utf-8 -*- | |
|
2 | ||
|
3 | # Copyright (C) 2016-2016 RhodeCode GmbH | |
|
4 | # | |
|
5 | # This program is free software: you can redistribute it and/or modify | |
|
6 | # it under the terms of the GNU Affero General Public License, version 3 | |
|
7 | # (only), as published by the Free Software Foundation. | |
|
8 | # | |
|
9 | # This program is distributed in the hope that it will be useful, | |
|
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
|
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
|
12 | # GNU General Public License for more details. | |
|
13 | # | |
|
14 | # You should have received a copy of the GNU Affero General Public License | |
|
15 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
|
16 | # | |
|
17 | # This program is dual-licensed. If you wish to learn more about the | |
|
18 | # RhodeCode Enterprise Edition, including its added features, Support services, | |
|
19 | # and proprietary license terms, please see https://rhodecode.com/licenses/ | |
|
20 | ||
|
21 | import logging | |
|
22 | import os | |
|
23 | ||
|
24 | import itsdangerous | |
|
25 | import requests | |
|
26 | ||
|
27 | from dogpile.core import ReadWriteMutex | |
|
28 | ||
|
29 | import rhodecode.lib.helpers as h | |
|
30 | ||
|
31 | from rhodecode.lib.auth import HasRepoPermissionAny | |
|
32 | from rhodecode.lib.ext_json import json | |
|
33 | from rhodecode.model.db import User | |
|
34 | ||
|
35 | log = logging.getLogger(__name__) | |
|
36 | ||
|
37 | LOCK = ReadWriteMutex() | |
|
38 | ||
|
39 | STATE_PUBLIC_KEYS = ['id', 'username', 'first_name', 'last_name', | |
|
40 | 'icon_link', 'display_name', 'display_link'] | |
|
41 | ||
|
42 | ||
|
43 | class ChannelstreamException(Exception): | |
|
44 | pass | |
|
45 | ||
|
46 | ||
|
47 | class ChannelstreamConnectionException(Exception): | |
|
48 | pass | |
|
49 | ||
|
50 | ||
|
51 | class ChannelstreamPermissionException(Exception): | |
|
52 | pass | |
|
53 | ||
|
54 | ||
|
55 | def channelstream_request(config, payload, endpoint, raise_exc=True): | |
|
56 | signer = itsdangerous.TimestampSigner(config['secret']) | |
|
57 | sig_for_server = signer.sign(endpoint) | |
|
58 | secret_headers = {'x-channelstream-secret': sig_for_server, | |
|
59 | 'x-channelstream-endpoint': endpoint, | |
|
60 | 'Content-Type': 'application/json'} | |
|
61 | req_url = 'http://{}{}'.format(config['server'], endpoint) | |
|
62 | response = None | |
|
63 | try: | |
|
64 | response = requests.post(req_url, data=json.dumps(payload), | |
|
65 | headers=secret_headers).json() | |
|
66 | except requests.ConnectionError: | |
|
67 | log.exception('ConnectionError happened') | |
|
68 | if raise_exc: | |
|
69 | raise ChannelstreamConnectionException() | |
|
70 | except Exception: | |
|
71 | log.exception('Exception related to channelstream happened') | |
|
72 | if raise_exc: | |
|
73 | raise ChannelstreamConnectionException() | |
|
74 | return response | |
|
75 | ||
|
76 | ||
|
77 | def get_user_data(user_id): | |
|
78 | user = User.get(user_id) | |
|
79 | return { | |
|
80 | 'id': user.user_id, | |
|
81 | 'username': user.username, | |
|
82 | 'first_name': user.name, | |
|
83 | 'last_name': user.lastname, | |
|
84 | 'icon_link': h.gravatar_url(user.email, 14), | |
|
85 | 'display_name': h.person(user, 'username_or_name_or_email'), | |
|
86 | 'display_link': h.link_to_user(user), | |
|
87 | } | |
|
88 | ||
|
89 | ||
|
90 | def broadcast_validator(channel_name): | |
|
91 | """ checks if user can access the broadcast channel """ | |
|
92 | if channel_name == 'broadcast': | |
|
93 | return True | |
|
94 | ||
|
95 | ||
|
96 | def repo_validator(channel_name): | |
|
97 | """ checks if user can access the broadcast channel """ | |
|
98 | channel_prefix = '/repo$' | |
|
99 | if channel_name.startswith(channel_prefix): | |
|
100 | elements = channel_name[len(channel_prefix):].split('$') | |
|
101 | repo_name = elements[0] | |
|
102 | can_access = HasRepoPermissionAny( | |
|
103 | 'repository.read', | |
|
104 | 'repository.write', | |
|
105 | 'repository.admin')(repo_name) | |
|
106 | log.debug('permission check for {} channel ' | |
|
107 | 'resulted in {}'.format(repo_name, can_access)) | |
|
108 | if can_access: | |
|
109 | return True | |
|
110 | return False | |
|
111 | ||
|
112 | ||
|
113 | def check_channel_permissions(channels, plugin_validators, should_raise=True): | |
|
114 | valid_channels = [] | |
|
115 | ||
|
116 | validators = [broadcast_validator, repo_validator] | |
|
117 | if plugin_validators: | |
|
118 | validators.extend(plugin_validators) | |
|
119 | for channel_name in channels: | |
|
120 | is_valid = False | |
|
121 | for validator in validators: | |
|
122 | if validator(channel_name): | |
|
123 | is_valid = True | |
|
124 | break | |
|
125 | if is_valid: | |
|
126 | valid_channels.append(channel_name) | |
|
127 | else: | |
|
128 | if should_raise: | |
|
129 | raise ChannelstreamPermissionException() | |
|
130 | return valid_channels | |
|
131 | ||
|
132 | ||
|
133 | def get_channels_info(self, channels): | |
|
134 | payload = {'channels': channels} | |
|
135 | # gather persistence info | |
|
136 | return channelstream_request(self._config(), payload, '/info') | |
|
137 | ||
|
138 | ||
|
139 | def parse_channels_info(info_result, include_channel_info=None): | |
|
140 | """ | |
|
141 | Returns data that contains only secure information that can be | |
|
142 | presented to clients | |
|
143 | """ | |
|
144 | include_channel_info = include_channel_info or [] | |
|
145 | ||
|
146 | user_state_dict = {} | |
|
147 | for userinfo in info_result['users']: | |
|
148 | user_state_dict[userinfo['user']] = { | |
|
149 | k: v for k, v in userinfo['state'].items() | |
|
150 | if k in STATE_PUBLIC_KEYS | |
|
151 | } | |
|
152 | ||
|
153 | channels_info = {} | |
|
154 | ||
|
155 | for c_name, c_info in info_result['channels'].items(): | |
|
156 | if c_name not in include_channel_info: | |
|
157 | continue | |
|
158 | connected_list = [] | |
|
159 | for userinfo in c_info['users']: | |
|
160 | connected_list.append({ | |
|
161 | 'user': userinfo['user'], | |
|
162 | 'state': user_state_dict[userinfo['user']] | |
|
163 | }) | |
|
164 | channels_info[c_name] = {'users': connected_list, | |
|
165 | 'history': c_info['history']} | |
|
166 | ||
|
167 | return channels_info | |
|
168 | ||
|
169 | ||
|
170 | def log_filepath(history_location, channel_name): | |
|
171 | filename = '{}.log'.format(channel_name.encode('hex')) | |
|
172 | filepath = os.path.join(history_location, filename) | |
|
173 | return filepath | |
|
174 | ||
|
175 | ||
|
176 | def read_history(history_location, channel_name): | |
|
177 | filepath = log_filepath(history_location, channel_name) | |
|
178 | if not os.path.exists(filepath): | |
|
179 | return [] | |
|
180 | history_lines_limit = -100 | |
|
181 | history = [] | |
|
182 | with open(filepath, 'rb') as f: | |
|
183 | for line in f.readlines()[history_lines_limit:]: | |
|
184 | try: | |
|
185 | history.append(json.loads(line)) | |
|
186 | except Exception: | |
|
187 | log.exception('Failed to load history') | |
|
188 | return history | |
|
189 | ||
|
190 | ||
|
191 | def update_history_from_logs(config, channels, payload): | |
|
192 | history_location = config.get('history.location') | |
|
193 | for channel in channels: | |
|
194 | history = read_history(history_location, channel) | |
|
195 | payload['channels_info'][channel]['history'] = history | |
|
196 | ||
|
197 | ||
|
198 | def write_history(config, message): | |
|
199 | """ writes a messge to a base64encoded filename """ | |
|
200 | history_location = config.get('history.location') | |
|
201 | if not os.path.exists(history_location): | |
|
202 | return | |
|
203 | try: | |
|
204 | LOCK.acquire_write_lock() | |
|
205 | filepath = log_filepath(history_location, message['channel']) | |
|
206 | with open(filepath, 'ab') as f: | |
|
207 | json.dump(message, f) | |
|
208 | f.write('\n') | |
|
209 | finally: | |
|
210 | LOCK.release_write_lock() | |
|
211 | ||
|
212 | ||
|
213 | def get_connection_validators(registry): | |
|
214 | validators = [] | |
|
215 | for k, config in registry.rhodecode_plugins.iteritems(): | |
|
216 | validator = config.get('channelstream', {}).get('connect_validator') | |
|
217 | if validator: | |
|
218 | validators.append(validator) | |
|
219 | return validators |
@@ -0,0 +1,268 b'' | |||
|
1 | // Mix-ins | |
|
2 | .borderRadius(@radius) { | |
|
3 | -moz-border-radius: @radius; | |
|
4 | -webkit-border-radius: @radius; | |
|
5 | border-radius: @radius; | |
|
6 | } | |
|
7 | ||
|
8 | .boxShadow(@boxShadow) { | |
|
9 | -moz-box-shadow: @boxShadow; | |
|
10 | -webkit-box-shadow: @boxShadow; | |
|
11 | box-shadow: @boxShadow; | |
|
12 | } | |
|
13 | ||
|
14 | .opacity(@opacity) { | |
|
15 | @opacityPercent: @opacity * 100; | |
|
16 | opacity: @opacity; | |
|
17 | -ms-filter: ~"progid:DXImageTransform.Microsoft.Alpha(Opacity=@{opacityPercent})"; | |
|
18 | filter: ~"alpha(opacity=@{opacityPercent})"; | |
|
19 | } | |
|
20 | ||
|
21 | .wordWrap(@wordWrap: break-word) { | |
|
22 | -ms-word-wrap: @wordWrap; | |
|
23 | word-wrap: @wordWrap; | |
|
24 | } | |
|
25 | ||
|
26 | // Variables | |
|
27 | @black: #000000; | |
|
28 | @grey: #999999; | |
|
29 | @light-grey: #CCCCCC; | |
|
30 | @white: #FFFFFF; | |
|
31 | @near-black: #030303; | |
|
32 | @green: #51A351; | |
|
33 | @red: #BD362F; | |
|
34 | @blue: #2F96B4; | |
|
35 | @orange: #F89406; | |
|
36 | @default-container-opacity: .8; | |
|
37 | ||
|
38 | // Styles | |
|
39 | .toast-title { | |
|
40 | font-weight: bold; | |
|
41 | } | |
|
42 | ||
|
43 | .toast-message { | |
|
44 | .wordWrap(); | |
|
45 | ||
|
46 | a, | |
|
47 | label { | |
|
48 | color: @near-black; | |
|
49 | } | |
|
50 | ||
|
51 | a:hover { | |
|
52 | color: @light-grey; | |
|
53 | text-decoration: none; | |
|
54 | } | |
|
55 | } | |
|
56 | ||
|
57 | .toast-close-button { | |
|
58 | position: relative; | |
|
59 | right: -0.3em; | |
|
60 | top: -0.3em; | |
|
61 | float: right; | |
|
62 | font-size: 20px; | |
|
63 | font-weight: bold; | |
|
64 | color: @black; | |
|
65 | -webkit-text-shadow: 0 1px 0 rgba(255,255,255,1); | |
|
66 | text-shadow: 0 1px 0 rgba(255,255,255,1); | |
|
67 | .opacity(0.8); | |
|
68 | ||
|
69 | &:hover, | |
|
70 | &:focus { | |
|
71 | color: @black; | |
|
72 | text-decoration: none; | |
|
73 | cursor: pointer; | |
|
74 | .opacity(0.4); | |
|
75 | } | |
|
76 | } | |
|
77 | ||
|
78 | /*Additional properties for button version | |
|
79 | iOS requires the button element instead of an anchor tag. | |
|
80 | If you want the anchor version, it requires `href="#"`.*/ | |
|
81 | button.toast-close-button { | |
|
82 | padding: 0; | |
|
83 | cursor: pointer; | |
|
84 | background: transparent; | |
|
85 | border: 0; | |
|
86 | -webkit-appearance: none; | |
|
87 | } | |
|
88 | ||
|
89 | //#endregion | |
|
90 | ||
|
91 | .toast-top-center { | |
|
92 | top: 0; | |
|
93 | right: 0; | |
|
94 | width: 100%; | |
|
95 | } | |
|
96 | ||
|
97 | .toast-bottom-center { | |
|
98 | bottom: 0; | |
|
99 | right: 0; | |
|
100 | width: 100%; | |
|
101 | } | |
|
102 | ||
|
103 | .toast-top-full-width { | |
|
104 | top: 0; | |
|
105 | right: 0; | |
|
106 | width: 100%; | |
|
107 | } | |
|
108 | ||
|
109 | .toast-bottom-full-width { | |
|
110 | bottom: 0; | |
|
111 | right: 0; | |
|
112 | width: 100%; | |
|
113 | } | |
|
114 | ||
|
115 | .toast-top-left { | |
|
116 | top: 12px; | |
|
117 | left: 12px; | |
|
118 | } | |
|
119 | ||
|
120 | .toast-top-right { | |
|
121 | top: 12px; | |
|
122 | right: 12px; | |
|
123 | } | |
|
124 | ||
|
125 | .toast-bottom-right { | |
|
126 | right: 12px; | |
|
127 | bottom: 12px; | |
|
128 | } | |
|
129 | ||
|
130 | .toast-bottom-left { | |
|
131 | bottom: 12px; | |
|
132 | left: 12px; | |
|
133 | } | |
|
134 | ||
|
135 | #toast-container { | |
|
136 | position: fixed; | |
|
137 | z-index: 999999; | |
|
138 | // The container should not be clickable. | |
|
139 | pointer-events: none; | |
|
140 | * { | |
|
141 | -moz-box-sizing: border-box; | |
|
142 | -webkit-box-sizing: border-box; | |
|
143 | box-sizing: border-box; | |
|
144 | } | |
|
145 | ||
|
146 | > div { | |
|
147 | position: relative; | |
|
148 | // The toast itself should be clickable. | |
|
149 | pointer-events: auto; | |
|
150 | overflow: hidden; | |
|
151 | margin: 0 0 6px; | |
|
152 | padding: 15px; | |
|
153 | width: 300px; | |
|
154 | .borderRadius(1px 1px 1px 1px); | |
|
155 | background-position: 15px center; | |
|
156 | background-repeat: no-repeat; | |
|
157 | color: @near-black; | |
|
158 | .opacity(@default-container-opacity); | |
|
159 | } | |
|
160 | ||
|
161 | > :hover { | |
|
162 | .opacity(1); | |
|
163 | cursor: pointer; | |
|
164 | } | |
|
165 | ||
|
166 | > .toast-info { | |
|
167 | //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGwSURBVEhLtZa9SgNBEMc9sUxxRcoUKSzSWIhXpFMhhYWFhaBg4yPYiWCXZxBLERsLRS3EQkEfwCKdjWJAwSKCgoKCcudv4O5YLrt7EzgXhiU3/4+b2ckmwVjJSpKkQ6wAi4gwhT+z3wRBcEz0yjSseUTrcRyfsHsXmD0AmbHOC9Ii8VImnuXBPglHpQ5wwSVM7sNnTG7Za4JwDdCjxyAiH3nyA2mtaTJufiDZ5dCaqlItILh1NHatfN5skvjx9Z38m69CgzuXmZgVrPIGE763Jx9qKsRozWYw6xOHdER+nn2KkO+Bb+UV5CBN6WC6QtBgbRVozrahAbmm6HtUsgtPC19tFdxXZYBOfkbmFJ1VaHA1VAHjd0pp70oTZzvR+EVrx2Ygfdsq6eu55BHYR8hlcki+n+kERUFG8BrA0BwjeAv2M8WLQBtcy+SD6fNsmnB3AlBLrgTtVW1c2QN4bVWLATaIS60J2Du5y1TiJgjSBvFVZgTmwCU+dAZFoPxGEEs8nyHC9Bwe2GvEJv2WXZb0vjdyFT4Cxk3e/kIqlOGoVLwwPevpYHT+00T+hWwXDf4AJAOUqWcDhbwAAAAASUVORK5CYII=") !important; | |
|
168 | } | |
|
169 | ||
|
170 | > .toast-error { | |
|
171 | //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; | |
|
172 | } | |
|
173 | ||
|
174 | > .toast-success { | |
|
175 | //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADsSURBVEhLY2AYBfQMgf///3P8+/evAIgvA/FsIF+BavYDDWMBGroaSMMBiE8VC7AZDrIFaMFnii3AZTjUgsUUWUDA8OdAH6iQbQEhw4HyGsPEcKBXBIC4ARhex4G4BsjmweU1soIFaGg/WtoFZRIZdEvIMhxkCCjXIVsATV6gFGACs4Rsw0EGgIIH3QJYJgHSARQZDrWAB+jawzgs+Q2UO49D7jnRSRGoEFRILcdmEMWGI0cm0JJ2QpYA1RDvcmzJEWhABhD/pqrL0S0CWuABKgnRki9lLseS7g2AlqwHWQSKH4oKLrILpRGhEQCw2LiRUIa4lwAAAABJRU5ErkJggg==") !important; | |
|
176 | } | |
|
177 | ||
|
178 | > .toast-warning { | |
|
179 | //background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGYSURBVEhL5ZSvTsNQFMbXZGICMYGYmJhAQIJAICYQPAACiSDB8AiICQQJT4CqQEwgJvYASAQCiZiYmJhAIBATCARJy+9rTsldd8sKu1M0+dLb057v6/lbq/2rK0mS/TRNj9cWNAKPYIJII7gIxCcQ51cvqID+GIEX8ASG4B1bK5gIZFeQfoJdEXOfgX4QAQg7kH2A65yQ87lyxb27sggkAzAuFhbbg1K2kgCkB1bVwyIR9m2L7PRPIhDUIXgGtyKw575yz3lTNs6X4JXnjV+LKM/m3MydnTbtOKIjtz6VhCBq4vSm3ncdrD2lk0VgUXSVKjVDJXJzijW1RQdsU7F77He8u68koNZTz8Oz5yGa6J3H3lZ0xYgXBK2QymlWWA+RWnYhskLBv2vmE+hBMCtbA7KX5drWyRT/2JsqZ2IvfB9Y4bWDNMFbJRFmC9E74SoS0CqulwjkC0+5bpcV1CZ8NMej4pjy0U+doDQsGyo1hzVJttIjhQ7GnBtRFN1UarUlH8F3xict+HY07rEzoUGPlWcjRFRr4/gChZgc3ZL2d8oAAAAASUVORK5CYII=") !important; | |
|
180 | } | |
|
181 | ||
|
182 | /*overrides*/ | |
|
183 | &.toast-top-center > div, | |
|
184 | &.toast-bottom-center > div { | |
|
185 | width: 400px; | |
|
186 | margin-left: auto; | |
|
187 | margin-right: auto; | |
|
188 | } | |
|
189 | ||
|
190 | &.toast-top-full-width > div, | |
|
191 | &.toast-bottom-full-width > div { | |
|
192 | width: 96%; | |
|
193 | margin-left: auto; | |
|
194 | margin-right: auto; | |
|
195 | } | |
|
196 | } | |
|
197 | ||
|
198 | .toast { | |
|
199 | border-color: @near-black; | |
|
200 | border-style: solid; | |
|
201 | border-width: 2px 2px 2px 25px; | |
|
202 | background-color: @white; | |
|
203 | } | |
|
204 | ||
|
205 | .toast-success { | |
|
206 | border-color: @green; | |
|
207 | } | |
|
208 | ||
|
209 | .toast-error { | |
|
210 | border-color: @red; | |
|
211 | } | |
|
212 | ||
|
213 | .toast-info { | |
|
214 | border-color: @blue; | |
|
215 | } | |
|
216 | ||
|
217 | .toast-warning { | |
|
218 | border-color: @orange; | |
|
219 | } | |
|
220 | ||
|
221 | .toast-progress { | |
|
222 | position: absolute; | |
|
223 | left: 0; | |
|
224 | bottom: 0; | |
|
225 | height: 4px; | |
|
226 | background-color: @black; | |
|
227 | .opacity(0.4); | |
|
228 | } | |
|
229 | ||
|
230 | /*Responsive Design*/ | |
|
231 | ||
|
232 | @media all and (max-width: 240px) { | |
|
233 | #toast-container { | |
|
234 | ||
|
235 | > div { | |
|
236 | padding: 8px; | |
|
237 | width: 11em; | |
|
238 | } | |
|
239 | ||
|
240 | & .toast-close-button { | |
|
241 | right: -0.2em; | |
|
242 | top: -0.2em; | |
|
243 | } | |
|
244 | } | |
|
245 | } | |
|
246 | ||
|
247 | @media all and (min-width: 241px) and (max-width: 480px) { | |
|
248 | #toast-container { | |
|
249 | > div { | |
|
250 | padding: 8px; | |
|
251 | width: 18em; | |
|
252 | } | |
|
253 | ||
|
254 | & .toast-close-button { | |
|
255 | right: -0.2em; | |
|
256 | top: -0.2em; | |
|
257 | } | |
|
258 | } | |
|
259 | } | |
|
260 | ||
|
261 | @media all and (min-width: 481px) and (max-width: 768px) { | |
|
262 | #toast-container { | |
|
263 | > div { | |
|
264 | padding: 15px; | |
|
265 | width: 25em; | |
|
266 | } | |
|
267 | } | |
|
268 | } |
@@ -0,0 +1,435 b'' | |||
|
1 | /* | |
|
2 | * Toastr | |
|
3 | * Copyright 2012-2015 | |
|
4 | * Authors: John Papa, Hans Fjällemark, and Tim Ferrell. | |
|
5 | * All Rights Reserved. | |
|
6 | * Use, reproduction, distribution, and modification of this code is subject to the terms and | |
|
7 | * conditions of the MIT license, available at http://www.opensource.org/licenses/mit-license.php | |
|
8 | * | |
|
9 | * ARIA Support: Greta Krafsig | |
|
10 | * | |
|
11 | * Project: https://github.com/CodeSeven/toastr | |
|
12 | */ | |
|
13 | /* global define */ | |
|
14 | (function (define) { | |
|
15 | define(['jquery'], function ($) { | |
|
16 | return (function () { | |
|
17 | var $container; | |
|
18 | var listener; | |
|
19 | var toastId = 0; | |
|
20 | var toastType = { | |
|
21 | error: 'error', | |
|
22 | info: 'info', | |
|
23 | success: 'success', | |
|
24 | warning: 'warning' | |
|
25 | }; | |
|
26 | ||
|
27 | var toastr = { | |
|
28 | clear: clear, | |
|
29 | remove: remove, | |
|
30 | error: error, | |
|
31 | getContainer: getContainer, | |
|
32 | info: info, | |
|
33 | options: {}, | |
|
34 | subscribe: subscribe, | |
|
35 | success: success, | |
|
36 | version: '2.1.2', | |
|
37 | warning: warning | |
|
38 | }; | |
|
39 | ||
|
40 | var previousToast; | |
|
41 | ||
|
42 | return toastr; | |
|
43 | ||
|
44 | //////////////// | |
|
45 | ||
|
46 | function error(message, title, optionsOverride) { | |
|
47 | return notify({ | |
|
48 | type: toastType.error, | |
|
49 | iconClass: getOptions().iconClasses.error, | |
|
50 | message: message, | |
|
51 | optionsOverride: optionsOverride, | |
|
52 | title: title | |
|
53 | }); | |
|
54 | } | |
|
55 | ||
|
56 | function getContainer(options, create) { | |
|
57 | if (!options) { options = getOptions(); } | |
|
58 | $container = $('#' + options.containerId); | |
|
59 | if ($container.length) { | |
|
60 | return $container; | |
|
61 | } | |
|
62 | if (create) { | |
|
63 | $container = createContainer(options); | |
|
64 | } | |
|
65 | return $container; | |
|
66 | } | |
|
67 | ||
|
68 | function info(message, title, optionsOverride) { | |
|
69 | return notify({ | |
|
70 | type: toastType.info, | |
|
71 | iconClass: getOptions().iconClasses.info, | |
|
72 | message: message, | |
|
73 | optionsOverride: optionsOverride, | |
|
74 | title: title | |
|
75 | }); | |
|
76 | } | |
|
77 | ||
|
78 | function subscribe(callback) { | |
|
79 | listener = callback; | |
|
80 | } | |
|
81 | ||
|
82 | function success(message, title, optionsOverride) { | |
|
83 | return notify({ | |
|
84 | type: toastType.success, | |
|
85 | iconClass: getOptions().iconClasses.success, | |
|
86 | message: message, | |
|
87 | optionsOverride: optionsOverride, | |
|
88 | title: title | |
|
89 | }); | |
|
90 | } | |
|
91 | ||
|
92 | function warning(message, title, optionsOverride) { | |
|
93 | return notify({ | |
|
94 | type: toastType.warning, | |
|
95 | iconClass: getOptions().iconClasses.warning, | |
|
96 | message: message, | |
|
97 | optionsOverride: optionsOverride, | |
|
98 | title: title | |
|
99 | }); | |
|
100 | } | |
|
101 | ||
|
102 | function clear($toastElement, clearOptions) { | |
|
103 | var options = getOptions(); | |
|
104 | if (!$container) { getContainer(options); } | |
|
105 | if (!clearToast($toastElement, options, clearOptions)) { | |
|
106 | clearContainer(options); | |
|
107 | } | |
|
108 | } | |
|
109 | ||
|
110 | function remove($toastElement) { | |
|
111 | var options = getOptions(); | |
|
112 | if (!$container) { getContainer(options); } | |
|
113 | if ($toastElement && $(':focus', $toastElement).length === 0) { | |
|
114 | removeToast($toastElement); | |
|
115 | return; | |
|
116 | } | |
|
117 | if ($container.children().length) { | |
|
118 | $container.remove(); | |
|
119 | } | |
|
120 | } | |
|
121 | ||
|
122 | // internal functions | |
|
123 | ||
|
124 | function clearContainer (options) { | |
|
125 | var toastsToClear = $container.children(); | |
|
126 | for (var i = toastsToClear.length - 1; i >= 0; i--) { | |
|
127 | clearToast($(toastsToClear[i]), options); | |
|
128 | } | |
|
129 | } | |
|
130 | ||
|
131 | function clearToast ($toastElement, options, clearOptions) { | |
|
132 | var force = clearOptions && clearOptions.force ? clearOptions.force : false; | |
|
133 | if ($toastElement && (force || $(':focus', $toastElement).length === 0)) { | |
|
134 | $toastElement[options.hideMethod]({ | |
|
135 | duration: options.hideDuration, | |
|
136 | easing: options.hideEasing, | |
|
137 | complete: function () { removeToast($toastElement); } | |
|
138 | }); | |
|
139 | return true; | |
|
140 | } | |
|
141 | return false; | |
|
142 | } | |
|
143 | ||
|
144 | function createContainer(options) { | |
|
145 | $container = $('<div/>') | |
|
146 | .attr('id', options.containerId) | |
|
147 | .addClass(options.positionClass) | |
|
148 | .attr('aria-live', 'polite') | |
|
149 | .attr('role', 'alert'); | |
|
150 | ||
|
151 | $container.appendTo($(options.target)); | |
|
152 | return $container; | |
|
153 | } | |
|
154 | ||
|
155 | function getDefaults() { | |
|
156 | return { | |
|
157 | tapToDismiss: true, | |
|
158 | toastClass: 'toast', | |
|
159 | containerId: 'toast-container', | |
|
160 | debug: false, | |
|
161 | ||
|
162 | showMethod: 'fadeIn', //fadeIn, slideDown, and show are built into jQuery | |
|
163 | showDuration: 300, | |
|
164 | showEasing: 'swing', //swing and linear are built into jQuery | |
|
165 | onShown: undefined, | |
|
166 | hideMethod: 'fadeOut', | |
|
167 | hideDuration: 1000, | |
|
168 | hideEasing: 'swing', | |
|
169 | onHidden: undefined, | |
|
170 | closeMethod: false, | |
|
171 | closeDuration: false, | |
|
172 | closeEasing: false, | |
|
173 | ||
|
174 | extendedTimeOut: 1000, | |
|
175 | iconClasses: { | |
|
176 | error: 'toast-error', | |
|
177 | info: 'toast-info', | |
|
178 | success: 'toast-success', | |
|
179 | warning: 'toast-warning' | |
|
180 | }, | |
|
181 | iconClass: 'toast-info', | |
|
182 | positionClass: 'toast-top-right', | |
|
183 | timeOut: 5000, // Set timeOut and extendedTimeOut to 0 to make it sticky | |
|
184 | titleClass: 'toast-title', | |
|
185 | messageClass: 'toast-message', | |
|
186 | escapeHtml: false, | |
|
187 | target: 'body', | |
|
188 | closeHtml: '<button type="button">×</button>', | |
|
189 | newestOnTop: true, | |
|
190 | preventDuplicates: false, | |
|
191 | progressBar: false | |
|
192 | }; | |
|
193 | } | |
|
194 | ||
|
195 | function publish(args) { | |
|
196 | if (!listener) { return; } | |
|
197 | listener(args); | |
|
198 | } | |
|
199 | ||
|
200 | function notify(map) { | |
|
201 | var options = getOptions(); | |
|
202 | var iconClass = map.iconClass || options.iconClass; | |
|
203 | ||
|
204 | if (typeof (map.optionsOverride) !== 'undefined') { | |
|
205 | options = $.extend(options, map.optionsOverride); | |
|
206 | iconClass = map.optionsOverride.iconClass || iconClass; | |
|
207 | } | |
|
208 | ||
|
209 | if (shouldExit(options, map)) { return; } | |
|
210 | ||
|
211 | toastId++; | |
|
212 | ||
|
213 | $container = getContainer(options, true); | |
|
214 | ||
|
215 | var intervalId = null; | |
|
216 | var $toastElement = $('<div/>'); | |
|
217 | var $titleElement = $('<div/>'); | |
|
218 | var $messageElement = $('<div/>'); | |
|
219 | var $progressElement = $('<div/>'); | |
|
220 | var $closeElement = $(options.closeHtml); | |
|
221 | var progressBar = { | |
|
222 | intervalId: null, | |
|
223 | hideEta: null, | |
|
224 | maxHideTime: null | |
|
225 | }; | |
|
226 | var response = { | |
|
227 | toastId: toastId, | |
|
228 | state: 'visible', | |
|
229 | startTime: new Date(), | |
|
230 | options: options, | |
|
231 | map: map | |
|
232 | }; | |
|
233 | ||
|
234 | personalizeToast(); | |
|
235 | ||
|
236 | displayToast(); | |
|
237 | ||
|
238 | handleEvents(); | |
|
239 | ||
|
240 | publish(response); | |
|
241 | ||
|
242 | if (options.debug && console) { | |
|
243 | console.log(response); | |
|
244 | } | |
|
245 | ||
|
246 | return $toastElement; | |
|
247 | ||
|
248 | function escapeHtml(source) { | |
|
249 | if (source == null) | |
|
250 | source = ""; | |
|
251 | ||
|
252 | return new String(source) | |
|
253 | .replace(/&/g, '&') | |
|
254 | .replace(/"/g, '"') | |
|
255 | .replace(/'/g, ''') | |
|
256 | .replace(/</g, '<') | |
|
257 | .replace(/>/g, '>'); | |
|
258 | } | |
|
259 | ||
|
260 | function personalizeToast() { | |
|
261 | setIcon(); | |
|
262 | setTitle(); | |
|
263 | setMessage(); | |
|
264 | setCloseButton(); | |
|
265 | setProgressBar(); | |
|
266 | setSequence(); | |
|
267 | } | |
|
268 | ||
|
269 | function handleEvents() { | |
|
270 | $toastElement.hover(stickAround, delayedHideToast); | |
|
271 | if (!options.onclick && options.tapToDismiss) { | |
|
272 | $toastElement.click(hideToast); | |
|
273 | } | |
|
274 | ||
|
275 | if (options.closeButton && $closeElement) { | |
|
276 | $closeElement.click(function (event) { | |
|
277 | if (event.stopPropagation) { | |
|
278 | event.stopPropagation(); | |
|
279 | } else if (event.cancelBubble !== undefined && event.cancelBubble !== true) { | |
|
280 | event.cancelBubble = true; | |
|
281 | } | |
|
282 | hideToast(true); | |
|
283 | }); | |
|
284 | } | |
|
285 | ||
|
286 | if (options.onclick) { | |
|
287 | $toastElement.click(function (event) { | |
|
288 | options.onclick(event); | |
|
289 | hideToast(); | |
|
290 | }); | |
|
291 | } | |
|
292 | } | |
|
293 | ||
|
294 | function displayToast() { | |
|
295 | $toastElement.hide(); | |
|
296 | ||
|
297 | $toastElement[options.showMethod]( | |
|
298 | {duration: options.showDuration, easing: options.showEasing, complete: options.onShown} | |
|
299 | ); | |
|
300 | ||
|
301 | if (options.timeOut > 0) { | |
|
302 | intervalId = setTimeout(hideToast, options.timeOut); | |
|
303 | progressBar.maxHideTime = parseFloat(options.timeOut); | |
|
304 | progressBar.hideEta = new Date().getTime() + progressBar.maxHideTime; | |
|
305 | if (options.progressBar) { | |
|
306 | progressBar.intervalId = setInterval(updateProgress, 10); | |
|
307 | } | |
|
308 | } | |
|
309 | } | |
|
310 | ||
|
311 | function setIcon() { | |
|
312 | if (map.iconClass) { | |
|
313 | $toastElement.addClass(options.toastClass).addClass(iconClass); | |
|
314 | } | |
|
315 | } | |
|
316 | ||
|
317 | function setSequence() { | |
|
318 | if (options.newestOnTop) { | |
|
319 | $container.prepend($toastElement); | |
|
320 | } else { | |
|
321 | $container.append($toastElement); | |
|
322 | } | |
|
323 | } | |
|
324 | ||
|
325 | function setTitle() { | |
|
326 | if (map.title) { | |
|
327 | $titleElement.append(!options.escapeHtml ? map.title : escapeHtml(map.title)).addClass(options.titleClass); | |
|
328 | $toastElement.append($titleElement); | |
|
329 | } | |
|
330 | } | |
|
331 | ||
|
332 | function setMessage() { | |
|
333 | if (map.message) { | |
|
334 | $messageElement.append(!options.escapeHtml ? map.message : escapeHtml(map.message)).addClass(options.messageClass); | |
|
335 | $toastElement.append($messageElement); | |
|
336 | } | |
|
337 | } | |
|
338 | ||
|
339 | function setCloseButton() { | |
|
340 | if (options.closeButton) { | |
|
341 | $closeElement.addClass('toast-close-button').attr('role', 'button'); | |
|
342 | $toastElement.prepend($closeElement); | |
|
343 | } | |
|
344 | } | |
|
345 | ||
|
346 | function setProgressBar() { | |
|
347 | if (options.progressBar) { | |
|
348 | $progressElement.addClass('toast-progress'); | |
|
349 | $toastElement.prepend($progressElement); | |
|
350 | } | |
|
351 | } | |
|
352 | ||
|
353 | function shouldExit(options, map) { | |
|
354 | if (options.preventDuplicates) { | |
|
355 | if (map.message === previousToast) { | |
|
356 | return true; | |
|
357 | } else { | |
|
358 | previousToast = map.message; | |
|
359 | } | |
|
360 | } | |
|
361 | return false; | |
|
362 | } | |
|
363 | ||
|
364 | function hideToast(override) { | |
|
365 | var method = override && options.closeMethod !== false ? options.closeMethod : options.hideMethod; | |
|
366 | var duration = override && options.closeDuration !== false ? | |
|
367 | options.closeDuration : options.hideDuration; | |
|
368 | var easing = override && options.closeEasing !== false ? options.closeEasing : options.hideEasing; | |
|
369 | if ($(':focus', $toastElement).length && !override) { | |
|
370 | return; | |
|
371 | } | |
|
372 | clearTimeout(progressBar.intervalId); | |
|
373 | return $toastElement[method]({ | |
|
374 | duration: duration, | |
|
375 | easing: easing, | |
|
376 | complete: function () { | |
|
377 | removeToast($toastElement); | |
|
378 | if (options.onHidden && response.state !== 'hidden') { | |
|
379 | options.onHidden(); | |
|
380 | } | |
|
381 | response.state = 'hidden'; | |
|
382 | response.endTime = new Date(); | |
|
383 | publish(response); | |
|
384 | } | |
|
385 | }); | |
|
386 | } | |
|
387 | ||
|
388 | function delayedHideToast() { | |
|
389 | if (options.timeOut > 0 || options.extendedTimeOut > 0) { | |
|
390 | intervalId = setTimeout(hideToast, options.extendedTimeOut); | |
|
391 | progressBar.maxHideTime = parseFloat(options.extendedTimeOut); | |
|
392 | progressBar.hideEta = new Date().getTime() + progressBar.maxHideTime; | |
|
393 | } | |
|
394 | } | |
|
395 | ||
|
396 | function stickAround() { | |
|
397 | clearTimeout(intervalId); | |
|
398 | progressBar.hideEta = 0; | |
|
399 | $toastElement.stop(true, true)[options.showMethod]( | |
|
400 | {duration: options.showDuration, easing: options.showEasing} | |
|
401 | ); | |
|
402 | } | |
|
403 | ||
|
404 | function updateProgress() { | |
|
405 | var percentage = ((progressBar.hideEta - (new Date().getTime())) / progressBar.maxHideTime) * 100; | |
|
406 | $progressElement.width(percentage + '%'); | |
|
407 | } | |
|
408 | } | |
|
409 | ||
|
410 | function getOptions() { | |
|
411 | return $.extend({}, getDefaults(), toastr.options); | |
|
412 | } | |
|
413 | ||
|
414 | function removeToast($toastElement) { | |
|
415 | if (!$container) { $container = getContainer(); } | |
|
416 | if ($toastElement.is(':visible')) { | |
|
417 | return; | |
|
418 | } | |
|
419 | $toastElement.remove(); | |
|
420 | $toastElement = null; | |
|
421 | if ($container.children().length === 0) { | |
|
422 | $container.remove(); | |
|
423 | previousToast = undefined; | |
|
424 | } | |
|
425 | } | |
|
426 | ||
|
427 | })(); | |
|
428 | }); | |
|
429 | }(typeof define === 'function' && define.amd ? define : function (deps, factory) { | |
|
430 | if (typeof module !== 'undefined' && module.exports) { //Node | |
|
431 | module.exports = factory(require('jquery')); | |
|
432 | } else { | |
|
433 | window.toastr = factory(window.jQuery); | |
|
434 | } | |
|
435 | })); |
@@ -0,0 +1,219 b'' | |||
|
1 | "use strict"; | |
|
2 | /** leak object to top level scope **/ | |
|
3 | var ccLog = undefined; | |
|
4 | // global code-mirror logger;, to enable run | |
|
5 | // Logger.get('ConnectionController').setLevel(Logger.DEBUG) | |
|
6 | ccLog = Logger.get('ConnectionController'); | |
|
7 | ccLog.setLevel(Logger.OFF); | |
|
8 | ||
|
9 | var ConnectionController; | |
|
10 | var connCtrlr; | |
|
11 | var registerViewChannels; | |
|
12 | ||
|
13 | (function () { | |
|
14 | ConnectionController = function (webappUrl, serverUrl, urls) { | |
|
15 | var self = this; | |
|
16 | ||
|
17 | var channels = ['broadcast']; | |
|
18 | this.state = { | |
|
19 | open: false, | |
|
20 | webappUrl: webappUrl, | |
|
21 | serverUrl: serverUrl, | |
|
22 | connId: null, | |
|
23 | socket: null, | |
|
24 | channels: channels, | |
|
25 | heartbeat: null, | |
|
26 | channelsInfo: {}, | |
|
27 | urls: urls | |
|
28 | }; | |
|
29 | this.channelNameParsers = []; | |
|
30 | ||
|
31 | this.addChannelNameParser = function (fn) { | |
|
32 | if (this.channelNameParsers.indexOf(fn) === -1) { | |
|
33 | this.channelNameParsers.push(fn); | |
|
34 | } | |
|
35 | }; | |
|
36 | ||
|
37 | this.listen = function () { | |
|
38 | if (window.WebSocket) { | |
|
39 | ccLog.debug('attempting to create socket'); | |
|
40 | var socket_url = self.state.serverUrl + "/ws?conn_id=" + self.state.connId; | |
|
41 | var socket_conf = { | |
|
42 | url: socket_url, | |
|
43 | handleAs: 'json', | |
|
44 | headers: { | |
|
45 | "Accept": "application/json", | |
|
46 | "Content-Type": "application/json" | |
|
47 | } | |
|
48 | }; | |
|
49 | self.state.socket = new WebSocket(socket_conf.url); | |
|
50 | ||
|
51 | self.state.socket.onopen = function (event) { | |
|
52 | ccLog.debug('open event', event); | |
|
53 | if (self.state.heartbeat === null) { | |
|
54 | self.state.heartbeat = setInterval(function () { | |
|
55 | if (self.state.socket.readyState === WebSocket.OPEN) { | |
|
56 | self.state.socket.send('heartbeat'); | |
|
57 | } | |
|
58 | }, 10000) | |
|
59 | } | |
|
60 | }; | |
|
61 | self.state.socket.onmessage = function (event) { | |
|
62 | var data = $.parseJSON(event.data); | |
|
63 | for (var i = 0; i < data.length; i++) { | |
|
64 | if (data[i].message.topic) { | |
|
65 | ccLog.debug('publishing', | |
|
66 | data[i].message.topic, data[i]); | |
|
67 | $.Topic(data[i].message.topic).publish(data[i]) | |
|
68 | } | |
|
69 | else { | |
|
70 | cclog.warning('unhandled message', data); | |
|
71 | } | |
|
72 | } | |
|
73 | }; | |
|
74 | self.state.socket.onclose = function (event) { | |
|
75 | ccLog.debug('closed event', event); | |
|
76 | setTimeout(function () { | |
|
77 | self.connect(true); | |
|
78 | }, 5000); | |
|
79 | }; | |
|
80 | ||
|
81 | self.state.socket.onerror = function (event) { | |
|
82 | ccLog.debug('error event', event); | |
|
83 | }; | |
|
84 | } | |
|
85 | else { | |
|
86 | ccLog.debug('attempting to create long polling connection'); | |
|
87 | var poolUrl = self.state.serverUrl + "/listen?conn_id=" + self.state.connId; | |
|
88 | self.state.socket = $.ajax({ | |
|
89 | url: poolUrl | |
|
90 | }).done(function (data) { | |
|
91 | ccLog.debug('data', data); | |
|
92 | var data = $.parseJSON(data); | |
|
93 | for (var i = 0; i < data.length; i++) { | |
|
94 | if (data[i].message.topic) { | |
|
95 | ccLog.info('publishing', | |
|
96 | data[i].message.topic, data[i]); | |
|
97 | $.Topic(data[i].message.topic).publish(data[i]) | |
|
98 | } | |
|
99 | else { | |
|
100 | cclog.warning('unhandled message', data); | |
|
101 | } | |
|
102 | } | |
|
103 | self.listen(); | |
|
104 | }).fail(function () { | |
|
105 | ccLog.debug('longpoll error'); | |
|
106 | setTimeout(function () { | |
|
107 | self.connect(true); | |
|
108 | }, 5000); | |
|
109 | }); | |
|
110 | } | |
|
111 | ||
|
112 | }; | |
|
113 | ||
|
114 | this.connect = function (create_new_socket) { | |
|
115 | var connReq = {'channels': self.state.channels}; | |
|
116 | ccLog.debug('try obtaining connection info', connReq); | |
|
117 | $.ajax({ | |
|
118 | url: self.state.urls.connect, | |
|
119 | type: "POST", | |
|
120 | contentType: "application/json", | |
|
121 | data: JSON.stringify(connReq), | |
|
122 | dataType: "json" | |
|
123 | }).done(function (data) { | |
|
124 | ccLog.debug('Got connection:', data.conn_id); | |
|
125 | self.state.channels = data.channels; | |
|
126 | self.state.channelsInfo = data.channels_info; | |
|
127 | self.state.connId = data.conn_id; | |
|
128 | if (create_new_socket) { | |
|
129 | self.listen(); | |
|
130 | } | |
|
131 | self.update(); | |
|
132 | }).fail(function () { | |
|
133 | setTimeout(function () { | |
|
134 | self.connect(create_new_socket); | |
|
135 | }, 5000); | |
|
136 | }); | |
|
137 | self.update(); | |
|
138 | }; | |
|
139 | ||
|
140 | this.subscribeToChannels = function (channels) { | |
|
141 | var new_channels = []; | |
|
142 | for (var i = 0; i < channels.length; i++) { | |
|
143 | var channel = channels[i]; | |
|
144 | if (self.state.channels.indexOf(channel)) { | |
|
145 | self.state.channels.push(channel); | |
|
146 | new_channels.push(channel) | |
|
147 | } | |
|
148 | } | |
|
149 | /** | |
|
150 | * only execute the request if socket is present because subscribe | |
|
151 | * can actually add channels before initial app connection | |
|
152 | **/ | |
|
153 | if (new_channels && self.state.socket !== null) { | |
|
154 | var connReq = { | |
|
155 | 'channels': self.state.channels, | |
|
156 | 'conn_id': self.state.connId | |
|
157 | }; | |
|
158 | $.ajax({ | |
|
159 | url: self.state.urls.subscribe, | |
|
160 | type: "POST", | |
|
161 | contentType: "application/json", | |
|
162 | data: JSON.stringify(connReq), | |
|
163 | dataType: "json" | |
|
164 | }).done(function (data) { | |
|
165 | self.state.channels = data.channels; | |
|
166 | self.state.channelsInfo = data.channels_info; | |
|
167 | self.update(); | |
|
168 | }); | |
|
169 | } | |
|
170 | self.update(); | |
|
171 | }; | |
|
172 | ||
|
173 | this.update = function () { | |
|
174 | for (var key in this.state.channelsInfo) { | |
|
175 | if (this.state.channelsInfo.hasOwnProperty(key)) { | |
|
176 | // update channels with latest info | |
|
177 | $.Topic('/connection_controller/channel_update').publish( | |
|
178 | {channel: key, state: this.state.channelsInfo[key]}); | |
|
179 | } | |
|
180 | } | |
|
181 | /** | |
|
182 | * checks current channel list in state and if channel is not present | |
|
183 | * converts them into executable "commands" and pushes them on topics | |
|
184 | */ | |
|
185 | for (var i = 0; i < this.state.channels.length; i++) { | |
|
186 | var channel = this.state.channels[i]; | |
|
187 | for (var j = 0; j < this.channelNameParsers.length; j++) { | |
|
188 | this.channelNameParsers[j](channel); | |
|
189 | } | |
|
190 | } | |
|
191 | }; | |
|
192 | ||
|
193 | this.run = function () { | |
|
194 | this.connect(true); | |
|
195 | }; | |
|
196 | ||
|
197 | $.Topic('/connection_controller/subscribe').subscribe( | |
|
198 | self.subscribeToChannels); | |
|
199 | }; | |
|
200 | ||
|
201 | $.Topic('/plugins/__REGISTER__').subscribe(function (data) { | |
|
202 | // enable chat controller | |
|
203 | if (window.CHANNELSTREAM_SETTINGS && window.CHANNELSTREAM_SETTINGS.enabled) { | |
|
204 | $(document).ready(function () { | |
|
205 | connCtrlr.run(); | |
|
206 | }); | |
|
207 | } | |
|
208 | }); | |
|
209 | ||
|
210 | registerViewChannels = function (){ | |
|
211 | // subscribe to PR repo channel for PR's' | |
|
212 | if (templateContext.pull_request_data.pull_request_id) { | |
|
213 | var channelName = '/repo$' + templateContext.repo_name + '$/pr/' + | |
|
214 | String(templateContext.pull_request_data.pull_request_id); | |
|
215 | connCtrlr.state.channels.push(channelName); | |
|
216 | } | |
|
217 | } | |
|
218 | ||
|
219 | })(); |
@@ -0,0 +1,60 b'' | |||
|
1 | "use strict"; | |
|
2 | ||
|
3 | toastr.options = { | |
|
4 | "closeButton": true, | |
|
5 | "debug": false, | |
|
6 | "newestOnTop": false, | |
|
7 | "progressBar": false, | |
|
8 | "positionClass": "toast-top-center", | |
|
9 | "preventDuplicates": false, | |
|
10 | "onclick": null, | |
|
11 | "showDuration": "300", | |
|
12 | "hideDuration": "300", | |
|
13 | "timeOut": "0", | |
|
14 | "extendedTimeOut": "0", | |
|
15 | "showEasing": "swing", | |
|
16 | "hideEasing": "linear", | |
|
17 | "showMethod": "fadeIn", | |
|
18 | "hideMethod": "fadeOut" | |
|
19 | }; | |
|
20 | ||
|
21 | function notifySystem(data) { | |
|
22 | var notification = new Notification(data.message.level + ': ' + data.message.message); | |
|
23 | }; | |
|
24 | ||
|
25 | function notifyToaster(data){ | |
|
26 | toastr[data.message.level](data.message.message); | |
|
27 | } | |
|
28 | ||
|
29 | function handleNotifications(data) { | |
|
30 | ||
|
31 | if (!templateContext.rhodecode_user.notification_status && !data.testMessage) { | |
|
32 | // do not act if notifications are disabled | |
|
33 | return | |
|
34 | } | |
|
35 | // use only js notifications for now | |
|
36 | var onlyJS = true; | |
|
37 | if (!("Notification" in window) || onlyJS) { | |
|
38 | // use legacy notificartion | |
|
39 | notifyToaster(data); | |
|
40 | } | |
|
41 | else { | |
|
42 | // Let's check whether notification permissions have already been granted | |
|
43 | if (Notification.permission === "granted") { | |
|
44 | notifySystem(data); | |
|
45 | } | |
|
46 | // Otherwise, we need to ask the user for permission | |
|
47 | else if (Notification.permission !== 'denied') { | |
|
48 | Notification.requestPermission(function (permission) { | |
|
49 | if (permission === "granted") { | |
|
50 | notifySystem(data); | |
|
51 | } | |
|
52 | }); | |
|
53 | } | |
|
54 | else{ | |
|
55 | notifyToaster(data); | |
|
56 | } | |
|
57 | } | |
|
58 | }; | |
|
59 | ||
|
60 | $.Topic('/notifications').subscribe(handleNotifications); |
@@ -0,0 +1,24 b'' | |||
|
1 | <script> | |
|
2 | var CHANNELSTREAM_URLS = ${config['url_gen'](request)|n}; | |
|
3 | %if request.registry.rhodecode_plugins['channelstream']['enabled'] and c.rhodecode_user.username != h.DEFAULT_USER: | |
|
4 | var CHANNELSTREAM_SETTINGS = { | |
|
5 | 'enabled': true, | |
|
6 | 'ws_location': '${request.registry.settings.get('channelstream.ws_url')}', | |
|
7 | 'webapp_location': '${h.url('/', qualified=True)[:-1]}' | |
|
8 | }; | |
|
9 | %else: | |
|
10 | var CHANNELSTREAM_SETTINGS = { | |
|
11 | 'enabled':false, | |
|
12 | 'ws_location': '', | |
|
13 | 'webapp_location': ''}; | |
|
14 | %endif | |
|
15 | ||
|
16 | if (CHANNELSTREAM_SETTINGS.enabled) { | |
|
17 | connCtrlr = new ConnectionController( | |
|
18 | CHANNELSTREAM_SETTINGS.webapp_location, | |
|
19 | CHANNELSTREAM_SETTINGS.ws_location, | |
|
20 | CHANNELSTREAM_URLS | |
|
21 | ); | |
|
22 | registerViewChannels(); | |
|
23 | } | |
|
24 | </script> |
@@ -32,6 +32,7 b' module.exports = function(grunt) {' | |||
|
32 | 32 | '<%= dirs.js.src %>/plugins/jquery.mark.js', |
|
33 | 33 | '<%= dirs.js.src %>/plugins/jquery.timeago.js', |
|
34 | 34 | '<%= dirs.js.src %>/plugins/jquery.timeago-extension.js', |
|
35 | '<%= dirs.js.src %>/plugins/toastr.js', | |
|
35 | 36 | |
|
36 | 37 | // Select2 |
|
37 | 38 | '<%= dirs.js.src %>/select2/select2.js', |
@@ -64,6 +65,7 b' module.exports = function(grunt) {' | |||
|
64 | 65 | |
|
65 | 66 | // Rhodecode components |
|
66 | 67 | '<%= dirs.js.src %>/rhodecode/init.js', |
|
68 | '<%= dirs.js.src %>/rhodecode/connection_controller.js', | |
|
67 | 69 | '<%= dirs.js.src %>/rhodecode/codemirror.js', |
|
68 | 70 | '<%= dirs.js.src %>/rhodecode/comments.js', |
|
69 | 71 | '<%= dirs.js.src %>/rhodecode/constants.js', |
@@ -78,6 +80,7 b' module.exports = function(grunt) {' | |||
|
78 | 80 | '<%= dirs.js.src %>/rhodecode/select2_widgets.js', |
|
79 | 81 | '<%= dirs.js.src %>/rhodecode/tooltips.js', |
|
80 | 82 | '<%= dirs.js.src %>/rhodecode/users.js', |
|
83 | '<%= dirs.js.src %>/rhodecode/utils/notifications.js', | |
|
81 | 84 | '<%= dirs.js.src %>/rhodecode/appenlight.js', |
|
82 | 85 | |
|
83 | 86 | // Rhodecode main module |
@@ -391,6 +391,17 b' beaker.session.auto = false' | |||
|
391 | 391 | search.module = rhodecode.lib.index.whoosh |
|
392 | 392 | search.location = %(here)s/data/index |
|
393 | 393 | |
|
394 | ######################################## | |
|
395 | ### CHANNELSTREAM CONFIG #### | |
|
396 | ######################################## | |
|
397 | ||
|
398 | channelstream.enabled = true | |
|
399 | # location of channelstream server on the backend | |
|
400 | channelstream.server = 127.0.0.1:9800 | |
|
401 | # location of the channelstream server from outside world | |
|
402 | channelstream.ws_url = ws://127.0.0.1:9800 | |
|
403 | channelstream.secret = secret | |
|
404 | ||
|
394 | 405 | ################################### |
|
395 | 406 | ## APPENLIGHT CONFIG ## |
|
396 | 407 | ################################### |
@@ -365,6 +365,17 b' beaker.session.auto = false' | |||
|
365 | 365 | search.module = rhodecode.lib.index.whoosh |
|
366 | 366 | search.location = %(here)s/data/index |
|
367 | 367 | |
|
368 | ######################################## | |
|
369 | ### CHANNELSTREAM CONFIG #### | |
|
370 | ######################################## | |
|
371 | ||
|
372 | channelstream.enabled = true | |
|
373 | # location of channelstream server on the backend | |
|
374 | channelstream.server = 127.0.0.1:9800 | |
|
375 | # location of the channelstream server from outside world | |
|
376 | channelstream.ws_url = ws://127.0.0.1:9800 | |
|
377 | channelstream.secret = secret | |
|
378 | ||
|
368 | 379 | ################################### |
|
369 | 380 | ## APPENLIGHT CONFIG ## |
|
370 | 381 | ################################### |
@@ -166,6 +166,7 b' let' | |||
|
166 | 166 | ln -s ${self.supervisor}/bin/supervisor* $out/bin/ |
|
167 | 167 | ln -s ${self.gunicorn}/bin/gunicorn $out/bin/ |
|
168 | 168 | ln -s ${self.PasteScript}/bin/paster $out/bin/ |
|
169 | ln -s ${self.channelstream}/bin/channelstream $out/bin/ | |
|
169 | 170 | ln -s ${self.pyramid}/bin/* $out/bin/ #*/ |
|
170 | 171 | |
|
171 | 172 | # rhodecode-tools |
@@ -112,6 +112,13 b' def load_environment(global_conf, app_co' | |||
|
112 | 112 | # sets the c attribute access when don't existing attribute are accessed |
|
113 | 113 | config['pylons.strict_tmpl_context'] = True |
|
114 | 114 | |
|
115 | # configure channelstream | |
|
116 | config['channelstream_config'] = { | |
|
117 | 'enabled': asbool(config.get('channelstream.enabled', False)), | |
|
118 | 'server': config.get('channelstream.server'), | |
|
119 | 'secret': config.get('channelstream.secret') | |
|
120 | } | |
|
121 | ||
|
115 | 122 | # Limit backends to "vcs.backends" from configuration |
|
116 | 123 | backends = config['vcs.backends'] = aslist( |
|
117 | 124 | config.get('vcs.backends', 'hg,git'), sep=',') |
@@ -22,6 +22,7 b'' | |||
|
22 | 22 | Pylons middleware initialization |
|
23 | 23 | """ |
|
24 | 24 | import logging |
|
25 | from collections import OrderedDict | |
|
25 | 26 | |
|
26 | 27 | from paste.registry import RegistryManager |
|
27 | 28 | from paste.gzipper import make_gzip_middleware |
@@ -68,6 +69,12 b' class SkippableRoutesMiddleware(RoutesMi' | |||
|
68 | 69 | def __call__(self, environ, start_response): |
|
69 | 70 | for prefix in self.skip_prefixes: |
|
70 | 71 | if environ['PATH_INFO'].startswith(prefix): |
|
72 | # added to avoid the case when a missing /_static route falls | |
|
73 | # through to pylons and causes an exception as pylons is | |
|
74 | # expecting wsgiorg.routingargs to be set in the environ | |
|
75 | # by RoutesMiddleware. | |
|
76 | if 'wsgiorg.routing_args' not in environ: | |
|
77 | environ['wsgiorg.routing_args'] = (None, {}) | |
|
71 | 78 | return self.app(environ, start_response) |
|
72 | 79 | |
|
73 | 80 | return super(SkippableRoutesMiddleware, self).__call__( |
@@ -228,7 +235,7 b' def includeme(config):' | |||
|
228 | 235 | settings = config.registry.settings |
|
229 | 236 | |
|
230 | 237 | # plugin information |
|
231 |
config.registry.rhodecode_plugins = |
|
|
238 | config.registry.rhodecode_plugins = OrderedDict() | |
|
232 | 239 | |
|
233 | 240 | config.add_directive( |
|
234 | 241 | 'register_rhodecode_plugin', register_rhodecode_plugin) |
@@ -239,6 +246,7 b' def includeme(config):' | |||
|
239 | 246 | # Includes which are required. The application would fail without them. |
|
240 | 247 | config.include('pyramid_mako') |
|
241 | 248 | config.include('pyramid_beaker') |
|
249 | config.include('rhodecode.channelstream') | |
|
242 | 250 | config.include('rhodecode.admin') |
|
243 | 251 | config.include('rhodecode.authentication') |
|
244 | 252 | config.include('rhodecode.integrations') |
@@ -560,6 +560,13 b' def make_map(config):' | |||
|
560 | 560 | action='my_account_auth_tokens_add', conditions={'method': ['POST']}) |
|
561 | 561 | m.connect('my_account_auth_tokens', '/my_account/auth_tokens', |
|
562 | 562 | action='my_account_auth_tokens_delete', conditions={'method': ['DELETE']}) |
|
563 | m.connect('my_account_notifications', '/my_account/notifications', | |
|
564 | action='my_notifications', | |
|
565 | conditions={'method': ['GET']}) | |
|
566 | m.connect('my_account_notifications_toggle_visibility', | |
|
567 | '/my_account/toggle_visibility', | |
|
568 | action='my_notifications_toggle_visibility', | |
|
569 | conditions={'method': ['POST']}) | |
|
563 | 570 | |
|
564 | 571 | # NOTIFICATION REST ROUTES |
|
565 | 572 | with rmap.submapper(path_prefix=ADMIN_PREFIX, |
@@ -568,7 +575,6 b' def make_map(config):' | |||
|
568 | 575 | action='index', conditions={'method': ['GET']}) |
|
569 | 576 | m.connect('notifications_mark_all_read', '/notifications/mark_all_read', |
|
570 | 577 | action='mark_all_read', conditions={'method': ['POST']}) |
|
571 | ||
|
572 | 578 | m.connect('/notifications/{notification_id}', |
|
573 | 579 | action='update', conditions={'method': ['PUT']}) |
|
574 | 580 | m.connect('/notifications/{notification_id}', |
@@ -346,3 +346,17 b' class MyAccountController(BaseController' | |||
|
346 | 346 | h.flash(_("Auth token successfully deleted"), category='success') |
|
347 | 347 | |
|
348 | 348 | return redirect(url('my_account_auth_tokens')) |
|
349 | ||
|
350 | def my_notifications(self): | |
|
351 | c.active = 'notifications' | |
|
352 | return render('admin/my_account/my_account.html') | |
|
353 | ||
|
354 | @auth.CSRFRequired() | |
|
355 | def my_notifications_toggle_visibility(self): | |
|
356 | user = c.rhodecode_user.get_instance() | |
|
357 | user_data = user.user_data | |
|
358 | status = user_data.get('notification_status', False) | |
|
359 | user_data['notification_status'] = not status | |
|
360 | user.user_data = user_data | |
|
361 | Session().commit() | |
|
362 | return redirect(url('my_account_notifications')) |
@@ -86,6 +86,7 b' class NotificationsController(BaseContro' | |||
|
86 | 86 | |
|
87 | 87 | return render('admin/notifications/notifications.html') |
|
88 | 88 | |
|
89 | ||
|
89 | 90 | @auth.CSRFRequired() |
|
90 | 91 | def mark_all_read(self): |
|
91 | 92 | if request.is_xhr: |
@@ -332,6 +332,7 b' def attach_context_attributes(context, r' | |||
|
332 | 332 | 'rhodecode_user': { |
|
333 | 333 | 'username': None, |
|
334 | 334 | 'email': None, |
|
335 | 'notification_status': False | |
|
335 | 336 | }, |
|
336 | 337 | 'visual': { |
|
337 | 338 | 'default_renderer': None |
@@ -26,10 +26,15 b' import logging' | |||
|
26 | 26 | import traceback |
|
27 | 27 | import collections |
|
28 | 28 | |
|
29 | from datetime import datetime | |
|
30 | ||
|
31 | from pylons.i18n.translation import _ | |
|
32 | from pyramid.threadlocal import get_current_registry | |
|
29 | 33 | from sqlalchemy.sql.expression import null |
|
30 | 34 | from sqlalchemy.sql.functions import coalesce |
|
31 | 35 | |
|
32 | 36 | from rhodecode.lib import helpers as h, diffs |
|
37 | from rhodecode.lib.channelstream import channelstream_request | |
|
33 | 38 | from rhodecode.lib.utils import action_logger |
|
34 | 39 | from rhodecode.lib.utils2 import extract_mentioned_users |
|
35 | 40 | from rhodecode.model import BaseModel |
@@ -134,84 +139,82 b' class ChangesetCommentsModel(BaseModel):' | |||
|
134 | 139 | |
|
135 | 140 | Session().add(comment) |
|
136 | 141 | Session().flush() |
|
137 | ||
|
138 | if send_email: | |
|
139 | kwargs = { | |
|
140 | 'user': user, | |
|
141 | 'renderer_type': renderer, | |
|
142 | 'repo_name': repo.repo_name, | |
|
143 | 'status_change': status_change, | |
|
144 |
|
|
|
145 | 'comment_file': f_path, | |
|
146 | 'comment_line': line_no, | |
|
147 | } | |
|
142 | kwargs = { | |
|
143 | 'user': user, | |
|
144 | 'renderer_type': renderer, | |
|
145 | 'repo_name': repo.repo_name, | |
|
146 | 'status_change': status_change, | |
|
147 | 'comment_body': text, | |
|
148 | 'comment_file': f_path, | |
|
149 | 'comment_line': line_no, | |
|
150 | } | |
|
148 | 151 | |
|
149 |
|
|
|
150 |
|
|
|
151 |
|
|
|
152 |
|
|
|
153 |
|
|
|
154 |
|
|
|
155 |
|
|
|
156 |
|
|
|
157 |
|
|
|
152 | if commit_obj: | |
|
153 | recipients = ChangesetComment.get_users( | |
|
154 | revision=commit_obj.raw_id) | |
|
155 | # add commit author if it's in RhodeCode system | |
|
156 | cs_author = User.get_from_cs_author(commit_obj.author) | |
|
157 | if not cs_author: | |
|
158 | # use repo owner if we cannot extract the author correctly | |
|
159 | cs_author = repo.user | |
|
160 | recipients += [cs_author] | |
|
158 | 161 | |
|
159 |
|
|
|
162 | commit_comment_url = self.get_url(comment) | |
|
160 | 163 | |
|
161 |
|
|
|
162 |
|
|
|
163 |
|
|
|
164 |
|
|
|
164 | target_repo_url = h.link_to( | |
|
165 | repo.repo_name, | |
|
166 | h.url('summary_home', | |
|
167 | repo_name=repo.repo_name, qualified=True)) | |
|
165 | 168 | |
|
166 |
|
|
|
167 |
|
|
|
168 |
|
|
|
169 |
|
|
|
170 |
|
|
|
171 |
|
|
|
172 |
|
|
|
169 | # commit specifics | |
|
170 | kwargs.update({ | |
|
171 | 'commit': commit_obj, | |
|
172 | 'commit_message': commit_obj.message, | |
|
173 | 'commit_target_repo': target_repo_url, | |
|
174 | 'commit_comment_url': commit_comment_url, | |
|
175 | }) | |
|
173 | 176 | |
|
174 |
|
|
|
175 |
|
|
|
176 |
|
|
|
177 |
|
|
|
178 |
|
|
|
179 |
|
|
|
177 | elif pull_request_obj: | |
|
178 | # get the current participants of this pull request | |
|
179 | recipients = ChangesetComment.get_users( | |
|
180 | pull_request_id=pull_request_obj.pull_request_id) | |
|
181 | # add pull request author | |
|
182 | recipients += [pull_request_obj.author] | |
|
180 | 183 | |
|
181 |
|
|
|
182 |
|
|
|
184 | # add the reviewers to notification | |
|
185 | recipients += [x.user for x in pull_request_obj.reviewers] | |
|
183 | 186 | |
|
184 |
|
|
|
185 |
|
|
|
187 | pr_target_repo = pull_request_obj.target_repo | |
|
188 | pr_source_repo = pull_request_obj.source_repo | |
|
186 | 189 | |
|
187 |
|
|
|
188 |
|
|
|
189 |
|
|
|
190 |
|
|
|
191 |
|
|
|
192 |
|
|
|
190 | pr_comment_url = h.url( | |
|
191 | 'pullrequest_show', | |
|
192 | repo_name=pr_target_repo.repo_name, | |
|
193 | pull_request_id=pull_request_obj.pull_request_id, | |
|
194 | anchor='comment-%s' % comment.comment_id, | |
|
195 | qualified=True,) | |
|
193 | 196 | |
|
194 |
|
|
|
195 |
|
|
|
196 |
|
|
|
197 |
|
|
|
197 | # set some variables for email notification | |
|
198 | pr_target_repo_url = h.url( | |
|
199 | 'summary_home', repo_name=pr_target_repo.repo_name, | |
|
200 | qualified=True) | |
|
198 | 201 | |
|
199 |
|
|
|
200 |
|
|
|
201 |
|
|
|
202 | pr_source_repo_url = h.url( | |
|
203 | 'summary_home', repo_name=pr_source_repo.repo_name, | |
|
204 | qualified=True) | |
|
202 | 205 | |
|
203 |
|
|
|
204 |
|
|
|
205 |
|
|
|
206 |
|
|
|
207 |
|
|
|
208 |
|
|
|
209 |
|
|
|
210 |
|
|
|
211 |
|
|
|
212 |
|
|
|
213 |
|
|
|
214 | ||
|
206 | # pull request specifics | |
|
207 | kwargs.update({ | |
|
208 | 'pull_request': pull_request_obj, | |
|
209 | 'pr_id': pull_request_obj.pull_request_id, | |
|
210 | 'pr_target_repo': pr_target_repo, | |
|
211 | 'pr_target_repo_url': pr_target_repo_url, | |
|
212 | 'pr_source_repo': pr_source_repo, | |
|
213 | 'pr_source_repo_url': pr_source_repo_url, | |
|
214 | 'pr_comment_url': pr_comment_url, | |
|
215 | 'pr_closing': closing_pr, | |
|
216 | }) | |
|
217 | if send_email: | |
|
215 | 218 | # pre-generate the subject for notification itself |
|
216 | 219 | (subject, |
|
217 | 220 | _h, _e, # we don't care about those |
@@ -240,6 +243,44 b' class ChangesetCommentsModel(BaseModel):' | |||
|
240 | 243 | ) |
|
241 | 244 | action_logger(user, action, comment.repo) |
|
242 | 245 | |
|
246 | registry = get_current_registry() | |
|
247 | rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {}) | |
|
248 | channelstream_config = rhodecode_plugins.get('channelstream') | |
|
249 | msg_url = '' | |
|
250 | if commit_obj: | |
|
251 | msg_url = commit_comment_url | |
|
252 | repo_name = repo.repo_name | |
|
253 | elif pull_request_obj: | |
|
254 | msg_url = pr_comment_url | |
|
255 | repo_name = pr_target_repo.repo_name | |
|
256 | ||
|
257 | if channelstream_config: | |
|
258 | message = '<strong>{}</strong> {} - ' \ | |
|
259 | '<a onclick="window.location=\'{}\';' \ | |
|
260 | 'window.location.reload()">' \ | |
|
261 | '<strong>{}</strong></a>' | |
|
262 | message = message.format( | |
|
263 | user.username, _('made a comment'), msg_url, | |
|
264 | _('Refresh page')) | |
|
265 | if channelstream_config: | |
|
266 | channel = '/repo${}$/pr/{}'.format( | |
|
267 | repo_name, | |
|
268 | pull_request_id | |
|
269 | ) | |
|
270 | payload = { | |
|
271 | 'type': 'message', | |
|
272 | 'timestamp': datetime.utcnow(), | |
|
273 | 'user': 'system', | |
|
274 | 'channel': channel, | |
|
275 | 'message': { | |
|
276 | 'message': message, | |
|
277 | 'level': 'info', | |
|
278 | 'topic': '/notifications' | |
|
279 | } | |
|
280 | } | |
|
281 | channelstream_request(channelstream_config, [payload], | |
|
282 | '/message', raise_exc=False) | |
|
283 | ||
|
243 | 284 | return comment |
|
244 | 285 | |
|
245 | 286 | def delete(self, comment): |
@@ -25,6 +25,7 b'' | |||
|
25 | 25 | @import 'comments'; |
|
26 | 26 | @import 'panels-bootstrap'; |
|
27 | 27 | @import 'panels'; |
|
28 | @import 'toastr'; | |
|
28 | 29 | @import 'deform'; |
|
29 | 30 | |
|
30 | 31 | |
@@ -1615,6 +1616,10 b' BIN_FILENODE = 7' | |||
|
1615 | 1616 | float: right; |
|
1616 | 1617 | } |
|
1617 | 1618 | |
|
1619 | #notification-status{ | |
|
1620 | display: inline; | |
|
1621 | } | |
|
1622 | ||
|
1618 | 1623 | // Repositories |
|
1619 | 1624 | |
|
1620 | 1625 | #summary.fields{ |
@@ -1,3 +1,4 b'' | |||
|
1 | 1 | /plugins/__REGISTER__ - launched after the onDomReady() code from rhodecode.js is executed |
|
2 | 2 | /ui/plugins/code/anchor_focus - launched when rc starts to scroll on load to anchor on PR/Codeview |
|
3 | 3 | /ui/plugins/code/comment_form_built - launched when injectInlineForm() is executed and the form object is created |
|
4 | /notifications - shows new event notifications No newline at end of file |
@@ -39,6 +39,7 b'' | |||
|
39 | 39 | <li class="${'active' if c.active=='watched' else ''}"><a href="${h.url('my_account_watched')}">${_('Watched')}</a></li> |
|
40 | 40 | <li class="${'active' if c.active=='pullrequests' else ''}"><a href="${h.url('my_account_pullrequests')}">${_('Pull Requests')}</a></li> |
|
41 | 41 | <li class="${'active' if c.active=='perms' else ''}"><a href="${h.url('my_account_perms')}">${_('My Permissions')}</a></li> |
|
42 | <li class="${'active' if c.active=='my_notifications' else ''}"><a href="${h.url('my_account_notifications')}">${_('My Live Notifications')}</a></li> | |
|
42 | 43 | </ul> |
|
43 | 44 | </div> |
|
44 | 45 |
@@ -1,72 +1,56 b'' | |||
|
1 | <%namespace name="base" file="/base/base.html"/> | |
|
2 | ||
|
3 | 1 |
|
|
4 | 2 | <div class="panel-heading"> |
|
5 |
<h3 class="panel-title">${_(' |
|
|
3 | <h3 class="panel-title">${_('Your live notification settings')}</h3> | |
|
6 | 4 | </div> |
|
7 | 5 | |
|
8 | 6 | <div class="panel-body"> |
|
9 | <div class="emails_wrap"> | |
|
10 | <table class="rctable account_emails"> | |
|
11 | <tr> | |
|
12 | <td class="td-user"> | |
|
13 | ${base.gravatar(c.user.email, 16)} | |
|
14 | <span class="user email">${c.user.email}</span> | |
|
15 | </td> | |
|
16 | <td class="td-tags"> | |
|
17 | <span class="tag tag1">${_('Primary')}</span> | |
|
18 | </td> | |
|
19 | </tr> | |
|
20 | %if c.user_email_map: | |
|
21 | %for em in c.user_email_map: | |
|
22 | <tr> | |
|
23 | <td class="td-user"> | |
|
24 | ${base.gravatar(em.email, 16)} | |
|
25 | <span class="user email">${em.email}</span> | |
|
26 | </td> | |
|
27 | <td class="td-action"> | |
|
28 | ${h.secure_form(url('my_account_emails'),method='delete')} | |
|
29 | ${h.hidden('del_email_id',em.email_id)} | |
|
30 | <button class="btn btn-link btn-danger" type="submit" id="remove_email_%s" % em.email_id | |
|
31 | onclick="return confirm('${_('Confirm to delete this email: %s') % em.email}');"> | |
|
32 | ${_('Delete')} | |
|
33 | </button> | |
|
34 | ${h.end_form()} | |
|
35 | </td> | |
|
36 | </tr> | |
|
37 | %endfor | |
|
38 | %else: | |
|
39 | <tr class="noborder"> | |
|
40 | <td colspan="3"> | |
|
41 | <div class="td-email"> | |
|
42 | ${_('No additional emails specified')} | |
|
43 | </div> | |
|
44 | </td> | |
|
45 | </tr> | |
|
46 | %endif | |
|
47 | </table> | |
|
48 | </div> | |
|
7 | ||
|
8 | <p><strong>IMPORTANT:</strong> This feature requires enabled channelstream websocket server to function correctly.</p> | |
|
9 | ||
|
10 | <p class="hidden">Status of browser notifications permission: <strong id="browser-notification-status"></strong></p> | |
|
49 | 11 | |
|
50 | <div> | |
|
51 | ${h.secure_form(url('my_account_emails'), method='post')} | |
|
52 | <div class="form"> | |
|
53 | <!-- fields --> | |
|
54 | <div class="fields"> | |
|
55 | <div class="field"> | |
|
56 | <div class="label"> | |
|
57 | <label for="new_email">${_('New email address')}:</label> | |
|
58 | </div> | |
|
59 | <div class="input"> | |
|
60 | ${h.text('new_email', class_='medium')} | |
|
61 | </div> | |
|
62 | </div> | |
|
63 | <div class="buttons"> | |
|
64 | ${h.submit('save',_('Add'),class_="btn")} | |
|
65 | ${h.reset('reset',_('Reset'),class_="btn")} | |
|
66 | </div> | |
|
67 | </div> | |
|
68 | </div> | |
|
12 | ${h.secure_form(url('my_account_notifications_toggle_visibility'), method='post', id='notification-status')} | |
|
13 | <button class="btn btn-default" type="submit"> | |
|
14 | ${_('Notifications')} <strong>${_('Enabled') if c.rhodecode_user.get_instance().user_data.get('notification_status') else _('Disabled')}</strong> | |
|
15 | </button> | |
|
69 | 16 | ${h.end_form()} |
|
70 | </div> | |
|
17 | ||
|
18 | <a class="btn btn-info" id="test-notification">Test notification</a> | |
|
19 | ||
|
71 | 20 | </div> |
|
72 | 21 | </div> |
|
22 | ||
|
23 | <script type="application/javascript"> | |
|
24 | ||
|
25 | function checkBrowserStatus(){ | |
|
26 | var browserStatus = 'Unknown'; | |
|
27 | ||
|
28 | if (!("Notification" in window)) { | |
|
29 | browserStatus = 'Not supported' | |
|
30 | } | |
|
31 | else if(Notification.permission === 'denied'){ | |
|
32 | browserStatus = 'Denied'; | |
|
33 | $('.flash_msg').append('<div class="alert alert-error">Notifications are blocked on browser level - you need to enable them in your browser settings.</div>') | |
|
34 | } | |
|
35 | else if(Notification.permission === 'granted'){ | |
|
36 | browserStatus = 'Allowed'; | |
|
37 | } | |
|
38 | ||
|
39 | $('#browser-notification-status').text(browserStatus); | |
|
40 | }; | |
|
41 | ||
|
42 | checkBrowserStatus(); | |
|
43 | ||
|
44 | $('#test-notification').on('click', function(e){ | |
|
45 | var levels = ['info', 'error', 'warning', 'success']; | |
|
46 | var level = levels[Math.floor(Math.random()*levels.length)]; | |
|
47 | var payload = { | |
|
48 | message: { | |
|
49 | message: 'This is a test notification.', | |
|
50 | level: level, | |
|
51 | testMessage: true | |
|
52 | } | |
|
53 | }; | |
|
54 | $.Topic('/notifications').publish(payload); | |
|
55 | }) | |
|
56 | </script> |
@@ -3,7 +3,7 b' from pyramid.renderers import render as ' | |||
|
3 | 3 | from pyramid.threadlocal import get_current_registry, get_current_request |
|
4 | 4 | pyramid_registry = get_current_registry() |
|
5 | 5 | %> |
|
6 |
% for plugin, config in pyramid_registry |
|
|
6 | % for plugin, config in getattr(pyramid_registry, 'rhodecode_plugins', {}).items(): | |
|
7 | 7 | % if config['template_hooks'].get('plugin_init_template'): |
|
8 | 8 | ${pyramid_render(config['template_hooks'].get('plugin_init_template'), |
|
9 | 9 | {'config':config}, request=get_current_request(), package='rc_ae')|n} |
@@ -11,6 +11,7 b" if hasattr(c, 'rhodecode_db_repo'):" | |||
|
11 | 11 | if getattr(c, 'rhodecode_user', None) and c.rhodecode_user.user_id: |
|
12 | 12 | c.template_context['rhodecode_user']['username'] = c.rhodecode_user.username |
|
13 | 13 | c.template_context['rhodecode_user']['email'] = c.rhodecode_user.email |
|
14 | c.template_context['rhodecode_user']['notification_status'] = c.rhodecode_user.get_instance().user_data.get('notification_status', True) | |
|
14 | 15 | |
|
15 | 16 | c.template_context['visual']['default_renderer'] = h.get_visual_attr(c, 'default_renderer') |
|
16 | 17 | %> |
@@ -77,7 +78,6 b" c.template_context['visual']['default_re" | |||
|
77 | 78 | } |
|
78 | 79 | }; |
|
79 | 80 | </script> |
|
80 | ||
|
81 | 81 | <!--[if lt IE 9]> |
|
82 | 82 | <script language="javascript" type="text/javascript" src="${h.asset('js/excanvas.min.js')}"></script> |
|
83 | 83 | <![endif]--> |
@@ -105,7 +105,6 b" c.template_context['visual']['default_re" | |||
|
105 | 105 | |
|
106 | 106 | <%def name="head_extra()"></%def> |
|
107 | 107 | ${self.head_extra()} |
|
108 | ||
|
109 | 108 | <%include file="/base/plugins_base.html"/> |
|
110 | 109 | |
|
111 | 110 | ## extra stuff |
General Comments 0
You need to be logged in to leave comments.
Login now