diff --git a/sao/CHANGELOG b/sao/CHANGELOG
index cdec25be88..338d6f8667 100644
--- a/sao/CHANGELOG
+++ b/sao/CHANGELOG
@@ -1,3 +1,4 @@
+* Add simple chat widget on sidebar
* Support color as type of icon and image
* Support color widget on form view
diff --git a/sao/Gruntfile.js b/sao/Gruntfile.js
index f4082e28dc..58f5bcd7a3 100644
--- a/sao/Gruntfile.js
+++ b/sao/Gruntfile.js
@@ -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'
];
diff --git a/sao/images/tryton-chat.svg b/sao/images/tryton-chat.svg
new file mode 100644
index 0000000000..8f0557ea3e
--- /dev/null
+++ b/sao/images/tryton-chat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/sao/images/tryton-note.svg b/sao/images/tryton-note.svg
index a10719ede4..ba38a98242 100644
--- a/sao/images/tryton-note.svg
+++ b/sao/images/tryton-note.svg
@@ -1,4 +1 @@
-
+
\ No newline at end of file
diff --git a/sao/src/bus.js b/sao/src/bus.js
index dbdd1afa1c..6ef8f2f980 100644
--- a/sao/src/bus.js
+++ b/sao/src/bus.js
@@ -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);
+
}());
diff --git a/sao/src/chat.js b/sao/src/chat.js
new file mode 100644
index 0000000000..f8ac11b425
--- /dev/null
+++ b/sao/src/chat.js
@@ -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('
', {
+ 'class': 'chat',
+ });
+
+ this._messages = jQuery('', {
+ 'class': 'chat-messages',
+ }).appendTo(jQuery('', {
+ 'class': 'chat-messages-outer',
+ }).appendTo(el));
+
+ let input = jQuery('', {
+ 'class': 'input-sm form-control',
+ 'placeholder': Sao.i18n.gettext("Enter a message"),
+ });
+ let submit = jQuery('', {
+ '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('', {
+ 'type': 'checkbox',
+ });
+ jQuery('
').appendTo(el);
+ let form = jQuery('')
+ .append(jQuery('', {
+ 'class': 'form-group',
+ }).append(input))
+ .append(jQuery('', {
+ 'class': 'checkbox',
+ }).append(jQuery('')
+ .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('', {
+ 'class': 'media chat-message',
+ }).append(jQuery('', {
+ 'class': 'media-left',
+ }).append(jQuery('
', {
+ 'class': 'media-object img-circle chat-avatar',
+ 'src': avatar_url,
+ 'alt': message.author,
+ 'style': `width: ${avatar_size}px; height: ${avatar_size}px;`,
+ }))).append(jQuery('', {
+ 'class': 'media-body well well-sm',
+ }).append(jQuery('', {
+ 'class': 'media-heading',
+ }).text(message.author)
+ .append(jQuery('', {
+ 'class': 'text-muted pull-right',
+ }).text(timestamp)))
+ .append(jQuery('', {
+ 'class': `chat-content chat-content-${message.audience}`,
+ }).text(message.content)));
+ }
+ }
+ Sao.Chat = _Chat;
+
+}());
diff --git a/sao/src/common.js b/sao/src/common.js
index f5df8cd465..123f9d5a0a 100644
--- a/sao/src/common.js
+++ b/sao/src/common.js
@@ -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',
diff --git a/sao/src/sao.less b/sao/src/sao.less
index 09aec03e49..842ab067d4 100644
--- a/sao/src/sao.less
+++ b/sao/src/sao.less
@@ -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 */
diff --git a/sao/src/screen.js b/sao/src/screen.js
index f2fc65457a..183d6bd735 100644
--- a/sao/src/screen.js
+++ b/sao/src/screen.js
@@ -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;
diff --git a/sao/src/tab.js b/sao/src/tab.js
index 58fa3698d9..d68080e0d7 100644
--- a/sao/src/tab.js
+++ b/sao/src/tab.js
@@ -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();
diff --git a/tryton/CHANGELOG b/tryton/CHANGELOG
index 40b1613ea0..75f6c70221 100644
--- a/tryton/CHANGELOG
+++ b/tryton/CHANGELOG
@@ -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
diff --git a/tryton/tryton/bus.py b/tryton/tryton/bus.py
index cc6343f4c6..b1944c4c00 100644
--- a/tryton/tryton/bus.py
+++ b/tryton/tryton/bus.py
@@ -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)
diff --git a/tryton/tryton/chat.py b/tryton/tryton/chat.py
new file mode 100644
index 0000000000..51ad0f1b3c
--- /dev/null
+++ b/tryton/tryton/chat.py
@@ -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
diff --git a/tryton/tryton/client.py b/tryton/tryton/client.py
index 58c49fddb4..a6eabbfb7b 100644
--- a/tryton/tryton/client.py
+++ b/tryton/tryton/client.py
@@ -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;
diff --git a/tryton/tryton/common/__init__.py b/tryton/tryton/common/__init__.py
index d3697dabb4..3b58595a62 100644
--- a/tryton/tryton/common/__init__.py
+++ b/tryton/tryton/common/__init__.py
@@ -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,
diff --git a/tryton/tryton/common/common.py b/tryton/tryton/common/common.py
index 1892e9113c..d552d8e950 100644
--- a/tryton/tryton/common/common.py
+++ b/tryton/tryton/common/common.py
@@ -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 = {}
diff --git a/tryton/tryton/data/pixmaps/tryton/tryton-chat.svg b/tryton/tryton/data/pixmaps/tryton/tryton-chat.svg
new file mode 100644
index 0000000000..8f0557ea3e
--- /dev/null
+++ b/tryton/tryton/data/pixmaps/tryton/tryton-chat.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tryton/tryton/data/pixmaps/tryton/tryton-note.svg b/tryton/tryton/data/pixmaps/tryton/tryton-note.svg
index a10719ede4..ba38a98242 100644
--- a/tryton/tryton/data/pixmaps/tryton/tryton-note.svg
+++ b/tryton/tryton/data/pixmaps/tryton/tryton-note.svg
@@ -1,4 +1 @@
-
+
\ No newline at end of file
diff --git a/tryton/tryton/gui/window/form.py b/tryton/tryton/gui/window/form.py
index b6724df8f5..5e7121f820 100644
--- a/tryton/tryton/gui/window/form.py
+++ b/tryton/tryton/gui/window/form.py
@@ -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():
diff --git a/tryton/tryton/gui/window/tabcontent.py b/tryton/tryton/gui/window/tabcontent.py
index ac29310f50..972919c82e 100644
--- a/tryton/tryton/gui/window/tabcontent.py
+++ b/tryton/tryton/gui/window/tabcontent.py
@@ -118,6 +118,13 @@ class TabContent(InfoBar):
tooltip=_("Add a note to the record"),
icon_name='tryton-note',
accel_path='/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='/Form/Chat',
+ toggle=True),
ToolbarItem(
id='action',
label=_("_Actions..."),
diff --git a/tryton/tryton/gui/window/view_form/screen/screen.py b/tryton/tryton/gui/window/view_form/screen/screen.py
index 90e795facb..01f1776346 100644
--- a/tryton/tryton/gui/window/view_form/screen/screen.py
+++ b/tryton/tryton/gui/window/view_form/screen/screen.py
@@ -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()
diff --git a/tryton/tryton/rpc.py b/tryton/tryton/rpc.py
index a8b16dbe2c..c3f1c316ba 100644
--- a/tryton/tryton/rpc.py
+++ b/tryton/tryton/rpc.py
@@ -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():
diff --git a/trytond/trytond/ir/chat.py b/trytond/trytond/ir/chat.py
index 6b896e3579..25202ca6ed 100644
--- a/trytond/trytond/ir/chat.py
+++ b/trytond/trytond/ir/chat.py
@@ -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"
diff --git a/trytond/trytond/ir/ui/menu.py b/trytond/trytond/ir/ui/menu.py
index f687cf89b5..cb28fedf1d 100644
--- a/trytond/trytond/ir/ui/menu.py
+++ b/trytond/trytond/ir/ui/menu.py
@@ -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',