Add simple chat widget on sidebar of clients

This commit is contained in:
Nicolas Évrard
2025-04-12 17:25:28 +02:00
parent e5c35f988b
commit 4f195c7ffe
24 changed files with 748 additions and 118 deletions
+1
View File
@@ -1,3 +1,4 @@
* Add simple chat widget on sidebar
* Support color as type of icon and image
* Support color widget on form view
+1
View File
@@ -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'
];
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}());
+20
View File
@@ -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',
+64
View File
@@ -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 */
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+179
View File
@@ -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
+7
View File
@@ -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;
+7 -6
View File
@@ -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,
+19
View File
@@ -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

+44 -4
View File
@@ -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():
+7
View File
@@ -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()
+7 -4
View File
@@ -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():
+13
View File
@@ -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"
+1
View File
@@ -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',