Add simple chat widget on sidebar of clients
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
* Add simple chat widget on sidebar
|
||||
* Support color as type of icon and image
|
||||
* Support color widget on form view
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ module.exports = function(grunt) {
|
||||
'src/wizard.js',
|
||||
'src/board.js',
|
||||
'src/bus.js',
|
||||
'src/chat.js',
|
||||
'src/plugins.js',
|
||||
'src/html_sanitizer.js'
|
||||
];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M880-80 720-240H320q-33 0-56.5-23.5T240-320v-40h440q33 0 56.5-23.5T760-440v-280h40q33 0 56.5 23.5T880-640v560ZM160-473l47-47h393v-280H160v327ZM80-280v-520q0-33 23.5-56.5T160-880h440q33 0 56.5 23.5T680-800v280q0 33-23.5 56.5T600-440H240L80-280Zm80-240v-280 280Z"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 14H6v-2h2v2zm0-3H6V9h2v2zm0-3H6V6h2v2zm7 6h-5v-2h5v2zm3-3h-8V9h8v2zm0-3h-8V6h8v2z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M280-160v-441q0-33 24-56t57-23h439q33 0 56.5 23.5T880-600v320L680-80H360q-33 0-56.5-23.5T280-160ZM81-710q-6-33 13-59.5t52-32.5l434-77q33-6 59.5 13t32.5 52l10 54h-82l-7-40-433 77 40 226v279q-16-9-27.5-24T158-276L81-710Zm279 110v440h280v-160h160v-280H360Zm220 220Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 387 B |
+65
-28
@@ -7,23 +7,30 @@
|
||||
|
||||
// Bus Identifier
|
||||
Sao.Bus.id = Sao.common.uuid4();
|
||||
Sao.Bus.channels = ['client:' + Sao.Bus.id];
|
||||
Sao.Bus.channel_actions = {};
|
||||
Sao.Bus.request = null;
|
||||
Sao.Bus.last_message = undefined;
|
||||
Sao.Bus.listening = false;
|
||||
|
||||
Sao.Bus.listen = function(last_message, wait) {
|
||||
wait = wait || 1;
|
||||
last_message = last_message || Sao.Bus.last_message;
|
||||
var session = Sao.Session.current_session;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
Sao.Bus.listening = true;
|
||||
|
||||
var prm = jQuery.ajax({
|
||||
let channels = Object.keys(Sao.Bus.channel_actions);
|
||||
Sao.Bus.last_message = last_message;
|
||||
Sao.Bus.request = jQuery.ajax({
|
||||
headers: {
|
||||
Authorization: 'Session ' + session.get_auth(),
|
||||
},
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
last_message: last_message,
|
||||
channels: Sao.Bus.channels
|
||||
channels: channels,
|
||||
}),
|
||||
dataType: 'json',
|
||||
url: '/' + session.database + '/bus',
|
||||
@@ -31,7 +38,7 @@
|
||||
timeout: Sao.config.bus_timeout,
|
||||
});
|
||||
|
||||
prm.done(function(response) {
|
||||
Sao.Bus.request.done(function(response) {
|
||||
if (Sao.Session.current_session != session) {
|
||||
return;
|
||||
}
|
||||
@@ -40,25 +47,26 @@
|
||||
Sao.Logger.info(
|
||||
"poll channels %s with last message",
|
||||
Sao.Bus.channels, last_message);
|
||||
Sao.Bus.handle(response.message);
|
||||
Sao.Bus.handle(response.channel, response.message);
|
||||
}
|
||||
Sao.Bus.listen(last_message, 1);
|
||||
});
|
||||
|
||||
prm.fail(function(response, status, error) {
|
||||
Sao.Bus.request.fail(function(response, status, error) {
|
||||
if (Sao.Session.current_session != session) {
|
||||
return;
|
||||
}
|
||||
if (error === "timeout") {
|
||||
if ((error == "abort") || (error === "timeout")) {
|
||||
Sao.Bus.listen(last_message, 1);
|
||||
} else if (response.status == 501) {
|
||||
Sao.Logger.info("Bus not supported");
|
||||
return;
|
||||
Sao.Bus.listening = false;
|
||||
} else {
|
||||
Sao.Logger.error(
|
||||
"An exception occured while connection to the bus. " +
|
||||
"Sleeping for %s seconds",
|
||||
wait, error);
|
||||
Sao.Bus.listening = false;
|
||||
window.setTimeout(
|
||||
Sao.Bus.listen,
|
||||
Math.min(wait * 1000, Sao.config.bus_timeout),
|
||||
@@ -67,27 +75,56 @@
|
||||
});
|
||||
};
|
||||
|
||||
Sao.Bus.handle = function(message) {
|
||||
var notify = function(message) {
|
||||
try {
|
||||
if (Notification.permission != "granted") {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
Sao.Logger.error(e.message, e.stack);
|
||||
return;
|
||||
}
|
||||
|
||||
new Notification(message.title, {
|
||||
body: message.body || '',
|
||||
});
|
||||
};
|
||||
|
||||
switch (message.type) {
|
||||
case 'notification':
|
||||
notify(message);
|
||||
break;
|
||||
Sao.Bus.handle = function(channel, message) {
|
||||
let actions = Sao.Bus.channel_actions[channel] || [];
|
||||
for (let callback of actions) {
|
||||
callback(message);
|
||||
}
|
||||
};
|
||||
|
||||
Sao.Bus.register = function(channel, func) {
|
||||
if (!Object.hasOwn(Sao.Bus.channel_actions, channel)) {
|
||||
Sao.Bus.channel_actions[channel] = [];
|
||||
}
|
||||
Sao.Bus.channel_actions[channel].push(func);
|
||||
|
||||
if (Sao.Bus.request) {
|
||||
Sao.Bus.request.abort();
|
||||
}
|
||||
};
|
||||
|
||||
Sao.Bus.unregister = function(channel, func) {
|
||||
if (Object.hasOwn(Sao.Bus.channel_actions, channel)) {
|
||||
let actions = Sao.Bus.channel_actions[channel];
|
||||
let func_idx = actions.indexOf(func);
|
||||
if (func_idx != -1) {
|
||||
actions.splice(func_idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if ((Sao.Bus.channel_actions[channel] || []).length == 0) {
|
||||
delete Sao.Bus.channel_actions[channel];
|
||||
}
|
||||
}
|
||||
|
||||
let popup_notification = function(message) {
|
||||
if (message.type != 'notification') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Notification.permission != "granted") {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
Sao.Logger.error(e.message, e.stack);
|
||||
return;
|
||||
}
|
||||
|
||||
new Notification(message.title, {
|
||||
body: message.body || '',
|
||||
});
|
||||
}
|
||||
Sao.Bus.register(`client:${Sao.Bus.id}`, popup_notification);
|
||||
|
||||
}());
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/* This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
this repository contains the full copyright notices and license terms. */
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class _Chat {
|
||||
constructor(record) {
|
||||
this.record = record;
|
||||
Sao.Bus.register(`chat:${this.record}`, this.notify.bind(this));
|
||||
this.el = this.__build();
|
||||
}
|
||||
|
||||
unregister() {
|
||||
Sao.Bus.unregister(`chat:${this.record}`, this.notify);
|
||||
}
|
||||
|
||||
send_message(message, internal) {
|
||||
return Sao.rpc({
|
||||
'method': 'model.ir.chat.channel.post',
|
||||
'params': [this.record, message, internal ? 'internal' : 'public', {}],
|
||||
}, Sao.Session.current_session)
|
||||
}
|
||||
|
||||
get_messages() {
|
||||
return Sao.rpc({
|
||||
'method': 'model.ir.chat.channel.get',
|
||||
'params': [this.record, {}],
|
||||
}, Sao.Session.current_session);
|
||||
}
|
||||
|
||||
notify(message) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
let prm = this.get_messages();
|
||||
prm.done((posts) => {
|
||||
this._messages.empty();
|
||||
for (let post of posts) {
|
||||
this._messages.append(this.create_message(post));
|
||||
}
|
||||
|
||||
let messages = this._messages[this._messages.length - 1];
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
__build() {
|
||||
let el = jQuery('<div/>', {
|
||||
'class': 'chat',
|
||||
});
|
||||
|
||||
this._messages = jQuery('<div/>', {
|
||||
'class': 'chat-messages',
|
||||
}).appendTo(jQuery('<div/>', {
|
||||
'class': 'chat-messages-outer',
|
||||
}).appendTo(el));
|
||||
|
||||
let input = jQuery('<textarea/>', {
|
||||
'class': 'input-sm form-control',
|
||||
'placeholder': Sao.i18n.gettext("Enter a message"),
|
||||
});
|
||||
let submit = jQuery('<button/>', {
|
||||
'class': 'btn btn-block btn-default',
|
||||
'type': 'submit',
|
||||
'aria-label': Sao.i18n.gettext("Submit the message"),
|
||||
'title': Sao.i18n.gettext("Send"),
|
||||
'text': Sao.i18n.gettext("Send"),
|
||||
});
|
||||
let internal = jQuery('<input/>', {
|
||||
'type': 'checkbox',
|
||||
});
|
||||
jQuery('<hr/>').appendTo(el);
|
||||
let form = jQuery('<form/>')
|
||||
.append(jQuery('<div/>', {
|
||||
'class': 'form-group',
|
||||
}).append(input))
|
||||
.append(jQuery('<div/>', {
|
||||
'class': 'checkbox',
|
||||
}).append(jQuery('<label/>')
|
||||
.append(internal)
|
||||
.append(Sao.i18n.gettext("Make this an internal message"))))
|
||||
.append(submit)
|
||||
.appendTo(el);
|
||||
|
||||
let send = (evt) => {
|
||||
evt.preventDefault();
|
||||
submit.prop('disabled', true);
|
||||
input.prop('disabled', true);
|
||||
this.send_message(
|
||||
input.val(),
|
||||
internal.prop('checked')
|
||||
).done(() => {
|
||||
input.val('');
|
||||
submit.prop('disabled', false);
|
||||
input.prop('disabled', false);
|
||||
input.trigger('focus');
|
||||
internal.prop('checked', false);
|
||||
if (!Sao.Bus.listening) {
|
||||
this.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
form.submit(send);
|
||||
input.keypress((evt) => {
|
||||
if ((evt.which == Sao.common.RETURN_KEYCODE) && evt.ctrlKey) {
|
||||
evt.preventDefault();
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
create_message(message) {
|
||||
let avatar_size = 32;
|
||||
let timestamp = Sao.common.format_datetime(
|
||||
Sao.common.date_format() + ' %X', message.timestamp);
|
||||
let avatar_url = '';
|
||||
if (message.avatar_url) {
|
||||
let url = new URL(message.avatar_url, window.location);
|
||||
url.searchParams.set('s', avatar_size);
|
||||
avatar_url = url.href;
|
||||
}
|
||||
return jQuery('<div/>', {
|
||||
'class': 'media chat-message',
|
||||
}).append(jQuery('<div/>', {
|
||||
'class': 'media-left',
|
||||
}).append(jQuery('<img/>', {
|
||||
'class': 'media-object img-circle chat-avatar',
|
||||
'src': avatar_url,
|
||||
'alt': message.author,
|
||||
'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
|
||||
}))).append(jQuery('<div/>', {
|
||||
'class': 'media-body well well-sm',
|
||||
}).append(jQuery('<h6/>', {
|
||||
'class': 'media-heading',
|
||||
}).text(message.author)
|
||||
.append(jQuery('<small/>', {
|
||||
'class': 'text-muted pull-right',
|
||||
}).text(timestamp)))
|
||||
.append(jQuery('<div/>', {
|
||||
'class': `chat-content chat-content-${message.audience}`,
|
||||
}).text(message.content)));
|
||||
}
|
||||
}
|
||||
Sao.Chat = _Chat;
|
||||
|
||||
}());
|
||||
@@ -585,6 +585,25 @@
|
||||
});
|
||||
Sao.common.MODELNOTIFICATION = new Sao.common.ModelNotification();
|
||||
|
||||
Sao.common.ModelChat = Sao.class_(Object, {
|
||||
init: function() {
|
||||
this._models = null;
|
||||
},
|
||||
load_names: function() {
|
||||
this._models = Object.fromEntries(Sao.rpc({
|
||||
'method': 'model.ir.chat.channel.get_models',
|
||||
'params': [{}],
|
||||
}, Sao.Session.current_session, false));
|
||||
},
|
||||
hasOwn: function(model) {
|
||||
if (jQuery.isEmptyObject(this._models)) {
|
||||
this.load_names();
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(this._models, model);
|
||||
},
|
||||
});
|
||||
Sao.common.MODELCHAT = new Sao.common.ModelChat();
|
||||
|
||||
Sao.common.ViewSearch = Sao.class_(Object, {
|
||||
init: function() {
|
||||
this.encoder = new Sao.PYSON.Encoder();
|
||||
@@ -3068,6 +3087,7 @@
|
||||
'tryton-bookmarks',
|
||||
'tryton-bookmark',
|
||||
'tryton-cancel',
|
||||
'tryton-chat',
|
||||
'tryton-clear',
|
||||
'tryton-close',
|
||||
'tryton-copy',
|
||||
|
||||
@@ -341,6 +341,7 @@ html[theme="default"] {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
transform: scaleX(-1);
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,11 +415,74 @@ html[theme="default"] {
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
border-bottom: 1px solid @hr-border;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat {
|
||||
border-bottom: 1px solid @hr-border;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding-bottom: 20px; // Like .tab-form
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
|
||||
.chat-messages-outer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
|
||||
}
|
||||
|
||||
.chat-content-internal {
|
||||
color: @text-muted;
|
||||
}
|
||||
|
||||
.media-body.well {
|
||||
border-radius: .5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-style: dotted;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
margin-bottom: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.list-group-item-variant(selected; @state-selected-bg; @state-selected-text);
|
||||
.table-row-variant(selected; @state-selected-bg);
|
||||
/* Duplicate bootstrap variant to have precedence over selected */
|
||||
|
||||
@@ -876,6 +876,12 @@
|
||||
get count_limit() {
|
||||
return this.limit * 100 + this.offset;
|
||||
},
|
||||
get current_reference() {
|
||||
if (this.current_record && (this.current_record.id > 0)) {
|
||||
return `${this.model_name},${this.current_record.id}`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
load_next_view: function() {
|
||||
if (!jQuery.isEmptyObject(this.view_to_load)) {
|
||||
var view_id;
|
||||
|
||||
+36
-1
@@ -84,6 +84,13 @@
|
||||
icon: 'tryton-note',
|
||||
label: Sao.i18n.gettext('Note'),
|
||||
tooltip: Sao.i18n.gettext('Add a note to the record'),
|
||||
}, {
|
||||
id: (this.screen &&
|
||||
Sao.common.MODELCHAT.hasOwn(this.screen.model_name)) ?
|
||||
'chat' : null,
|
||||
icon: 'tryton-chat',
|
||||
label: Sao.i18n.gettext("Chat"),
|
||||
tooltip: Sao.i18n.gettext("Chat on the record"),
|
||||
}, {
|
||||
id: 'action',
|
||||
icon: 'tryton-launch',
|
||||
@@ -341,6 +348,9 @@
|
||||
Sao.set_url();
|
||||
}
|
||||
tabs.trigger('ready');
|
||||
if (this._chat) {
|
||||
this._chat.unregister();
|
||||
}
|
||||
});
|
||||
},
|
||||
_close_allowed: function() {
|
||||
@@ -1382,6 +1392,17 @@
|
||||
});
|
||||
}
|
||||
},
|
||||
chat: function(evt) {
|
||||
let chat = this.sidebar_content.find('.chat');
|
||||
if (chat.length) {
|
||||
chat.remove();
|
||||
} else {
|
||||
this._chat = new Sao.Chat(this.screen.current_reference);
|
||||
this._chat.refresh();
|
||||
this.sidebar_content.append(this._chat.el);
|
||||
}
|
||||
this.update_sidebar();
|
||||
},
|
||||
note: function() {
|
||||
var record = this.screen.current_record;
|
||||
if (!record || (record.id < 0)) {
|
||||
@@ -1513,9 +1534,12 @@
|
||||
const view_type = this.screen.current_view.view_type;
|
||||
var next_view_type = this.screen.next_view_type;
|
||||
const has_views = this.screen.number_of_views > 1;
|
||||
var buttons = ['print', 'relate', 'email', 'attach'];
|
||||
var buttons = ['print', 'relate', 'email', 'attach', 'chat'];
|
||||
for (const button_id of buttons) {
|
||||
const button = this.buttons[button_id];
|
||||
if (!button) {
|
||||
continue;
|
||||
}
|
||||
let can_be_sensitive = button._can_be_sensitive;
|
||||
if (can_be_sensitive === undefined) {
|
||||
can_be_sensitive = true;
|
||||
@@ -1555,6 +1579,17 @@
|
||||
this.info_bar.clear();
|
||||
this.set_buttons_sensitive();
|
||||
this.refresh_attachment_preview();
|
||||
|
||||
if (this._chat) {
|
||||
let chat = this.sidebar_content.find('.chat');
|
||||
this._chat.unregister();
|
||||
chat.remove();
|
||||
if (this.screen.current_reference) {
|
||||
this._chat = new Sao.Chat(this.screen.current_reference);
|
||||
this._chat.refresh();
|
||||
this.sidebar_content.append(this._chat.el);
|
||||
}
|
||||
}
|
||||
},
|
||||
record_modified: function() {
|
||||
this.set_buttons_sensitive();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
* Add simple chat widget on sidebar
|
||||
* Remove support for Python 3.8
|
||||
* Add support for Python 3.13
|
||||
* Support color as type of icon and image
|
||||
|
||||
+111
-67
@@ -7,6 +7,7 @@ import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
@@ -18,82 +19,125 @@ from tryton.jsonrpc import object_hook
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ID = str(uuid.uuid4())
|
||||
CHANNELS = [
|
||||
'client:%s' % ID,
|
||||
]
|
||||
class Bus:
|
||||
|
||||
ID = str(uuid.uuid4())
|
||||
current_thread = None
|
||||
channel_actions = defaultdict(list)
|
||||
listening = False
|
||||
|
||||
def listen(connection):
|
||||
if not CONFIG['thread']:
|
||||
return
|
||||
listener = threading.Thread(
|
||||
target=_listen, args=(connection,), daemon=True)
|
||||
listener.start()
|
||||
@classmethod
|
||||
def listen(cls, connection):
|
||||
if not CONFIG['thread']:
|
||||
return
|
||||
listener = threading.Thread(
|
||||
target=cls._listen, args=(connection,), daemon=True)
|
||||
listener.start()
|
||||
cls.current_thread = listener.ident
|
||||
|
||||
@classmethod
|
||||
def _listen(cls, connection):
|
||||
bus_timeout = CONFIG['client.bus_timeout']
|
||||
session = connection.session
|
||||
authorization = base64.b64encode(session.encode('utf-8'))
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': b'Session ' + authorization,
|
||||
}
|
||||
|
||||
def _listen(connection):
|
||||
bus_timeout = CONFIG['client.bus_timeout']
|
||||
session = connection.session
|
||||
authorization = base64.b64encode(session.encode('utf-8'))
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': b'Session ' + authorization,
|
||||
}
|
||||
|
||||
wait = 1
|
||||
last_message = None
|
||||
url = None
|
||||
while connection.session == session:
|
||||
if url is None:
|
||||
if connection.url is None:
|
||||
time.sleep(1)
|
||||
continue
|
||||
url = connection.url + '/bus'
|
||||
request = Request(url,
|
||||
data=json.dumps({
|
||||
'last_message': last_message,
|
||||
'channels': CHANNELS,
|
||||
}).encode('utf-8'),
|
||||
headers=headers)
|
||||
logger.info('poll channels %s with last message %s',
|
||||
CHANNELS, last_message)
|
||||
try:
|
||||
response = urlopen(request, timeout=bus_timeout)
|
||||
wait = 1
|
||||
except socket.timeout:
|
||||
wait = 1
|
||||
continue
|
||||
except Exception as error:
|
||||
if isinstance(error, HTTPError):
|
||||
if error.code in (301, 302, 303, 307, 308):
|
||||
url = error.headers.get('Location')
|
||||
thread_id = threading.get_ident()
|
||||
wait = 1
|
||||
last_message = None
|
||||
url = None
|
||||
while connection.session == session:
|
||||
if url is None:
|
||||
if connection.url is None:
|
||||
time.sleep(1)
|
||||
continue
|
||||
elif error.code == 501:
|
||||
logger.info("Bus not supported")
|
||||
url = connection.url + '/bus'
|
||||
cls.listening = True
|
||||
|
||||
channels = list(cls.channel_actions)
|
||||
request = Request(url,
|
||||
data=json.dumps({
|
||||
'last_message': last_message,
|
||||
'channels': channels,
|
||||
}).encode('utf-8'),
|
||||
headers=headers)
|
||||
logger.info('poll channels %s with last message %s',
|
||||
channels, last_message)
|
||||
try:
|
||||
response = urlopen(request, timeout=bus_timeout)
|
||||
wait = 1
|
||||
except socket.timeout:
|
||||
wait = 1
|
||||
continue
|
||||
except Exception as error:
|
||||
if thread_id != cls.current_thread:
|
||||
break
|
||||
logger.error(
|
||||
"An exception occurred while connecting to the bus. "
|
||||
"Sleeping for %s seconds",
|
||||
wait, exc_info=error)
|
||||
time.sleep(min(wait, bus_timeout))
|
||||
wait *= 2
|
||||
continue
|
||||
if isinstance(error, HTTPError):
|
||||
if error.code in (301, 302, 303, 307, 308):
|
||||
url = error.headers.get('Location')
|
||||
continue
|
||||
elif error.code == 501:
|
||||
logger.info("Bus not supported")
|
||||
break
|
||||
logger.error(
|
||||
"An exception occurred while connecting to the bus. "
|
||||
"Sleeping for %s seconds",
|
||||
wait, exc_info=error)
|
||||
cls.listening = False
|
||||
time.sleep(min(wait, bus_timeout))
|
||||
wait *= 2
|
||||
continue
|
||||
|
||||
if connection.session != session:
|
||||
break
|
||||
if connection.session != session:
|
||||
break
|
||||
if thread_id != cls.current_thread:
|
||||
break
|
||||
|
||||
data = json.loads(response.read(), object_hook=object_hook)
|
||||
if data['message']:
|
||||
last_message = data['message']['message_id']
|
||||
GLib.idle_add(handle, data['message'])
|
||||
data = json.loads(response.read(), object_hook=object_hook)
|
||||
if data['message']:
|
||||
last_message = data['message']['message_id']
|
||||
GLib.idle_add(cls.handle, data['channel'], data['message'])
|
||||
cls.listening = False
|
||||
|
||||
@classmethod
|
||||
def handle(cls, channel, message):
|
||||
for callback in cls.channel_actions[channel]:
|
||||
callback(message)
|
||||
|
||||
@classmethod
|
||||
def register(cls, channel, function):
|
||||
from tryton import rpc
|
||||
|
||||
restart = channel not in cls.channel_actions
|
||||
cls.channel_actions[channel].append(function)
|
||||
if restart:
|
||||
# We can not really abort a thread, so we will just start a new one
|
||||
# and ignore the result of the one already running
|
||||
Bus.listen(rpc.CONNECTION)
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, channel, function):
|
||||
try:
|
||||
cls.channel_actions[channel].remove(function)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not cls.channel_actions[channel]:
|
||||
del cls.channel_actions[channel]
|
||||
|
||||
|
||||
def handle(message):
|
||||
def popup_notification(message):
|
||||
if message['type'] != 'notification':
|
||||
return
|
||||
|
||||
from tryton.gui.main import Main
|
||||
|
||||
app = Main()
|
||||
if message['type'] == 'notification':
|
||||
app.show_notification(
|
||||
message.get('title', ''), message.get('body', ''),
|
||||
message.get('priority', 1))
|
||||
app.show_notification(
|
||||
message.get('title', ''), message.get('body', ''),
|
||||
message.get('priority', 1))
|
||||
|
||||
|
||||
Bus.register(f'client:{Bus.ID}', popup_notification)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# This file is part of Tryton. The COPYRIGHT file at the top level of
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
import gettext
|
||||
|
||||
from gi.repository import Gdk, Gio, GObject, Gtk
|
||||
|
||||
from tryton import common, rpc
|
||||
from tryton.bus import Bus
|
||||
|
||||
_ = gettext.gettext
|
||||
|
||||
|
||||
class MessageItem(GObject.GObject):
|
||||
|
||||
def __init__(self, message):
|
||||
super().__init__()
|
||||
self.message = message
|
||||
|
||||
|
||||
class MessageList(GObject.GObject, Gio.ListModel):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._messages = []
|
||||
self._items = {}
|
||||
|
||||
def do_get_item(self, position):
|
||||
if position >= len(self._messages):
|
||||
return None
|
||||
message = self._messages[position]
|
||||
if message['id'] not in self._items:
|
||||
self._items[message['id']] = MessageItem(message)
|
||||
return self._items[message['id']]
|
||||
|
||||
def do_get_item_type(self):
|
||||
return MessageItem
|
||||
|
||||
def do_get_n_items(self):
|
||||
return len(self._messages)
|
||||
|
||||
def clear(self):
|
||||
self.emit('items-changed', 0, len(self._messages), 0)
|
||||
self._messages = []
|
||||
self._items = {}
|
||||
|
||||
def append(self, message):
|
||||
self._messages.append(message)
|
||||
self.emit('items-changed', len(self._messages) - 1, 0, 1)
|
||||
|
||||
|
||||
class Chat:
|
||||
|
||||
def __init__(self, record):
|
||||
self.record = record
|
||||
Bus.register(f"chat:{self.record}", self.notify)
|
||||
self.widget = self.__build()
|
||||
|
||||
def unregister(self):
|
||||
Bus.unregister(f"chat:{self.record}", self.notify)
|
||||
|
||||
def send_message(self, message, internal):
|
||||
rpc.execute(
|
||||
'model', 'ir.chat.channel', 'post', self.record, message,
|
||||
'internal' if internal else 'public', rpc.CONTEXT)
|
||||
|
||||
def get_messages(self):
|
||||
return rpc.execute(
|
||||
'model', 'ir.chat.channel', 'get', self.record, rpc.CONTEXT)
|
||||
|
||||
def notify(self, message):
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
self._messages.clear()
|
||||
for post in self.get_messages():
|
||||
self._messages.append(post)
|
||||
self.widget.show_all()
|
||||
|
||||
def scroll_to_bottom(*args):
|
||||
self._messages_sw.disconnect(signal_id)
|
||||
vadj = self._messages_sw.get_vadjustment()
|
||||
vadj.props.value = vadj.get_upper()
|
||||
signal_id = self._messages_sw.connect(
|
||||
'size-allocate', scroll_to_bottom)
|
||||
|
||||
def __build(self):
|
||||
widget = Gtk.VBox()
|
||||
widget.set_spacing(3)
|
||||
|
||||
def _submit(button):
|
||||
buffer = input_.get_buffer()
|
||||
self.send_message(
|
||||
buffer.get_text(
|
||||
buffer.get_start_iter(), buffer.get_end_iter(), False),
|
||||
internal.get_active())
|
||||
buffer.set_text('')
|
||||
if not Bus.listening:
|
||||
self.refresh()
|
||||
|
||||
def _keypress(entry, event):
|
||||
if (event.state & Gdk.ModifierType.CONTROL_MASK
|
||||
and event.keyval == Gdk.KEY_Return):
|
||||
_submit(None)
|
||||
return True
|
||||
|
||||
self._messages = MessageList()
|
||||
chat_messages = Gtk.ListBox.new()
|
||||
chat_messages.props.activate_on_single_click = False
|
||||
chat_messages.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
chat_messages.bind_model(self._messages, self.create_message)
|
||||
self._messages_sw = scrolledwindow = Gtk.ScrolledWindow()
|
||||
viewport = Gtk.Viewport()
|
||||
viewport.add(chat_messages)
|
||||
viewport.set_valign(Gtk.Align.END)
|
||||
scrolledwindow.set_shadow_type(Gtk.ShadowType.NONE)
|
||||
scrolledwindow.set_policy(
|
||||
Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scrolledwindow.add(viewport)
|
||||
widget.pack_start(scrolledwindow, True, True, 0)
|
||||
|
||||
input_ = Gtk.TextView()
|
||||
input_.connect('key-press-event', _keypress)
|
||||
input_.set_size_request(-1, 100)
|
||||
widget.pack_start(input_, False, True, 0)
|
||||
|
||||
internal = Gtk.CheckButton.new_with_mnemonic(
|
||||
_("Make this an _internal message"))
|
||||
widget.pack_start(internal, False, True, 0)
|
||||
|
||||
submit = Gtk.Button.new_with_label(_("Send"))
|
||||
submit.connect('clicked', _submit)
|
||||
widget.pack_start(submit, False, False, 0)
|
||||
|
||||
return widget
|
||||
|
||||
def create_message(self, item):
|
||||
message = item.message
|
||||
|
||||
row = Gtk.ListBoxRow()
|
||||
row.set_selectable(False)
|
||||
row.set_margin_top(5)
|
||||
|
||||
hbox = Gtk.HBox(spacing=10)
|
||||
row.add(hbox)
|
||||
|
||||
if avatar_url := message.get('avatar_url'):
|
||||
image = Gtk.Image()
|
||||
pixbuf = common.IconFactory.get_pixbuf_url(
|
||||
avatar_url, size=32, size_param='s',
|
||||
callback=image.set_from_pixbuf)
|
||||
image.set_from_pixbuf(pixbuf)
|
||||
image.set_valign(Gtk.Align.START)
|
||||
hbox.pack_start(image, False, False, 0)
|
||||
|
||||
bubble = Gtk.VBox()
|
||||
hbox.pack_end(bubble, True, True, 0)
|
||||
|
||||
author = Gtk.Label(label=message['author'])
|
||||
author.set_xalign(0)
|
||||
author.get_style_context().add_class('dim')
|
||||
timestamp = Gtk.Label(label=message['timestamp'].strftime('%x %X'))
|
||||
timestamp.set_xalign(1)
|
||||
timestamp.get_style_context().add_class('dim')
|
||||
|
||||
meta = Gtk.HBox()
|
||||
meta.pack_start(author, True, True, 0)
|
||||
meta.pack_end(timestamp, False, False, 0)
|
||||
|
||||
content = Gtk.Label(label=message['content'])
|
||||
content.set_xalign(0)
|
||||
content.set_line_wrap(True)
|
||||
content.set_selectable(True)
|
||||
content.get_style_context().add_class(
|
||||
f"chat-content-{message['audience']}")
|
||||
|
||||
bubble.pack_start(meta, False, False, 0)
|
||||
bubble.pack_start(content, False, False, 0)
|
||||
|
||||
return row
|
||||
@@ -33,6 +33,13 @@ def main():
|
||||
label.warning {
|
||||
color: @warning_color;
|
||||
}
|
||||
label.dim {
|
||||
font-size: smaller;
|
||||
font-weight: lighter;
|
||||
}
|
||||
.chat-content-internal {
|
||||
font-weight: lighter;
|
||||
}
|
||||
.window-title, .wizard-title {
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
# this repository contains the full copyright notices and license terms.
|
||||
from . import timedelta
|
||||
from .common import (
|
||||
COLOR_SCHEMES, MODELACCESS, MODELHISTORY, MODELNAME, MODELNOTIFICATION,
|
||||
TRYTON_ICON, VIEW_SEARCH, IconFactory, Logout, RPCContextReload,
|
||||
RPCException, RPCExecute, RPCProgress, SolidColorFactory, Tooltips,
|
||||
apply_label_attributes, ask, check_version, concurrency, data2pixbuf,
|
||||
date_format, ellipsize, error, file_open, file_selection, file_write,
|
||||
filter_domain, generateColorscheme, get_align, get_credentials,
|
||||
COLOR_SCHEMES, MODELACCESS, MODELCHAT, MODELHISTORY, MODELNAME,
|
||||
MODELNOTIFICATION, TRYTON_ICON, VIEW_SEARCH, IconFactory, Logout,
|
||||
RPCContextReload, RPCException, RPCExecute, RPCProgress, SolidColorFactory,
|
||||
Tooltips, apply_label_attributes, ask, check_version, concurrency,
|
||||
data2pixbuf, date_format, ellipsize, error, file_open, file_selection,
|
||||
file_write, filter_domain, generateColorscheme, get_align, get_credentials,
|
||||
get_gdk_backend, get_hostname, get_port, get_sensible_widget,
|
||||
get_toplevel_window, hex2rgb, highlight_rgb, humanize, idle_add, mailto,
|
||||
message, node_attributes, open_documentation, play_sound,
|
||||
@@ -29,6 +29,7 @@ __all__ = [
|
||||
MODELHISTORY,
|
||||
MODELNAME,
|
||||
MODELNOTIFICATION,
|
||||
MODELCHAT,
|
||||
RPCContextReload,
|
||||
RPCException,
|
||||
RPCExecute,
|
||||
|
||||
@@ -379,6 +379,25 @@ class ModelNotification:
|
||||
MODELNOTIFICATION = ModelNotification()
|
||||
|
||||
|
||||
class ModelChat:
|
||||
_models = None
|
||||
|
||||
def load_names(self):
|
||||
try:
|
||||
self._models = dict(rpc.execute(
|
||||
'model', 'ir.chat.channel', 'get_models', rpc.CONTEXT))
|
||||
except TrytonServerError:
|
||||
pass
|
||||
|
||||
def __contains__(self, model):
|
||||
if self._models is None:
|
||||
self.load_names()
|
||||
return model in self._models
|
||||
|
||||
|
||||
MODELCHAT = ModelChat()
|
||||
|
||||
|
||||
class ViewSearch(object):
|
||||
searches = {}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M880-80 720-240H320q-33 0-56.5-23.5T240-320v-40h440q33 0 56.5-23.5T760-440v-280h40q33 0 56.5 23.5T880-640v560ZM160-473l47-47h393v-280H160v327ZM80-280v-520q0-33 23.5-56.5T160-880h440q33 0 56.5 23.5T680-800v280q0 33-23.5 56.5T600-440H240L80-280Zm80-240v-280 280Z"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -1,4 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 14H6v-2h2v2zm0-3H6V9h2v2zm0-3H6V6h2v2zm7 6h-5v-2h5v2zm3-3h-8V9h8v2zm0-3h-8V6h8v2z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M280-160v-441q0-33 24-56t57-23h439q33 0 56.5 23.5T880-600v320L680-80H360q-33 0-56.5-23.5T280-160ZM81-710q-6-33 13-59.5t52-32.5l434-77q33-6 59.5 13t32.5 52l10 54h-82l-7-40-433 77 40 226v279q-16-9-27.5-24T158-276L81-710Zm279 110v440h280v-160h160v-280H360Zm220 220Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 387 B |
@@ -12,6 +12,7 @@ from gi.repository import Gdk, GLib, Gtk
|
||||
import tryton.common as common
|
||||
from tryton import plugins
|
||||
from tryton.action import Action
|
||||
from tryton.chat import Chat
|
||||
from tryton.common import RPCException, RPCExecute, sur, sur_3b, tempfile
|
||||
from tryton.common.common import selection as selection_
|
||||
from tryton.common.popup_menu import popup
|
||||
@@ -104,6 +105,11 @@ class Form(TabContent):
|
||||
self.sidebar.pack_start(
|
||||
scrolledwindow, expand=True, fill=True, padding=0)
|
||||
|
||||
self.chat = Gtk.HBox()
|
||||
self.chat.props.width_request = 300
|
||||
self._chat = None
|
||||
self.sidebar.pack_start(self.chat, expand=True, fill=True, padding=0)
|
||||
|
||||
def widget_get(self):
|
||||
return self.screen.widget
|
||||
|
||||
@@ -191,7 +197,7 @@ class Form(TabContent):
|
||||
vbox.set_margin_start(4)
|
||||
hbox = Gtk.HBox(homogeneous=False, spacing=0)
|
||||
hbox.set_halign(Gtk.Align.CENTER)
|
||||
vbox.pack_start(hbox, expand=False, fill=True, padding=0)
|
||||
vbox.pack_end(hbox, expand=False, fill=True, padding=0)
|
||||
hbox.set_border_width(2)
|
||||
tooltips = common.Tooltips()
|
||||
|
||||
@@ -252,6 +258,24 @@ class Form(TabContent):
|
||||
self.attachment_screen.current_record = group[0]
|
||||
self.attachment_screen.display()
|
||||
|
||||
def sig_chat(self, widget=None):
|
||||
button = self.buttons['chat']
|
||||
if widget != button:
|
||||
if button.props.sensitive:
|
||||
button.props.active = True
|
||||
return
|
||||
|
||||
if button.get_active():
|
||||
self._chat = Chat(self.screen.current_reference)
|
||||
self.chat.pack_start(self._chat.widget, True, True, padding=3)
|
||||
self.chat.show_all()
|
||||
self._chat.refresh()
|
||||
else:
|
||||
self.chat.hide()
|
||||
self.chat.remove(self._chat.widget)
|
||||
self._chat.unregister()
|
||||
self._chat = None
|
||||
|
||||
def sig_note(self, widget=None):
|
||||
record = self.screen.current_record
|
||||
if not record or record.id < 0:
|
||||
@@ -590,8 +614,11 @@ class Form(TabContent):
|
||||
has_views = self.screen.number_of_views > 1
|
||||
if selected > 1:
|
||||
name += '#%i' % selected
|
||||
for button_id in ['print', 'relate', 'email', 'open', 'attach']:
|
||||
button = self.buttons[button_id]
|
||||
for button_id in [
|
||||
'print', 'relate', 'email', 'open', 'attach', 'chat']:
|
||||
button = self.buttons.get(button_id)
|
||||
if not button:
|
||||
continue
|
||||
can_be_sensitive = getattr(button, '_can_be_sensitive', True)
|
||||
if button_id in {'print', 'relate', 'email', 'open'}:
|
||||
action_type = button_id
|
||||
@@ -620,6 +647,14 @@ class Form(TabContent):
|
||||
self.info_bar_clear()
|
||||
self.set_buttons_sensitive()
|
||||
self.refresh_attachment_preview()
|
||||
if self._chat:
|
||||
self._chat.unregister()
|
||||
self.chat.remove(self._chat.widget)
|
||||
if self.screen.current_reference:
|
||||
self._chat = Chat(self.screen.current_reference)
|
||||
self.chat.add(self._chat.widget)
|
||||
self.chat.show_all()
|
||||
self._chat.refresh()
|
||||
|
||||
def record_modified(self):
|
||||
def _record_modified():
|
||||
@@ -657,7 +692,12 @@ class Form(TabContent):
|
||||
for dialog in reversed(self.dialogs[:]):
|
||||
dialog.destroy()
|
||||
modified_save = self.modified_save()
|
||||
return True if modified_save is None else modified_save
|
||||
can_close = True if modified_save is None else modified_save
|
||||
if can_close and self._chat:
|
||||
self._chat.unregister()
|
||||
self.chat.remove(self._chat.widget)
|
||||
self._chat = None
|
||||
return can_close
|
||||
|
||||
def _action(self, action, atype):
|
||||
if not self.modified_save():
|
||||
|
||||
@@ -118,6 +118,13 @@ class TabContent(InfoBar):
|
||||
tooltip=_("Add a note to the record"),
|
||||
icon_name='tryton-note',
|
||||
accel_path='<tryton>/Form/Notes'),
|
||||
ToolbarItem(
|
||||
id='chat' if self.model in common.MODELCHAT else None,
|
||||
label=_("C_hat"),
|
||||
tooltip=_("Chat on the record"),
|
||||
icon_name='tryton-chat',
|
||||
accel_path='<tryton>/Form/Chat',
|
||||
toggle=True),
|
||||
ToolbarItem(
|
||||
id='action',
|
||||
label=_("_Actions..."),
|
||||
|
||||
@@ -162,6 +162,12 @@ class Screen:
|
||||
def count_limit(self):
|
||||
return self.limit * 100 + self.offset
|
||||
|
||||
@property
|
||||
def current_reference(self):
|
||||
if self.current_record and self.current_record.id > 0:
|
||||
return f"{self.model_name},{self.current_record.id}"
|
||||
return None
|
||||
|
||||
def search_active(self, active=True):
|
||||
if active and not self.parent:
|
||||
self.screen_container.show_filter()
|
||||
|
||||
@@ -12,7 +12,7 @@ except ImportError:
|
||||
|
||||
from functools import partial
|
||||
|
||||
from tryton import bus, device_cookie, fingerprints
|
||||
from tryton import device_cookie, fingerprints
|
||||
from tryton.config import CONFIG, get_config_dir
|
||||
from tryton.exceptions import TrytonServerError, TrytonServerUnavailable
|
||||
from tryton.jsonrpc import Fault, ServerPool, ServerProxy
|
||||
@@ -35,8 +35,9 @@ ServerPool = partial(ServerPool, fingerprints=fingerprints,
|
||||
|
||||
|
||||
def context_reset():
|
||||
from tryton.bus import Bus
|
||||
CONTEXT.clear()
|
||||
CONTEXT['client'] = bus.ID
|
||||
CONTEXT['client'] = Bus.ID
|
||||
|
||||
|
||||
context_reset()
|
||||
@@ -83,6 +84,7 @@ def authentication_services(host, port):
|
||||
|
||||
def set_service_session(parameters):
|
||||
from tryton import common
|
||||
from tryton.bus import Bus
|
||||
global CONNECTION, _USER
|
||||
host = CONFIG['login.host']
|
||||
hostname = common.get_hostname(host)
|
||||
@@ -104,11 +106,12 @@ def set_service_session(parameters):
|
||||
CONNECTION.close()
|
||||
CONNECTION = ServerPool(
|
||||
hostname, port, database, session=session, cache=not CONFIG['dev'])
|
||||
bus.listen(CONNECTION)
|
||||
Bus.listen(CONNECTION)
|
||||
|
||||
|
||||
def login(parameters):
|
||||
from tryton import common
|
||||
from tryton.bus import Bus
|
||||
global CONNECTION, _USER
|
||||
host = CONFIG['login.host']
|
||||
hostname = common.get_hostname(host)
|
||||
@@ -128,7 +131,7 @@ def login(parameters):
|
||||
CONNECTION = ServerPool(
|
||||
hostname, port, database, session=session, cache=not CONFIG['dev'])
|
||||
device_cookie.renew()
|
||||
bus.listen(CONNECTION)
|
||||
Bus.listen(CONNECTION)
|
||||
|
||||
|
||||
def logout():
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from sql import Null
|
||||
from sql.conditionals import NullIf
|
||||
|
||||
from trytond.bus import Bus
|
||||
from trytond.i18n import gettext
|
||||
from trytond.model import ChatMixin, Check, ModelSQL, Unique, fields
|
||||
from trytond.model.exceptions import ValidationError
|
||||
@@ -121,6 +122,15 @@ class Channel(ModelSQL):
|
||||
content=content,
|
||||
audience=audience)
|
||||
message.save()
|
||||
|
||||
Bus.publish(
|
||||
f'chat:{str(resource)}', {
|
||||
'type': 'message',
|
||||
'message': message.as_dict(),
|
||||
})
|
||||
for follower in channel.followers:
|
||||
follower.notify(message)
|
||||
|
||||
return message
|
||||
|
||||
@classmethod
|
||||
@@ -242,6 +252,9 @@ class Follower(AuthorMixin, ModelSQL):
|
||||
('email', '=', email),
|
||||
]))
|
||||
|
||||
def notify(self, message):
|
||||
pass
|
||||
|
||||
|
||||
class Message(AuthorMixin, ModelSQL):
|
||||
"Chat Message"
|
||||
|
||||
@@ -34,6 +34,7 @@ CLIENT_ICONS = [(x, x) for x in [
|
||||
'tryton-close',
|
||||
'tryton-copy',
|
||||
'tryton-create',
|
||||
'tryton-chat',
|
||||
'tryton-date',
|
||||
'tryton-delete',
|
||||
'tryton-download',
|
||||
|
||||
Reference in New Issue
Block a user